surf-cli 2.2.0 → 2.4.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 +125 -3
- package/dist/service-worker/index.js +11 -11
- package/dist/service-worker/index.js.map +1 -1
- package/native/chatgpt-client.cjs +29 -22
- package/native/cli.cjs +184 -3
- package/native/config.cjs +12 -0
- package/native/do-executor.cjs +273 -0
- package/native/do-parser.cjs +232 -0
- package/native/grok-client.cjs +906 -0
- package/native/host-helpers.cjs +58 -1
- package/native/host.cjs +198 -0
- package/native/perplexity-client.cjs +26 -22
- package/package.json +1 -1
- package/native/CHANGELOG.md +0 -136
- package/native/README.md +0 -141
|
@@ -150,22 +150,23 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
|
|
|
150
150
|
})()`
|
|
151
151
|
);
|
|
152
152
|
await delay(300);
|
|
153
|
+
// Select from menu - loop in Node.js to avoid CDP timeout issues
|
|
153
154
|
const normalizedModel = desiredModel.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
155
|
+
const deadline = Date.now() + timeoutMs;
|
|
156
|
+
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
const result = await evaluate(
|
|
159
|
+
cdp,
|
|
160
|
+
`(() => {
|
|
161
|
+
${buildClickDispatcher()}
|
|
162
|
+
const targetModel = ${JSON.stringify(normalizedModel)};
|
|
163
|
+
const menuSelector = '${SELECTORS.menuContainer}';
|
|
164
|
+
const itemSelector = '${SELECTORS.menuItem}';
|
|
165
|
+
const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
166
|
+
|
|
165
167
|
const menu = document.querySelector(menuSelector);
|
|
166
168
|
if (!menu) {
|
|
167
|
-
|
|
168
|
-
continue;
|
|
169
|
+
return { found: false, waiting: true };
|
|
169
170
|
}
|
|
170
171
|
const items = Array.from(menu.querySelectorAll(itemSelector));
|
|
171
172
|
let bestMatch = null;
|
|
@@ -183,18 +184,24 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
|
|
|
183
184
|
}
|
|
184
185
|
if (bestMatch) {
|
|
185
186
|
dispatchClickSequence(bestMatch);
|
|
186
|
-
|
|
187
|
-
return { success: true, label: bestMatch.textContent?.trim() };
|
|
187
|
+
return { found: true, success: true, label: bestMatch.textContent?.trim() };
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
return { found: true, success: false, error: 'No matching model in menu' };
|
|
190
|
+
})()`
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (result && result.found) {
|
|
194
|
+
if (result.success) {
|
|
195
|
+
await delay(200);
|
|
196
|
+
return result.label;
|
|
190
197
|
}
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
throw new Error(`Model not found: ${desiredModel}`);
|
|
198
|
+
throw new Error(`Model not found: ${desiredModel}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await delay(100);
|
|
196
202
|
}
|
|
197
|
-
|
|
203
|
+
|
|
204
|
+
throw new Error(`Model not found: ${desiredModel} (timeout)`);
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
async function typePrompt(cdp, inputCdp, prompt) {
|
package/native/cli.cjs
CHANGED
|
@@ -5,6 +5,8 @@ const { execSync } = require("child_process");
|
|
|
5
5
|
const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
|
|
6
6
|
const networkFormatters = require("./formatters/network.cjs");
|
|
7
7
|
const networkStore = require("./network-store.cjs");
|
|
8
|
+
const { parseDoCommands } = require("./do-parser.cjs");
|
|
9
|
+
const { executeDoSteps } = require("./do-executor.cjs");
|
|
8
10
|
|
|
9
11
|
const SOCKET_PATH = "/tmp/surf.sock";
|
|
10
12
|
|
|
@@ -146,6 +148,27 @@ const TOOLS = {
|
|
|
146
148
|
{ cmd: 'perplexity "latest AI news" --model sonar', desc: "Specify model (Pro)" },
|
|
147
149
|
]
|
|
148
150
|
},
|
|
151
|
+
"grok": {
|
|
152
|
+
desc: "Query Grok AI with real-time X/Twitter data access (uses browser session)",
|
|
153
|
+
args: ["query"],
|
|
154
|
+
opts: {
|
|
155
|
+
"with-page": "Include current page context",
|
|
156
|
+
model: "Model: auto, fast, expert, thinking (default)",
|
|
157
|
+
"deep-search": "Enable DeepSearch for X post searching",
|
|
158
|
+
timeout: "Timeout in seconds (default: 300 for thinking models)",
|
|
159
|
+
validate: "Check Grok UI and scrape available models (no query sent)",
|
|
160
|
+
"save-models": "Save discovered models to surf.json config"
|
|
161
|
+
},
|
|
162
|
+
examples: [
|
|
163
|
+
{ cmd: 'grok "what are the latest AI agent trends on X"', desc: "Search X posts" },
|
|
164
|
+
{ cmd: 'grok "analyze @username recent activity"', desc: "Profile analysis" },
|
|
165
|
+
{ cmd: 'grok "summarize this page" --with-page', desc: "With page context" },
|
|
166
|
+
{ cmd: 'grok "find viral AI posts" --deep-search', desc: "DeepSearch mode" },
|
|
167
|
+
{ cmd: 'grok "quick question" --model fast', desc: "Faster model" },
|
|
168
|
+
{ cmd: 'grok --validate', desc: "Check UI and list available models" },
|
|
169
|
+
{ cmd: 'grok --validate --save-models', desc: "Save discovered models to settings" },
|
|
170
|
+
]
|
|
171
|
+
},
|
|
149
172
|
"ai": {
|
|
150
173
|
desc: "Analyze page with AI (requires GOOGLE_API_KEY)",
|
|
151
174
|
args: ["query"],
|
|
@@ -780,6 +803,27 @@ const TOOLS = {
|
|
|
780
803
|
},
|
|
781
804
|
}
|
|
782
805
|
},
|
|
806
|
+
workflow: {
|
|
807
|
+
desc: "Workflow execution",
|
|
808
|
+
commands: {
|
|
809
|
+
"do": {
|
|
810
|
+
desc: "Execute multiple commands as a single workflow",
|
|
811
|
+
args: ["commands"],
|
|
812
|
+
opts: {
|
|
813
|
+
file: "Load workflow from JSON file",
|
|
814
|
+
"on-error": "stop (default) | continue",
|
|
815
|
+
"no-auto-wait": "Disable automatic waits between steps",
|
|
816
|
+
"step-delay": "Delay between steps in ms (default: 100)",
|
|
817
|
+
"dry-run": "Parse and validate without executing"
|
|
818
|
+
},
|
|
819
|
+
examples: [
|
|
820
|
+
{ cmd: 'do \'go "https://example.com"\\nclick e5\\nscreenshot\'', desc: "Inline workflow" },
|
|
821
|
+
{ cmd: 'do -f login.json', desc: "From JSON file" },
|
|
822
|
+
{ cmd: 'do \'go "url"\\nclick e5\' --dry-run', desc: "Validate without running" },
|
|
823
|
+
]
|
|
824
|
+
},
|
|
825
|
+
}
|
|
826
|
+
},
|
|
783
827
|
zoom: {
|
|
784
828
|
desc: "Zoom control",
|
|
785
829
|
commands: {
|
|
@@ -1704,7 +1748,139 @@ if (args.includes("--script")) {
|
|
|
1704
1748
|
return;
|
|
1705
1749
|
}
|
|
1706
1750
|
|
|
1707
|
-
|
|
1751
|
+
// Handle `surf do` workflow command
|
|
1752
|
+
// Must be parsed before general parseArgs since it uses its own arg handling
|
|
1753
|
+
if (args[0] === "do") {
|
|
1754
|
+
const doArgs = args.slice(1);
|
|
1755
|
+
let commandsInput = null;
|
|
1756
|
+
let fileInput = null;
|
|
1757
|
+
let dryRun = false;
|
|
1758
|
+
let onError = "stop";
|
|
1759
|
+
let noAutoWait = false;
|
|
1760
|
+
let stepDelay = 100;
|
|
1761
|
+
let wantJson = false;
|
|
1762
|
+
let tabId = undefined;
|
|
1763
|
+
let windowId = undefined;
|
|
1764
|
+
|
|
1765
|
+
// Parse do-specific arguments
|
|
1766
|
+
for (let i = 0; i < doArgs.length; i++) {
|
|
1767
|
+
const arg = doArgs[i];
|
|
1768
|
+
if (arg === "--file" || arg === "-f") {
|
|
1769
|
+
fileInput = doArgs[i + 1];
|
|
1770
|
+
i++;
|
|
1771
|
+
} else if (arg === "--dry-run") {
|
|
1772
|
+
dryRun = true;
|
|
1773
|
+
} else if (arg === "--on-error") {
|
|
1774
|
+
onError = doArgs[i + 1] || "stop";
|
|
1775
|
+
i++;
|
|
1776
|
+
} else if (arg === "--no-auto-wait") {
|
|
1777
|
+
noAutoWait = true;
|
|
1778
|
+
} else if (arg === "--step-delay") {
|
|
1779
|
+
const parsed = parseInt(doArgs[i + 1], 10);
|
|
1780
|
+
stepDelay = isNaN(parsed) ? 100 : parsed;
|
|
1781
|
+
i++;
|
|
1782
|
+
} else if (arg === "--json") {
|
|
1783
|
+
wantJson = true;
|
|
1784
|
+
} else if (arg === "--tab-id") {
|
|
1785
|
+
tabId = parseInt(doArgs[i + 1], 10);
|
|
1786
|
+
i++;
|
|
1787
|
+
} else if (arg === "--window-id") {
|
|
1788
|
+
windowId = parseInt(doArgs[i + 1], 10);
|
|
1789
|
+
i++;
|
|
1790
|
+
} else if (!arg.startsWith("-")) {
|
|
1791
|
+
commandsInput = arg;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if (!commandsInput && !fileInput) {
|
|
1796
|
+
console.error("Error: commands string or --file required");
|
|
1797
|
+
console.error('Usage: surf do \'go "url"\\nclick e5\'');
|
|
1798
|
+
console.error(" surf do --file workflow.json");
|
|
1799
|
+
process.exit(1);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
let steps;
|
|
1803
|
+
try {
|
|
1804
|
+
if (fileInput) {
|
|
1805
|
+
if (!fs.existsSync(fileInput)) {
|
|
1806
|
+
console.error(`Error: File not found: ${fileInput}`);
|
|
1807
|
+
process.exit(1);
|
|
1808
|
+
}
|
|
1809
|
+
const content = fs.readFileSync(fileInput, "utf8");
|
|
1810
|
+
// JSON file format (same as --script)
|
|
1811
|
+
const script = JSON.parse(content);
|
|
1812
|
+
if (!script.steps || !Array.isArray(script.steps)) {
|
|
1813
|
+
throw new Error("JSON must have a 'steps' array");
|
|
1814
|
+
}
|
|
1815
|
+
// Convert --script format { tool, args } to do format { cmd, args }
|
|
1816
|
+
steps = script.steps.map(s => ({ cmd: s.tool, args: s.args || {} }));
|
|
1817
|
+
} else {
|
|
1818
|
+
// Inline string parsing
|
|
1819
|
+
steps = parseDoCommands(commandsInput);
|
|
1820
|
+
}
|
|
1821
|
+
} catch (e) {
|
|
1822
|
+
console.error(`Error: Failed to parse workflow: ${e.message}`);
|
|
1823
|
+
process.exit(1);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
if (steps.length === 0) {
|
|
1827
|
+
console.error("Error: No commands found in workflow");
|
|
1828
|
+
process.exit(1);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// Validate with --dry-run
|
|
1832
|
+
if (dryRun) {
|
|
1833
|
+
console.log(`Would execute ${steps.length} steps:`);
|
|
1834
|
+
steps.forEach((s, i) => {
|
|
1835
|
+
const argStr = Object.entries(s.args || {})
|
|
1836
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
1837
|
+
.join(" ");
|
|
1838
|
+
console.log(` ${i + 1}. ${s.cmd} ${argStr}`);
|
|
1839
|
+
});
|
|
1840
|
+
process.exit(0);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
if (!wantJson) {
|
|
1844
|
+
console.log(`Running workflow (${steps.length} steps)...\n`);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const runWorkflow = async () => {
|
|
1848
|
+
const result = await executeDoSteps(steps, {
|
|
1849
|
+
onError,
|
|
1850
|
+
autoWait: !noAutoWait,
|
|
1851
|
+
stepDelay,
|
|
1852
|
+
quiet: wantJson,
|
|
1853
|
+
context: {
|
|
1854
|
+
tabId,
|
|
1855
|
+
windowId,
|
|
1856
|
+
},
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// Print summary
|
|
1860
|
+
if (wantJson) {
|
|
1861
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1862
|
+
process.exit(result.status === "completed" ? 0 : 1);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
console.log("");
|
|
1866
|
+
if (result.status === "completed") {
|
|
1867
|
+
console.log(`Completed: ${result.completedSteps}/${result.totalSteps} steps (${result.totalMs}ms)`);
|
|
1868
|
+
process.exit(0);
|
|
1869
|
+
} else if (result.status === "partial") {
|
|
1870
|
+
console.log(`Partial: ${result.completedSteps}/${result.totalSteps} steps completed, ${result.failed} failed`);
|
|
1871
|
+
process.exit(1);
|
|
1872
|
+
} else {
|
|
1873
|
+
console.error(`Failed: ${result.completedSteps}/${result.totalSteps} steps completed`);
|
|
1874
|
+
if (result.error) console.error(`Error: ${result.error}`);
|
|
1875
|
+
process.exit(1);
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
runWorkflow();
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait"];
|
|
1708
1884
|
|
|
1709
1885
|
const AUTO_SCREENSHOT_TOOLS = ["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"];
|
|
1710
1886
|
|
|
@@ -1734,8 +1910,12 @@ const parseArgs = (rawArgs) => {
|
|
|
1734
1910
|
result.options.v = true;
|
|
1735
1911
|
} else if (arg === "-vv") {
|
|
1736
1912
|
result.options.vv = true;
|
|
1913
|
+
} else if (arg === "-f" && rawArgs[i + 1] && !rawArgs[i + 1].startsWith("-")) {
|
|
1914
|
+
// -f takes a file path argument (for surf do -f <file>)
|
|
1915
|
+
result.options.file = rawArgs[i + 1];
|
|
1916
|
+
i++;
|
|
1737
1917
|
} else if (arg.startsWith("-") && arg.length === 2) {
|
|
1738
|
-
// Short flag like -n
|
|
1918
|
+
// Short flag like -n
|
|
1739
1919
|
result.options[arg.slice(1)] = true;
|
|
1740
1920
|
} else {
|
|
1741
1921
|
result.positional.push(arg);
|
|
@@ -1802,6 +1982,7 @@ const PRIMARY_ARG_MAP = {
|
|
|
1802
1982
|
gemini: "query",
|
|
1803
1983
|
chatgpt: "query",
|
|
1804
1984
|
perplexity: "query",
|
|
1985
|
+
grok: "query",
|
|
1805
1986
|
navigate: "url",
|
|
1806
1987
|
go: "url",
|
|
1807
1988
|
js: "code",
|
|
@@ -2168,7 +2349,7 @@ const socket = net.createConnection(SOCKET_PATH, () => {
|
|
|
2168
2349
|
socket.write(JSON.stringify(request) + "\n");
|
|
2169
2350
|
});
|
|
2170
2351
|
|
|
2171
|
-
const AI_TOOLS = ["smoke", "chatgpt", "gemini", "perplexity", "ai"];
|
|
2352
|
+
const AI_TOOLS = ["smoke", "chatgpt", "gemini", "perplexity", "grok", "ai"];
|
|
2172
2353
|
const requestTimeout = AI_TOOLS.includes(tool) ? 300000 : 30000;
|
|
2173
2354
|
const timeout = setTimeout(() => {
|
|
2174
2355
|
console.error(`Error: Request timed out (${requestTimeout / 1000}s)`);
|
package/native/config.cjs
CHANGED
|
@@ -21,6 +21,18 @@ const STARTER_CONFIG = {
|
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Grok models can be customized in surf.json if X.com UI changes:
|
|
25
|
+
// {
|
|
26
|
+
// "grok": {
|
|
27
|
+
// "models": {
|
|
28
|
+
// "thinking": { "id": "thinking", "name": "Grok 4.1 Thinking" },
|
|
29
|
+
// "auto": { "id": "auto", "name": "Auto" },
|
|
30
|
+
// "fast": { "id": "fast", "name": "Fast" },
|
|
31
|
+
// "expert": { "id": "expert", "name": "Expert" }
|
|
32
|
+
// }
|
|
33
|
+
// }
|
|
34
|
+
// }
|
|
35
|
+
|
|
24
36
|
function findConfigPath() {
|
|
25
37
|
const cwdPath = path.join(process.cwd(), CONFIG_NAME);
|
|
26
38
|
if (fs.existsSync(cwdPath)) {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor for surf `do` workflow commands
|
|
3
|
+
*
|
|
4
|
+
* Executes steps sequentially with auto-waits and streaming progress output.
|
|
5
|
+
* Follows the same socket communication pattern as --script mode in cli.cjs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const net = require("net");
|
|
9
|
+
|
|
10
|
+
const SOCKET_PATH = "/tmp/surf.sock";
|
|
11
|
+
|
|
12
|
+
// Commands that trigger auto-wait after execution
|
|
13
|
+
// Note: 'type' is intentionally excluded - typing doesn't trigger navigation or DOM changes
|
|
14
|
+
const AUTO_WAIT_COMMANDS = [
|
|
15
|
+
'go', 'navigate', 'click', 'key', 'form.fill', 'submit',
|
|
16
|
+
'tab.switch', 'tab.new', 'back', 'forward'
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Auto-wait strategies per command type
|
|
20
|
+
const AUTO_WAIT_MAP = {
|
|
21
|
+
'navigate': 'wait.load',
|
|
22
|
+
'go': 'wait.load',
|
|
23
|
+
'click': 'wait.dom',
|
|
24
|
+
'key': 'wait.dom',
|
|
25
|
+
'form.fill': 'wait.dom',
|
|
26
|
+
'submit': 'wait.load', // Form submission typically triggers navigation
|
|
27
|
+
'tab.switch': 'wait.load',
|
|
28
|
+
'tab.new': 'wait.load',
|
|
29
|
+
'back': 'wait.load',
|
|
30
|
+
'forward': 'wait.load',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a command should trigger an auto-wait
|
|
35
|
+
* @param {string} cmd - Command name
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function shouldAutoWait(cmd) {
|
|
39
|
+
return AUTO_WAIT_COMMANDS.some(c => cmd === c || cmd.startsWith(c + '.'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the appropriate auto-wait command for a given command
|
|
44
|
+
* @param {string} cmd - Command name
|
|
45
|
+
* @returns {string|null} - Wait command to execute, or null
|
|
46
|
+
*/
|
|
47
|
+
function getAutoWaitCommand(cmd) {
|
|
48
|
+
// Check exact match first
|
|
49
|
+
if (AUTO_WAIT_MAP[cmd] !== undefined) return AUTO_WAIT_MAP[cmd];
|
|
50
|
+
|
|
51
|
+
// Check prefix match
|
|
52
|
+
for (const [prefix, waitCmd] of Object.entries(AUTO_WAIT_MAP)) {
|
|
53
|
+
if (cmd.startsWith(prefix + '.')) return waitCmd;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Send a single tool request over socket
|
|
61
|
+
* @param {string} toolName - Tool/command name
|
|
62
|
+
* @param {object} toolArgs - Tool arguments
|
|
63
|
+
* @param {object} context - Execution context (tabId, windowId)
|
|
64
|
+
* @returns {Promise<object>} - Response from host
|
|
65
|
+
*/
|
|
66
|
+
function sendDoRequest(toolName, toolArgs, context = {}) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const sock = net.createConnection(SOCKET_PATH, () => {
|
|
69
|
+
const req = {
|
|
70
|
+
type: "tool_request",
|
|
71
|
+
method: "execute_tool",
|
|
72
|
+
params: { tool: toolName, args: toolArgs },
|
|
73
|
+
id: "do-" + Date.now() + "-" + Math.random(),
|
|
74
|
+
};
|
|
75
|
+
if (context.tabId) req.tabId = context.tabId;
|
|
76
|
+
if (context.windowId) req.windowId = context.windowId;
|
|
77
|
+
sock.write(JSON.stringify(req) + "\n");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
let buf = "";
|
|
81
|
+
sock.on("data", (d) => {
|
|
82
|
+
buf += d.toString();
|
|
83
|
+
const lines = buf.split("\n");
|
|
84
|
+
buf = lines.pop();
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (!line.trim()) continue;
|
|
87
|
+
try {
|
|
88
|
+
const resp = JSON.parse(line);
|
|
89
|
+
sock.end();
|
|
90
|
+
resolve(resp);
|
|
91
|
+
} catch {
|
|
92
|
+
sock.end();
|
|
93
|
+
reject(new Error("Invalid JSON response"));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
sock.on("error", (e) => {
|
|
99
|
+
if (e.code === "ENOENT") {
|
|
100
|
+
reject(new Error("Socket not found. Is Chrome running with the extension?"));
|
|
101
|
+
} else if (e.code === "ECONNREFUSED") {
|
|
102
|
+
reject(new Error("Connection refused. Native host not running."));
|
|
103
|
+
} else {
|
|
104
|
+
reject(e);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const timeoutId = setTimeout(() => {
|
|
109
|
+
sock.destroy();
|
|
110
|
+
reject(new Error("Request timeout"));
|
|
111
|
+
}, 30000);
|
|
112
|
+
|
|
113
|
+
sock.on("close", () => clearTimeout(timeoutId));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Substitute variables in arguments using %{varname} syntax
|
|
119
|
+
* @param {object} args - Arguments object
|
|
120
|
+
* @param {object} vars - Variables map
|
|
121
|
+
* @returns {object} - Arguments with variables substituted
|
|
122
|
+
*/
|
|
123
|
+
function substituteVars(args, vars) {
|
|
124
|
+
if (!args || typeof args !== 'object') return args;
|
|
125
|
+
|
|
126
|
+
const result = {};
|
|
127
|
+
for (const [key, val] of Object.entries(args)) {
|
|
128
|
+
if (typeof val === 'string') {
|
|
129
|
+
result[key] = val.replace(/%\{(\w+)\}/g, (_, name) => vars[name] ?? `%{${name}}`);
|
|
130
|
+
} else {
|
|
131
|
+
result[key] = val;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Execute all workflow steps sequentially
|
|
139
|
+
* @param {Array<{ cmd: string, args: object }>} steps - Steps to execute
|
|
140
|
+
* @param {object} options - Execution options
|
|
141
|
+
* @returns {Promise<object>} - Execution result
|
|
142
|
+
*/
|
|
143
|
+
async function executeDoSteps(steps, options = {}) {
|
|
144
|
+
const {
|
|
145
|
+
onError = 'stop',
|
|
146
|
+
autoWait = true,
|
|
147
|
+
stepDelay = 100,
|
|
148
|
+
context = {},
|
|
149
|
+
quiet = false, // For --json mode, suppress streaming output
|
|
150
|
+
} = options;
|
|
151
|
+
|
|
152
|
+
const results = [];
|
|
153
|
+
const vars = context.vars || {};
|
|
154
|
+
const total = steps.length;
|
|
155
|
+
let failed = 0;
|
|
156
|
+
const startTotal = Date.now();
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < total; i++) {
|
|
159
|
+
const step = steps[i];
|
|
160
|
+
const startTime = Date.now();
|
|
161
|
+
const stepNum = `[${i + 1}/${total}]`;
|
|
162
|
+
|
|
163
|
+
// Build description (matches --script output format)
|
|
164
|
+
const argSummary = Object.entries(step.args || {})
|
|
165
|
+
.map(([k, v]) => typeof v === "string" && v.length > 40
|
|
166
|
+
? `${k}="${v.slice(0, 37)}..."`
|
|
167
|
+
: `${k}=${JSON.stringify(v)}`)
|
|
168
|
+
.join(" ");
|
|
169
|
+
const desc = argSummary ? `${step.cmd} ${argSummary}` : step.cmd;
|
|
170
|
+
|
|
171
|
+
// Print step prefix (streaming output)
|
|
172
|
+
if (!quiet) {
|
|
173
|
+
process.stdout.write(`${stepNum} ${desc} ... `);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// Substitute variables in args
|
|
178
|
+
const resolvedArgs = substituteVars(step.args, vars);
|
|
179
|
+
|
|
180
|
+
const resp = await sendDoRequest(step.cmd, resolvedArgs, context);
|
|
181
|
+
const ms = Date.now() - startTime;
|
|
182
|
+
|
|
183
|
+
if (resp.error) {
|
|
184
|
+
const errText = resp.error.content?.[0]?.text || JSON.stringify(resp.error);
|
|
185
|
+
|
|
186
|
+
if (!quiet) {
|
|
187
|
+
console.log(`FAIL`);
|
|
188
|
+
console.log(` Error: ${errText}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: errText, ms });
|
|
192
|
+
failed++;
|
|
193
|
+
|
|
194
|
+
if (onError === 'stop') {
|
|
195
|
+
return {
|
|
196
|
+
status: 'failed',
|
|
197
|
+
completedSteps: i,
|
|
198
|
+
totalSteps: total,
|
|
199
|
+
results,
|
|
200
|
+
error: errText,
|
|
201
|
+
totalMs: Date.now() - startTotal
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
if (!quiet) {
|
|
206
|
+
console.log(`OK (${ms}ms)`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
results.push({ step: i + 1, cmd: step.cmd, status: 'ok', ms });
|
|
210
|
+
|
|
211
|
+
// Command-specific auto-wait
|
|
212
|
+
if (autoWait) {
|
|
213
|
+
const waitCmd = getAutoWaitCommand(step.cmd);
|
|
214
|
+
if (waitCmd) {
|
|
215
|
+
const waitArgs = waitCmd === 'wait.load'
|
|
216
|
+
? { timeout: 10000 }
|
|
217
|
+
: { stable: 100, timeout: 5000 };
|
|
218
|
+
try {
|
|
219
|
+
await sendDoRequest(waitCmd, waitArgs, context);
|
|
220
|
+
} catch {
|
|
221
|
+
// Ignore auto-wait failures silently
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Fixed delay between steps
|
|
228
|
+
if (stepDelay > 0 && i < total - 1) {
|
|
229
|
+
await new Promise(r => setTimeout(r, stepDelay));
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const ms = Date.now() - startTime;
|
|
233
|
+
|
|
234
|
+
if (!quiet) {
|
|
235
|
+
console.log(`FAIL`);
|
|
236
|
+
console.log(` Error: ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: err.message, ms });
|
|
240
|
+
failed++;
|
|
241
|
+
|
|
242
|
+
if (onError === 'stop') {
|
|
243
|
+
return {
|
|
244
|
+
status: 'failed',
|
|
245
|
+
completedSteps: i,
|
|
246
|
+
totalSteps: total,
|
|
247
|
+
results,
|
|
248
|
+
error: err.message,
|
|
249
|
+
totalMs: Date.now() - startTotal
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
status: failed > 0 ? 'partial' : 'completed',
|
|
257
|
+
completedSteps: total - failed,
|
|
258
|
+
totalSteps: total,
|
|
259
|
+
results,
|
|
260
|
+
failed,
|
|
261
|
+
totalMs: Date.now() - startTotal
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
executeDoSteps,
|
|
267
|
+
sendDoRequest,
|
|
268
|
+
shouldAutoWait,
|
|
269
|
+
getAutoWaitCommand,
|
|
270
|
+
substituteVars,
|
|
271
|
+
AUTO_WAIT_COMMANDS,
|
|
272
|
+
AUTO_WAIT_MAP
|
|
273
|
+
};
|