@ytspar/sweetlink 1.23.0 → 1.24.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.
- package/dist/auto.d.ts.map +1 -1
- package/dist/auto.js +3 -4
- package/dist/auto.js.map +1 -1
- package/dist/browser/commands/exec.d.ts.map +1 -1
- package/dist/browser/commands/exec.js +54 -1
- package/dist/browser/commands/exec.js.map +1 -1
- package/dist/browser/consoleCapture.d.ts +10 -1
- package/dist/browser/consoleCapture.d.ts.map +1 -1
- package/dist/browser/consoleCapture.js +44 -13
- package/dist/browser/consoleCapture.js.map +1 -1
- package/dist/cdp.d.ts +1 -1
- package/dist/cdp.d.ts.map +1 -1
- package/dist/cdp.js +1 -11
- package/dist/cdp.js.map +1 -1
- package/dist/cli/sweetlink-dev.js +4 -8
- package/dist/cli/sweetlink-dev.js.map +1 -1
- package/dist/cli/sweetlink.js +1048 -1020
- package/dist/cli/sweetlink.js.map +1 -1
- package/dist/daemon/client.d.ts +2 -2
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/diff.d.ts.map +1 -1
- package/dist/daemon/diff.js +21 -1
- package/dist/daemon/diff.js.map +1 -1
- package/dist/daemon/index.js +17 -18
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/listeners.d.ts.map +1 -1
- package/dist/daemon/listeners.js +12 -7
- package/dist/daemon/listeners.js.map +1 -1
- package/dist/daemon/recording.d.ts.map +1 -1
- package/dist/daemon/recording.js +50 -8
- package/dist/daemon/recording.js.map +1 -1
- package/dist/daemon/refs.d.ts.map +1 -1
- package/dist/daemon/refs.js +6 -2
- package/dist/daemon/refs.js.map +1 -1
- package/dist/daemon/ringBuffer.d.ts +10 -0
- package/dist/daemon/ringBuffer.d.ts.map +1 -1
- package/dist/daemon/ringBuffer.js +13 -0
- package/dist/daemon/ringBuffer.js.map +1 -1
- package/dist/daemon/server.d.ts +12 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +99 -11
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/stateFile.d.ts +15 -1
- package/dist/daemon/stateFile.d.ts.map +1 -1
- package/dist/daemon/stateFile.js +96 -33
- package/dist/daemon/stateFile.js.map +1 -1
- package/dist/daemon/types.d.ts +99 -10
- package/dist/daemon/types.d.ts.map +1 -1
- package/dist/daemon/types.js.map +1 -1
- package/dist/daemon/utils.d.ts +17 -4
- package/dist/daemon/utils.d.ts.map +1 -1
- package/dist/daemon/utils.js +30 -4
- package/dist/daemon/utils.js.map +1 -1
- package/dist/daemon/viewer.d.ts.map +1 -1
- package/dist/daemon/viewer.js +13 -5
- package/dist/daemon/viewer.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/playwright.d.ts +1 -1
- package/dist/playwright.d.ts.map +1 -1
- package/dist/playwright.js +3 -22
- package/dist/playwright.js.map +1 -1
- package/dist/screenshotConstants.d.ts +13 -0
- package/dist/screenshotConstants.d.ts.map +1 -0
- package/dist/screenshotConstants.js +20 -0
- package/dist/screenshotConstants.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +125 -97
- package/dist/server/index.js.map +1 -1
- package/dist/simulator/android.d.ts.map +1 -1
- package/dist/simulator/android.js +2 -22
- package/dist/simulator/android.js.map +1 -1
- package/dist/simulator/env.d.ts +10 -0
- package/dist/simulator/env.d.ts.map +1 -0
- package/dist/simulator/env.js +30 -0
- package/dist/simulator/env.js.map +1 -0
- package/dist/simulator/ios.d.ts.map +1 -1
- package/dist/simulator/ios.js +2 -22
- package/dist/simulator/ios.js.map +1 -1
- package/dist/types.d.ts +100 -70
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/urlUtils.d.ts.map +1 -1
- package/dist/urlUtils.js +13 -2
- package/dist/urlUtils.js.map +1 -1
- package/dist/viewportUtils.d.ts +10 -1
- package/dist/viewportUtils.d.ts.map +1 -1
- package/dist/viewportUtils.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/sweetlink.js
CHANGED
|
@@ -13,6 +13,7 @@ import { detectCDP, getNetworkRequestsViaCDP } from '../cdp.js';
|
|
|
13
13
|
import { DaemonRequestError, daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon, } from '../daemon/client.js';
|
|
14
14
|
import { uploadEvidence } from '../daemon/evidence.js';
|
|
15
15
|
import { extractPort } from '../daemon/stateFile.js';
|
|
16
|
+
import { ensureDir } from '../daemon/utils.js';
|
|
16
17
|
import { screenshotViaPlaywright } from '../playwright.js';
|
|
17
18
|
import { getCardHeaderPreset, getNavigationPreset, measureViaPlaywright } from '../ruler.js';
|
|
18
19
|
import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, WS_PORT_OFFSET } from '../types.js';
|
|
@@ -28,7 +29,19 @@ const COMMON_APP_PORTS = [3000, 3001, 4000, 5173, 5174, 8000, 8080];
|
|
|
28
29
|
* 2. Script location - Fallback for edge cases
|
|
29
30
|
* 3. cwd as final fallback
|
|
30
31
|
*/
|
|
32
|
+
// Memoize findProjectRoot — its result depends only on process.cwd() and
|
|
33
|
+
// the filesystem, both of which are effectively immutable for a CLI run.
|
|
34
|
+
// Without this, a single CLI invocation can call findProjectRoot 5–10
|
|
35
|
+
// times (assertOutputInRoot, getDefaultScreenshotPath, getRelativePath,
|
|
36
|
+
// reportScreenshotSuccess, …), each walking up the directory tree.
|
|
37
|
+
let cachedProjectRoot = null;
|
|
31
38
|
function findProjectRoot() {
|
|
39
|
+
if (cachedProjectRoot !== null)
|
|
40
|
+
return cachedProjectRoot;
|
|
41
|
+
cachedProjectRoot = findProjectRootUncached();
|
|
42
|
+
return cachedProjectRoot;
|
|
43
|
+
}
|
|
44
|
+
function findProjectRootUncached() {
|
|
32
45
|
const debug = process.env.SWEETLINK_DEBUG === '1';
|
|
33
46
|
const root = path.parse(process.cwd()).root;
|
|
34
47
|
const cwd = process.cwd();
|
|
@@ -82,15 +95,7 @@ function findProjectRoot() {
|
|
|
82
95
|
console.error('[Sweetlink Debug] Using final fallback cwd:', cwd);
|
|
83
96
|
return cwd;
|
|
84
97
|
}
|
|
85
|
-
|
|
86
|
-
* Ensure the directory for a file path exists
|
|
87
|
-
*/
|
|
88
|
-
function ensureDir(filePath) {
|
|
89
|
-
const dir = path.dirname(filePath);
|
|
90
|
-
if (dir && dir !== '.' && !fs.existsSync(dir)) {
|
|
91
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
98
|
+
// ensureDir is imported from daemon/utils — single source of truth.
|
|
94
99
|
/**
|
|
95
100
|
* Find the most recent recording-session directory.
|
|
96
101
|
* Returns the absolute path or null if no sessions exist.
|
|
@@ -2526,6 +2531,995 @@ async function handleStatusCommand() {
|
|
|
2526
2531
|
process.exit(1);
|
|
2527
2532
|
}
|
|
2528
2533
|
}
|
|
2534
|
+
async function handleScreenshotCmd() {
|
|
2535
|
+
return screenshot({
|
|
2536
|
+
selector: getArg('--selector'),
|
|
2537
|
+
output: getArg('--output'),
|
|
2538
|
+
fullPage: hasFlag('--full-page'),
|
|
2539
|
+
forceCDP: hasFlag('--force-cdp'),
|
|
2540
|
+
forceWS: hasFlag('--force-ws'),
|
|
2541
|
+
hifi: hasFlag('--hifi'),
|
|
2542
|
+
responsive: hasFlag('--responsive'),
|
|
2543
|
+
a11y: hasFlag('--a11y'),
|
|
2544
|
+
viewport: getArg('--viewport'),
|
|
2545
|
+
width: getArg('--width') ? parseInt(getArg('--width'), 10) : undefined,
|
|
2546
|
+
height: getArg('--height') ? parseInt(getArg('--height'), 10) : undefined,
|
|
2547
|
+
hover: hasFlag('--hover'),
|
|
2548
|
+
hideDevbar: hasFlag('--hide-devbar'),
|
|
2549
|
+
padding: getArg('--padding') ? parseInt(getArg('--padding'), 10) : undefined,
|
|
2550
|
+
theme: getArg('--theme'),
|
|
2551
|
+
url: getArg('--url'),
|
|
2552
|
+
wait: !hasFlag('--no-wait'),
|
|
2553
|
+
waitTimeout: getArg('--wait-timeout') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
async function handleInspectCmd() {
|
|
2557
|
+
const projRoot = findProjectRoot();
|
|
2558
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2559
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2560
|
+
const actionTranscript = [];
|
|
2561
|
+
args.forEach((arg, index) => {
|
|
2562
|
+
if (arg !== '--action')
|
|
2563
|
+
return;
|
|
2564
|
+
const value = args[index + 1];
|
|
2565
|
+
if (!value)
|
|
2566
|
+
return;
|
|
2567
|
+
actionTranscript.push({ action: value });
|
|
2568
|
+
});
|
|
2569
|
+
const resp = await daemonRequest(state, 'inspect', {
|
|
2570
|
+
last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
|
|
2571
|
+
label: getArg('--label'),
|
|
2572
|
+
expectedOutcome: getArg('--expected'),
|
|
2573
|
+
actionTranscript,
|
|
2574
|
+
includeA11y: !hasFlag('--no-a11y'),
|
|
2575
|
+
});
|
|
2576
|
+
const data = resp.data;
|
|
2577
|
+
const output = getArg('--output');
|
|
2578
|
+
if (output) {
|
|
2579
|
+
ensureDir(output);
|
|
2580
|
+
fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
|
|
2581
|
+
}
|
|
2582
|
+
if (getArg('--format') === 'json') {
|
|
2583
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2584
|
+
}
|
|
2585
|
+
else {
|
|
2586
|
+
printInspectSummary(data);
|
|
2587
|
+
}
|
|
2588
|
+
return data;
|
|
2589
|
+
}
|
|
2590
|
+
async function handleQueryCmd() {
|
|
2591
|
+
const selector = getArg('--selector');
|
|
2592
|
+
if (!selector) {
|
|
2593
|
+
console.error('[Sweetlink] Error: --selector is required for query command');
|
|
2594
|
+
process.exit(1);
|
|
2595
|
+
}
|
|
2596
|
+
if (getArg('--url')) {
|
|
2597
|
+
const navigated = await navigateBrowser(getArg('--url'));
|
|
2598
|
+
if (!navigated) {
|
|
2599
|
+
console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
|
|
2600
|
+
process.exit(1);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return queryDOM({
|
|
2604
|
+
selector,
|
|
2605
|
+
property: getArg('--property'),
|
|
2606
|
+
waitFor: getArg('--wait-for'),
|
|
2607
|
+
waitTimeout: getArg('--wait-timeout') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
async function handleLogsCmd() {
|
|
2611
|
+
const format = getArg('--format');
|
|
2612
|
+
return getLogs({
|
|
2613
|
+
filter: getArg('--filter'),
|
|
2614
|
+
format: format || 'text',
|
|
2615
|
+
dedupe: hasFlag('--dedupe'),
|
|
2616
|
+
output: getArg('--output'),
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
async function handleExecCmd() {
|
|
2620
|
+
const code = getArg('--code');
|
|
2621
|
+
if (!code) {
|
|
2622
|
+
console.error('[Sweetlink] Error: --code is required for exec command');
|
|
2623
|
+
process.exit(1);
|
|
2624
|
+
}
|
|
2625
|
+
if (getArg('--url')) {
|
|
2626
|
+
const navigated = await navigateBrowser(getArg('--url'));
|
|
2627
|
+
if (!navigated) {
|
|
2628
|
+
console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
|
|
2629
|
+
process.exit(1);
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
return execJS({
|
|
2633
|
+
code,
|
|
2634
|
+
waitFor: getArg('--wait-for'),
|
|
2635
|
+
waitTimeout: getArg('--wait-timeout') ? parseInt(getArg('--wait-timeout'), 10) : undefined,
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
async function handleClickCmd() {
|
|
2639
|
+
const clickTarget = getArg('--selector') ?? args[1];
|
|
2640
|
+
const clickText = getArg('--text');
|
|
2641
|
+
const clickIndex = getArg('--index') ? parseInt(getArg('--index'), 10) : 0;
|
|
2642
|
+
const projRoot = findProjectRoot();
|
|
2643
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2644
|
+
// Route @e refs to daemon
|
|
2645
|
+
if (clickTarget && /^@e\d+$/.test(clickTarget)) {
|
|
2646
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2647
|
+
await daemonRequest(state, 'click-ref', { ref: clickTarget });
|
|
2648
|
+
console.log(`[Sweetlink] Clicked ${clickTarget}`);
|
|
2649
|
+
return { clicked: clickTarget, found: 1, index: 0 };
|
|
2650
|
+
}
|
|
2651
|
+
// If a recording is in progress, route CSS clicks through the daemon
|
|
2652
|
+
// so they target the recording page (which has no devbar/WebSocket
|
|
2653
|
+
// bridge) and get logged into the session manifest.
|
|
2654
|
+
try {
|
|
2655
|
+
const status = await getDaemonStatus(projRoot, extractPort(targetUrl));
|
|
2656
|
+
if (status.running) {
|
|
2657
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2658
|
+
const recStatus = await daemonRequest(state, 'record-status');
|
|
2659
|
+
const recData = recStatus.data;
|
|
2660
|
+
if (recData?.recording) {
|
|
2661
|
+
const resp = await daemonRequest(state, 'click-css', {
|
|
2662
|
+
selector: clickTarget,
|
|
2663
|
+
text: clickText,
|
|
2664
|
+
index: clickIndex,
|
|
2665
|
+
});
|
|
2666
|
+
const data = resp.data;
|
|
2667
|
+
console.log(`[Sweetlink] Clicked (recording): ${data.clicked ?? clickTarget ?? clickText}`);
|
|
2668
|
+
return {
|
|
2669
|
+
clicked: data.clicked ?? 'unknown',
|
|
2670
|
+
found: data.found ?? 1,
|
|
2671
|
+
index: data.index ?? clickIndex,
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
catch {
|
|
2677
|
+
/* fall through to WS path */
|
|
2678
|
+
}
|
|
2679
|
+
return click({
|
|
2680
|
+
selector: clickTarget,
|
|
2681
|
+
text: clickText,
|
|
2682
|
+
index: clickIndex,
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
async function handleNetworkCmd() {
|
|
2686
|
+
if (hasFlag('--failed')) {
|
|
2687
|
+
const projRoot = findProjectRoot();
|
|
2688
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2689
|
+
const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
|
|
2690
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2691
|
+
const resp = await daemonRequest(state, 'network-read', {
|
|
2692
|
+
failed: true,
|
|
2693
|
+
last: lastN,
|
|
2694
|
+
});
|
|
2695
|
+
const data = resp.data;
|
|
2696
|
+
console.log(data.formatted);
|
|
2697
|
+
console.log(`\nTotal: ${data.total} | Failed: ${data.failedCount}`);
|
|
2698
|
+
return data;
|
|
2699
|
+
}
|
|
2700
|
+
return getNetwork({ filter: getArg('--filter') });
|
|
2701
|
+
}
|
|
2702
|
+
async function handleRefreshCmd() {
|
|
2703
|
+
return refresh({ hard: hasFlag('--hard') });
|
|
2704
|
+
}
|
|
2705
|
+
async function handleRulerCmd() {
|
|
2706
|
+
const rulerSelectors = [];
|
|
2707
|
+
args.forEach((arg, i) => {
|
|
2708
|
+
if (arg === '--selector' && args[i + 1]) {
|
|
2709
|
+
rulerSelectors.push(args[i + 1]);
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
return ruler({
|
|
2713
|
+
selectors: rulerSelectors.length > 0 ? rulerSelectors : undefined,
|
|
2714
|
+
preset: getArg('--preset'),
|
|
2715
|
+
url: getArg('--url'),
|
|
2716
|
+
output: getArg('--output'),
|
|
2717
|
+
showCenterLines: !hasFlag('--no-center-lines'),
|
|
2718
|
+
showDimensions: !hasFlag('--no-dimensions'),
|
|
2719
|
+
showPosition: hasFlag('--show-position'),
|
|
2720
|
+
showAlignment: !hasFlag('--no-alignment'),
|
|
2721
|
+
limit: getArg('--limit') ? parseInt(getArg('--limit'), 10) : undefined,
|
|
2722
|
+
format: getArg('--format'),
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2725
|
+
async function handleSchemaCmd() {
|
|
2726
|
+
return getSchema({
|
|
2727
|
+
format: getArg('--format'),
|
|
2728
|
+
output: getArg('--output'),
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
async function handleOutlineCmd() {
|
|
2732
|
+
return getOutline({
|
|
2733
|
+
format: getArg('--format'),
|
|
2734
|
+
output: getArg('--output'),
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
async function handleA11yCmd() {
|
|
2738
|
+
return getA11y({
|
|
2739
|
+
format: getArg('--format'),
|
|
2740
|
+
output: getArg('--output'),
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
async function handleVitalsCmd() {
|
|
2744
|
+
return getVitals({ format: getArg('--format') });
|
|
2745
|
+
}
|
|
2746
|
+
async function handleCleanupCmd() {
|
|
2747
|
+
return cleanup({ force: hasFlag('--force'), verbose: hasFlag('--verbose') });
|
|
2748
|
+
}
|
|
2749
|
+
async function handleSetupCmd() {
|
|
2750
|
+
const { execFileSync } = await import('child_process');
|
|
2751
|
+
const scriptDir = path.dirname(import.meta.url.replace('file://', ''));
|
|
2752
|
+
const setupScript = path.resolve(scriptDir, '..', '..', 'scripts', 'setup-claude-context.mjs');
|
|
2753
|
+
execFileSync('node', [setupScript], { stdio: 'inherit' });
|
|
2754
|
+
return undefined;
|
|
2755
|
+
}
|
|
2756
|
+
async function handleConsoleCmd() {
|
|
2757
|
+
const projRoot = findProjectRoot();
|
|
2758
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2759
|
+
const errorsOnly = hasFlag('--errors');
|
|
2760
|
+
const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
|
|
2761
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2762
|
+
const resp = await daemonRequest(state, 'console-read', {
|
|
2763
|
+
errors: errorsOnly,
|
|
2764
|
+
last: lastN,
|
|
2765
|
+
});
|
|
2766
|
+
const data = resp.data;
|
|
2767
|
+
console.log(data.formatted);
|
|
2768
|
+
console.log(`\nTotal: ${data.total} | Errors: ${data.errorCount} | Warnings: ${data.warningCount}`);
|
|
2769
|
+
return data;
|
|
2770
|
+
}
|
|
2771
|
+
async function handleSessionsCmd() {
|
|
2772
|
+
const sub = args[1];
|
|
2773
|
+
const projRoot = findProjectRoot();
|
|
2774
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2775
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2776
|
+
const resp = await daemonRequest(state, 'sessions-list');
|
|
2777
|
+
const data = resp.data;
|
|
2778
|
+
if (sub === 'list' || !sub) {
|
|
2779
|
+
if (data.sessions.length === 0) {
|
|
2780
|
+
console.log('[Sweetlink] No sessions found.');
|
|
2781
|
+
}
|
|
2782
|
+
else {
|
|
2783
|
+
console.log(`[Sweetlink] ${data.sessions.length} session(s):\n`);
|
|
2784
|
+
for (const s of data.sessions) {
|
|
2785
|
+
const errTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
|
|
2786
|
+
const errBadge = errTotal > 0 ? ` · ${errTotal} err` : '';
|
|
2787
|
+
const labelTxt = s.label ? ` [${s.label}]` : '';
|
|
2788
|
+
const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
|
|
2789
|
+
console.log(` ${s.sessionId}${labelTxt} · ${dur} · ${s.actionCount} actions${errBadge}`);
|
|
2790
|
+
}
|
|
2791
|
+
if (data.indexPath)
|
|
2792
|
+
console.log(`\n Index: ${data.indexPath}`);
|
|
2793
|
+
}
|
|
2794
|
+
return { sessions: data.sessions };
|
|
2795
|
+
}
|
|
2796
|
+
if (sub === 'diff') {
|
|
2797
|
+
// sessions diff <a> <b> — compare two recordings
|
|
2798
|
+
const [aId, bId] = [args[2], args[3]];
|
|
2799
|
+
if (!aId || !bId) {
|
|
2800
|
+
console.error('[Sweetlink] Usage: sessions diff <session-A> <session-B>');
|
|
2801
|
+
process.exit(1);
|
|
2802
|
+
}
|
|
2803
|
+
const findSession = (id) => data.sessions.find((s) => s.sessionId === id || s.sessionId.endsWith(id));
|
|
2804
|
+
const a = findSession(aId);
|
|
2805
|
+
const b = findSession(bId);
|
|
2806
|
+
if (!a || !b) {
|
|
2807
|
+
console.error(`[Sweetlink] Could not find session: ${!a ? aId : bId}`);
|
|
2808
|
+
process.exit(1);
|
|
2809
|
+
}
|
|
2810
|
+
const aManifest = JSON.parse(fs.readFileSync(a.manifestPath, 'utf-8'));
|
|
2811
|
+
const bManifest = JSON.parse(fs.readFileSync(b.manifestPath, 'utf-8'));
|
|
2812
|
+
const aActions = aManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
2813
|
+
const bActions = bManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
2814
|
+
console.log(`\n${a.sessionId}${a.label ? ` "${a.label}"` : ''} vs ${b.sessionId}${b.label ? ` "${b.label}"` : ''}\n`);
|
|
2815
|
+
console.log(`Duration: ${a.duration?.toFixed(1)}s vs ${b.duration?.toFixed(1)}s`);
|
|
2816
|
+
console.log(`Actions: ${a.actionCount} vs ${b.actionCount}`);
|
|
2817
|
+
const aErr = a.errors ? a.errors.console + a.errors.network + a.errors.server : 0;
|
|
2818
|
+
const bErr = b.errors ? b.errors.console + b.errors.network + b.errors.server : 0;
|
|
2819
|
+
console.log(`Errors: ${aErr} vs ${bErr}`);
|
|
2820
|
+
// Action diff (myers-style "added/removed" by line)
|
|
2821
|
+
const inA = new Set(aActions);
|
|
2822
|
+
const inB = new Set(bActions);
|
|
2823
|
+
const added = bActions.filter((x) => !inA.has(x));
|
|
2824
|
+
const removed = aActions.filter((x) => !inB.has(x));
|
|
2825
|
+
if (removed.length) {
|
|
2826
|
+
console.log(`\nOnly in ${a.sessionId}:`);
|
|
2827
|
+
removed.forEach((s) => console.log(` - ${s}`));
|
|
2828
|
+
}
|
|
2829
|
+
if (added.length) {
|
|
2830
|
+
console.log(`\nOnly in ${b.sessionId}:`);
|
|
2831
|
+
added.forEach((s) => console.log(` + ${s}`));
|
|
2832
|
+
}
|
|
2833
|
+
if (!added.length && !removed.length) {
|
|
2834
|
+
console.log('\nAction sequences are identical.');
|
|
2835
|
+
}
|
|
2836
|
+
return {
|
|
2837
|
+
a: {
|
|
2838
|
+
id: a.sessionId,
|
|
2839
|
+
label: a.label,
|
|
2840
|
+
duration: a.duration,
|
|
2841
|
+
actions: a.actionCount,
|
|
2842
|
+
errors: aErr,
|
|
2843
|
+
},
|
|
2844
|
+
b: {
|
|
2845
|
+
id: b.sessionId,
|
|
2846
|
+
label: b.label,
|
|
2847
|
+
duration: b.duration,
|
|
2848
|
+
actions: b.actionCount,
|
|
2849
|
+
errors: bErr,
|
|
2850
|
+
},
|
|
2851
|
+
added,
|
|
2852
|
+
removed,
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
if (sub === 'open') {
|
|
2856
|
+
if (data.indexPath) {
|
|
2857
|
+
openInBrowser(data.indexPath);
|
|
2858
|
+
console.log(`[Sweetlink] Opened ${data.indexPath}`);
|
|
2859
|
+
}
|
|
2860
|
+
return { indexPath: data.indexPath };
|
|
2861
|
+
}
|
|
2862
|
+
console.error(`[Sweetlink] Unknown sessions subcommand: ${sub}. Try: list, open`);
|
|
2863
|
+
process.exit(1);
|
|
2864
|
+
}
|
|
2865
|
+
async function handleDemoCmd() {
|
|
2866
|
+
const sub = args[1];
|
|
2867
|
+
const projRoot = findProjectRoot();
|
|
2868
|
+
const demoDir = getArg('--output') ?? path.join(projRoot, '.sweetlink', 'demo');
|
|
2869
|
+
const stateFile = path.join(demoDir, 'demo-state.json');
|
|
2870
|
+
// Lazy import demo module
|
|
2871
|
+
const demoMod = await import('../daemon/demo.js');
|
|
2872
|
+
if (sub === 'init') {
|
|
2873
|
+
const title = args[2];
|
|
2874
|
+
if (!title) {
|
|
2875
|
+
console.error('[Sweetlink] Error: demo init requires a title');
|
|
2876
|
+
process.exit(1);
|
|
2877
|
+
}
|
|
2878
|
+
const demoState = await demoMod.initDemo(title, demoDir, { url: getArg('--url') });
|
|
2879
|
+
await demoMod.writeDemo(demoState);
|
|
2880
|
+
console.log(`[Sweetlink] Demo initialized: ${demoState.filePath}`);
|
|
2881
|
+
return { filePath: demoState.filePath };
|
|
2882
|
+
}
|
|
2883
|
+
if (sub === 'note') {
|
|
2884
|
+
const text = args.slice(2).join(' ');
|
|
2885
|
+
if (!text) {
|
|
2886
|
+
console.error('[Sweetlink] Error: demo note requires text');
|
|
2887
|
+
process.exit(1);
|
|
2888
|
+
}
|
|
2889
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2890
|
+
const updated = demoMod.addNote(state, text);
|
|
2891
|
+
await demoMod.writeDemo(updated);
|
|
2892
|
+
console.log(`[Sweetlink] Note added (${updated.sections.length} sections)`);
|
|
2893
|
+
return { sections: updated.sections.length };
|
|
2894
|
+
}
|
|
2895
|
+
if (sub === 'exec') {
|
|
2896
|
+
const cmd = args.slice(2).join(' ');
|
|
2897
|
+
if (!cmd) {
|
|
2898
|
+
console.error('[Sweetlink] Error: demo exec requires a command');
|
|
2899
|
+
process.exit(1);
|
|
2900
|
+
}
|
|
2901
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2902
|
+
const updated = await demoMod.addExec(state, cmd, []);
|
|
2903
|
+
await demoMod.writeDemo(updated);
|
|
2904
|
+
const lastSection = updated.sections[updated.sections.length - 1];
|
|
2905
|
+
console.log(`[Sweetlink] Exec added: ${cmd} (exit ${lastSection.exitCode ?? 0})`);
|
|
2906
|
+
return { sections: updated.sections.length, exitCode: lastSection.exitCode };
|
|
2907
|
+
}
|
|
2908
|
+
if (sub === 'screenshot') {
|
|
2909
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2910
|
+
const caption = getArg('--caption') ?? 'Screenshot';
|
|
2911
|
+
const daemonState = await ensureDaemon(projRoot, targetUrl);
|
|
2912
|
+
const resp = await daemonRequest(daemonState, 'screenshot', {});
|
|
2913
|
+
const data = resp.data;
|
|
2914
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2915
|
+
const updated = await demoMod.addScreenshot(state, Buffer.from(data.screenshot, 'base64'), caption);
|
|
2916
|
+
await demoMod.writeDemo(updated);
|
|
2917
|
+
console.log(`[Sweetlink] Screenshot added: ${caption}`);
|
|
2918
|
+
return { sections: updated.sections.length };
|
|
2919
|
+
}
|
|
2920
|
+
if (sub === 'snapshot') {
|
|
2921
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2922
|
+
const daemonState = await ensureDaemon(projRoot, targetUrl);
|
|
2923
|
+
const resp = await daemonRequest(daemonState, 'snapshot', { interactive: true });
|
|
2924
|
+
const data = resp.data;
|
|
2925
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2926
|
+
const updated = demoMod.addSnapshot(state, data.tree);
|
|
2927
|
+
await demoMod.writeDemo(updated);
|
|
2928
|
+
console.log(`[Sweetlink] Snapshot added (${updated.sections.length} sections)`);
|
|
2929
|
+
return { sections: updated.sections.length };
|
|
2930
|
+
}
|
|
2931
|
+
if (sub === 'pop') {
|
|
2932
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2933
|
+
const updated = demoMod.popSection(state);
|
|
2934
|
+
await demoMod.writeDemo(updated);
|
|
2935
|
+
console.log(`[Sweetlink] Last section removed (${updated.sections.length} remaining)`);
|
|
2936
|
+
return { sections: updated.sections.length };
|
|
2937
|
+
}
|
|
2938
|
+
if (sub === 'verify') {
|
|
2939
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2940
|
+
const verifyResult = await demoMod.verifyDemo(state);
|
|
2941
|
+
if (verifyResult.passed) {
|
|
2942
|
+
console.log('[Sweetlink] Demo verified: all outputs match');
|
|
2943
|
+
}
|
|
2944
|
+
else {
|
|
2945
|
+
console.log(`[Sweetlink] Demo verification FAILED: ${verifyResult.failures.length} mismatch(es)`);
|
|
2946
|
+
for (const f of verifyResult.failures) {
|
|
2947
|
+
console.log(` Section ${f.index}: ${f.command}`);
|
|
2948
|
+
console.log(` Expected: ${f.expected.substring(0, 80)}...`);
|
|
2949
|
+
console.log(` Actual: ${f.actual.substring(0, 80)}...`);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
return verifyResult;
|
|
2953
|
+
}
|
|
2954
|
+
// Default: status
|
|
2955
|
+
if (fs.existsSync(stateFile)) {
|
|
2956
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
2957
|
+
console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
|
|
2958
|
+
console.log(` File: ${state.filePath}`);
|
|
2959
|
+
for (const s of state.sections) {
|
|
2960
|
+
const preview = s.type === 'note'
|
|
2961
|
+
? s.content.substring(0, 60)
|
|
2962
|
+
: s.type === 'exec'
|
|
2963
|
+
? `$ ${s.command}`
|
|
2964
|
+
: s.type === 'screenshot'
|
|
2965
|
+
? `[image] ${s.screenshotFile}`
|
|
2966
|
+
: '[snapshot]';
|
|
2967
|
+
console.log(` ${s.type.padEnd(12)} ${preview}`);
|
|
2968
|
+
}
|
|
2969
|
+
return state;
|
|
2970
|
+
}
|
|
2971
|
+
console.log('[Sweetlink] No demo in progress. Run `demo init <title>` to start.');
|
|
2972
|
+
return null;
|
|
2973
|
+
}
|
|
2974
|
+
async function handleDaemonCmd() {
|
|
2975
|
+
const subcommand = args[1];
|
|
2976
|
+
const projRoot = findProjectRoot();
|
|
2977
|
+
// Daemon state files are scoped by app port (`daemon-<port>.json`),
|
|
2978
|
+
// so honour --url for status/stop too — otherwise they look up the
|
|
2979
|
+
// un-suffixed `daemon.json` and miss the daemon that `start`
|
|
2980
|
+
// wrote with --url.
|
|
2981
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2982
|
+
const appPort = extractPort(targetUrl);
|
|
2983
|
+
if (subcommand === 'stop') {
|
|
2984
|
+
const stopped = await stopDaemon(projRoot, appPort);
|
|
2985
|
+
console.log(stopped ? '[Sweetlink] Daemon stopped.' : '[Sweetlink] No daemon running.');
|
|
2986
|
+
return { running: false };
|
|
2987
|
+
}
|
|
2988
|
+
if (subcommand === 'start') {
|
|
2989
|
+
const headedFlag = hasFlag('--headed');
|
|
2990
|
+
const state = await ensureDaemon(projRoot, targetUrl, { headed: headedFlag });
|
|
2991
|
+
console.log(`[Sweetlink] Daemon running on port ${state.port} (PID: ${state.pid})`);
|
|
2992
|
+
return {
|
|
2993
|
+
running: true,
|
|
2994
|
+
pid: state.pid,
|
|
2995
|
+
port: state.port,
|
|
2996
|
+
url: state.url,
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
// Default: status
|
|
3000
|
+
const status = await getDaemonStatus(projRoot, appPort);
|
|
3001
|
+
if (status.running) {
|
|
3002
|
+
console.log(`[Sweetlink] Daemon running: port=${status.port} pid=${status.pid} uptime=${status.uptime}s`);
|
|
3003
|
+
}
|
|
3004
|
+
else {
|
|
3005
|
+
console.log('[Sweetlink] No daemon running.');
|
|
3006
|
+
}
|
|
3007
|
+
return status;
|
|
3008
|
+
}
|
|
3009
|
+
async function handleFillCmd() {
|
|
3010
|
+
const fillTarget = getArg('--selector') ?? args[1];
|
|
3011
|
+
const fillValue = getArg('--value') ?? args[2];
|
|
3012
|
+
if (!fillTarget) {
|
|
3013
|
+
console.error('[Sweetlink] Error: fill requires a target (@ref or --selector)');
|
|
3014
|
+
process.exit(1);
|
|
3015
|
+
}
|
|
3016
|
+
if (fillValue === undefined) {
|
|
3017
|
+
console.error('[Sweetlink] Error: fill requires a value (--value or positional arg)');
|
|
3018
|
+
process.exit(1);
|
|
3019
|
+
}
|
|
3020
|
+
if (/^@e\d+$/.test(fillTarget)) {
|
|
3021
|
+
const projRoot = findProjectRoot();
|
|
3022
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3023
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
3024
|
+
await daemonRequest(state, 'fill-ref', { ref: fillTarget, value: fillValue });
|
|
3025
|
+
console.log(`[Sweetlink] Filled ${fillTarget} with "${fillValue}"`);
|
|
3026
|
+
return { clicked: fillTarget, found: 1, index: 0 };
|
|
3027
|
+
}
|
|
3028
|
+
console.error('[Sweetlink] Error: fill currently only supports @e refs. Run `snapshot -i` first.');
|
|
3029
|
+
process.exit(1);
|
|
3030
|
+
}
|
|
3031
|
+
async function handleSnapshotCmd() {
|
|
3032
|
+
const projRoot = findProjectRoot();
|
|
3033
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3034
|
+
const interactive = hasFlag('-i') || hasFlag('--interactive');
|
|
3035
|
+
const doDiff = hasFlag('-D') || hasFlag('--diff');
|
|
3036
|
+
const doAnnotate = hasFlag('-a') || hasFlag('--annotate');
|
|
3037
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
3038
|
+
const resp = await daemonRequest(state, 'snapshot', {
|
|
3039
|
+
interactive,
|
|
3040
|
+
diff: doDiff,
|
|
3041
|
+
annotate: doAnnotate,
|
|
3042
|
+
});
|
|
3043
|
+
const data = resp.data;
|
|
3044
|
+
if (doDiff && data.diff) {
|
|
3045
|
+
console.log(data.diff);
|
|
3046
|
+
}
|
|
3047
|
+
else if (doAnnotate && data.screenshot) {
|
|
3048
|
+
const outputPath = getArg('--output') ?? getArg('-o') ?? 'annotated-snapshot.png';
|
|
3049
|
+
fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
|
|
3050
|
+
console.log(`[Sweetlink] Annotated screenshot saved: ${outputPath}`);
|
|
3051
|
+
}
|
|
3052
|
+
else {
|
|
3053
|
+
console.log(data.tree);
|
|
3054
|
+
}
|
|
3055
|
+
console.log(`\n${data.count} elements found`);
|
|
3056
|
+
return {
|
|
3057
|
+
tree: data.tree,
|
|
3058
|
+
refs: data.refs,
|
|
3059
|
+
diff: data.diff,
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
async function handleReportCmd() {
|
|
3063
|
+
const sessionDirArg = getArg('--session') ?? '.sweetlink';
|
|
3064
|
+
if (!fs.existsSync(sessionDirArg)) {
|
|
3065
|
+
console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
|
|
3066
|
+
process.exit(1);
|
|
3067
|
+
}
|
|
3068
|
+
const reportSessionDir = findLatestSessionDir(sessionDirArg);
|
|
3069
|
+
if (!reportSessionDir) {
|
|
3070
|
+
console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
|
|
3071
|
+
process.exit(1);
|
|
3072
|
+
}
|
|
3073
|
+
if (hasFlag('--clipboard')) {
|
|
3074
|
+
const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
|
|
3075
|
+
if (!fs.existsSync(summaryPath)) {
|
|
3076
|
+
console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
|
|
3077
|
+
process.exit(1);
|
|
3078
|
+
}
|
|
3079
|
+
const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
|
|
3080
|
+
const { execFileSync } = await import('child_process');
|
|
3081
|
+
const clipCmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip';
|
|
3082
|
+
const clipArgs = process.platform === 'darwin' ? [] : ['-selection', 'clipboard'];
|
|
3083
|
+
try {
|
|
3084
|
+
execFileSync(clipCmd, clipArgs, { input: summaryContent });
|
|
3085
|
+
console.log('[Sweetlink] SUMMARY.md copied to clipboard.');
|
|
3086
|
+
}
|
|
3087
|
+
catch (err) {
|
|
3088
|
+
console.error(`[Sweetlink] Failed to copy to clipboard (${clipCmd}):`, err instanceof Error ? err.message : err);
|
|
3089
|
+
process.exit(1);
|
|
3090
|
+
}
|
|
3091
|
+
return { mode: 'clipboard', session: path.basename(reportSessionDir) };
|
|
3092
|
+
}
|
|
3093
|
+
if (hasFlag('--serve')) {
|
|
3094
|
+
const viewerPath = path.join(reportSessionDir, 'viewer.html');
|
|
3095
|
+
if (!fs.existsSync(viewerPath)) {
|
|
3096
|
+
console.error(`[Sweetlink] viewer.html not found at ${viewerPath}`);
|
|
3097
|
+
process.exit(1);
|
|
3098
|
+
}
|
|
3099
|
+
const viewerContent = fs.readFileSync(viewerPath, 'utf-8');
|
|
3100
|
+
const http = await import('http');
|
|
3101
|
+
const port = 10000 + Math.floor(Math.random() * 50000);
|
|
3102
|
+
const server = http.createServer((_req, res) => {
|
|
3103
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
3104
|
+
res.end(viewerContent);
|
|
3105
|
+
});
|
|
3106
|
+
server.listen(port, '0.0.0.0', () => {
|
|
3107
|
+
const os = require('os');
|
|
3108
|
+
const nets = os.networkInterfaces();
|
|
3109
|
+
let lanIp = 'localhost';
|
|
3110
|
+
for (const name of Object.keys(nets)) {
|
|
3111
|
+
for (const net of nets[name]) {
|
|
3112
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
3113
|
+
lanIp = net.address;
|
|
3114
|
+
break;
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
if (lanIp !== 'localhost')
|
|
3118
|
+
break;
|
|
3119
|
+
}
|
|
3120
|
+
console.log(`[Sweetlink] Serving viewer at:`);
|
|
3121
|
+
console.log(` Local: http://localhost:${port}`);
|
|
3122
|
+
console.log(` Network: http://${lanIp}:${port}`);
|
|
3123
|
+
console.log(' Press Ctrl+C to stop.');
|
|
3124
|
+
});
|
|
3125
|
+
// Keep running until Ctrl+C
|
|
3126
|
+
await new Promise(() => { });
|
|
3127
|
+
return undefined;
|
|
3128
|
+
}
|
|
3129
|
+
if (getArg('--webhook')) {
|
|
3130
|
+
const webhookUrl = getArg('--webhook');
|
|
3131
|
+
const manifestPath = path.join(reportSessionDir, 'sweetlink-session.json');
|
|
3132
|
+
const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
|
|
3133
|
+
if (!fs.existsSync(manifestPath)) {
|
|
3134
|
+
console.error(`[Sweetlink] Manifest not found at ${manifestPath}`);
|
|
3135
|
+
process.exit(1);
|
|
3136
|
+
}
|
|
3137
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
3138
|
+
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
|
|
3139
|
+
const payload = {
|
|
3140
|
+
summary,
|
|
3141
|
+
manifest,
|
|
3142
|
+
};
|
|
3143
|
+
// Include viewer HTML for Slack/Discord webhooks
|
|
3144
|
+
if (/slack|discord/i.test(webhookUrl)) {
|
|
3145
|
+
const viewerPath = path.join(reportSessionDir, 'viewer.html');
|
|
3146
|
+
if (fs.existsSync(viewerPath)) {
|
|
3147
|
+
payload.viewerHtml = fs.readFileSync(viewerPath, 'utf-8');
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
const body = JSON.stringify(payload);
|
|
3151
|
+
const res = await fetch(webhookUrl, {
|
|
3152
|
+
method: 'POST',
|
|
3153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3154
|
+
body,
|
|
3155
|
+
});
|
|
3156
|
+
if (res.ok) {
|
|
3157
|
+
console.log(`[Sweetlink] Report posted to ${webhookUrl} (${res.status})`);
|
|
3158
|
+
}
|
|
3159
|
+
else {
|
|
3160
|
+
console.error(`[Sweetlink] Webhook failed: ${res.status} ${res.statusText}`);
|
|
3161
|
+
process.exit(1);
|
|
3162
|
+
}
|
|
3163
|
+
return { mode: 'webhook', url: webhookUrl, status: res.status };
|
|
3164
|
+
}
|
|
3165
|
+
// Default: print SUMMARY.md to stdout
|
|
3166
|
+
const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
|
|
3167
|
+
if (!fs.existsSync(summaryPath)) {
|
|
3168
|
+
console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
|
|
3169
|
+
process.exit(1);
|
|
3170
|
+
}
|
|
3171
|
+
const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
|
|
3172
|
+
process.stdout.write(summaryContent);
|
|
3173
|
+
return { mode: 'stdout', session: path.basename(reportSessionDir) };
|
|
3174
|
+
}
|
|
3175
|
+
async function handleSimCmd() {
|
|
3176
|
+
// Record iOS Simulator or Android Emulator screen while running a command.
|
|
3177
|
+
const platform = args[1];
|
|
3178
|
+
if (platform !== 'ios' && platform !== 'android') {
|
|
3179
|
+
console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
|
|
3180
|
+
process.exit(1);
|
|
3181
|
+
}
|
|
3182
|
+
const flagsWithValues = new Set([
|
|
3183
|
+
'--output',
|
|
3184
|
+
'--label',
|
|
3185
|
+
'--device',
|
|
3186
|
+
'--time-limit',
|
|
3187
|
+
'--app',
|
|
3188
|
+
'--run',
|
|
3189
|
+
]);
|
|
3190
|
+
const positional = [];
|
|
3191
|
+
for (let i = 2; i < args.length; i++) {
|
|
3192
|
+
const a = args[i];
|
|
3193
|
+
if (a.startsWith('--')) {
|
|
3194
|
+
if (flagsWithValues.has(a))
|
|
3195
|
+
i++;
|
|
3196
|
+
continue;
|
|
3197
|
+
}
|
|
3198
|
+
positional.push(a);
|
|
3199
|
+
}
|
|
3200
|
+
const command = positional.join(' ').trim();
|
|
3201
|
+
if (!command) {
|
|
3202
|
+
console.error(`[Sweetlink] Error: sim ${platform} requires a command. Example: sweetlink sim ${platform} "fastlane scan"`);
|
|
3203
|
+
process.exit(1);
|
|
3204
|
+
}
|
|
3205
|
+
const label = getArg('--label');
|
|
3206
|
+
const labelSlug = label
|
|
3207
|
+
? label
|
|
3208
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
3209
|
+
.toLowerCase()
|
|
3210
|
+
.slice(0, 40)
|
|
3211
|
+
: `sim-${platform}`;
|
|
3212
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
3213
|
+
const { runSlot: simRunSlot } = await import('../runs.js');
|
|
3214
|
+
const defaultDir = simRunSlot({
|
|
3215
|
+
baseDir: findProjectRoot(),
|
|
3216
|
+
app: getArg('--app'),
|
|
3217
|
+
run: getArg('--run'),
|
|
3218
|
+
kind: 'sim',
|
|
3219
|
+
});
|
|
3220
|
+
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
|
|
3221
|
+
ensureDir(output);
|
|
3222
|
+
const device = getArg('--device');
|
|
3223
|
+
console.log(`[Sweetlink] Recording ${platform} simulator: ${command}`);
|
|
3224
|
+
let recResult;
|
|
3225
|
+
if (platform === 'ios') {
|
|
3226
|
+
const { recordIosSimulator } = await import('../simulator/ios.js');
|
|
3227
|
+
recResult = await recordIosSimulator({ command, output, device });
|
|
3228
|
+
}
|
|
3229
|
+
else {
|
|
3230
|
+
const { recordAndroidEmulator } = await import('../simulator/android.js');
|
|
3231
|
+
const tl = getArg('--time-limit');
|
|
3232
|
+
recResult = await recordAndroidEmulator({
|
|
3233
|
+
command,
|
|
3234
|
+
output,
|
|
3235
|
+
device,
|
|
3236
|
+
timeLimit: tl ? parseInt(tl, 10) : undefined,
|
|
3237
|
+
overlays: !hasFlag('--no-overlays'),
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
let sizeKb = '?';
|
|
3241
|
+
try {
|
|
3242
|
+
sizeKb = String(Math.round(fs.statSync(output).size / 1024));
|
|
3243
|
+
}
|
|
3244
|
+
catch {
|
|
3245
|
+
/* file may not exist if recordingClosed is false */
|
|
3246
|
+
}
|
|
3247
|
+
const tapSuffix = (recResult.tapCount ?? 0) > 0
|
|
3248
|
+
? ` · ${recResult.tapCount} taps${recResult.overlaysApplied ? ' (overlaid)' : ' (sidecar only — install ffmpeg for overlays)'}`
|
|
3249
|
+
: '';
|
|
3250
|
+
console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
|
|
3251
|
+
`${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
|
|
3252
|
+
tapSuffix +
|
|
3253
|
+
(recResult.recordingClosed
|
|
3254
|
+
? ''
|
|
3255
|
+
: ' (recording was force-killed; mp4 may be incomplete)'));
|
|
3256
|
+
const result = {
|
|
3257
|
+
path: output,
|
|
3258
|
+
device: recResult.device,
|
|
3259
|
+
durationSec: recResult.durationSec,
|
|
3260
|
+
exitCode: recResult.exitCode,
|
|
3261
|
+
recordingClosed: recResult.recordingClosed,
|
|
3262
|
+
tapCount: recResult.tapCount,
|
|
3263
|
+
tapsJsonPath: recResult.tapsJsonPath,
|
|
3264
|
+
overlaysApplied: recResult.overlaysApplied,
|
|
3265
|
+
};
|
|
3266
|
+
if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
|
|
3267
|
+
process.exit(recResult.exitCode);
|
|
3268
|
+
}
|
|
3269
|
+
return result;
|
|
3270
|
+
}
|
|
3271
|
+
async function handleTermCmd() {
|
|
3272
|
+
// Record a shell command's stdout/stderr into asciicast v2 + HTML player.
|
|
3273
|
+
const flagsWithValues = new Set([
|
|
3274
|
+
'--output',
|
|
3275
|
+
'--label',
|
|
3276
|
+
'--shell',
|
|
3277
|
+
'--cols',
|
|
3278
|
+
'--rows',
|
|
3279
|
+
'--app',
|
|
3280
|
+
'--run',
|
|
3281
|
+
]);
|
|
3282
|
+
const positional = [];
|
|
3283
|
+
for (let i = 1; i < args.length; i++) {
|
|
3284
|
+
const a = args[i];
|
|
3285
|
+
if (a.startsWith('--')) {
|
|
3286
|
+
if (flagsWithValues.has(a))
|
|
3287
|
+
i++;
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
3290
|
+
positional.push(a);
|
|
3291
|
+
}
|
|
3292
|
+
const command = positional.join(' ').trim();
|
|
3293
|
+
if (!command) {
|
|
3294
|
+
console.error('[Sweetlink] Error: term requires a command. Example: sweetlink term "pytest tests/"');
|
|
3295
|
+
process.exit(1);
|
|
3296
|
+
}
|
|
3297
|
+
const label = getArg('--label');
|
|
3298
|
+
const labelSlug = label
|
|
3299
|
+
? label
|
|
3300
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
3301
|
+
.toLowerCase()
|
|
3302
|
+
.slice(0, 40)
|
|
3303
|
+
: 'term';
|
|
3304
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
3305
|
+
const { runSlot } = await import('../runs.js');
|
|
3306
|
+
const defaultDir = runSlot({
|
|
3307
|
+
baseDir: findProjectRoot(),
|
|
3308
|
+
app: getArg('--app'),
|
|
3309
|
+
run: getArg('--run'),
|
|
3310
|
+
kind: 'term',
|
|
3311
|
+
});
|
|
3312
|
+
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
|
|
3313
|
+
ensureDir(output);
|
|
3314
|
+
console.log(`[Sweetlink] Recording terminal: ${command}`);
|
|
3315
|
+
const { captureTerminal } = await import('../term/recorder.js');
|
|
3316
|
+
const { generatePlayer } = await import('../term/player.js');
|
|
3317
|
+
const cap = await captureTerminal({
|
|
3318
|
+
command,
|
|
3319
|
+
output,
|
|
3320
|
+
label,
|
|
3321
|
+
shell: getArg('--shell'),
|
|
3322
|
+
cols: getArg('--cols') ? parseInt(getArg('--cols'), 10) : undefined,
|
|
3323
|
+
rows: getArg('--rows') ? parseInt(getArg('--rows'), 10) : undefined,
|
|
3324
|
+
});
|
|
3325
|
+
const playerPath = await generatePlayer({ castPath: output });
|
|
3326
|
+
console.log(`[Sweetlink] ✓ ${getRelativePath(output)} · ${cap.durationSec.toFixed(1)}s · ` +
|
|
3327
|
+
`${cap.events} events · ${(cap.bytes / 1024).toFixed(0)}KB · exit=${cap.exitCode}`);
|
|
3328
|
+
console.log(`[Sweetlink] ▶ ${getRelativePath(playerPath)}`);
|
|
3329
|
+
const result = {
|
|
3330
|
+
castPath: output,
|
|
3331
|
+
playerPath,
|
|
3332
|
+
durationSec: cap.durationSec,
|
|
3333
|
+
bytes: cap.bytes,
|
|
3334
|
+
events: cap.events,
|
|
3335
|
+
exitCode: cap.exitCode,
|
|
3336
|
+
};
|
|
3337
|
+
// Propagate the recorded command's exit code by default so CI fails
|
|
3338
|
+
// when the wrapped tests fail.
|
|
3339
|
+
if (cap.exitCode !== 0 && !hasFlag('--ignore-exit')) {
|
|
3340
|
+
process.exit(cap.exitCode);
|
|
3341
|
+
}
|
|
3342
|
+
return result;
|
|
3343
|
+
}
|
|
3344
|
+
async function handleRecordCmd() {
|
|
3345
|
+
const projRoot = findProjectRoot();
|
|
3346
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3347
|
+
const subcommand = args[1];
|
|
3348
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
3349
|
+
if (subcommand === 'start') {
|
|
3350
|
+
const params = {};
|
|
3351
|
+
const label = getArg('--label');
|
|
3352
|
+
const viewport = getArg('--viewport');
|
|
3353
|
+
const storageState = getArg('--storage-state');
|
|
3354
|
+
if (label)
|
|
3355
|
+
params.label = label;
|
|
3356
|
+
if (viewport)
|
|
3357
|
+
params.viewport = viewport;
|
|
3358
|
+
if (storageState)
|
|
3359
|
+
params.storageState = storageState;
|
|
3360
|
+
if (hasFlag('--trace'))
|
|
3361
|
+
params.trace = true;
|
|
3362
|
+
const resp = await daemonRequest(state, 'record-start', params);
|
|
3363
|
+
const data = resp.data;
|
|
3364
|
+
console.log(`[Sweetlink] Recording started: ${data.sessionId}` +
|
|
3365
|
+
(data.label ? ` (${data.label})` : ''));
|
|
3366
|
+
return data;
|
|
3367
|
+
}
|
|
3368
|
+
if (subcommand === 'stop') {
|
|
3369
|
+
const resp = await daemonRequest(state, 'record-stop');
|
|
3370
|
+
const data = resp.data;
|
|
3371
|
+
const m = data.manifest;
|
|
3372
|
+
console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
|
|
3373
|
+
console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
|
|
3374
|
+
// Auto-open the viewer (cross-platform; --no-open to suppress)
|
|
3375
|
+
if (data.viewerPath && !hasFlag('--no-open')) {
|
|
3376
|
+
console.log(` Viewer: ${data.viewerPath}`);
|
|
3377
|
+
openInBrowser(data.viewerPath);
|
|
3378
|
+
console.log(` Opened in browser.`);
|
|
3379
|
+
}
|
|
3380
|
+
else if (data.viewerPath) {
|
|
3381
|
+
console.log(` Viewer: ${data.viewerPath}`);
|
|
3382
|
+
}
|
|
3383
|
+
return data;
|
|
3384
|
+
}
|
|
3385
|
+
if (subcommand === 'exec') {
|
|
3386
|
+
// record exec "click @e2; fill @e3 hello world; click @e5"
|
|
3387
|
+
// Runs a semicolon-separated DSL inside a fresh recording, then
|
|
3388
|
+
// auto-stops. Each step is one of:
|
|
3389
|
+
// click <selector|@ref>
|
|
3390
|
+
// fill <@ref> <value> (rest of line after ref = value)
|
|
3391
|
+
// press <key>
|
|
3392
|
+
// sleep <ms>
|
|
3393
|
+
// Strip known --flag value pairs from positional args before
|
|
3394
|
+
// joining what remains as the script body.
|
|
3395
|
+
const flagsWithValues = new Set(['--url', '--label', '--viewport', '--storage-state']);
|
|
3396
|
+
const positional = [];
|
|
3397
|
+
for (let i = 2; i < args.length; i++) {
|
|
3398
|
+
const a = args[i];
|
|
3399
|
+
if (a.startsWith('--')) {
|
|
3400
|
+
if (flagsWithValues.has(a))
|
|
3401
|
+
i++; // skip its value
|
|
3402
|
+
continue;
|
|
3403
|
+
}
|
|
3404
|
+
positional.push(a);
|
|
3405
|
+
}
|
|
3406
|
+
const script = positional.join(' ').trim();
|
|
3407
|
+
if (!script) {
|
|
3408
|
+
console.error('[Sweetlink] Error: record exec requires a script. Example: `record exec "click @e2; fill @e3 hello"`');
|
|
3409
|
+
process.exit(1);
|
|
3410
|
+
}
|
|
3411
|
+
const label = getArg('--label');
|
|
3412
|
+
const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
|
|
3413
|
+
const startData = startResp.data;
|
|
3414
|
+
console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
|
|
3415
|
+
const steps = script
|
|
3416
|
+
.split(';')
|
|
3417
|
+
.map((s) => s.trim())
|
|
3418
|
+
.filter(Boolean);
|
|
3419
|
+
// Snapshot once up-front so refs resolve.
|
|
3420
|
+
await daemonRequest(state, 'snapshot', { interactive: true });
|
|
3421
|
+
for (const step of steps) {
|
|
3422
|
+
const [verb, ...rest] = step.split(/\s+/);
|
|
3423
|
+
try {
|
|
3424
|
+
if (verb === 'click') {
|
|
3425
|
+
const target = rest[0];
|
|
3426
|
+
if (!target)
|
|
3427
|
+
throw new Error('click needs a target');
|
|
3428
|
+
if (/^@e\d+$/.test(target)) {
|
|
3429
|
+
await daemonRequest(state, 'click-ref', { ref: target });
|
|
3430
|
+
}
|
|
3431
|
+
else {
|
|
3432
|
+
await daemonRequest(state, 'click-css', { selector: target });
|
|
3433
|
+
}
|
|
3434
|
+
console.log(` · click ${target}`);
|
|
3435
|
+
}
|
|
3436
|
+
else if (verb === 'fill') {
|
|
3437
|
+
const ref = rest[0];
|
|
3438
|
+
const value = rest.slice(1).join(' ');
|
|
3439
|
+
if (!ref || !/^@e\d+$/.test(ref))
|
|
3440
|
+
throw new Error('fill needs a @ref and a value');
|
|
3441
|
+
await daemonRequest(state, 'fill-ref', { ref, value });
|
|
3442
|
+
console.log(` · fill ${ref} = "${value}"`);
|
|
3443
|
+
}
|
|
3444
|
+
else if (verb === 'press') {
|
|
3445
|
+
const key = rest[0];
|
|
3446
|
+
if (!key)
|
|
3447
|
+
throw new Error('press needs a key');
|
|
3448
|
+
await daemonRequest(state, 'press-key', { key });
|
|
3449
|
+
console.log(` · press ${key}`);
|
|
3450
|
+
}
|
|
3451
|
+
else if (verb === 'sleep') {
|
|
3452
|
+
const ms = parseInt(rest[0] ?? '0', 10);
|
|
3453
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
3454
|
+
console.log(` · sleep ${ms}ms`);
|
|
3455
|
+
}
|
|
3456
|
+
else {
|
|
3457
|
+
throw new Error(`Unknown verb '${verb}'. Allowed: click, fill, press, sleep.`);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
catch (err) {
|
|
3461
|
+
console.error(` ✗ step "${step}" failed: ${err instanceof Error ? err.message : err}`);
|
|
3462
|
+
// Continue to record-stop so the partial recording is preserved.
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
const stopResp = await daemonRequest(state, 'record-stop');
|
|
3466
|
+
const stopData = stopResp.data;
|
|
3467
|
+
console.log(`[Sweetlink] Done: ${stopData.manifest.commands.length} actions in ` +
|
|
3468
|
+
`${stopData.manifest.duration.toFixed(1)}s${stopData.viewerPath ? ` · ${stopData.viewerPath}` : ''}`);
|
|
3469
|
+
return stopData;
|
|
3470
|
+
}
|
|
3471
|
+
if (subcommand === 'pause') {
|
|
3472
|
+
const resp = await daemonRequest(state, 'record-pause');
|
|
3473
|
+
console.log('[Sweetlink] Recording paused. Use `record resume` to continue.');
|
|
3474
|
+
return resp.data;
|
|
3475
|
+
}
|
|
3476
|
+
if (subcommand === 'resume') {
|
|
3477
|
+
const resp = await daemonRequest(state, 'record-resume');
|
|
3478
|
+
const d = resp.data;
|
|
3479
|
+
console.log(`[Sweetlink] Recording resumed. Paused for ${(d.pausedDurationMs / 1000).toFixed(1)}s.`);
|
|
3480
|
+
return resp.data;
|
|
3481
|
+
}
|
|
3482
|
+
// Default: status
|
|
3483
|
+
const resp = await daemonRequest(state, 'record-status');
|
|
3484
|
+
const data = resp.data;
|
|
3485
|
+
if (data.recording) {
|
|
3486
|
+
console.log(`[Sweetlink] Recording in progress: ${data.sessionId} (${Math.round(data.duration ?? 0)}s, ${data.actionCount} actions)`);
|
|
3487
|
+
}
|
|
3488
|
+
else {
|
|
3489
|
+
console.log('[Sweetlink] No recording in progress.');
|
|
3490
|
+
}
|
|
3491
|
+
return data;
|
|
3492
|
+
}
|
|
3493
|
+
async function handleProofCmd() {
|
|
3494
|
+
const prNum = getArg('--pr');
|
|
3495
|
+
if (!prNum) {
|
|
3496
|
+
console.error('[Sweetlink] Error: --pr <number> is required');
|
|
3497
|
+
process.exit(1);
|
|
3498
|
+
}
|
|
3499
|
+
const sessionDirArg = getArg('--session') ?? '.sweetlink';
|
|
3500
|
+
const latestSession = findLatestSessionDir(sessionDirArg);
|
|
3501
|
+
if (!latestSession) {
|
|
3502
|
+
console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
|
|
3503
|
+
process.exit(1);
|
|
3504
|
+
}
|
|
3505
|
+
const manifestPath = path.join(latestSession, 'sweetlink-session.json');
|
|
3506
|
+
if (!fs.existsSync(manifestPath)) {
|
|
3507
|
+
console.error(`[Sweetlink] No manifest found at ${manifestPath}`);
|
|
3508
|
+
process.exit(1);
|
|
3509
|
+
}
|
|
3510
|
+
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
3511
|
+
try {
|
|
3512
|
+
const { commentUrl } = await uploadEvidence(manifestData, latestSession, parseInt(prNum, 10), {
|
|
3513
|
+
repo: getArg('--repo') ?? undefined,
|
|
3514
|
+
});
|
|
3515
|
+
console.log(`[Sweetlink] Evidence posted: ${commentUrl}`);
|
|
3516
|
+
return { commentUrl };
|
|
3517
|
+
}
|
|
3518
|
+
catch (error) {
|
|
3519
|
+
console.error('[Sweetlink] Failed to upload evidence:', error instanceof Error ? error.message : error);
|
|
3520
|
+
process.exit(1);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
2529
3523
|
(async () => {
|
|
2530
3524
|
const startTime = Date.now();
|
|
2531
3525
|
// Resolve --app flag: for WS-bridge commands, this discovers the matching
|
|
@@ -2554,221 +3548,34 @@ async function handleStatusCommand() {
|
|
|
2554
3548
|
let result;
|
|
2555
3549
|
switch (commandType) {
|
|
2556
3550
|
case 'screenshot':
|
|
2557
|
-
result = await
|
|
2558
|
-
selector: getArg('--selector'),
|
|
2559
|
-
output: getArg('--output'),
|
|
2560
|
-
fullPage: hasFlag('--full-page'),
|
|
2561
|
-
forceCDP: hasFlag('--force-cdp'),
|
|
2562
|
-
forceWS: hasFlag('--force-ws'),
|
|
2563
|
-
hifi: hasFlag('--hifi'),
|
|
2564
|
-
responsive: hasFlag('--responsive'),
|
|
2565
|
-
a11y: hasFlag('--a11y'),
|
|
2566
|
-
viewport: getArg('--viewport'),
|
|
2567
|
-
width: getArg('--width') ? parseInt(getArg('--width'), 10) : undefined,
|
|
2568
|
-
height: getArg('--height') ? parseInt(getArg('--height'), 10) : undefined,
|
|
2569
|
-
hover: hasFlag('--hover'),
|
|
2570
|
-
hideDevbar: hasFlag('--hide-devbar'),
|
|
2571
|
-
padding: getArg('--padding') ? parseInt(getArg('--padding'), 10) : undefined,
|
|
2572
|
-
theme: getArg('--theme'),
|
|
2573
|
-
url: getArg('--url'),
|
|
2574
|
-
wait: !hasFlag('--no-wait'), // Wait by default, --no-wait to skip
|
|
2575
|
-
waitTimeout: getArg('--wait-timeout')
|
|
2576
|
-
? parseInt(getArg('--wait-timeout'), 10)
|
|
2577
|
-
: undefined,
|
|
2578
|
-
});
|
|
3551
|
+
result = await handleScreenshotCmd();
|
|
2579
3552
|
break;
|
|
2580
3553
|
case 'inspect':
|
|
2581
|
-
case 'context':
|
|
2582
|
-
|
|
2583
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2584
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2585
|
-
const actionTranscript = [];
|
|
2586
|
-
args.forEach((arg, index) => {
|
|
2587
|
-
if (arg !== '--action')
|
|
2588
|
-
return;
|
|
2589
|
-
const value = args[index + 1];
|
|
2590
|
-
if (!value)
|
|
2591
|
-
return;
|
|
2592
|
-
actionTranscript.push({ action: value });
|
|
2593
|
-
});
|
|
2594
|
-
const resp = await daemonRequest(state, 'inspect', {
|
|
2595
|
-
last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
|
|
2596
|
-
label: getArg('--label'),
|
|
2597
|
-
expectedOutcome: getArg('--expected'),
|
|
2598
|
-
actionTranscript,
|
|
2599
|
-
includeA11y: !hasFlag('--no-a11y'),
|
|
2600
|
-
});
|
|
2601
|
-
const data = resp.data;
|
|
2602
|
-
const output = getArg('--output');
|
|
2603
|
-
if (output) {
|
|
2604
|
-
ensureDir(output);
|
|
2605
|
-
fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
|
|
2606
|
-
}
|
|
2607
|
-
if (getArg('--format') === 'json') {
|
|
2608
|
-
console.log(JSON.stringify(data, null, 2));
|
|
2609
|
-
}
|
|
2610
|
-
else {
|
|
2611
|
-
printInspectSummary(data);
|
|
2612
|
-
}
|
|
2613
|
-
result = data;
|
|
3554
|
+
case 'context':
|
|
3555
|
+
result = await handleInspectCmd();
|
|
2614
3556
|
break;
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
const selector = getArg('--selector');
|
|
2618
|
-
if (!selector) {
|
|
2619
|
-
console.error('[Sweetlink] Error: --selector is required for query command');
|
|
2620
|
-
process.exit(1);
|
|
2621
|
-
}
|
|
2622
|
-
if (getArg('--url')) {
|
|
2623
|
-
const navigated = await navigateBrowser(getArg('--url'));
|
|
2624
|
-
if (!navigated) {
|
|
2625
|
-
console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
|
|
2626
|
-
process.exit(1);
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
result = await queryDOM({
|
|
2630
|
-
selector,
|
|
2631
|
-
property: getArg('--property'),
|
|
2632
|
-
waitFor: getArg('--wait-for'),
|
|
2633
|
-
waitTimeout: getArg('--wait-timeout')
|
|
2634
|
-
? parseInt(getArg('--wait-timeout'), 10)
|
|
2635
|
-
: undefined,
|
|
2636
|
-
});
|
|
3557
|
+
case 'query':
|
|
3558
|
+
result = await handleQueryCmd();
|
|
2637
3559
|
break;
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
const format = getArg('--format');
|
|
2641
|
-
result = await getLogs({
|
|
2642
|
-
filter: getArg('--filter'),
|
|
2643
|
-
format: format || 'text',
|
|
2644
|
-
dedupe: hasFlag('--dedupe'),
|
|
2645
|
-
output: getArg('--output'),
|
|
2646
|
-
});
|
|
3560
|
+
case 'logs':
|
|
3561
|
+
result = await handleLogsCmd();
|
|
2647
3562
|
break;
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
const code = getArg('--code');
|
|
2651
|
-
if (!code) {
|
|
2652
|
-
console.error('[Sweetlink] Error: --code is required for exec command');
|
|
2653
|
-
process.exit(1);
|
|
2654
|
-
}
|
|
2655
|
-
if (getArg('--url')) {
|
|
2656
|
-
const navigated = await navigateBrowser(getArg('--url'));
|
|
2657
|
-
if (!navigated) {
|
|
2658
|
-
console.error('[Sweetlink] Could not navigate browser to', getArg('--url'));
|
|
2659
|
-
process.exit(1);
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
result = await execJS({
|
|
2663
|
-
code,
|
|
2664
|
-
waitFor: getArg('--wait-for'),
|
|
2665
|
-
waitTimeout: getArg('--wait-timeout')
|
|
2666
|
-
? parseInt(getArg('--wait-timeout'), 10)
|
|
2667
|
-
: undefined,
|
|
2668
|
-
});
|
|
3563
|
+
case 'exec':
|
|
3564
|
+
result = await handleExecCmd();
|
|
2669
3565
|
break;
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
const clickTarget = getArg('--selector') ?? args[1];
|
|
2673
|
-
const clickText = getArg('--text');
|
|
2674
|
-
const clickIndex = getArg('--index') ? parseInt(getArg('--index'), 10) : 0;
|
|
2675
|
-
const projRoot = findProjectRoot();
|
|
2676
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2677
|
-
// Route @e refs to daemon
|
|
2678
|
-
if (clickTarget && /^@e\d+$/.test(clickTarget)) {
|
|
2679
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2680
|
-
await daemonRequest(state, 'click-ref', { ref: clickTarget });
|
|
2681
|
-
console.log(`[Sweetlink] Clicked ${clickTarget}`);
|
|
2682
|
-
result = { clicked: clickTarget, found: 1, index: 0 };
|
|
2683
|
-
break;
|
|
2684
|
-
}
|
|
2685
|
-
// If a recording is in progress, route CSS clicks through the daemon
|
|
2686
|
-
// so they target the recording page (which has no devbar/WebSocket
|
|
2687
|
-
// bridge) and get logged into the session manifest.
|
|
2688
|
-
try {
|
|
2689
|
-
const status = await getDaemonStatus(projRoot, extractPort(targetUrl));
|
|
2690
|
-
if (status.running) {
|
|
2691
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2692
|
-
const recStatus = await daemonRequest(state, 'record-status');
|
|
2693
|
-
const recData = recStatus.data;
|
|
2694
|
-
if (recData?.recording) {
|
|
2695
|
-
const resp = await daemonRequest(state, 'click-css', {
|
|
2696
|
-
selector: clickTarget,
|
|
2697
|
-
text: clickText,
|
|
2698
|
-
index: clickIndex,
|
|
2699
|
-
});
|
|
2700
|
-
const data = resp.data;
|
|
2701
|
-
console.log(`[Sweetlink] Clicked (recording): ${data.clicked ?? clickTarget ?? clickText}`);
|
|
2702
|
-
result = {
|
|
2703
|
-
clicked: data.clicked ?? 'unknown',
|
|
2704
|
-
found: data.found ?? 1,
|
|
2705
|
-
index: data.index ?? clickIndex,
|
|
2706
|
-
};
|
|
2707
|
-
break;
|
|
2708
|
-
}
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
catch {
|
|
2712
|
-
/* fall through to WS path */
|
|
2713
|
-
}
|
|
2714
|
-
result = await click({
|
|
2715
|
-
selector: clickTarget,
|
|
2716
|
-
text: clickText,
|
|
2717
|
-
index: clickIndex,
|
|
2718
|
-
});
|
|
3566
|
+
case 'click':
|
|
3567
|
+
result = await handleClickCmd();
|
|
2719
3568
|
break;
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
// If --failed flag is present and daemon is running, use daemon ring buffer
|
|
2723
|
-
if (hasFlag('--failed')) {
|
|
2724
|
-
const projRoot = findProjectRoot();
|
|
2725
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2726
|
-
const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
|
|
2727
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2728
|
-
const resp = await daemonRequest(state, 'network-read', {
|
|
2729
|
-
failed: true,
|
|
2730
|
-
last: lastN,
|
|
2731
|
-
});
|
|
2732
|
-
const data = resp.data;
|
|
2733
|
-
console.log(data.formatted);
|
|
2734
|
-
console.log(`\nTotal: ${data.total} | Failed: ${data.failedCount}`);
|
|
2735
|
-
result = data;
|
|
2736
|
-
}
|
|
2737
|
-
else {
|
|
2738
|
-
result = await getNetwork({
|
|
2739
|
-
filter: getArg('--filter'),
|
|
2740
|
-
});
|
|
2741
|
-
}
|
|
3569
|
+
case 'network':
|
|
3570
|
+
result = await handleNetworkCmd();
|
|
2742
3571
|
break;
|
|
2743
|
-
}
|
|
2744
3572
|
case 'refresh':
|
|
2745
|
-
result = await
|
|
2746
|
-
hard: hasFlag('--hard'),
|
|
2747
|
-
});
|
|
3573
|
+
result = await handleRefreshCmd();
|
|
2748
3574
|
break;
|
|
2749
3575
|
case 'ruler':
|
|
2750
|
-
case 'measure':
|
|
2751
|
-
|
|
2752
|
-
const rulerSelectors = [];
|
|
2753
|
-
args.forEach((arg, i) => {
|
|
2754
|
-
if (arg === '--selector' && args[i + 1]) {
|
|
2755
|
-
rulerSelectors.push(args[i + 1]);
|
|
2756
|
-
}
|
|
2757
|
-
});
|
|
2758
|
-
result = await ruler({
|
|
2759
|
-
selectors: rulerSelectors.length > 0 ? rulerSelectors : undefined,
|
|
2760
|
-
preset: getArg('--preset'),
|
|
2761
|
-
url: getArg('--url'),
|
|
2762
|
-
output: getArg('--output'),
|
|
2763
|
-
showCenterLines: !hasFlag('--no-center-lines'),
|
|
2764
|
-
showDimensions: !hasFlag('--no-dimensions'),
|
|
2765
|
-
showPosition: hasFlag('--show-position'),
|
|
2766
|
-
showAlignment: !hasFlag('--no-alignment'),
|
|
2767
|
-
limit: getArg('--limit') ? parseInt(getArg('--limit'), 10) : undefined,
|
|
2768
|
-
format: getArg('--format'),
|
|
2769
|
-
});
|
|
3576
|
+
case 'measure':
|
|
3577
|
+
result = await handleRulerCmd();
|
|
2770
3578
|
break;
|
|
2771
|
-
}
|
|
2772
3579
|
case 'wait':
|
|
2773
3580
|
result = await handleWaitCommand();
|
|
2774
3581
|
break;
|
|
@@ -2776,836 +3583,57 @@ async function handleStatusCommand() {
|
|
|
2776
3583
|
result = await handleStatusCommand();
|
|
2777
3584
|
break;
|
|
2778
3585
|
case 'schema':
|
|
2779
|
-
result = await
|
|
2780
|
-
format: getArg('--format'),
|
|
2781
|
-
output: getArg('--output'),
|
|
2782
|
-
});
|
|
3586
|
+
result = await handleSchemaCmd();
|
|
2783
3587
|
break;
|
|
2784
3588
|
case 'outline':
|
|
2785
|
-
result = await
|
|
2786
|
-
format: getArg('--format'),
|
|
2787
|
-
output: getArg('--output'),
|
|
2788
|
-
});
|
|
3589
|
+
result = await handleOutlineCmd();
|
|
2789
3590
|
break;
|
|
2790
3591
|
case 'a11y':
|
|
2791
3592
|
case 'accessibility':
|
|
2792
|
-
result = await
|
|
2793
|
-
format: getArg('--format'),
|
|
2794
|
-
output: getArg('--output'),
|
|
2795
|
-
});
|
|
3593
|
+
result = await handleA11yCmd();
|
|
2796
3594
|
break;
|
|
2797
3595
|
case 'vitals':
|
|
2798
|
-
result = await
|
|
2799
|
-
format: getArg('--format'),
|
|
2800
|
-
});
|
|
3596
|
+
result = await handleVitalsCmd();
|
|
2801
3597
|
break;
|
|
2802
3598
|
case 'cleanup':
|
|
2803
|
-
result = await
|
|
2804
|
-
force: hasFlag('--force'),
|
|
2805
|
-
verbose: hasFlag('--verbose'),
|
|
2806
|
-
});
|
|
3599
|
+
result = await handleCleanupCmd();
|
|
2807
3600
|
break;
|
|
2808
|
-
case 'setup':
|
|
2809
|
-
|
|
2810
|
-
const { execFileSync } = await import('child_process');
|
|
2811
|
-
const scriptDir = path.dirname(import.meta.url.replace('file://', ''));
|
|
2812
|
-
const setupScript = path.resolve(scriptDir, '..', '..', 'scripts', 'setup-claude-context.mjs');
|
|
2813
|
-
execFileSync('node', [setupScript], { stdio: 'inherit' });
|
|
3601
|
+
case 'setup':
|
|
3602
|
+
result = await handleSetupCmd();
|
|
2814
3603
|
break;
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
// Route to daemon ring buffer when daemon is alive
|
|
2818
|
-
const projRoot = findProjectRoot();
|
|
2819
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2820
|
-
const errorsOnly = hasFlag('--errors');
|
|
2821
|
-
const lastN = getArg('--last') ? parseInt(getArg('--last'), 10) : undefined;
|
|
2822
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2823
|
-
const resp = await daemonRequest(state, 'console-read', {
|
|
2824
|
-
errors: errorsOnly,
|
|
2825
|
-
last: lastN,
|
|
2826
|
-
});
|
|
2827
|
-
const data = resp.data;
|
|
2828
|
-
console.log(data.formatted);
|
|
2829
|
-
console.log(`\nTotal: ${data.total} | Errors: ${data.errorCount} | Warnings: ${data.warningCount}`);
|
|
2830
|
-
result = data;
|
|
3604
|
+
case 'console':
|
|
3605
|
+
result = await handleConsoleCmd();
|
|
2831
3606
|
break;
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
const prNum = getArg('--pr');
|
|
2835
|
-
if (!prNum) {
|
|
2836
|
-
console.error('[Sweetlink] Error: --pr <number> is required');
|
|
2837
|
-
process.exit(1);
|
|
2838
|
-
}
|
|
2839
|
-
const sessionDirArg = getArg('--session') ?? '.sweetlink';
|
|
2840
|
-
const latestSession = findLatestSessionDir(sessionDirArg);
|
|
2841
|
-
if (!latestSession) {
|
|
2842
|
-
console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
|
|
2843
|
-
process.exit(1);
|
|
2844
|
-
}
|
|
2845
|
-
const manifestPath = path.join(latestSession, 'sweetlink-session.json');
|
|
2846
|
-
if (!fs.existsSync(manifestPath)) {
|
|
2847
|
-
console.error(`[Sweetlink] No manifest found at ${manifestPath}`);
|
|
2848
|
-
process.exit(1);
|
|
2849
|
-
}
|
|
2850
|
-
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
2851
|
-
try {
|
|
2852
|
-
const { commentUrl } = await uploadEvidence(manifestData, latestSession, parseInt(prNum, 10), { repo: getArg('--repo') ?? undefined });
|
|
2853
|
-
console.log(`[Sweetlink] Evidence posted: ${commentUrl}`);
|
|
2854
|
-
result = { commentUrl };
|
|
2855
|
-
}
|
|
2856
|
-
catch (error) {
|
|
2857
|
-
console.error('[Sweetlink] Failed to upload evidence:', error instanceof Error ? error.message : error);
|
|
2858
|
-
process.exit(1);
|
|
2859
|
-
}
|
|
3607
|
+
case 'proof':
|
|
3608
|
+
result = await handleProofCmd();
|
|
2860
3609
|
break;
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
const projRoot = findProjectRoot();
|
|
2864
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2865
|
-
const subcommand = args[1];
|
|
2866
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2867
|
-
if (subcommand === 'start') {
|
|
2868
|
-
const params = {};
|
|
2869
|
-
const label = getArg('--label');
|
|
2870
|
-
const viewport = getArg('--viewport');
|
|
2871
|
-
const storageState = getArg('--storage-state');
|
|
2872
|
-
if (label)
|
|
2873
|
-
params.label = label;
|
|
2874
|
-
if (viewport)
|
|
2875
|
-
params.viewport = viewport;
|
|
2876
|
-
if (storageState)
|
|
2877
|
-
params.storageState = storageState;
|
|
2878
|
-
if (hasFlag('--trace'))
|
|
2879
|
-
params.trace = true;
|
|
2880
|
-
const resp = await daemonRequest(state, 'record-start', params);
|
|
2881
|
-
const data = resp.data;
|
|
2882
|
-
console.log(`[Sweetlink] Recording started: ${data.sessionId}` +
|
|
2883
|
-
(data.label ? ` (${data.label})` : ''));
|
|
2884
|
-
result = data;
|
|
2885
|
-
}
|
|
2886
|
-
else if (subcommand === 'stop') {
|
|
2887
|
-
const resp = await daemonRequest(state, 'record-stop');
|
|
2888
|
-
const data = resp.data;
|
|
2889
|
-
const m = data.manifest;
|
|
2890
|
-
console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
|
|
2891
|
-
console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
|
|
2892
|
-
// Auto-open the viewer (cross-platform; --no-open to suppress)
|
|
2893
|
-
if (data.viewerPath && !hasFlag('--no-open')) {
|
|
2894
|
-
console.log(` Viewer: ${data.viewerPath}`);
|
|
2895
|
-
openInBrowser(data.viewerPath);
|
|
2896
|
-
console.log(` Opened in browser.`);
|
|
2897
|
-
}
|
|
2898
|
-
else if (data.viewerPath) {
|
|
2899
|
-
console.log(` Viewer: ${data.viewerPath}`);
|
|
2900
|
-
}
|
|
2901
|
-
result = data;
|
|
2902
|
-
}
|
|
2903
|
-
else if (subcommand === 'exec') {
|
|
2904
|
-
// record exec "click @e2; fill @e3 hello world; click @e5"
|
|
2905
|
-
// Runs a semicolon-separated DSL inside a fresh recording, then
|
|
2906
|
-
// auto-stops. Each step is one of:
|
|
2907
|
-
// click <selector|@ref>
|
|
2908
|
-
// fill <@ref> <value> (rest of line after ref = value)
|
|
2909
|
-
// press <key>
|
|
2910
|
-
// sleep <ms>
|
|
2911
|
-
// Strip known --flag value pairs from positional args before
|
|
2912
|
-
// joining what remains as the script body.
|
|
2913
|
-
const flagsWithValues = new Set(['--url', '--label', '--viewport', '--storage-state']);
|
|
2914
|
-
const positional = [];
|
|
2915
|
-
for (let i = 2; i < args.length; i++) {
|
|
2916
|
-
const a = args[i];
|
|
2917
|
-
if (a.startsWith('--')) {
|
|
2918
|
-
if (flagsWithValues.has(a))
|
|
2919
|
-
i++; // skip its value
|
|
2920
|
-
continue;
|
|
2921
|
-
}
|
|
2922
|
-
positional.push(a);
|
|
2923
|
-
}
|
|
2924
|
-
const script = positional.join(' ').trim();
|
|
2925
|
-
if (!script) {
|
|
2926
|
-
console.error('[Sweetlink] Error: record exec requires a script. Example: `record exec "click @e2; fill @e3 hello"`');
|
|
2927
|
-
process.exit(1);
|
|
2928
|
-
}
|
|
2929
|
-
const label = getArg('--label');
|
|
2930
|
-
const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
|
|
2931
|
-
const startData = startResp.data;
|
|
2932
|
-
console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
|
|
2933
|
-
const steps = script
|
|
2934
|
-
.split(';')
|
|
2935
|
-
.map((s) => s.trim())
|
|
2936
|
-
.filter(Boolean);
|
|
2937
|
-
// Snapshot once up-front so refs resolve.
|
|
2938
|
-
await daemonRequest(state, 'snapshot', { interactive: true });
|
|
2939
|
-
for (const step of steps) {
|
|
2940
|
-
const [verb, ...rest] = step.split(/\s+/);
|
|
2941
|
-
try {
|
|
2942
|
-
if (verb === 'click') {
|
|
2943
|
-
const target = rest[0];
|
|
2944
|
-
if (!target)
|
|
2945
|
-
throw new Error('click needs a target');
|
|
2946
|
-
if (/^@e\d+$/.test(target)) {
|
|
2947
|
-
await daemonRequest(state, 'click-ref', { ref: target });
|
|
2948
|
-
}
|
|
2949
|
-
else {
|
|
2950
|
-
await daemonRequest(state, 'click-css', { selector: target });
|
|
2951
|
-
}
|
|
2952
|
-
console.log(` · click ${target}`);
|
|
2953
|
-
}
|
|
2954
|
-
else if (verb === 'fill') {
|
|
2955
|
-
const ref = rest[0];
|
|
2956
|
-
const value = rest.slice(1).join(' ');
|
|
2957
|
-
if (!ref || !/^@e\d+$/.test(ref))
|
|
2958
|
-
throw new Error('fill needs a @ref and a value');
|
|
2959
|
-
await daemonRequest(state, 'fill-ref', { ref, value });
|
|
2960
|
-
console.log(` · fill ${ref} = "${value}"`);
|
|
2961
|
-
}
|
|
2962
|
-
else if (verb === 'press') {
|
|
2963
|
-
const key = rest[0];
|
|
2964
|
-
if (!key)
|
|
2965
|
-
throw new Error('press needs a key');
|
|
2966
|
-
await daemonRequest(state, 'press-key', { key });
|
|
2967
|
-
console.log(` · press ${key}`);
|
|
2968
|
-
}
|
|
2969
|
-
else if (verb === 'sleep') {
|
|
2970
|
-
const ms = parseInt(rest[0] ?? '0', 10);
|
|
2971
|
-
await new Promise((r) => setTimeout(r, ms));
|
|
2972
|
-
console.log(` · sleep ${ms}ms`);
|
|
2973
|
-
}
|
|
2974
|
-
else {
|
|
2975
|
-
throw new Error(`Unknown verb '${verb}'. Allowed: click, fill, press, sleep.`);
|
|
2976
|
-
}
|
|
2977
|
-
}
|
|
2978
|
-
catch (err) {
|
|
2979
|
-
console.error(` ✗ step "${step}" failed: ${err instanceof Error ? err.message : err}`);
|
|
2980
|
-
// Continue to record-stop so the partial recording is preserved.
|
|
2981
|
-
}
|
|
2982
|
-
}
|
|
2983
|
-
const stopResp = await daemonRequest(state, 'record-stop');
|
|
2984
|
-
const stopData = stopResp.data;
|
|
2985
|
-
console.log(`[Sweetlink] Done: ${stopData.manifest.commands.length} actions in ` +
|
|
2986
|
-
`${stopData.manifest.duration.toFixed(1)}s${stopData.viewerPath ? ` · ${stopData.viewerPath}` : ''}`);
|
|
2987
|
-
result = stopData;
|
|
2988
|
-
}
|
|
2989
|
-
else if (subcommand === 'pause') {
|
|
2990
|
-
const resp = await daemonRequest(state, 'record-pause');
|
|
2991
|
-
console.log('[Sweetlink] Recording paused. Use `record resume` to continue.');
|
|
2992
|
-
result = resp.data;
|
|
2993
|
-
}
|
|
2994
|
-
else if (subcommand === 'resume') {
|
|
2995
|
-
const resp = await daemonRequest(state, 'record-resume');
|
|
2996
|
-
const d = resp.data;
|
|
2997
|
-
console.log(`[Sweetlink] Recording resumed. Paused for ${(d.pausedDurationMs / 1000).toFixed(1)}s.`);
|
|
2998
|
-
result = resp.data;
|
|
2999
|
-
}
|
|
3000
|
-
else {
|
|
3001
|
-
const resp = await daemonRequest(state, 'record-status');
|
|
3002
|
-
const data = resp.data;
|
|
3003
|
-
if (data.recording) {
|
|
3004
|
-
console.log(`[Sweetlink] Recording in progress: ${data.sessionId} (${Math.round(data.duration ?? 0)}s, ${data.actionCount} actions)`);
|
|
3005
|
-
}
|
|
3006
|
-
else {
|
|
3007
|
-
console.log('[Sweetlink] No recording in progress.');
|
|
3008
|
-
}
|
|
3009
|
-
result = data;
|
|
3010
|
-
}
|
|
3610
|
+
case 'record':
|
|
3611
|
+
result = await handleRecordCmd();
|
|
3011
3612
|
break;
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
const sessionDirArg = getArg('--session') ?? '.sweetlink';
|
|
3015
|
-
if (!fs.existsSync(sessionDirArg)) {
|
|
3016
|
-
console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
|
|
3017
|
-
process.exit(1);
|
|
3018
|
-
}
|
|
3019
|
-
const reportSessionDir = findLatestSessionDir(sessionDirArg);
|
|
3020
|
-
if (!reportSessionDir) {
|
|
3021
|
-
console.error('[Sweetlink] No session found. Run `record start` and `record stop` first.');
|
|
3022
|
-
process.exit(1);
|
|
3023
|
-
}
|
|
3024
|
-
if (hasFlag('--clipboard')) {
|
|
3025
|
-
// Copy SUMMARY.md to clipboard
|
|
3026
|
-
const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
|
|
3027
|
-
if (!fs.existsSync(summaryPath)) {
|
|
3028
|
-
console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
|
|
3029
|
-
process.exit(1);
|
|
3030
|
-
}
|
|
3031
|
-
const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
|
|
3032
|
-
const { execFileSync } = await import('child_process');
|
|
3033
|
-
const clipCmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip';
|
|
3034
|
-
const clipArgs = process.platform === 'darwin' ? [] : ['-selection', 'clipboard'];
|
|
3035
|
-
try {
|
|
3036
|
-
execFileSync(clipCmd, clipArgs, { input: summaryContent });
|
|
3037
|
-
console.log('[Sweetlink] SUMMARY.md copied to clipboard.');
|
|
3038
|
-
}
|
|
3039
|
-
catch (err) {
|
|
3040
|
-
console.error(`[Sweetlink] Failed to copy to clipboard (${clipCmd}):`, err instanceof Error ? err.message : err);
|
|
3041
|
-
process.exit(1);
|
|
3042
|
-
}
|
|
3043
|
-
result = { mode: 'clipboard', session: path.basename(reportSessionDir) };
|
|
3044
|
-
}
|
|
3045
|
-
else if (hasFlag('--serve')) {
|
|
3046
|
-
// Serve viewer.html on a random port
|
|
3047
|
-
const viewerPath = path.join(reportSessionDir, 'viewer.html');
|
|
3048
|
-
if (!fs.existsSync(viewerPath)) {
|
|
3049
|
-
console.error(`[Sweetlink] viewer.html not found at ${viewerPath}`);
|
|
3050
|
-
process.exit(1);
|
|
3051
|
-
}
|
|
3052
|
-
const viewerContent = fs.readFileSync(viewerPath, 'utf-8');
|
|
3053
|
-
const http = await import('http');
|
|
3054
|
-
const port = 10000 + Math.floor(Math.random() * 50000);
|
|
3055
|
-
const server = http.createServer((_req, res) => {
|
|
3056
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
3057
|
-
res.end(viewerContent);
|
|
3058
|
-
});
|
|
3059
|
-
server.listen(port, '0.0.0.0', () => {
|
|
3060
|
-
const os = require('os');
|
|
3061
|
-
const nets = os.networkInterfaces();
|
|
3062
|
-
let lanIp = 'localhost';
|
|
3063
|
-
for (const name of Object.keys(nets)) {
|
|
3064
|
-
for (const net of nets[name]) {
|
|
3065
|
-
if (net.family === 'IPv4' && !net.internal) {
|
|
3066
|
-
lanIp = net.address;
|
|
3067
|
-
break;
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
if (lanIp !== 'localhost')
|
|
3071
|
-
break;
|
|
3072
|
-
}
|
|
3073
|
-
console.log(`[Sweetlink] Serving viewer at:`);
|
|
3074
|
-
console.log(` Local: http://localhost:${port}`);
|
|
3075
|
-
console.log(` Network: http://${lanIp}:${port}`);
|
|
3076
|
-
console.log(' Press Ctrl+C to stop.');
|
|
3077
|
-
});
|
|
3078
|
-
// Keep running until Ctrl+C
|
|
3079
|
-
await new Promise(() => { });
|
|
3080
|
-
}
|
|
3081
|
-
else if (getArg('--webhook')) {
|
|
3082
|
-
// POST session data to webhook
|
|
3083
|
-
const webhookUrl = getArg('--webhook');
|
|
3084
|
-
const manifestPath = path.join(reportSessionDir, 'sweetlink-session.json');
|
|
3085
|
-
const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
|
|
3086
|
-
if (!fs.existsSync(manifestPath)) {
|
|
3087
|
-
console.error(`[Sweetlink] Manifest not found at ${manifestPath}`);
|
|
3088
|
-
process.exit(1);
|
|
3089
|
-
}
|
|
3090
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
3091
|
-
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
|
|
3092
|
-
const payload = {
|
|
3093
|
-
summary,
|
|
3094
|
-
manifest,
|
|
3095
|
-
};
|
|
3096
|
-
// Include viewer HTML for Slack/Discord webhooks
|
|
3097
|
-
if (/slack|discord/i.test(webhookUrl)) {
|
|
3098
|
-
const viewerPath = path.join(reportSessionDir, 'viewer.html');
|
|
3099
|
-
if (fs.existsSync(viewerPath)) {
|
|
3100
|
-
payload.viewerHtml = fs.readFileSync(viewerPath, 'utf-8');
|
|
3101
|
-
}
|
|
3102
|
-
}
|
|
3103
|
-
const body = JSON.stringify(payload);
|
|
3104
|
-
const res = await fetch(webhookUrl, {
|
|
3105
|
-
method: 'POST',
|
|
3106
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3107
|
-
body,
|
|
3108
|
-
});
|
|
3109
|
-
if (res.ok) {
|
|
3110
|
-
console.log(`[Sweetlink] Report posted to ${webhookUrl} (${res.status})`);
|
|
3111
|
-
}
|
|
3112
|
-
else {
|
|
3113
|
-
console.error(`[Sweetlink] Webhook failed: ${res.status} ${res.statusText}`);
|
|
3114
|
-
process.exit(1);
|
|
3115
|
-
}
|
|
3116
|
-
result = { mode: 'webhook', url: webhookUrl, status: res.status };
|
|
3117
|
-
}
|
|
3118
|
-
else {
|
|
3119
|
-
// Default: print SUMMARY.md to stdout
|
|
3120
|
-
const summaryPath = path.join(reportSessionDir, 'SUMMARY.md');
|
|
3121
|
-
if (!fs.existsSync(summaryPath)) {
|
|
3122
|
-
console.error(`[Sweetlink] SUMMARY.md not found at ${summaryPath}`);
|
|
3123
|
-
process.exit(1);
|
|
3124
|
-
}
|
|
3125
|
-
const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
|
|
3126
|
-
process.stdout.write(summaryContent);
|
|
3127
|
-
result = { mode: 'stdout', session: path.basename(reportSessionDir) };
|
|
3128
|
-
}
|
|
3613
|
+
case 'report':
|
|
3614
|
+
result = await handleReportCmd();
|
|
3129
3615
|
break;
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
// Record iOS Simulator or Android Emulator screen while running a command.
|
|
3133
|
-
// Example: sweetlink sim ios "fastlane scan" --device "iPhone 15"
|
|
3134
|
-
const platform = args[1];
|
|
3135
|
-
if (platform !== 'ios' && platform !== 'android') {
|
|
3136
|
-
console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
|
|
3137
|
-
process.exit(1);
|
|
3138
|
-
}
|
|
3139
|
-
const flagsWithValues = new Set([
|
|
3140
|
-
'--output',
|
|
3141
|
-
'--label',
|
|
3142
|
-
'--device',
|
|
3143
|
-
'--time-limit',
|
|
3144
|
-
'--app',
|
|
3145
|
-
'--run',
|
|
3146
|
-
]);
|
|
3147
|
-
const positional = [];
|
|
3148
|
-
for (let i = 2; i < args.length; i++) {
|
|
3149
|
-
const a = args[i];
|
|
3150
|
-
if (a.startsWith('--')) {
|
|
3151
|
-
if (flagsWithValues.has(a))
|
|
3152
|
-
i++;
|
|
3153
|
-
continue;
|
|
3154
|
-
}
|
|
3155
|
-
positional.push(a);
|
|
3156
|
-
}
|
|
3157
|
-
const command = positional.join(' ').trim();
|
|
3158
|
-
if (!command) {
|
|
3159
|
-
console.error(`[Sweetlink] Error: sim ${platform} requires a command. Example: sweetlink sim ${platform} "fastlane scan"`);
|
|
3160
|
-
process.exit(1);
|
|
3161
|
-
}
|
|
3162
|
-
const label = getArg('--label');
|
|
3163
|
-
const labelSlug = label
|
|
3164
|
-
? label
|
|
3165
|
-
.replace(/[^a-z0-9]/gi, '-')
|
|
3166
|
-
.toLowerCase()
|
|
3167
|
-
.slice(0, 40)
|
|
3168
|
-
: `sim-${platform}`;
|
|
3169
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
3170
|
-
const { runSlot: simRunSlot } = await import('../runs.js');
|
|
3171
|
-
const defaultDir = simRunSlot({
|
|
3172
|
-
baseDir: findProjectRoot(),
|
|
3173
|
-
app: getArg('--app'),
|
|
3174
|
-
run: getArg('--run'),
|
|
3175
|
-
kind: 'sim',
|
|
3176
|
-
});
|
|
3177
|
-
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
|
|
3178
|
-
ensureDir(output);
|
|
3179
|
-
const device = getArg('--device');
|
|
3180
|
-
console.log(`[Sweetlink] Recording ${platform} simulator: ${command}`);
|
|
3181
|
-
let recResult;
|
|
3182
|
-
if (platform === 'ios') {
|
|
3183
|
-
const { recordIosSimulator } = await import('../simulator/ios.js');
|
|
3184
|
-
recResult = await recordIosSimulator({ command, output, device });
|
|
3185
|
-
}
|
|
3186
|
-
else {
|
|
3187
|
-
const { recordAndroidEmulator } = await import('../simulator/android.js');
|
|
3188
|
-
const tl = getArg('--time-limit');
|
|
3189
|
-
recResult = await recordAndroidEmulator({
|
|
3190
|
-
command,
|
|
3191
|
-
output,
|
|
3192
|
-
device,
|
|
3193
|
-
timeLimit: tl ? parseInt(tl, 10) : undefined,
|
|
3194
|
-
overlays: !hasFlag('--no-overlays'),
|
|
3195
|
-
});
|
|
3196
|
-
}
|
|
3197
|
-
let sizeKb = '?';
|
|
3198
|
-
try {
|
|
3199
|
-
sizeKb = String(Math.round(fs.statSync(output).size / 1024));
|
|
3200
|
-
}
|
|
3201
|
-
catch {
|
|
3202
|
-
/* file may not exist if recordingClosed is false */
|
|
3203
|
-
}
|
|
3204
|
-
const tapSuffix = (recResult.tapCount ?? 0) > 0
|
|
3205
|
-
? ` · ${recResult.tapCount} taps${recResult.overlaysApplied ? ' (overlaid)' : ' (sidecar only — install ffmpeg for overlays)'}`
|
|
3206
|
-
: '';
|
|
3207
|
-
console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
|
|
3208
|
-
`${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
|
|
3209
|
-
tapSuffix +
|
|
3210
|
-
(recResult.recordingClosed
|
|
3211
|
-
? ''
|
|
3212
|
-
: ' (recording was force-killed; mp4 may be incomplete)'));
|
|
3213
|
-
result = {
|
|
3214
|
-
path: output,
|
|
3215
|
-
device: recResult.device,
|
|
3216
|
-
durationSec: recResult.durationSec,
|
|
3217
|
-
exitCode: recResult.exitCode,
|
|
3218
|
-
recordingClosed: recResult.recordingClosed,
|
|
3219
|
-
tapCount: recResult.tapCount,
|
|
3220
|
-
tapsJsonPath: recResult.tapsJsonPath,
|
|
3221
|
-
overlaysApplied: recResult.overlaysApplied,
|
|
3222
|
-
};
|
|
3223
|
-
if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
|
|
3224
|
-
process.exit(recResult.exitCode);
|
|
3225
|
-
}
|
|
3616
|
+
case 'sim':
|
|
3617
|
+
result = await handleSimCmd();
|
|
3226
3618
|
break;
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
// Record a shell command's stdout/stderr into asciicast v2 + HTML player.
|
|
3230
|
-
// Example: sweetlink term "pytest -v" --label api-tests --app my-app
|
|
3231
|
-
const flagsWithValues = new Set([
|
|
3232
|
-
'--output',
|
|
3233
|
-
'--label',
|
|
3234
|
-
'--shell',
|
|
3235
|
-
'--cols',
|
|
3236
|
-
'--rows',
|
|
3237
|
-
'--app',
|
|
3238
|
-
'--run',
|
|
3239
|
-
]);
|
|
3240
|
-
const positional = [];
|
|
3241
|
-
for (let i = 1; i < args.length; i++) {
|
|
3242
|
-
const a = args[i];
|
|
3243
|
-
if (a.startsWith('--')) {
|
|
3244
|
-
if (flagsWithValues.has(a))
|
|
3245
|
-
i++;
|
|
3246
|
-
continue;
|
|
3247
|
-
}
|
|
3248
|
-
positional.push(a);
|
|
3249
|
-
}
|
|
3250
|
-
const command = positional.join(' ').trim();
|
|
3251
|
-
if (!command) {
|
|
3252
|
-
console.error('[Sweetlink] Error: term requires a command. Example: sweetlink term "pytest tests/"');
|
|
3253
|
-
process.exit(1);
|
|
3254
|
-
}
|
|
3255
|
-
const label = getArg('--label');
|
|
3256
|
-
const labelSlug = label
|
|
3257
|
-
? label
|
|
3258
|
-
.replace(/[^a-z0-9]/gi, '-')
|
|
3259
|
-
.toLowerCase()
|
|
3260
|
-
.slice(0, 40)
|
|
3261
|
-
: 'term';
|
|
3262
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
3263
|
-
const { runSlot } = await import('../runs.js');
|
|
3264
|
-
const defaultDir = runSlot({
|
|
3265
|
-
baseDir: findProjectRoot(),
|
|
3266
|
-
app: getArg('--app'),
|
|
3267
|
-
run: getArg('--run'),
|
|
3268
|
-
kind: 'term',
|
|
3269
|
-
});
|
|
3270
|
-
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
|
|
3271
|
-
ensureDir(output);
|
|
3272
|
-
console.log(`[Sweetlink] Recording terminal: ${command}`);
|
|
3273
|
-
const { captureTerminal } = await import('../term/recorder.js');
|
|
3274
|
-
const { generatePlayer } = await import('../term/player.js');
|
|
3275
|
-
const cap = await captureTerminal({
|
|
3276
|
-
command,
|
|
3277
|
-
output,
|
|
3278
|
-
label,
|
|
3279
|
-
shell: getArg('--shell'),
|
|
3280
|
-
cols: getArg('--cols') ? parseInt(getArg('--cols'), 10) : undefined,
|
|
3281
|
-
rows: getArg('--rows') ? parseInt(getArg('--rows'), 10) : undefined,
|
|
3282
|
-
});
|
|
3283
|
-
const playerPath = await generatePlayer({ castPath: output });
|
|
3284
|
-
console.log(`[Sweetlink] ✓ ${getRelativePath(output)} · ${cap.durationSec.toFixed(1)}s · ` +
|
|
3285
|
-
`${cap.events} events · ${(cap.bytes / 1024).toFixed(0)}KB · exit=${cap.exitCode}`);
|
|
3286
|
-
console.log(`[Sweetlink] ▶ ${getRelativePath(playerPath)}`);
|
|
3287
|
-
result = {
|
|
3288
|
-
castPath: output,
|
|
3289
|
-
playerPath,
|
|
3290
|
-
durationSec: cap.durationSec,
|
|
3291
|
-
bytes: cap.bytes,
|
|
3292
|
-
events: cap.events,
|
|
3293
|
-
exitCode: cap.exitCode,
|
|
3294
|
-
};
|
|
3295
|
-
// Propagate the recorded command's exit code by default so CI fails
|
|
3296
|
-
// when the wrapped tests fail.
|
|
3297
|
-
if (cap.exitCode !== 0 && !hasFlag('--ignore-exit')) {
|
|
3298
|
-
process.exit(cap.exitCode);
|
|
3299
|
-
}
|
|
3619
|
+
case 'term':
|
|
3620
|
+
result = await handleTermCmd();
|
|
3300
3621
|
break;
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
const sub = args[1];
|
|
3304
|
-
const projRoot = findProjectRoot();
|
|
3305
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3306
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
3307
|
-
const resp = await daemonRequest(state, 'sessions-list');
|
|
3308
|
-
const data = resp.data;
|
|
3309
|
-
if (sub === 'list' || !sub) {
|
|
3310
|
-
if (data.sessions.length === 0) {
|
|
3311
|
-
console.log('[Sweetlink] No sessions found.');
|
|
3312
|
-
}
|
|
3313
|
-
else {
|
|
3314
|
-
console.log(`[Sweetlink] ${data.sessions.length} session(s):\n`);
|
|
3315
|
-
for (const s of data.sessions) {
|
|
3316
|
-
const errTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
|
|
3317
|
-
const errBadge = errTotal > 0 ? ` · ${errTotal} err` : '';
|
|
3318
|
-
const labelTxt = s.label ? ` [${s.label}]` : '';
|
|
3319
|
-
const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
|
|
3320
|
-
console.log(` ${s.sessionId}${labelTxt} · ${dur} · ${s.actionCount} actions${errBadge}`);
|
|
3321
|
-
}
|
|
3322
|
-
if (data.indexPath)
|
|
3323
|
-
console.log(`\n Index: ${data.indexPath}`);
|
|
3324
|
-
}
|
|
3325
|
-
result = { sessions: data.sessions };
|
|
3326
|
-
}
|
|
3327
|
-
else if (sub === 'diff') {
|
|
3328
|
-
// sessions diff <a> <b> — compare two recordings
|
|
3329
|
-
const [aId, bId] = [args[2], args[3]];
|
|
3330
|
-
if (!aId || !bId) {
|
|
3331
|
-
console.error('[Sweetlink] Usage: sessions diff <session-A> <session-B>');
|
|
3332
|
-
process.exit(1);
|
|
3333
|
-
}
|
|
3334
|
-
const findSession = (id) => data.sessions.find((s) => s.sessionId === id || s.sessionId.endsWith(id));
|
|
3335
|
-
const a = findSession(aId);
|
|
3336
|
-
const b = findSession(bId);
|
|
3337
|
-
if (!a || !b) {
|
|
3338
|
-
console.error(`[Sweetlink] Could not find session: ${!a ? aId : bId}`);
|
|
3339
|
-
process.exit(1);
|
|
3340
|
-
}
|
|
3341
|
-
const aManifest = JSON.parse(fs.readFileSync(a.manifestPath, 'utf-8'));
|
|
3342
|
-
const bManifest = JSON.parse(fs.readFileSync(b.manifestPath, 'utf-8'));
|
|
3343
|
-
const aActions = aManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
3344
|
-
const bActions = bManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
3345
|
-
console.log(`\n${a.sessionId}${a.label ? ` "${a.label}"` : ''} vs ${b.sessionId}${b.label ? ` "${b.label}"` : ''}\n`);
|
|
3346
|
-
console.log(`Duration: ${a.duration?.toFixed(1)}s vs ${b.duration?.toFixed(1)}s`);
|
|
3347
|
-
console.log(`Actions: ${a.actionCount} vs ${b.actionCount}`);
|
|
3348
|
-
const aErr = a.errors ? a.errors.console + a.errors.network + a.errors.server : 0;
|
|
3349
|
-
const bErr = b.errors ? b.errors.console + b.errors.network + b.errors.server : 0;
|
|
3350
|
-
console.log(`Errors: ${aErr} vs ${bErr}`);
|
|
3351
|
-
// Action diff (myers-style "added/removed" by line)
|
|
3352
|
-
const inA = new Set(aActions);
|
|
3353
|
-
const inB = new Set(bActions);
|
|
3354
|
-
const added = bActions.filter((x) => !inA.has(x));
|
|
3355
|
-
const removed = aActions.filter((x) => !inB.has(x));
|
|
3356
|
-
if (removed.length) {
|
|
3357
|
-
console.log(`\nOnly in ${a.sessionId}:`);
|
|
3358
|
-
removed.forEach((s) => console.log(` - ${s}`));
|
|
3359
|
-
}
|
|
3360
|
-
if (added.length) {
|
|
3361
|
-
console.log(`\nOnly in ${b.sessionId}:`);
|
|
3362
|
-
added.forEach((s) => console.log(` + ${s}`));
|
|
3363
|
-
}
|
|
3364
|
-
if (!added.length && !removed.length) {
|
|
3365
|
-
console.log('\nAction sequences are identical.');
|
|
3366
|
-
}
|
|
3367
|
-
result = {
|
|
3368
|
-
a: {
|
|
3369
|
-
id: a.sessionId,
|
|
3370
|
-
label: a.label,
|
|
3371
|
-
duration: a.duration,
|
|
3372
|
-
actions: a.actionCount,
|
|
3373
|
-
errors: aErr,
|
|
3374
|
-
},
|
|
3375
|
-
b: {
|
|
3376
|
-
id: b.sessionId,
|
|
3377
|
-
label: b.label,
|
|
3378
|
-
duration: b.duration,
|
|
3379
|
-
actions: b.actionCount,
|
|
3380
|
-
errors: bErr,
|
|
3381
|
-
},
|
|
3382
|
-
added,
|
|
3383
|
-
removed,
|
|
3384
|
-
};
|
|
3385
|
-
}
|
|
3386
|
-
else if (sub === 'open') {
|
|
3387
|
-
// Open the index.html in the browser
|
|
3388
|
-
if (data.indexPath) {
|
|
3389
|
-
openInBrowser(data.indexPath);
|
|
3390
|
-
console.log(`[Sweetlink] Opened ${data.indexPath}`);
|
|
3391
|
-
}
|
|
3392
|
-
result = { indexPath: data.indexPath };
|
|
3393
|
-
}
|
|
3394
|
-
else {
|
|
3395
|
-
console.error(`[Sweetlink] Unknown sessions subcommand: ${sub}. Try: list, open`);
|
|
3396
|
-
process.exit(1);
|
|
3397
|
-
}
|
|
3622
|
+
case 'sessions':
|
|
3623
|
+
result = await handleSessionsCmd();
|
|
3398
3624
|
break;
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
const sub = args[1];
|
|
3402
|
-
const projRoot = findProjectRoot();
|
|
3403
|
-
const demoDir = getArg('--output') ?? path.join(projRoot, '.sweetlink', 'demo');
|
|
3404
|
-
const stateFile = path.join(demoDir, 'demo-state.json');
|
|
3405
|
-
// Lazy import demo module
|
|
3406
|
-
const demoMod = await import('../daemon/demo.js');
|
|
3407
|
-
if (sub === 'init') {
|
|
3408
|
-
const title = args[2];
|
|
3409
|
-
if (!title) {
|
|
3410
|
-
console.error('[Sweetlink] Error: demo init requires a title');
|
|
3411
|
-
process.exit(1);
|
|
3412
|
-
}
|
|
3413
|
-
const demoState = await demoMod.initDemo(title, demoDir, { url: getArg('--url') });
|
|
3414
|
-
await demoMod.writeDemo(demoState);
|
|
3415
|
-
console.log(`[Sweetlink] Demo initialized: ${demoState.filePath}`);
|
|
3416
|
-
result = { filePath: demoState.filePath };
|
|
3417
|
-
}
|
|
3418
|
-
else if (sub === 'note') {
|
|
3419
|
-
const text = args.slice(2).join(' ');
|
|
3420
|
-
if (!text) {
|
|
3421
|
-
console.error('[Sweetlink] Error: demo note requires text');
|
|
3422
|
-
process.exit(1);
|
|
3423
|
-
}
|
|
3424
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3425
|
-
const updated = demoMod.addNote(state, text);
|
|
3426
|
-
await demoMod.writeDemo(updated);
|
|
3427
|
-
console.log(`[Sweetlink] Note added (${updated.sections.length} sections)`);
|
|
3428
|
-
result = { sections: updated.sections.length };
|
|
3429
|
-
}
|
|
3430
|
-
else if (sub === 'exec') {
|
|
3431
|
-
const cmd = args.slice(2).join(' ');
|
|
3432
|
-
if (!cmd) {
|
|
3433
|
-
console.error('[Sweetlink] Error: demo exec requires a command');
|
|
3434
|
-
process.exit(1);
|
|
3435
|
-
}
|
|
3436
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3437
|
-
const updated = await demoMod.addExec(state, cmd, []);
|
|
3438
|
-
await demoMod.writeDemo(updated);
|
|
3439
|
-
const lastSection = updated.sections[updated.sections.length - 1];
|
|
3440
|
-
console.log(`[Sweetlink] Exec added: ${cmd} (exit ${lastSection.exitCode ?? 0})`);
|
|
3441
|
-
result = { sections: updated.sections.length, exitCode: lastSection.exitCode };
|
|
3442
|
-
}
|
|
3443
|
-
else if (sub === 'screenshot') {
|
|
3444
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3445
|
-
const caption = getArg('--caption') ?? 'Screenshot';
|
|
3446
|
-
const daemonState = await ensureDaemon(projRoot, targetUrl);
|
|
3447
|
-
const resp = await daemonRequest(daemonState, 'screenshot', {});
|
|
3448
|
-
const data = resp.data;
|
|
3449
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3450
|
-
const updated = await demoMod.addScreenshot(state, Buffer.from(data.screenshot, 'base64'), caption);
|
|
3451
|
-
await demoMod.writeDemo(updated);
|
|
3452
|
-
console.log(`[Sweetlink] Screenshot added: ${caption}`);
|
|
3453
|
-
result = { sections: updated.sections.length };
|
|
3454
|
-
}
|
|
3455
|
-
else if (sub === 'snapshot') {
|
|
3456
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3457
|
-
const daemonState = await ensureDaemon(projRoot, targetUrl);
|
|
3458
|
-
const resp = await daemonRequest(daemonState, 'snapshot', { interactive: true });
|
|
3459
|
-
const data = resp.data;
|
|
3460
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3461
|
-
const updated = demoMod.addSnapshot(state, data.tree);
|
|
3462
|
-
await demoMod.writeDemo(updated);
|
|
3463
|
-
console.log(`[Sweetlink] Snapshot added (${updated.sections.length} sections)`);
|
|
3464
|
-
result = { sections: updated.sections.length };
|
|
3465
|
-
}
|
|
3466
|
-
else if (sub === 'pop') {
|
|
3467
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3468
|
-
const updated = demoMod.popSection(state);
|
|
3469
|
-
await demoMod.writeDemo(updated);
|
|
3470
|
-
console.log(`[Sweetlink] Last section removed (${updated.sections.length} remaining)`);
|
|
3471
|
-
result = { sections: updated.sections.length };
|
|
3472
|
-
}
|
|
3473
|
-
else if (sub === 'verify') {
|
|
3474
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3475
|
-
const verifyResult = await demoMod.verifyDemo(state);
|
|
3476
|
-
if (verifyResult.passed) {
|
|
3477
|
-
console.log('[Sweetlink] Demo verified: all outputs match');
|
|
3478
|
-
}
|
|
3479
|
-
else {
|
|
3480
|
-
console.log(`[Sweetlink] Demo verification FAILED: ${verifyResult.failures.length} mismatch(es)`);
|
|
3481
|
-
for (const f of verifyResult.failures) {
|
|
3482
|
-
console.log(` Section ${f.index}: ${f.command}`);
|
|
3483
|
-
console.log(` Expected: ${f.expected.substring(0, 80)}...`);
|
|
3484
|
-
console.log(` Actual: ${f.actual.substring(0, 80)}...`);
|
|
3485
|
-
}
|
|
3486
|
-
}
|
|
3487
|
-
result = verifyResult;
|
|
3488
|
-
}
|
|
3489
|
-
else {
|
|
3490
|
-
// status
|
|
3491
|
-
if (fs.existsSync(stateFile)) {
|
|
3492
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
3493
|
-
console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
|
|
3494
|
-
console.log(` File: ${state.filePath}`);
|
|
3495
|
-
for (const s of state.sections) {
|
|
3496
|
-
const preview = s.type === 'note'
|
|
3497
|
-
? s.content.substring(0, 60)
|
|
3498
|
-
: s.type === 'exec'
|
|
3499
|
-
? `$ ${s.command}`
|
|
3500
|
-
: s.type === 'screenshot'
|
|
3501
|
-
? `[image] ${s.screenshotFile}`
|
|
3502
|
-
: '[snapshot]';
|
|
3503
|
-
console.log(` ${s.type.padEnd(12)} ${preview}`);
|
|
3504
|
-
}
|
|
3505
|
-
result = state;
|
|
3506
|
-
}
|
|
3507
|
-
else {
|
|
3508
|
-
console.log('[Sweetlink] No demo in progress. Run `demo init <title>` to start.');
|
|
3509
|
-
result = null;
|
|
3510
|
-
}
|
|
3511
|
-
}
|
|
3625
|
+
case 'demo':
|
|
3626
|
+
result = await handleDemoCmd();
|
|
3512
3627
|
break;
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
const subcommand = args[1];
|
|
3516
|
-
const projRoot = findProjectRoot();
|
|
3517
|
-
// Daemon state files are scoped by app port (`daemon-<port>.json`),
|
|
3518
|
-
// so honour --url for status/stop too — otherwise they look up the
|
|
3519
|
-
// un-suffixed `daemon.json` and miss the daemon that `start`
|
|
3520
|
-
// wrote with --url.
|
|
3521
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3522
|
-
const appPort = extractPort(targetUrl);
|
|
3523
|
-
if (subcommand === 'stop') {
|
|
3524
|
-
const stopped = await stopDaemon(projRoot, appPort);
|
|
3525
|
-
console.log(stopped ? '[Sweetlink] Daemon stopped.' : '[Sweetlink] No daemon running.');
|
|
3526
|
-
result = { running: false };
|
|
3527
|
-
}
|
|
3528
|
-
else if (subcommand === 'start') {
|
|
3529
|
-
const headedFlag = hasFlag('--headed');
|
|
3530
|
-
const state = await ensureDaemon(projRoot, targetUrl, { headed: headedFlag });
|
|
3531
|
-
console.log(`[Sweetlink] Daemon running on port ${state.port} (PID: ${state.pid})`);
|
|
3532
|
-
result = {
|
|
3533
|
-
running: true,
|
|
3534
|
-
pid: state.pid,
|
|
3535
|
-
port: state.port,
|
|
3536
|
-
url: state.url,
|
|
3537
|
-
};
|
|
3538
|
-
}
|
|
3539
|
-
else {
|
|
3540
|
-
// Default: status
|
|
3541
|
-
const status = await getDaemonStatus(projRoot, appPort);
|
|
3542
|
-
if (status.running) {
|
|
3543
|
-
console.log(`[Sweetlink] Daemon running: port=${status.port} pid=${status.pid} uptime=${status.uptime}s`);
|
|
3544
|
-
}
|
|
3545
|
-
else {
|
|
3546
|
-
console.log('[Sweetlink] No daemon running.');
|
|
3547
|
-
}
|
|
3548
|
-
result = status;
|
|
3549
|
-
}
|
|
3628
|
+
case 'daemon':
|
|
3629
|
+
result = await handleDaemonCmd();
|
|
3550
3630
|
break;
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
const fillTarget = getArg('--selector') ?? args[1];
|
|
3554
|
-
const fillValue = getArg('--value') ?? args[2];
|
|
3555
|
-
if (!fillTarget) {
|
|
3556
|
-
console.error('[Sweetlink] Error: fill requires a target (@ref or --selector)');
|
|
3557
|
-
process.exit(1);
|
|
3558
|
-
}
|
|
3559
|
-
if (fillValue === undefined) {
|
|
3560
|
-
console.error('[Sweetlink] Error: fill requires a value (--value or positional arg)');
|
|
3561
|
-
process.exit(1);
|
|
3562
|
-
}
|
|
3563
|
-
if (/^@e\d+$/.test(fillTarget)) {
|
|
3564
|
-
const projRoot = findProjectRoot();
|
|
3565
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3566
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
3567
|
-
await daemonRequest(state, 'fill-ref', { ref: fillTarget, value: fillValue });
|
|
3568
|
-
console.log(`[Sweetlink] Filled ${fillTarget} with "${fillValue}"`);
|
|
3569
|
-
result = { clicked: fillTarget, found: 1, index: 0 };
|
|
3570
|
-
}
|
|
3571
|
-
else {
|
|
3572
|
-
console.error('[Sweetlink] Error: fill currently only supports @e refs. Run `snapshot -i` first.');
|
|
3573
|
-
process.exit(1);
|
|
3574
|
-
}
|
|
3631
|
+
case 'fill':
|
|
3632
|
+
result = await handleFillCmd();
|
|
3575
3633
|
break;
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
const projRoot = findProjectRoot();
|
|
3579
|
-
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
3580
|
-
const interactive = hasFlag('-i') || hasFlag('--interactive');
|
|
3581
|
-
const doDiff = hasFlag('-D') || hasFlag('--diff');
|
|
3582
|
-
const doAnnotate = hasFlag('-a') || hasFlag('--annotate');
|
|
3583
|
-
const state = await ensureDaemon(projRoot, targetUrl);
|
|
3584
|
-
const resp = await daemonRequest(state, 'snapshot', {
|
|
3585
|
-
interactive,
|
|
3586
|
-
diff: doDiff,
|
|
3587
|
-
annotate: doAnnotate,
|
|
3588
|
-
});
|
|
3589
|
-
const data = resp.data;
|
|
3590
|
-
if (doDiff && data.diff) {
|
|
3591
|
-
console.log(data.diff);
|
|
3592
|
-
}
|
|
3593
|
-
else if (doAnnotate && data.screenshot) {
|
|
3594
|
-
const outputPath = getArg('--output') ?? getArg('-o') ?? 'annotated-snapshot.png';
|
|
3595
|
-
fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
|
|
3596
|
-
console.log(`[Sweetlink] Annotated screenshot saved: ${outputPath}`);
|
|
3597
|
-
}
|
|
3598
|
-
else {
|
|
3599
|
-
console.log(data.tree);
|
|
3600
|
-
}
|
|
3601
|
-
console.log(`\n${data.count} elements found`);
|
|
3602
|
-
result = {
|
|
3603
|
-
tree: data.tree,
|
|
3604
|
-
refs: data.refs,
|
|
3605
|
-
diff: data.diff,
|
|
3606
|
-
};
|
|
3634
|
+
case 'snapshot':
|
|
3635
|
+
result = await handleSnapshotCmd();
|
|
3607
3636
|
break;
|
|
3608
|
-
}
|
|
3609
3637
|
default:
|
|
3610
3638
|
console.error(`[Sweetlink] Unknown command: ${commandType}`);
|
|
3611
3639
|
console.log('Run "pnpm sweetlink --help" for usage information');
|