@ytspar/sweetlink 1.17.0 → 1.19.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/cli/sweetlink.js +431 -17
- package/dist/cli/sweetlink.js.map +1 -1
- package/dist/daemon/browser.d.ts +2 -0
- package/dist/daemon/browser.d.ts.map +1 -1
- package/dist/daemon/browser.js +5 -0
- package/dist/daemon/browser.js.map +1 -1
- package/dist/daemon/cursor.d.ts.map +1 -1
- package/dist/daemon/cursor.js +39 -8
- package/dist/daemon/cursor.js.map +1 -1
- package/dist/daemon/listeners.d.ts +6 -0
- package/dist/daemon/listeners.d.ts.map +1 -1
- package/dist/daemon/listeners.js +32 -1
- package/dist/daemon/listeners.js.map +1 -1
- package/dist/daemon/recording.d.ts +22 -2
- package/dist/daemon/recording.d.ts.map +1 -1
- package/dist/daemon/recording.js +79 -7
- package/dist/daemon/recording.js.map +1 -1
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +204 -12
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session.d.ts +2 -0
- package/dist/daemon/session.d.ts.map +1 -1
- package/dist/daemon/summary.d.ts.map +1 -1
- package/dist/daemon/summary.js +8 -4
- package/dist/daemon/summary.js.map +1 -1
- package/dist/daemon/types.d.ts +1 -1
- package/dist/daemon/types.d.ts.map +1 -1
- package/dist/daemon/types.js.map +1 -1
- package/dist/daemon/visualDiff.d.ts +2 -0
- package/dist/daemon/visualDiff.d.ts.map +1 -1
- package/dist/daemon/visualDiff.js +61 -2
- package/dist/daemon/visualDiff.js.map +1 -1
- package/dist/simulator/android.d.ts +35 -0
- package/dist/simulator/android.d.ts.map +1 -0
- package/dist/simulator/android.js +119 -0
- package/dist/simulator/android.js.map +1 -0
- package/dist/simulator/ios.d.ts +39 -0
- package/dist/simulator/ios.d.ts.map +1 -0
- package/dist/simulator/ios.js +115 -0
- package/dist/simulator/ios.js.map +1 -0
- package/dist/term/ansi.d.ts +37 -0
- package/dist/term/ansi.d.ts.map +1 -0
- package/dist/term/ansi.js +170 -0
- package/dist/term/ansi.js.map +1 -0
- package/dist/term/player.d.ts +25 -0
- package/dist/term/player.d.ts.map +1 -0
- package/dist/term/player.js +243 -0
- package/dist/term/player.js.map +1 -0
- package/dist/term/recorder.d.ts +33 -0
- package/dist/term/recorder.d.ts.map +1 -0
- package/dist/term/recorder.js +77 -0
- package/dist/term/recorder.js.map +1 -0
- package/package.json +1 -1
package/dist/cli/sweetlink.js
CHANGED
|
@@ -112,11 +112,24 @@ function getRelativePath(absolutePath) {
|
|
|
112
112
|
* Report screenshot success to console
|
|
113
113
|
*/
|
|
114
114
|
function reportScreenshotSuccess(outputPath, width, height, method, selector) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
// One compact line by default; the multi-line version is preserved
|
|
116
|
+
// behind SWEETLINK_VERBOSE=1 for log-scrapers and the curious.
|
|
117
|
+
let sizeKb = '';
|
|
118
|
+
try {
|
|
119
|
+
sizeKb = ` · ${Math.round(fs.statSync(outputPath).size / 1024)}KB`;
|
|
120
|
+
}
|
|
121
|
+
catch { /* file may have been moved or removed */ }
|
|
122
|
+
const selPart = selector ? ` · ${selector}` : '';
|
|
123
|
+
if (process.env.SWEETLINK_VERBOSE === '1') {
|
|
124
|
+
console.log(`[Sweetlink] ✓ Screenshot saved to: ${getRelativePath(outputPath)}`);
|
|
125
|
+
console.log(`[Sweetlink] Dimensions: ${width}x${height}`);
|
|
126
|
+
if (selector)
|
|
127
|
+
console.log(`[Sweetlink] Selector: ${selector}`);
|
|
128
|
+
console.log(`[Sweetlink] Method: ${method}`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
console.log(`[Sweetlink] ✓ Screenshot saved: ${getRelativePath(outputPath)} · ${width}x${height}${sizeKb}${selPart} · ${method}`);
|
|
132
|
+
}
|
|
120
133
|
}
|
|
121
134
|
let resolvedWsUrl = null;
|
|
122
135
|
const DEFAULT_WS_URL = process.env.SWEETLINK_WS_URL || 'ws://localhost:9223';
|
|
@@ -459,6 +472,7 @@ async function screenshot(options) {
|
|
|
459
472
|
fullPage: options.fullPage,
|
|
460
473
|
viewport: options.viewport,
|
|
461
474
|
padding: options.padding,
|
|
475
|
+
theme: options.theme,
|
|
462
476
|
});
|
|
463
477
|
const data = resp.data;
|
|
464
478
|
const outputPath = options.output || getDefaultScreenshotPath();
|
|
@@ -1852,11 +1866,62 @@ const COMMAND_HELP = {
|
|
|
1852
1866
|
stop Stop recording and generate session manifest
|
|
1853
1867
|
status Show recording status (default)
|
|
1854
1868
|
|
|
1869
|
+
Options for start:
|
|
1870
|
+
--label <text> Human-friendly label embedded in the manifest + SUMMARY title
|
|
1871
|
+
--viewport <preset|WxH> Recording viewport (default: 1512x982)
|
|
1872
|
+
|
|
1855
1873
|
Examples:
|
|
1856
|
-
pnpm sweetlink record start
|
|
1874
|
+
pnpm sweetlink record start --label "login flow"
|
|
1857
1875
|
pnpm sweetlink snapshot -i
|
|
1858
1876
|
pnpm sweetlink click @e3
|
|
1859
1877
|
pnpm sweetlink record stop`,
|
|
1878
|
+
sim: ` sim <ios|android> <command...>
|
|
1879
|
+
Record iOS Simulator or Android Emulator screen while running a command.
|
|
1880
|
+
Wraps \`xcrun simctl io booted recordVideo\` (iOS) or
|
|
1881
|
+
\`adb shell screenrecord\` (Android), writing an .mp4 of what was on
|
|
1882
|
+
screen during your XCUITest / Espresso / fastlane / appium run.
|
|
1883
|
+
|
|
1884
|
+
Options:
|
|
1885
|
+
--output <path> .mp4 path (default: .sweetlink/sim/<label>-<stamp>.mp4)
|
|
1886
|
+
--label <text> Embedded in filename
|
|
1887
|
+
--device <name|udid> Pick a specific simulator/emulator (default: first booted)
|
|
1888
|
+
--time-limit <sec> Android only — caps screen recording (max 180)
|
|
1889
|
+
--ignore-exit Don't propagate the recorded command's exit code
|
|
1890
|
+
|
|
1891
|
+
Requirements:
|
|
1892
|
+
iOS: Xcode + a booted Simulator (Simulator.app)
|
|
1893
|
+
Android: Android Platform Tools (\`adb\`) + a running emulator
|
|
1894
|
+
|
|
1895
|
+
Examples:
|
|
1896
|
+
pnpm sweetlink sim ios "fastlane scan" --device "iPhone 15"
|
|
1897
|
+
pnpm sweetlink sim android "./gradlew connectedAndroidTest"`,
|
|
1898
|
+
term: ` term <command...>
|
|
1899
|
+
Record a shell command's stdout/stderr into asciicast v2 + a self-contained
|
|
1900
|
+
HTML player. Captures real timing; the player has play/pause, 0.1×–4×
|
|
1901
|
+
speed, seek bar, and ANSI colour rendering.
|
|
1902
|
+
|
|
1903
|
+
Options:
|
|
1904
|
+
--output <path> .cast file path (default: .sweetlink/term/<label>-<stamp>.cast)
|
|
1905
|
+
--label <text> Label embedded in the .cast title + filename
|
|
1906
|
+
--shell <path> Shell to invoke the command in (default: /bin/sh)
|
|
1907
|
+
--cols <n> Reported terminal width (default: 120)
|
|
1908
|
+
--rows <n> Reported terminal height (default: 30)
|
|
1909
|
+
--ignore-exit Don't propagate the recorded command's exit code
|
|
1910
|
+
|
|
1911
|
+
Examples:
|
|
1912
|
+
pnpm sweetlink term "pytest -v" --label api-tests
|
|
1913
|
+
pnpm sweetlink term "go test ./..." --label go-tests
|
|
1914
|
+
pnpm sweetlink term "make build" --output .sweetlink/term/build.cast`,
|
|
1915
|
+
sessions: ` sessions [list|open]
|
|
1916
|
+
List or open all recorded sessions in this project.
|
|
1917
|
+
|
|
1918
|
+
Subcommands:
|
|
1919
|
+
list Print every session with label, duration, action count, error count (default)
|
|
1920
|
+
open Open .sweetlink/index.html in the default browser
|
|
1921
|
+
|
|
1922
|
+
Examples:
|
|
1923
|
+
pnpm sweetlink sessions list
|
|
1924
|
+
pnpm sweetlink sessions open`,
|
|
1860
1925
|
report: ` report [options]
|
|
1861
1926
|
Print or share the latest session report.
|
|
1862
1927
|
|
|
@@ -1935,13 +2000,29 @@ Sweetlink CLI - Autonomous Development Bridge
|
|
|
1935
2000
|
|
|
1936
2001
|
Usage:
|
|
1937
2002
|
pnpm sweetlink <command> [options]
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2003
|
+
pnpm sweetlink <command> --help Detailed help for a single command
|
|
2004
|
+
pnpm sweetlink --help --all Show full help for every command
|
|
2005
|
+
|
|
2006
|
+
Commands:`);
|
|
2007
|
+
// Extract the first descriptive sentence from each command's help block
|
|
2008
|
+
// so the top-level help is scannable in <40 lines.
|
|
2009
|
+
for (const [name, help] of Object.entries(COMMAND_HELP)) {
|
|
2010
|
+
// Each block looks like:
|
|
2011
|
+
// " command [args]\n First-line description.\n..."
|
|
2012
|
+
// We pick the first non-empty line after the signature.
|
|
2013
|
+
const lines = help.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
2014
|
+
const desc = lines[1] ?? '';
|
|
2015
|
+
const summary = desc.length > 70 ? desc.slice(0, 67) + '…' : desc;
|
|
2016
|
+
console.log(` ${name.padEnd(14)} ${summary}`);
|
|
2017
|
+
}
|
|
2018
|
+
if (process.argv.includes('--all')) {
|
|
2019
|
+
console.log('\n— Full per-command details —\n');
|
|
2020
|
+
for (const help of Object.values(COMMAND_HELP)) {
|
|
2021
|
+
console.log(help);
|
|
2022
|
+
console.log('');
|
|
2023
|
+
}
|
|
1944
2024
|
}
|
|
2025
|
+
console.log('');
|
|
1945
2026
|
console.log(GLOBAL_HELP);
|
|
1946
2027
|
}
|
|
1947
2028
|
function showCommandHelp(command) {
|
|
@@ -2003,6 +2084,9 @@ if (hasFlag('--output-schema')) {
|
|
|
2003
2084
|
'fill',
|
|
2004
2085
|
'console',
|
|
2005
2086
|
'record',
|
|
2087
|
+
'term',
|
|
2088
|
+
'sim',
|
|
2089
|
+
'sessions',
|
|
2006
2090
|
'proof',
|
|
2007
2091
|
'report',
|
|
2008
2092
|
'demo',
|
|
@@ -2131,6 +2215,7 @@ async function handleStatusCommand() {
|
|
|
2131
2215
|
height: getArg('--height') ? parseInt(getArg('--height'), 10) : undefined,
|
|
2132
2216
|
hover: hasFlag('--hover'),
|
|
2133
2217
|
padding: getArg('--padding') ? parseInt(getArg('--padding'), 10) : undefined,
|
|
2218
|
+
theme: getArg('--theme'),
|
|
2134
2219
|
url: getArg('--url'),
|
|
2135
2220
|
wait: !hasFlag('--no-wait'), // Wait by default, --no-wait to skip
|
|
2136
2221
|
waitTimeout: getArg('--wait-timeout')
|
|
@@ -2393,9 +2478,22 @@ async function handleStatusCommand() {
|
|
|
2393
2478
|
const subcommand = args[1];
|
|
2394
2479
|
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2395
2480
|
if (subcommand === 'start') {
|
|
2396
|
-
const
|
|
2481
|
+
const params = {};
|
|
2482
|
+
const label = getArg('--label');
|
|
2483
|
+
const viewport = getArg('--viewport');
|
|
2484
|
+
const storageState = getArg('--storage-state');
|
|
2485
|
+
if (label)
|
|
2486
|
+
params.label = label;
|
|
2487
|
+
if (viewport)
|
|
2488
|
+
params.viewport = viewport;
|
|
2489
|
+
if (storageState)
|
|
2490
|
+
params.storageState = storageState;
|
|
2491
|
+
if (hasFlag('--trace'))
|
|
2492
|
+
params.trace = true;
|
|
2493
|
+
const resp = await daemonRequest(state, 'record-start', params);
|
|
2397
2494
|
const data = resp.data;
|
|
2398
|
-
console.log(`[Sweetlink] Recording started: ${data.sessionId}`
|
|
2495
|
+
console.log(`[Sweetlink] Recording started: ${data.sessionId}` +
|
|
2496
|
+
(data.label ? ` (${data.label})` : ''));
|
|
2399
2497
|
result = data;
|
|
2400
2498
|
}
|
|
2401
2499
|
else if (subcommand === 'stop') {
|
|
@@ -2404,12 +2502,17 @@ async function handleStatusCommand() {
|
|
|
2404
2502
|
const m = data.manifest;
|
|
2405
2503
|
console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
|
|
2406
2504
|
console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ' | Video: ' + m.video : ''}`);
|
|
2407
|
-
// Auto-open the viewer
|
|
2505
|
+
// Auto-open the viewer (cross-platform; --no-open to suppress)
|
|
2408
2506
|
if (data.viewerPath && !hasFlag('--no-open')) {
|
|
2409
2507
|
console.log(` Viewer: ${data.viewerPath}`);
|
|
2410
2508
|
const { execFile } = await import('child_process');
|
|
2411
|
-
|
|
2412
|
-
|
|
2509
|
+
// `start` on Windows is a cmd builtin, not an exe — must invoke via cmd.
|
|
2510
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
2511
|
+
: process.platform === 'win32' ? 'cmd'
|
|
2512
|
+
: 'xdg-open';
|
|
2513
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', data.viewerPath]
|
|
2514
|
+
: [data.viewerPath];
|
|
2515
|
+
execFile(cmd, args, (err) => {
|
|
2413
2516
|
if (err)
|
|
2414
2517
|
console.error(' Could not open viewer:', err.message);
|
|
2415
2518
|
});
|
|
@@ -2420,6 +2523,100 @@ async function handleStatusCommand() {
|
|
|
2420
2523
|
}
|
|
2421
2524
|
result = data;
|
|
2422
2525
|
}
|
|
2526
|
+
else if (subcommand === 'exec') {
|
|
2527
|
+
// record exec "click @e2; fill @e3 hello world; click @e5"
|
|
2528
|
+
// Runs a semicolon-separated DSL inside a fresh recording, then
|
|
2529
|
+
// auto-stops. Each step is one of:
|
|
2530
|
+
// click <selector|@ref>
|
|
2531
|
+
// fill <@ref> <value> (rest of line after ref = value)
|
|
2532
|
+
// press <key>
|
|
2533
|
+
// sleep <ms>
|
|
2534
|
+
// Strip known --flag value pairs from positional args before
|
|
2535
|
+
// joining what remains as the script body.
|
|
2536
|
+
const flagsWithValues = new Set(['--url', '--label', '--viewport', '--storage-state']);
|
|
2537
|
+
const positional = [];
|
|
2538
|
+
for (let i = 2; i < args.length; i++) {
|
|
2539
|
+
const a = args[i];
|
|
2540
|
+
if (a.startsWith('--')) {
|
|
2541
|
+
if (flagsWithValues.has(a))
|
|
2542
|
+
i++; // skip its value
|
|
2543
|
+
continue;
|
|
2544
|
+
}
|
|
2545
|
+
positional.push(a);
|
|
2546
|
+
}
|
|
2547
|
+
const script = positional.join(' ').trim();
|
|
2548
|
+
if (!script) {
|
|
2549
|
+
console.error('[Sweetlink] Error: record exec requires a script. Example: `record exec "click @e2; fill @e3 hello"`');
|
|
2550
|
+
process.exit(1);
|
|
2551
|
+
}
|
|
2552
|
+
const label = getArg('--label');
|
|
2553
|
+
const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
|
|
2554
|
+
const startData = startResp.data;
|
|
2555
|
+
console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
|
|
2556
|
+
const steps = script.split(';').map((s) => s.trim()).filter(Boolean);
|
|
2557
|
+
// Snapshot once up-front so refs resolve.
|
|
2558
|
+
await daemonRequest(state, 'snapshot', { interactive: true });
|
|
2559
|
+
for (const step of steps) {
|
|
2560
|
+
const [verb, ...rest] = step.split(/\s+/);
|
|
2561
|
+
try {
|
|
2562
|
+
if (verb === 'click') {
|
|
2563
|
+
const target = rest[0];
|
|
2564
|
+
if (!target)
|
|
2565
|
+
throw new Error('click needs a target');
|
|
2566
|
+
if (/^@e\d+$/.test(target)) {
|
|
2567
|
+
await daemonRequest(state, 'click-ref', { ref: target });
|
|
2568
|
+
}
|
|
2569
|
+
else {
|
|
2570
|
+
await daemonRequest(state, 'click-css', { selector: target });
|
|
2571
|
+
}
|
|
2572
|
+
console.log(` · click ${target}`);
|
|
2573
|
+
}
|
|
2574
|
+
else if (verb === 'fill') {
|
|
2575
|
+
const ref = rest[0];
|
|
2576
|
+
const value = rest.slice(1).join(' ');
|
|
2577
|
+
if (!ref || !/^@e\d+$/.test(ref))
|
|
2578
|
+
throw new Error('fill needs a @ref and a value');
|
|
2579
|
+
await daemonRequest(state, 'fill-ref', { ref, value });
|
|
2580
|
+
console.log(` · fill ${ref} = "${value}"`);
|
|
2581
|
+
}
|
|
2582
|
+
else if (verb === 'press') {
|
|
2583
|
+
const key = rest[0];
|
|
2584
|
+
if (!key)
|
|
2585
|
+
throw new Error('press needs a key');
|
|
2586
|
+
await daemonRequest(state, 'press-key', { key });
|
|
2587
|
+
console.log(` · press ${key}`);
|
|
2588
|
+
}
|
|
2589
|
+
else if (verb === 'sleep') {
|
|
2590
|
+
const ms = parseInt(rest[0] ?? '0', 10);
|
|
2591
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
2592
|
+
console.log(` · sleep ${ms}ms`);
|
|
2593
|
+
}
|
|
2594
|
+
else {
|
|
2595
|
+
throw new Error(`Unknown verb '${verb}'. Allowed: click, fill, press, sleep.`);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
catch (err) {
|
|
2599
|
+
console.error(` ✗ step "${step}" failed: ${err instanceof Error ? err.message : err}`);
|
|
2600
|
+
// Continue to record-stop so the partial recording is preserved.
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
const stopResp = await daemonRequest(state, 'record-stop');
|
|
2604
|
+
const stopData = stopResp.data;
|
|
2605
|
+
console.log(`[Sweetlink] Done: ${stopData.manifest.commands.length} actions in ` +
|
|
2606
|
+
`${stopData.manifest.duration.toFixed(1)}s${stopData.viewerPath ? ` · ${stopData.viewerPath}` : ''}`);
|
|
2607
|
+
result = stopData;
|
|
2608
|
+
}
|
|
2609
|
+
else if (subcommand === 'pause') {
|
|
2610
|
+
const resp = await daemonRequest(state, 'record-pause');
|
|
2611
|
+
console.log('[Sweetlink] Recording paused. Use `record resume` to continue.');
|
|
2612
|
+
result = resp.data;
|
|
2613
|
+
}
|
|
2614
|
+
else if (subcommand === 'resume') {
|
|
2615
|
+
const resp = await daemonRequest(state, 'record-resume');
|
|
2616
|
+
const d = resp.data;
|
|
2617
|
+
console.log(`[Sweetlink] Recording resumed. Paused for ${(d.pausedDurationMs / 1000).toFixed(1)}s.`);
|
|
2618
|
+
result = resp.data;
|
|
2619
|
+
}
|
|
2423
2620
|
else {
|
|
2424
2621
|
const resp = await daemonRequest(state, 'record-status');
|
|
2425
2622
|
const data = resp.data;
|
|
@@ -2553,6 +2750,223 @@ async function handleStatusCommand() {
|
|
|
2553
2750
|
}
|
|
2554
2751
|
break;
|
|
2555
2752
|
}
|
|
2753
|
+
case 'sim': {
|
|
2754
|
+
// Record iOS Simulator or Android Emulator screen while running a command.
|
|
2755
|
+
// Example: sweetlink sim ios "fastlane scan" --device "iPhone 15"
|
|
2756
|
+
const platform = args[1];
|
|
2757
|
+
if (platform !== 'ios' && platform !== 'android') {
|
|
2758
|
+
console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
|
|
2759
|
+
process.exit(1);
|
|
2760
|
+
}
|
|
2761
|
+
const flagsWithValues = new Set(['--output', '--label', '--device', '--time-limit']);
|
|
2762
|
+
const positional = [];
|
|
2763
|
+
for (let i = 2; i < args.length; i++) {
|
|
2764
|
+
const a = args[i];
|
|
2765
|
+
if (a.startsWith('--')) {
|
|
2766
|
+
if (flagsWithValues.has(a))
|
|
2767
|
+
i++;
|
|
2768
|
+
continue;
|
|
2769
|
+
}
|
|
2770
|
+
positional.push(a);
|
|
2771
|
+
}
|
|
2772
|
+
const command = positional.join(' ').trim();
|
|
2773
|
+
if (!command) {
|
|
2774
|
+
console.error(`[Sweetlink] Error: sim ${platform} requires a command. Example: sweetlink sim ${platform} "fastlane scan"`);
|
|
2775
|
+
process.exit(1);
|
|
2776
|
+
}
|
|
2777
|
+
const label = getArg('--label');
|
|
2778
|
+
const labelSlug = label
|
|
2779
|
+
? label.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
|
|
2780
|
+
: `sim-${platform}`;
|
|
2781
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
2782
|
+
const defaultDir = path.join(findProjectRoot(), '.sweetlink', 'sim');
|
|
2783
|
+
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
|
|
2784
|
+
ensureDir(output);
|
|
2785
|
+
const device = getArg('--device');
|
|
2786
|
+
console.log(`[Sweetlink] Recording ${platform} simulator: ${command}`);
|
|
2787
|
+
let recResult;
|
|
2788
|
+
if (platform === 'ios') {
|
|
2789
|
+
const { recordIosSimulator } = await import('../simulator/ios.js');
|
|
2790
|
+
recResult = await recordIosSimulator({ command, output, device });
|
|
2791
|
+
}
|
|
2792
|
+
else {
|
|
2793
|
+
const { recordAndroidEmulator } = await import('../simulator/android.js');
|
|
2794
|
+
const tl = getArg('--time-limit');
|
|
2795
|
+
recResult = await recordAndroidEmulator({
|
|
2796
|
+
command, output, device,
|
|
2797
|
+
timeLimit: tl ? parseInt(tl, 10) : undefined,
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
let sizeKb = '?';
|
|
2801
|
+
try {
|
|
2802
|
+
sizeKb = String(Math.round(fs.statSync(output).size / 1024));
|
|
2803
|
+
}
|
|
2804
|
+
catch { /* file may not exist if recordingClosed is false */ }
|
|
2805
|
+
console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
|
|
2806
|
+
`${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
|
|
2807
|
+
(recResult.recordingClosed ? '' : ' (recording was force-killed; mp4 may be incomplete)'));
|
|
2808
|
+
result = {
|
|
2809
|
+
path: output,
|
|
2810
|
+
device: recResult.device,
|
|
2811
|
+
durationSec: recResult.durationSec,
|
|
2812
|
+
exitCode: recResult.exitCode,
|
|
2813
|
+
recordingClosed: recResult.recordingClosed,
|
|
2814
|
+
};
|
|
2815
|
+
if (recResult.exitCode !== 0 && !hasFlag('--ignore-exit')) {
|
|
2816
|
+
process.exit(recResult.exitCode);
|
|
2817
|
+
}
|
|
2818
|
+
break;
|
|
2819
|
+
}
|
|
2820
|
+
case 'term': {
|
|
2821
|
+
// Record a shell command's stdout/stderr into asciicast v2 + HTML player.
|
|
2822
|
+
// Example: sweetlink term "pytest -v" --label api-tests
|
|
2823
|
+
const flagsWithValues = new Set(['--output', '--label', '--shell', '--cols', '--rows']);
|
|
2824
|
+
const positional = [];
|
|
2825
|
+
for (let i = 1; i < args.length; i++) {
|
|
2826
|
+
const a = args[i];
|
|
2827
|
+
if (a.startsWith('--')) {
|
|
2828
|
+
if (flagsWithValues.has(a))
|
|
2829
|
+
i++;
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
positional.push(a);
|
|
2833
|
+
}
|
|
2834
|
+
const command = positional.join(' ').trim();
|
|
2835
|
+
if (!command) {
|
|
2836
|
+
console.error('[Sweetlink] Error: term requires a command. Example: sweetlink term "pytest tests/"');
|
|
2837
|
+
process.exit(1);
|
|
2838
|
+
}
|
|
2839
|
+
const label = getArg('--label');
|
|
2840
|
+
const labelSlug = label
|
|
2841
|
+
? label.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
|
|
2842
|
+
: 'term';
|
|
2843
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
2844
|
+
const defaultDir = path.join(findProjectRoot(), '.sweetlink', 'term');
|
|
2845
|
+
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
|
|
2846
|
+
ensureDir(output);
|
|
2847
|
+
console.log(`[Sweetlink] Recording terminal: ${command}`);
|
|
2848
|
+
const { captureTerminal } = await import('../term/recorder.js');
|
|
2849
|
+
const { generatePlayer } = await import('../term/player.js');
|
|
2850
|
+
const cap = await captureTerminal({
|
|
2851
|
+
command,
|
|
2852
|
+
output,
|
|
2853
|
+
label,
|
|
2854
|
+
shell: getArg('--shell'),
|
|
2855
|
+
cols: getArg('--cols') ? parseInt(getArg('--cols'), 10) : undefined,
|
|
2856
|
+
rows: getArg('--rows') ? parseInt(getArg('--rows'), 10) : undefined,
|
|
2857
|
+
});
|
|
2858
|
+
const playerPath = await generatePlayer({ castPath: output });
|
|
2859
|
+
console.log(`[Sweetlink] ✓ ${getRelativePath(output)} · ${cap.durationSec.toFixed(1)}s · ` +
|
|
2860
|
+
`${cap.events} events · ${(cap.bytes / 1024).toFixed(0)}KB · exit=${cap.exitCode}`);
|
|
2861
|
+
console.log(`[Sweetlink] ▶ ${getRelativePath(playerPath)}`);
|
|
2862
|
+
result = {
|
|
2863
|
+
castPath: output,
|
|
2864
|
+
playerPath,
|
|
2865
|
+
durationSec: cap.durationSec,
|
|
2866
|
+
bytes: cap.bytes,
|
|
2867
|
+
events: cap.events,
|
|
2868
|
+
exitCode: cap.exitCode,
|
|
2869
|
+
};
|
|
2870
|
+
// Propagate the recorded command's exit code by default so CI fails
|
|
2871
|
+
// when the wrapped tests fail.
|
|
2872
|
+
if (cap.exitCode !== 0 && !hasFlag('--ignore-exit')) {
|
|
2873
|
+
process.exit(cap.exitCode);
|
|
2874
|
+
}
|
|
2875
|
+
break;
|
|
2876
|
+
}
|
|
2877
|
+
case 'sessions': {
|
|
2878
|
+
const sub = args[1];
|
|
2879
|
+
const projRoot = findProjectRoot();
|
|
2880
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2881
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2882
|
+
const resp = await daemonRequest(state, 'sessions-list');
|
|
2883
|
+
const data = resp.data;
|
|
2884
|
+
if (sub === 'list' || !sub) {
|
|
2885
|
+
if (data.sessions.length === 0) {
|
|
2886
|
+
console.log('[Sweetlink] No sessions found.');
|
|
2887
|
+
}
|
|
2888
|
+
else {
|
|
2889
|
+
console.log(`[Sweetlink] ${data.sessions.length} session(s):\n`);
|
|
2890
|
+
for (const s of data.sessions) {
|
|
2891
|
+
const errTotal = s.errors ? s.errors.console + s.errors.network + s.errors.server : 0;
|
|
2892
|
+
const errBadge = errTotal > 0 ? ` · ${errTotal} err` : '';
|
|
2893
|
+
const labelTxt = s.label ? ` [${s.label}]` : '';
|
|
2894
|
+
const dur = s.duration ? `${s.duration.toFixed(1)}s` : '—';
|
|
2895
|
+
console.log(` ${s.sessionId}${labelTxt} · ${dur} · ${s.actionCount} actions${errBadge}`);
|
|
2896
|
+
}
|
|
2897
|
+
if (data.indexPath)
|
|
2898
|
+
console.log(`\n Index: ${data.indexPath}`);
|
|
2899
|
+
}
|
|
2900
|
+
result = { sessions: data.sessions };
|
|
2901
|
+
}
|
|
2902
|
+
else if (sub === 'diff') {
|
|
2903
|
+
// sessions diff <a> <b> — compare two recordings
|
|
2904
|
+
const [aId, bId] = [args[2], args[3]];
|
|
2905
|
+
if (!aId || !bId) {
|
|
2906
|
+
console.error('[Sweetlink] Usage: sessions diff <session-A> <session-B>');
|
|
2907
|
+
process.exit(1);
|
|
2908
|
+
}
|
|
2909
|
+
const findSession = (id) => data.sessions.find((s) => s.sessionId === id || s.sessionId.endsWith(id));
|
|
2910
|
+
const a = findSession(aId);
|
|
2911
|
+
const b = findSession(bId);
|
|
2912
|
+
if (!a || !b) {
|
|
2913
|
+
console.error(`[Sweetlink] Could not find session: ${!a ? aId : bId}`);
|
|
2914
|
+
process.exit(1);
|
|
2915
|
+
}
|
|
2916
|
+
const aManifest = JSON.parse(fs.readFileSync(a.manifestPath, 'utf-8'));
|
|
2917
|
+
const bManifest = JSON.parse(fs.readFileSync(b.manifestPath, 'utf-8'));
|
|
2918
|
+
const aActions = aManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
2919
|
+
const bActions = bManifest.commands.map((c) => `${c.action} ${c.args.join(' ')}`);
|
|
2920
|
+
console.log(`\n${a.sessionId}${a.label ? ` "${a.label}"` : ''} vs ${b.sessionId}${b.label ? ` "${b.label}"` : ''}\n`);
|
|
2921
|
+
console.log(`Duration: ${a.duration?.toFixed(1)}s vs ${b.duration?.toFixed(1)}s`);
|
|
2922
|
+
console.log(`Actions: ${a.actionCount} vs ${b.actionCount}`);
|
|
2923
|
+
const aErr = a.errors ? a.errors.console + a.errors.network + a.errors.server : 0;
|
|
2924
|
+
const bErr = b.errors ? b.errors.console + b.errors.network + b.errors.server : 0;
|
|
2925
|
+
console.log(`Errors: ${aErr} vs ${bErr}`);
|
|
2926
|
+
// Action diff (myers-style "added/removed" by line)
|
|
2927
|
+
const inA = new Set(aActions);
|
|
2928
|
+
const inB = new Set(bActions);
|
|
2929
|
+
const added = bActions.filter((x) => !inA.has(x));
|
|
2930
|
+
const removed = aActions.filter((x) => !inB.has(x));
|
|
2931
|
+
if (removed.length) {
|
|
2932
|
+
console.log(`\nOnly in ${a.sessionId}:`);
|
|
2933
|
+
removed.forEach((s) => console.log(` - ${s}`));
|
|
2934
|
+
}
|
|
2935
|
+
if (added.length) {
|
|
2936
|
+
console.log(`\nOnly in ${b.sessionId}:`);
|
|
2937
|
+
added.forEach((s) => console.log(` + ${s}`));
|
|
2938
|
+
}
|
|
2939
|
+
if (!added.length && !removed.length) {
|
|
2940
|
+
console.log('\nAction sequences are identical.');
|
|
2941
|
+
}
|
|
2942
|
+
result = {
|
|
2943
|
+
a: { id: a.sessionId, label: a.label, duration: a.duration, actions: a.actionCount, errors: aErr },
|
|
2944
|
+
b: { id: b.sessionId, label: b.label, duration: b.duration, actions: b.actionCount, errors: bErr },
|
|
2945
|
+
added,
|
|
2946
|
+
removed,
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
else if (sub === 'open') {
|
|
2950
|
+
// Open the index.html in the browser
|
|
2951
|
+
if (data.indexPath) {
|
|
2952
|
+
const { execFile } = await import('child_process');
|
|
2953
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
2954
|
+
: process.platform === 'win32' ? 'cmd'
|
|
2955
|
+
: 'xdg-open';
|
|
2956
|
+
const cmdArgs = process.platform === 'win32'
|
|
2957
|
+
? ['/c', 'start', '', data.indexPath]
|
|
2958
|
+
: [data.indexPath];
|
|
2959
|
+
execFile(cmd, cmdArgs, () => { });
|
|
2960
|
+
console.log(`[Sweetlink] Opened ${data.indexPath}`);
|
|
2961
|
+
}
|
|
2962
|
+
result = { indexPath: data.indexPath };
|
|
2963
|
+
}
|
|
2964
|
+
else {
|
|
2965
|
+
console.error(`[Sweetlink] Unknown sessions subcommand: ${sub}. Try: list, open`);
|
|
2966
|
+
process.exit(1);
|
|
2967
|
+
}
|
|
2968
|
+
break;
|
|
2969
|
+
}
|
|
2556
2970
|
case 'demo': {
|
|
2557
2971
|
const sub = args[1];
|
|
2558
2972
|
const projRoot = findProjectRoot();
|