@ytspar/sweetlink 1.19.0 → 1.20.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/README.md +40 -0
- package/dist/cli/outputSchemas.d.ts +57 -1
- package/dist/cli/outputSchemas.d.ts.map +1 -1
- package/dist/cli/outputSchemas.js +36 -1
- package/dist/cli/outputSchemas.js.map +1 -1
- package/dist/cli/sweetlink.js +385 -47
- package/dist/cli/sweetlink.js.map +1 -1
- package/dist/daemon/browser.d.ts.map +1 -1
- package/dist/daemon/browser.js.map +1 -1
- package/dist/daemon/client.d.ts +7 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +16 -2
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/demo.d.ts.map +1 -1
- package/dist/daemon/demo.js +6 -2
- package/dist/daemon/demo.js.map +1 -1
- package/dist/daemon/diff.d.ts.map +1 -1
- package/dist/daemon/diff.js +5 -3
- package/dist/daemon/diff.js.map +1 -1
- package/dist/daemon/evidence.d.ts.map +1 -1
- package/dist/daemon/evidence.js +5 -5
- package/dist/daemon/evidence.js.map +1 -1
- package/dist/daemon/index.js +1 -1
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/listeners.d.ts.map +1 -1
- package/dist/daemon/listeners.js +7 -5
- package/dist/daemon/listeners.js.map +1 -1
- package/dist/daemon/recording.d.ts +5 -0
- package/dist/daemon/recording.d.ts.map +1 -1
- package/dist/daemon/recording.js +34 -11
- package/dist/daemon/recording.js.map +1 -1
- package/dist/daemon/refs.d.ts.map +1 -1
- package/dist/daemon/refs.js.map +1 -1
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +419 -80
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/summary.d.ts +1 -1
- package/dist/daemon/summary.d.ts.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/viewer.d.ts +1 -1
- package/dist/daemon/viewer.d.ts.map +1 -1
- package/dist/daemon/viewer.js +18 -10
- package/dist/daemon/viewer.js.map +1 -1
- package/dist/ruler.js +3 -1
- package/dist/ruler.js.map +1 -1
- package/dist/runs.d.ts +34 -0
- package/dist/runs.d.ts.map +1 -0
- package/dist/runs.js +61 -0
- package/dist/runs.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +20 -10
- package/dist/server/index.js.map +1 -1
- package/dist/simulator/android.d.ts.map +1 -1
- package/dist/simulator/android.js +13 -5
- package/dist/simulator/android.js.map +1 -1
- package/dist/simulator/ios.d.ts.map +1 -1
- package/dist/simulator/ios.js +13 -5
- package/dist/simulator/ios.js.map +1 -1
- package/dist/term/ansi.d.ts.map +1 -1
- package/dist/term/ansi.js +49 -14
- package/dist/term/ansi.js.map +1 -1
- package/dist/term/player.d.ts.map +1 -1
- package/dist/term/player.js +5 -5
- package/dist/term/player.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +8 -4
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/sweetlink.js
CHANGED
|
@@ -9,13 +9,13 @@ import * as fs from 'fs';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { WebSocket } from 'ws';
|
|
11
11
|
import { detectCDP, getNetworkRequestsViaCDP } from '../cdp.js';
|
|
12
|
+
import { DaemonRequestError, daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon, } from '../daemon/client.js';
|
|
13
|
+
import { uploadEvidence } from '../daemon/evidence.js';
|
|
14
|
+
import { extractPort } from '../daemon/stateFile.js';
|
|
12
15
|
import { screenshotViaPlaywright } from '../playwright.js';
|
|
13
16
|
import { getCardHeaderPreset, getNavigationPreset, measureViaPlaywright } from '../ruler.js';
|
|
14
17
|
import { DEFAULT_WS_PORT, MAX_PORT_RETRIES, WS_PORT_OFFSET } from '../types.js';
|
|
15
18
|
import { SCREENSHOT_DIR } from '../urlUtils.js';
|
|
16
|
-
import { daemonRequest, ensureDaemon, getDaemonStatus, stopDaemon } from '../daemon/client.js';
|
|
17
|
-
import { extractPort } from '../daemon/stateFile.js';
|
|
18
|
-
import { uploadEvidence } from '../daemon/evidence.js';
|
|
19
19
|
import { emitJson, printOutputSchema } from './outputSchemas.js';
|
|
20
20
|
const COMMON_APP_PORTS = [3000, 3001, 4000, 5173, 5174, 8000, 8080];
|
|
21
21
|
/**
|
|
@@ -118,7 +118,9 @@ function reportScreenshotSuccess(outputPath, width, height, method, selector) {
|
|
|
118
118
|
try {
|
|
119
119
|
sizeKb = ` · ${Math.round(fs.statSync(outputPath).size / 1024)}KB`;
|
|
120
120
|
}
|
|
121
|
-
catch {
|
|
121
|
+
catch {
|
|
122
|
+
/* file may have been moved or removed */
|
|
123
|
+
}
|
|
122
124
|
const selPart = selector ? ` · ${selector}` : '';
|
|
123
125
|
if (process.env.SWEETLINK_VERBOSE === '1') {
|
|
124
126
|
console.log(`[Sweetlink] ✓ Screenshot saved to: ${getRelativePath(outputPath)}`);
|
|
@@ -188,8 +190,7 @@ async function discoverServer(target) {
|
|
|
188
190
|
}
|
|
189
191
|
const lowerTarget = target.toLowerCase();
|
|
190
192
|
// Exact match on branch or app name
|
|
191
|
-
const exact = results.find((r) => r.gitBranch?.toLowerCase() === lowerTarget ||
|
|
192
|
-
r.appName?.toLowerCase() === lowerTarget);
|
|
193
|
+
const exact = results.find((r) => r.gitBranch?.toLowerCase() === lowerTarget || r.appName?.toLowerCase() === lowerTarget);
|
|
193
194
|
if (exact)
|
|
194
195
|
return `ws://localhost:${exact.port}`;
|
|
195
196
|
// Partial match (branch contains target)
|
|
@@ -485,8 +486,11 @@ async function screenshot(options) {
|
|
|
485
486
|
`Use --index N (with click) or a more specific selector to pick another.`);
|
|
486
487
|
}
|
|
487
488
|
// UX: hint at --full-page when content extends below the viewport.
|
|
488
|
-
if (!options.selector &&
|
|
489
|
-
|
|
489
|
+
if (!options.selector &&
|
|
490
|
+
!options.fullPage &&
|
|
491
|
+
data.pageHeight &&
|
|
492
|
+
data.viewportHeight &&
|
|
493
|
+
data.pageHeight > data.viewportHeight + 4) {
|
|
490
494
|
const overflow = data.pageHeight - data.viewportHeight;
|
|
491
495
|
console.log(`[Sweetlink] ℹ Page extends ${overflow}px below the viewport. ` +
|
|
492
496
|
`Use --full-page to capture all of it.`);
|
|
@@ -1547,6 +1551,27 @@ async function getVitals(options) {
|
|
|
1547
1551
|
process.exit(1);
|
|
1548
1552
|
}
|
|
1549
1553
|
}
|
|
1554
|
+
function printInspectSummary(data) {
|
|
1555
|
+
console.log('\n[Sweetlink] Agent Inspect');
|
|
1556
|
+
console.log(` URL: ${data.url}`);
|
|
1557
|
+
console.log(` Title: ${data.title || '(untitled)'}`);
|
|
1558
|
+
console.log(` Viewport: ${data.viewport.width}x${data.viewport.height}` +
|
|
1559
|
+
(data.viewport.deviceScaleFactor ? ` @${data.viewport.deviceScaleFactor}x` : ''));
|
|
1560
|
+
console.log(` Artifacts: ${getRelativePath(data.artifacts.dir)}`);
|
|
1561
|
+
console.log(` Refs: ${data.counts.refs} | Console: ${data.counts.consoleErrors} errors, ${data.counts.consoleWarnings} warnings | Network: ${data.counts.networkFailures} failures`);
|
|
1562
|
+
if (data.counts.a11yViolations !== undefined) {
|
|
1563
|
+
console.log(` A11y: ${data.counts.a11yViolations} violations, ${data.counts.a11yIncomplete ?? 0} incomplete`);
|
|
1564
|
+
}
|
|
1565
|
+
if (data.nextActions.length > 0) {
|
|
1566
|
+
console.log('\n Suggested next actions:');
|
|
1567
|
+
for (const action of data.nextActions) {
|
|
1568
|
+
console.log(` - ${action}`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
console.log(`\n Summary: ${getRelativePath(data.artifacts.summaryMarkdown)}`);
|
|
1572
|
+
console.log(` JSON: ${getRelativePath(data.artifacts.contextJson)}`);
|
|
1573
|
+
console.log(` PNG: ${getRelativePath(data.artifacts.screenshotPng)}`);
|
|
1574
|
+
}
|
|
1550
1575
|
// Per-command help text, keyed by canonical command name
|
|
1551
1576
|
const COMMAND_HELP = {
|
|
1552
1577
|
screenshot: ` screenshot [options]
|
|
@@ -1595,6 +1620,30 @@ const COMMAND_HELP = {
|
|
|
1595
1620
|
pnpm sweetlink screenshot --force-cdp --width 375 --height 667 # Playwright at iPhone SE
|
|
1596
1621
|
pnpm sweetlink screenshot --hifi # Pixel-perfect via daemon
|
|
1597
1622
|
pnpm sweetlink screenshot --responsive # 3 breakpoints via daemon`,
|
|
1623
|
+
inspect: ` inspect [options] (alias: context)
|
|
1624
|
+
Capture one LLM-ready frontend context bundle from the daemon.
|
|
1625
|
+
|
|
1626
|
+
Includes:
|
|
1627
|
+
- Full-page screenshot artifact
|
|
1628
|
+
- Interactive @e refs and accessibility snapshot
|
|
1629
|
+
- Console and network deltas from daemon buffers
|
|
1630
|
+
- Axe accessibility summary when axe-core is available
|
|
1631
|
+
- Page timing/viewport metadata and suggested next actions
|
|
1632
|
+
|
|
1633
|
+
Options:
|
|
1634
|
+
--url <url> Target URL (default: http://localhost:3000)
|
|
1635
|
+
--last <number> Console/network entries to include (default: 50)
|
|
1636
|
+
--label <text> Scenario label for artifact directory
|
|
1637
|
+
--expected <text> Expected outcome to embed in the bundle
|
|
1638
|
+
--action <text> Action transcript item (repeatable)
|
|
1639
|
+
--no-a11y Skip axe accessibility audit
|
|
1640
|
+
--format <type> Output format: text (default), json
|
|
1641
|
+
--output <path> Also write the context JSON to this path
|
|
1642
|
+
|
|
1643
|
+
Examples:
|
|
1644
|
+
pnpm sweetlink inspect --url http://localhost:5173
|
|
1645
|
+
pnpm sweetlink context --label "checkout empty state" --expected "CTA is visible"
|
|
1646
|
+
pnpm sweetlink inspect --format json --output .tmp/inspect.json`,
|
|
1598
1647
|
query: ` query --selector <css-selector> [options]
|
|
1599
1648
|
Query DOM elements and return data
|
|
1600
1649
|
|
|
@@ -1903,6 +1952,8 @@ const COMMAND_HELP = {
|
|
|
1903
1952
|
Options:
|
|
1904
1953
|
--output <path> .cast file path (default: .sweetlink/term/<label>-<stamp>.cast)
|
|
1905
1954
|
--label <text> Label embedded in the .cast title + filename
|
|
1955
|
+
--app <name> Group artifacts under .sweetlink/<app>/<YYYYMMDD>/<run>/term/
|
|
1956
|
+
--run <id> Override the auto-generated run id (HHMM-SS or $SWEETLINK_RUN)
|
|
1906
1957
|
--shell <path> Shell to invoke the command in (default: /bin/sh)
|
|
1907
1958
|
--cols <n> Reported terminal width (default: 120)
|
|
1908
1959
|
--rows <n> Reported terminal height (default: 30)
|
|
@@ -1910,7 +1961,7 @@ const COMMAND_HELP = {
|
|
|
1910
1961
|
|
|
1911
1962
|
Examples:
|
|
1912
1963
|
pnpm sweetlink term "pytest -v" --label api-tests
|
|
1913
|
-
pnpm sweetlink term "go test ./..." --label go-tests
|
|
1964
|
+
pnpm sweetlink term "go test ./..." --app my-app --label go-tests
|
|
1914
1965
|
pnpm sweetlink term "make build" --output .sweetlink/term/build.cast`,
|
|
1915
1966
|
sessions: ` sessions [list|open]
|
|
1916
1967
|
List or open all recorded sessions in this project.
|
|
@@ -1970,6 +2021,7 @@ const COMMAND_HELP = {
|
|
|
1970
2021
|
const COMMAND_ALIASES = {
|
|
1971
2022
|
measure: 'ruler',
|
|
1972
2023
|
accessibility: 'a11y',
|
|
2024
|
+
context: 'inspect',
|
|
1973
2025
|
};
|
|
1974
2026
|
const GLOBAL_HELP = `
|
|
1975
2027
|
Global Flags:
|
|
@@ -2010,9 +2062,12 @@ Commands:`);
|
|
|
2010
2062
|
// Each block looks like:
|
|
2011
2063
|
// " command [args]\n First-line description.\n..."
|
|
2012
2064
|
// We pick the first non-empty line after the signature.
|
|
2013
|
-
const lines = help
|
|
2065
|
+
const lines = help
|
|
2066
|
+
.split('\n')
|
|
2067
|
+
.map((l) => l.trim())
|
|
2068
|
+
.filter(Boolean);
|
|
2014
2069
|
const desc = lines[1] ?? '';
|
|
2015
|
-
const summary = desc.length > 70 ? desc.slice(0, 67)
|
|
2070
|
+
const summary = desc.length > 70 ? `${desc.slice(0, 67)}…` : desc;
|
|
2016
2071
|
console.log(` ${name.padEnd(14)} ${summary}`);
|
|
2017
2072
|
}
|
|
2018
2073
|
if (process.argv.includes('--all')) {
|
|
@@ -2039,12 +2094,161 @@ function showCommandHelp(command) {
|
|
|
2039
2094
|
}
|
|
2040
2095
|
// CLI argument parsing
|
|
2041
2096
|
const args = process.argv.slice(2);
|
|
2042
|
-
// Skip global flags to find the actual command
|
|
2043
|
-
|
|
2097
|
+
// Skip global flags to find the actual command. Falling back to args[0]
|
|
2098
|
+
// would surface flags like `--json` as the command — we want undefined
|
|
2099
|
+
// in that case so the no-command paths (help / batch mode) can fire.
|
|
2100
|
+
const commandType = args.find((a) => !a.startsWith('--'));
|
|
2044
2101
|
if (!commandType || commandType === '--help' || commandType === '-h') {
|
|
2102
|
+
// `sweetlink --json` with stdin → multi-capture batch mode.
|
|
2103
|
+
// Reads { action: "capture", captures: [...] } from stdin and runs
|
|
2104
|
+
// each entry, aggregating results into a single JSON envelope on stdout.
|
|
2105
|
+
if (args.includes('--json') && !process.stdin.isTTY) {
|
|
2106
|
+
// Top-level await keeps the rest of the dispatch from running while
|
|
2107
|
+
// the batch is in flight; we exit before fallthrough either way.
|
|
2108
|
+
try {
|
|
2109
|
+
await runBatchFromStdin();
|
|
2110
|
+
process.exit(0);
|
|
2111
|
+
}
|
|
2112
|
+
catch (err) {
|
|
2113
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2114
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
|
|
2115
|
+
process.exit(1);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2045
2118
|
showHelp();
|
|
2046
2119
|
process.exit(0);
|
|
2047
2120
|
}
|
|
2121
|
+
async function runBatchFromStdin() {
|
|
2122
|
+
const chunks = [];
|
|
2123
|
+
for await (const chunk of process.stdin)
|
|
2124
|
+
chunks.push(chunk);
|
|
2125
|
+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
2126
|
+
if (!raw) {
|
|
2127
|
+
throw new Error('No input on stdin. Pipe a JSON document with {"action":"capture","captures":[...]}.');
|
|
2128
|
+
}
|
|
2129
|
+
let body;
|
|
2130
|
+
try {
|
|
2131
|
+
body = JSON.parse(raw);
|
|
2132
|
+
}
|
|
2133
|
+
catch (e) {
|
|
2134
|
+
throw new Error(`Could not parse stdin as JSON: ${e instanceof Error ? e.message : e}`);
|
|
2135
|
+
}
|
|
2136
|
+
if (body.action !== 'capture' || !Array.isArray(body.captures)) {
|
|
2137
|
+
throw new Error('Expected { "action": "capture", "captures": [...] }.');
|
|
2138
|
+
}
|
|
2139
|
+
const startTime = Date.now();
|
|
2140
|
+
const results = [];
|
|
2141
|
+
for (const cap of body.captures) {
|
|
2142
|
+
const t0 = Date.now();
|
|
2143
|
+
const mode = cap.mode;
|
|
2144
|
+
const label = cap.label;
|
|
2145
|
+
try {
|
|
2146
|
+
const data = await runOneBatchCapture(cap);
|
|
2147
|
+
results.push({ ok: true, mode, label, data, duration: Date.now() - t0 });
|
|
2148
|
+
}
|
|
2149
|
+
catch (err) {
|
|
2150
|
+
results.push({
|
|
2151
|
+
ok: false,
|
|
2152
|
+
mode,
|
|
2153
|
+
label,
|
|
2154
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2155
|
+
duration: Date.now() - t0,
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
const allOk = results.every((r) => r.ok);
|
|
2160
|
+
process.stdout.write(JSON.stringify({
|
|
2161
|
+
ok: allOk,
|
|
2162
|
+
duration: Date.now() - startTime,
|
|
2163
|
+
captures: results,
|
|
2164
|
+
}) + '\n');
|
|
2165
|
+
if (!allOk)
|
|
2166
|
+
process.exit(1);
|
|
2167
|
+
}
|
|
2168
|
+
async function runOneBatchCapture(cap) {
|
|
2169
|
+
const mode = cap.mode;
|
|
2170
|
+
if (!mode)
|
|
2171
|
+
throw new Error('Capture entry missing required "mode" field.');
|
|
2172
|
+
if (mode === 'term') {
|
|
2173
|
+
const command = cap.command;
|
|
2174
|
+
if (!command)
|
|
2175
|
+
throw new Error('term capture missing "command".');
|
|
2176
|
+
const label = cap.label ?? 'batch';
|
|
2177
|
+
const labelSlug = label
|
|
2178
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
2179
|
+
.toLowerCase()
|
|
2180
|
+
.slice(0, 40);
|
|
2181
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
2182
|
+
const output = cap.output ??
|
|
2183
|
+
path.join(findProjectRoot(), '.sweetlink', 'term', `${labelSlug}-${stamp}.cast`);
|
|
2184
|
+
ensureDir(output);
|
|
2185
|
+
const { captureTerminal } = await import('../term/recorder.js');
|
|
2186
|
+
const { generatePlayer } = await import('../term/player.js');
|
|
2187
|
+
const result = await captureTerminal({
|
|
2188
|
+
command,
|
|
2189
|
+
output,
|
|
2190
|
+
label,
|
|
2191
|
+
shell: cap.shell,
|
|
2192
|
+
cols: typeof cap.cols === 'number' ? cap.cols : undefined,
|
|
2193
|
+
rows: typeof cap.rows === 'number' ? cap.rows : undefined,
|
|
2194
|
+
});
|
|
2195
|
+
const playerPath = await generatePlayer({ castPath: output });
|
|
2196
|
+
return {
|
|
2197
|
+
castPath: output,
|
|
2198
|
+
playerPath,
|
|
2199
|
+
durationSec: result.durationSec,
|
|
2200
|
+
exitCode: result.exitCode,
|
|
2201
|
+
events: result.events,
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
if (mode === 'sim-ios' || mode === 'sim-android') {
|
|
2205
|
+
const command = cap.command;
|
|
2206
|
+
if (!command)
|
|
2207
|
+
throw new Error(`${mode} capture missing "command".`);
|
|
2208
|
+
const label = cap.label ?? 'batch';
|
|
2209
|
+
const labelSlug = label
|
|
2210
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
2211
|
+
.toLowerCase()
|
|
2212
|
+
.slice(0, 40);
|
|
2213
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
2214
|
+
const output = cap.output ??
|
|
2215
|
+
path.join(findProjectRoot(), '.sweetlink', 'sim', `${labelSlug}-${stamp}.mp4`);
|
|
2216
|
+
ensureDir(output);
|
|
2217
|
+
const device = cap.device;
|
|
2218
|
+
if (mode === 'sim-ios') {
|
|
2219
|
+
const { recordIosSimulator } = await import('../simulator/ios.js');
|
|
2220
|
+
return recordIosSimulator({ command, output, device });
|
|
2221
|
+
}
|
|
2222
|
+
const { recordAndroidEmulator } = await import('../simulator/android.js');
|
|
2223
|
+
return recordAndroidEmulator({
|
|
2224
|
+
command,
|
|
2225
|
+
output,
|
|
2226
|
+
device,
|
|
2227
|
+
timeLimit: typeof cap.timeLimit === 'number' ? cap.timeLimit : undefined,
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
if (mode === 'screenshot') {
|
|
2231
|
+
const targetUrl = cap.url ?? 'http://localhost:3000';
|
|
2232
|
+
const projRoot = findProjectRoot();
|
|
2233
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2234
|
+
const resp = await daemonRequest(state, 'screenshot', {
|
|
2235
|
+
selector: cap.selector,
|
|
2236
|
+
fullPage: cap.fullPage,
|
|
2237
|
+
viewport: cap.viewport,
|
|
2238
|
+
padding: cap.padding,
|
|
2239
|
+
theme: cap.theme,
|
|
2240
|
+
});
|
|
2241
|
+
const data = resp.data;
|
|
2242
|
+
if (cap.output) {
|
|
2243
|
+
const outputPath = cap.output;
|
|
2244
|
+
ensureDir(outputPath);
|
|
2245
|
+
fs.writeFileSync(outputPath, Buffer.from(data.screenshot, 'base64'));
|
|
2246
|
+
return { path: outputPath, width: data.width, height: data.height };
|
|
2247
|
+
}
|
|
2248
|
+
return { width: data.width, height: data.height, base64Length: data.screenshot.length };
|
|
2249
|
+
}
|
|
2250
|
+
throw new Error(`Unknown capture mode: ${mode}. Allowed: term, sim-ios, sim-android, screenshot.`);
|
|
2251
|
+
}
|
|
2048
2252
|
// Helper function to get argument value
|
|
2049
2253
|
function getArg(flag) {
|
|
2050
2254
|
const index = args.indexOf(flag);
|
|
@@ -2054,6 +2258,7 @@ function hasFlag(flag) {
|
|
|
2054
2258
|
return args.includes(flag);
|
|
2055
2259
|
}
|
|
2056
2260
|
// Per-command --help: `pnpm sweetlink screenshot --help`
|
|
2261
|
+
// Past the early-exit at the top of dispatch, commandType is non-null.
|
|
2057
2262
|
if (hasFlag('--help') || hasFlag('-h')) {
|
|
2058
2263
|
showCommandHelp(commandType);
|
|
2059
2264
|
process.exit(0);
|
|
@@ -2063,6 +2268,8 @@ if (hasFlag('--output-schema')) {
|
|
|
2063
2268
|
// If commandType is a known command, print just that schema; otherwise print all
|
|
2064
2269
|
const knownCommands = [
|
|
2065
2270
|
'screenshot',
|
|
2271
|
+
'inspect',
|
|
2272
|
+
'context',
|
|
2066
2273
|
'query',
|
|
2067
2274
|
'logs',
|
|
2068
2275
|
'exec',
|
|
@@ -2096,7 +2303,9 @@ if (hasFlag('--output-schema')) {
|
|
|
2096
2303
|
? 'ruler'
|
|
2097
2304
|
: commandType === 'accessibility'
|
|
2098
2305
|
? 'a11y'
|
|
2099
|
-
: commandType
|
|
2306
|
+
: commandType === 'context'
|
|
2307
|
+
? 'inspect'
|
|
2308
|
+
: commandType
|
|
2100
2309
|
: undefined;
|
|
2101
2310
|
printOutputSchema(schemaCommand);
|
|
2102
2311
|
process.exit(0);
|
|
@@ -2130,6 +2339,24 @@ function setupJsonMode(command, startTime) {
|
|
|
2130
2339
|
});
|
|
2131
2340
|
return { origExit, getLastError: () => lastErrorMsg };
|
|
2132
2341
|
}
|
|
2342
|
+
function getErrorData(error) {
|
|
2343
|
+
if (error instanceof DaemonRequestError) {
|
|
2344
|
+
return {
|
|
2345
|
+
action: error.action,
|
|
2346
|
+
status: error.status,
|
|
2347
|
+
...(error.data ? error.data : {}),
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
return null;
|
|
2351
|
+
}
|
|
2352
|
+
function printErrorContext(error) {
|
|
2353
|
+
if (!(error instanceof DaemonRequestError) || !error.data)
|
|
2354
|
+
return;
|
|
2355
|
+
const failureScreenshot = error.data.failureScreenshot;
|
|
2356
|
+
if (typeof failureScreenshot === 'string') {
|
|
2357
|
+
console.error(`[Sweetlink] Failure screenshot: ${failureScreenshot}`);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2133
2360
|
/**
|
|
2134
2361
|
* Handle the `wait` command: wait for a server to be ready.
|
|
2135
2362
|
*/
|
|
@@ -2180,9 +2407,14 @@ async function handleStatusCommand() {
|
|
|
2180
2407
|
}
|
|
2181
2408
|
(async () => {
|
|
2182
2409
|
const startTime = Date.now();
|
|
2183
|
-
// Resolve --app flag:
|
|
2410
|
+
// Resolve --app flag: for WS-bridge commands, this discovers the matching
|
|
2411
|
+
// Sweetlink server by branch/app name. For commands that produce artifacts
|
|
2412
|
+
// (term/sim), --app is a *namespace* used in the artifact directory layout
|
|
2413
|
+
// (.sweetlink/<app>/<YYYYMMDD>/<run>/...) — those handlers read --app
|
|
2414
|
+
// themselves, so we skip discovery here.
|
|
2184
2415
|
const appTarget = getArg('--app');
|
|
2185
|
-
|
|
2416
|
+
const isArtifactCommand = commandType === 'term' || commandType === 'sim';
|
|
2417
|
+
if (appTarget && !isArtifactCommand) {
|
|
2186
2418
|
try {
|
|
2187
2419
|
resolvedWsUrl = await discoverServer(appTarget);
|
|
2188
2420
|
console.log(`[Sweetlink] Targeting server: ${resolvedWsUrl}`);
|
|
@@ -2223,6 +2455,42 @@ async function handleStatusCommand() {
|
|
|
2223
2455
|
: undefined,
|
|
2224
2456
|
});
|
|
2225
2457
|
break;
|
|
2458
|
+
case 'inspect':
|
|
2459
|
+
case 'context': {
|
|
2460
|
+
const projRoot = findProjectRoot();
|
|
2461
|
+
const targetUrl = getArg('--url') ?? 'http://localhost:3000';
|
|
2462
|
+
const state = await ensureDaemon(projRoot, targetUrl);
|
|
2463
|
+
const actionTranscript = [];
|
|
2464
|
+
args.forEach((arg, index) => {
|
|
2465
|
+
if (arg !== '--action')
|
|
2466
|
+
return;
|
|
2467
|
+
const value = args[index + 1];
|
|
2468
|
+
if (!value)
|
|
2469
|
+
return;
|
|
2470
|
+
actionTranscript.push({ action: value });
|
|
2471
|
+
});
|
|
2472
|
+
const resp = await daemonRequest(state, 'inspect', {
|
|
2473
|
+
last: getArg('--last') ? parseInt(getArg('--last'), 10) : undefined,
|
|
2474
|
+
label: getArg('--label'),
|
|
2475
|
+
expectedOutcome: getArg('--expected'),
|
|
2476
|
+
actionTranscript,
|
|
2477
|
+
includeA11y: !hasFlag('--no-a11y'),
|
|
2478
|
+
});
|
|
2479
|
+
const data = resp.data;
|
|
2480
|
+
const output = getArg('--output');
|
|
2481
|
+
if (output) {
|
|
2482
|
+
ensureDir(output);
|
|
2483
|
+
fs.writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
|
|
2484
|
+
}
|
|
2485
|
+
if (getArg('--format') === 'json') {
|
|
2486
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
printInspectSummary(data);
|
|
2490
|
+
}
|
|
2491
|
+
result = data;
|
|
2492
|
+
break;
|
|
2493
|
+
}
|
|
2226
2494
|
case 'query': {
|
|
2227
2495
|
const selector = getArg('--selector');
|
|
2228
2496
|
if (!selector) {
|
|
@@ -2318,7 +2586,9 @@ async function handleStatusCommand() {
|
|
|
2318
2586
|
}
|
|
2319
2587
|
}
|
|
2320
2588
|
}
|
|
2321
|
-
catch {
|
|
2589
|
+
catch {
|
|
2590
|
+
/* fall through to WS path */
|
|
2591
|
+
}
|
|
2322
2592
|
result = await click({
|
|
2323
2593
|
selector: clickTarget,
|
|
2324
2594
|
text: clickText,
|
|
@@ -2446,7 +2716,8 @@ async function handleStatusCommand() {
|
|
|
2446
2716
|
}
|
|
2447
2717
|
const sessionDirArg = getArg('--session') ?? '.sweetlink';
|
|
2448
2718
|
// Find latest session manifest
|
|
2449
|
-
const sessionFiles = fs
|
|
2719
|
+
const sessionFiles = fs
|
|
2720
|
+
.readdirSync(sessionDirArg)
|
|
2450
2721
|
.filter((f) => f.startsWith('session-'))
|
|
2451
2722
|
.sort()
|
|
2452
2723
|
.reverse();
|
|
@@ -2501,16 +2772,19 @@ async function handleStatusCommand() {
|
|
|
2501
2772
|
const data = resp.data;
|
|
2502
2773
|
const m = data.manifest;
|
|
2503
2774
|
console.log(`[Sweetlink] Recording stopped: ${m.sessionId}`);
|
|
2504
|
-
console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ?
|
|
2775
|
+
console.log(` Duration: ${m.duration.toFixed(1)}s | Actions: ${m.commands.length}${m.video ? ` | Video: ${m.video}` : ''}`);
|
|
2505
2776
|
// Auto-open the viewer (cross-platform; --no-open to suppress)
|
|
2506
2777
|
if (data.viewerPath && !hasFlag('--no-open')) {
|
|
2507
2778
|
console.log(` Viewer: ${data.viewerPath}`);
|
|
2508
2779
|
const { execFile } = await import('child_process');
|
|
2509
2780
|
// `start` on Windows is a cmd builtin, not an exe — must invoke via cmd.
|
|
2510
|
-
const cmd = process.platform === 'darwin'
|
|
2511
|
-
|
|
2781
|
+
const cmd = process.platform === 'darwin'
|
|
2782
|
+
? 'open'
|
|
2783
|
+
: process.platform === 'win32'
|
|
2784
|
+
? 'cmd'
|
|
2512
2785
|
: 'xdg-open';
|
|
2513
|
-
const args = process.platform === 'win32'
|
|
2786
|
+
const args = process.platform === 'win32'
|
|
2787
|
+
? ['/c', 'start', '', data.viewerPath]
|
|
2514
2788
|
: [data.viewerPath];
|
|
2515
2789
|
execFile(cmd, args, (err) => {
|
|
2516
2790
|
if (err)
|
|
@@ -2553,7 +2827,10 @@ async function handleStatusCommand() {
|
|
|
2553
2827
|
const startResp = await daemonRequest(state, 'record-start', label ? { label } : {});
|
|
2554
2828
|
const startData = startResp.data;
|
|
2555
2829
|
console.log(`[Sweetlink] Recording: ${startData.sessionId}${label ? ` (${label})` : ''}`);
|
|
2556
|
-
const steps = script
|
|
2830
|
+
const steps = script
|
|
2831
|
+
.split(';')
|
|
2832
|
+
.map((s) => s.trim())
|
|
2833
|
+
.filter(Boolean);
|
|
2557
2834
|
// Snapshot once up-front so refs resolve.
|
|
2558
2835
|
await daemonRequest(state, 'snapshot', { interactive: true });
|
|
2559
2836
|
for (const step of steps) {
|
|
@@ -2637,7 +2914,8 @@ async function handleStatusCommand() {
|
|
|
2637
2914
|
console.error(`[Sweetlink] Session directory not found: ${sessionDirArg}`);
|
|
2638
2915
|
process.exit(1);
|
|
2639
2916
|
}
|
|
2640
|
-
const reportSessions = fs
|
|
2917
|
+
const reportSessions = fs
|
|
2918
|
+
.readdirSync(sessionDirArg)
|
|
2641
2919
|
.filter((f) => f.startsWith('session-'))
|
|
2642
2920
|
.sort()
|
|
2643
2921
|
.reverse();
|
|
@@ -2714,7 +2992,10 @@ async function handleStatusCommand() {
|
|
|
2714
2992
|
}
|
|
2715
2993
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
2716
2994
|
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
|
|
2717
|
-
const payload = {
|
|
2995
|
+
const payload = {
|
|
2996
|
+
summary,
|
|
2997
|
+
manifest,
|
|
2998
|
+
};
|
|
2718
2999
|
// Include viewer HTML for Slack/Discord webhooks
|
|
2719
3000
|
if (/slack|discord/i.test(webhookUrl)) {
|
|
2720
3001
|
const viewerPath = path.join(reportSessionDir, 'viewer.html');
|
|
@@ -2758,7 +3039,14 @@ async function handleStatusCommand() {
|
|
|
2758
3039
|
console.error('[Sweetlink] Usage: sweetlink sim <ios|android> "<command>" [--output path] [--device <name>]');
|
|
2759
3040
|
process.exit(1);
|
|
2760
3041
|
}
|
|
2761
|
-
const flagsWithValues = new Set([
|
|
3042
|
+
const flagsWithValues = new Set([
|
|
3043
|
+
'--output',
|
|
3044
|
+
'--label',
|
|
3045
|
+
'--device',
|
|
3046
|
+
'--time-limit',
|
|
3047
|
+
'--app',
|
|
3048
|
+
'--run',
|
|
3049
|
+
]);
|
|
2762
3050
|
const positional = [];
|
|
2763
3051
|
for (let i = 2; i < args.length; i++) {
|
|
2764
3052
|
const a = args[i];
|
|
@@ -2776,10 +3064,19 @@ async function handleStatusCommand() {
|
|
|
2776
3064
|
}
|
|
2777
3065
|
const label = getArg('--label');
|
|
2778
3066
|
const labelSlug = label
|
|
2779
|
-
? label
|
|
3067
|
+
? label
|
|
3068
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
3069
|
+
.toLowerCase()
|
|
3070
|
+
.slice(0, 40)
|
|
2780
3071
|
: `sim-${platform}`;
|
|
2781
3072
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
2782
|
-
const
|
|
3073
|
+
const { runSlot: simRunSlot } = await import('../runs.js');
|
|
3074
|
+
const defaultDir = simRunSlot({
|
|
3075
|
+
baseDir: findProjectRoot(),
|
|
3076
|
+
app: getArg('--app'),
|
|
3077
|
+
run: getArg('--run'),
|
|
3078
|
+
kind: 'sim',
|
|
3079
|
+
});
|
|
2783
3080
|
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.mp4`);
|
|
2784
3081
|
ensureDir(output);
|
|
2785
3082
|
const device = getArg('--device');
|
|
@@ -2793,7 +3090,9 @@ async function handleStatusCommand() {
|
|
|
2793
3090
|
const { recordAndroidEmulator } = await import('../simulator/android.js');
|
|
2794
3091
|
const tl = getArg('--time-limit');
|
|
2795
3092
|
recResult = await recordAndroidEmulator({
|
|
2796
|
-
command,
|
|
3093
|
+
command,
|
|
3094
|
+
output,
|
|
3095
|
+
device,
|
|
2797
3096
|
timeLimit: tl ? parseInt(tl, 10) : undefined,
|
|
2798
3097
|
});
|
|
2799
3098
|
}
|
|
@@ -2801,10 +3100,14 @@ async function handleStatusCommand() {
|
|
|
2801
3100
|
try {
|
|
2802
3101
|
sizeKb = String(Math.round(fs.statSync(output).size / 1024));
|
|
2803
3102
|
}
|
|
2804
|
-
catch {
|
|
3103
|
+
catch {
|
|
3104
|
+
/* file may not exist if recordingClosed is false */
|
|
3105
|
+
}
|
|
2805
3106
|
console.log(`[Sweetlink] ${recResult.recordingClosed ? '✓' : '⚠'} ${getRelativePath(output)} · ` +
|
|
2806
3107
|
`${recResult.durationSec.toFixed(1)}s · ${sizeKb}KB · ${recResult.device} · exit=${recResult.exitCode}` +
|
|
2807
|
-
(recResult.recordingClosed
|
|
3108
|
+
(recResult.recordingClosed
|
|
3109
|
+
? ''
|
|
3110
|
+
: ' (recording was force-killed; mp4 may be incomplete)'));
|
|
2808
3111
|
result = {
|
|
2809
3112
|
path: output,
|
|
2810
3113
|
device: recResult.device,
|
|
@@ -2819,8 +3122,16 @@ async function handleStatusCommand() {
|
|
|
2819
3122
|
}
|
|
2820
3123
|
case 'term': {
|
|
2821
3124
|
// 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([
|
|
3125
|
+
// Example: sweetlink term "pytest -v" --label api-tests --app my-app
|
|
3126
|
+
const flagsWithValues = new Set([
|
|
3127
|
+
'--output',
|
|
3128
|
+
'--label',
|
|
3129
|
+
'--shell',
|
|
3130
|
+
'--cols',
|
|
3131
|
+
'--rows',
|
|
3132
|
+
'--app',
|
|
3133
|
+
'--run',
|
|
3134
|
+
]);
|
|
2824
3135
|
const positional = [];
|
|
2825
3136
|
for (let i = 1; i < args.length; i++) {
|
|
2826
3137
|
const a = args[i];
|
|
@@ -2838,10 +3149,19 @@ async function handleStatusCommand() {
|
|
|
2838
3149
|
}
|
|
2839
3150
|
const label = getArg('--label');
|
|
2840
3151
|
const labelSlug = label
|
|
2841
|
-
? label
|
|
3152
|
+
? label
|
|
3153
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
3154
|
+
.toLowerCase()
|
|
3155
|
+
.slice(0, 40)
|
|
2842
3156
|
: 'term';
|
|
2843
3157
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
2844
|
-
const
|
|
3158
|
+
const { runSlot } = await import('../runs.js');
|
|
3159
|
+
const defaultDir = runSlot({
|
|
3160
|
+
baseDir: findProjectRoot(),
|
|
3161
|
+
app: getArg('--app'),
|
|
3162
|
+
run: getArg('--run'),
|
|
3163
|
+
kind: 'term',
|
|
3164
|
+
});
|
|
2845
3165
|
const output = getArg('--output') ?? path.join(defaultDir, `${labelSlug}-${stamp}.cast`);
|
|
2846
3166
|
ensureDir(output);
|
|
2847
3167
|
console.log(`[Sweetlink] Recording terminal: ${command}`);
|
|
@@ -2940,8 +3260,20 @@ async function handleStatusCommand() {
|
|
|
2940
3260
|
console.log('\nAction sequences are identical.');
|
|
2941
3261
|
}
|
|
2942
3262
|
result = {
|
|
2943
|
-
a: {
|
|
2944
|
-
|
|
3263
|
+
a: {
|
|
3264
|
+
id: a.sessionId,
|
|
3265
|
+
label: a.label,
|
|
3266
|
+
duration: a.duration,
|
|
3267
|
+
actions: a.actionCount,
|
|
3268
|
+
errors: aErr,
|
|
3269
|
+
},
|
|
3270
|
+
b: {
|
|
3271
|
+
id: b.sessionId,
|
|
3272
|
+
label: b.label,
|
|
3273
|
+
duration: b.duration,
|
|
3274
|
+
actions: b.actionCount,
|
|
3275
|
+
errors: bErr,
|
|
3276
|
+
},
|
|
2945
3277
|
added,
|
|
2946
3278
|
removed,
|
|
2947
3279
|
};
|
|
@@ -2950,12 +3282,12 @@ async function handleStatusCommand() {
|
|
|
2950
3282
|
// Open the index.html in the browser
|
|
2951
3283
|
if (data.indexPath) {
|
|
2952
3284
|
const { execFile } = await import('child_process');
|
|
2953
|
-
const cmd = process.platform === 'darwin'
|
|
2954
|
-
|
|
3285
|
+
const cmd = process.platform === 'darwin'
|
|
3286
|
+
? 'open'
|
|
3287
|
+
: process.platform === 'win32'
|
|
3288
|
+
? 'cmd'
|
|
2955
3289
|
: 'xdg-open';
|
|
2956
|
-
const cmdArgs = process.platform === 'win32'
|
|
2957
|
-
? ['/c', 'start', '', data.indexPath]
|
|
2958
|
-
: [data.indexPath];
|
|
3290
|
+
const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', data.indexPath] : [data.indexPath];
|
|
2959
3291
|
execFile(cmd, cmdArgs, () => { });
|
|
2960
3292
|
console.log(`[Sweetlink] Opened ${data.indexPath}`);
|
|
2961
3293
|
}
|
|
@@ -3063,10 +3395,13 @@ async function handleStatusCommand() {
|
|
|
3063
3395
|
console.log(`[Sweetlink] Demo: "${state.title}" (${state.sections.length} sections)`);
|
|
3064
3396
|
console.log(` File: ${state.filePath}`);
|
|
3065
3397
|
for (const s of state.sections) {
|
|
3066
|
-
const preview = s.type === 'note'
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3398
|
+
const preview = s.type === 'note'
|
|
3399
|
+
? s.content.substring(0, 60)
|
|
3400
|
+
: s.type === 'exec'
|
|
3401
|
+
? `$ ${s.command}`
|
|
3402
|
+
: s.type === 'screenshot'
|
|
3403
|
+
? `[image] ${s.screenshotFile}`
|
|
3404
|
+
: '[snapshot]';
|
|
3070
3405
|
console.log(` ${s.type.padEnd(12)} ${preview}`);
|
|
3071
3406
|
}
|
|
3072
3407
|
result = state;
|
|
@@ -3187,8 +3522,10 @@ async function handleStatusCommand() {
|
|
|
3187
3522
|
const msg = error instanceof Error ? error.message : String(error);
|
|
3188
3523
|
emitJson({
|
|
3189
3524
|
ok: false,
|
|
3525
|
+
// commandType is non-undefined here: the early-exit at the top of
|
|
3526
|
+
// CLI dispatch handles the bare `--json` (batch mode) case.
|
|
3190
3527
|
command: commandType,
|
|
3191
|
-
data:
|
|
3528
|
+
data: getErrorData(error),
|
|
3192
3529
|
error: msg,
|
|
3193
3530
|
duration: Date.now() - startTime,
|
|
3194
3531
|
});
|
|
@@ -3198,6 +3535,7 @@ async function handleStatusCommand() {
|
|
|
3198
3535
|
// to end users and clutters the output. Set SWEETLINK_DEBUG=1 to see it.
|
|
3199
3536
|
if (error instanceof Error) {
|
|
3200
3537
|
console.error(`[Sweetlink] ${error.message}`);
|
|
3538
|
+
printErrorContext(error);
|
|
3201
3539
|
if (process.env.SWEETLINK_DEBUG === '1' && error.stack) {
|
|
3202
3540
|
console.error(error.stack);
|
|
3203
3541
|
}
|