surf-cli 2.18.0 → 2.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/README.md +107 -5
- package/native/cli.cjs +322 -9
- package/native/doctor.cjs +11 -2
- package/native/endpoint.cjs +121 -36
- package/native/extract.cjs +362 -0
- package/native/host-helpers.cjs +39 -2
- package/native/host-sessions.cjs +10 -0
- package/native/host.cjs +29 -21
- package/native/mcp-server.cjs +25 -0
- package/native/script-options.cjs +33 -0
- package/native/stdin-frames.cjs +33 -0
- package/native/tool-scope.cjs +2 -2
- package/package.json +6 -6
- package/skills/surf/SKILL.md +18 -0
package/native/host-sessions.cjs
CHANGED
|
@@ -15,6 +15,9 @@ const QUEUE_TIMEOUT_MS = 60000;
|
|
|
15
15
|
const DEFAULT_DEADLINE_MS = 60000;
|
|
16
16
|
const MAX_DEADLINE_MS = 50 * 60 * 1000;
|
|
17
17
|
const CLEANUP_GRACE_MS = 60000;
|
|
18
|
+
const READINESS_DEFAULT_TIMEOUT_MS = 20000;
|
|
19
|
+
const READINESS_MAX_TIMEOUT_MS = 120000;
|
|
20
|
+
const READINESS_DEADLINE_GRACE_MS = 5000;
|
|
18
21
|
const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
|
|
19
22
|
ai: 300,
|
|
20
23
|
aistudio: 300,
|
|
@@ -29,6 +32,13 @@ const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
|
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
function resolveRequestDeadlineMs(tool, args = {}) {
|
|
35
|
+
if (tool === "wait.ready") {
|
|
36
|
+
const requestedMs = Number(args?.timeout);
|
|
37
|
+
const timeoutMs = Number.isFinite(requestedMs) && requestedMs > 0
|
|
38
|
+
? Math.min(requestedMs, READINESS_MAX_TIMEOUT_MS)
|
|
39
|
+
: READINESS_DEFAULT_TIMEOUT_MS;
|
|
40
|
+
return timeoutMs + READINESS_DEADLINE_GRACE_MS;
|
|
41
|
+
}
|
|
32
42
|
const defaultSeconds = PROVIDER_DEFAULT_TIMEOUT_SECONDS[tool];
|
|
33
43
|
if (defaultSeconds === undefined) return DEFAULT_DEADLINE_MS;
|
|
34
44
|
const rawTimeout = tool === "playbook.run" && args && typeof args === "object" && !Array.isArray(args)
|
package/native/host.cjs
CHANGED
|
@@ -20,6 +20,7 @@ const { createOracleHost } = require("./oracle-host.cjs");
|
|
|
20
20
|
|
|
21
21
|
const IS_WIN = process.platform === "win32";
|
|
22
22
|
const { SOCKET_PATH, SURF_TMP } = require("./socket-path.cjs");
|
|
23
|
+
const { takeFrames } = require("./stdin-frames.cjs");
|
|
23
24
|
const { parseListenEndpoint } = require("./listener.cjs");
|
|
24
25
|
const { getStateDir } = require("./remote-auth.cjs");
|
|
25
26
|
const { createFrameParser, createServerAuthSession, createSocketWriter, isClientAuthorized, writeFrame, MAX_FRAME_BYTES } = require("./remote-transport.cjs");
|
|
@@ -1765,7 +1766,12 @@ function sendToolResponse(socket, id, result, error) {
|
|
|
1765
1766
|
}
|
|
1766
1767
|
if (request?.notice) response.notice = request.notice;
|
|
1767
1768
|
if (formattedError) response.error = formattedError;
|
|
1768
|
-
else
|
|
1769
|
+
else {
|
|
1770
|
+
response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
|
|
1771
|
+
if (request?.tool === "tab.new" && Number.isInteger(output?.tabId) && output.tabId > 0) {
|
|
1772
|
+
response.result.tabId = output.tabId;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1769
1775
|
if (!context?.closed) await sendSocket(socket, response);
|
|
1770
1776
|
})().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
|
|
1771
1777
|
}
|
|
@@ -2805,25 +2811,23 @@ function writeMessage(msg) {
|
|
|
2805
2811
|
let inputBuffer = Buffer.alloc(0);
|
|
2806
2812
|
|
|
2807
2813
|
function processInput() {
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
inputBuffer = inputBuffer.slice(4 + msgLen);
|
|
2814
|
-
|
|
2814
|
+
// Take every complete frame out of the buffer before dispatching: one
|
|
2815
|
+
// chunk routinely carries a TARGET_EVENT and the reply to a tool request.
|
|
2816
|
+
const { frames, rest } = takeFrames(inputBuffer);
|
|
2817
|
+
inputBuffer = rest;
|
|
2818
|
+
for (const jsonStr of frames) {
|
|
2815
2819
|
try {
|
|
2816
2820
|
const msg = JSON.parse(jsonStr);
|
|
2817
2821
|
log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
|
|
2818
2822
|
|
|
2819
2823
|
if (msg.type === "EXTENSION_HELLO") {
|
|
2820
2824
|
setBrowserIdentity(msg);
|
|
2821
|
-
|
|
2825
|
+
continue;
|
|
2822
2826
|
}
|
|
2823
2827
|
|
|
2824
2828
|
if (msg.type === "TARGET_EVENT") {
|
|
2825
2829
|
handleTargetEvent(msg);
|
|
2826
|
-
|
|
2830
|
+
continue;
|
|
2827
2831
|
}
|
|
2828
2832
|
|
|
2829
2833
|
if (msg.type === "GET_AUTH") {
|
|
@@ -2847,12 +2851,12 @@ function processInput() {
|
|
|
2847
2851
|
hint: "Failed to read auth credentials. Run 'pi --login anthropic' in terminal to authenticate."
|
|
2848
2852
|
});
|
|
2849
2853
|
}
|
|
2850
|
-
|
|
2854
|
+
continue;
|
|
2851
2855
|
}
|
|
2852
2856
|
|
|
2853
2857
|
if (msg.type === "API_REQUEST") {
|
|
2854
2858
|
handleApiRequest(msg, writeMessage);
|
|
2855
|
-
|
|
2859
|
+
continue;
|
|
2856
2860
|
}
|
|
2857
2861
|
|
|
2858
2862
|
if (msg.type === "PLAYBOOK_WATCH_EVENT") {
|
|
@@ -2865,14 +2869,14 @@ function processInput() {
|
|
|
2865
2869
|
tabId: msg.tabId,
|
|
2866
2870
|
timestamp: msg.timestamp || new Date().toISOString(),
|
|
2867
2871
|
});
|
|
2868
|
-
|
|
2872
|
+
continue;
|
|
2869
2873
|
}
|
|
2870
2874
|
|
|
2871
2875
|
if (msg.type === "VIDEO_FRAME") {
|
|
2872
2876
|
if (activeVideoRecorder && msg.recorderId === activeVideoRecorder.recorderId && msg.tabId === activeVideoRecorder.tabId) {
|
|
2873
2877
|
activeVideoRecorder.recorder.addFrame(msg.data, Number.isFinite(msg.receivedAt) ? msg.receivedAt : Date.now());
|
|
2874
2878
|
}
|
|
2875
|
-
|
|
2879
|
+
continue;
|
|
2876
2880
|
}
|
|
2877
2881
|
|
|
2878
2882
|
if (msg.type === "VIDEO_ERROR") {
|
|
@@ -2882,7 +2886,7 @@ function processInput() {
|
|
|
2882
2886
|
msg.error || "Video screencast failed",
|
|
2883
2887
|
));
|
|
2884
2888
|
}
|
|
2885
|
-
|
|
2889
|
+
continue;
|
|
2886
2890
|
}
|
|
2887
2891
|
|
|
2888
2892
|
if (msg.type === "STREAM_EVENT") {
|
|
@@ -2894,7 +2898,7 @@ function processInput() {
|
|
|
2894
2898
|
stream.socket.destroy(error);
|
|
2895
2899
|
});
|
|
2896
2900
|
}
|
|
2897
|
-
|
|
2901
|
+
continue;
|
|
2898
2902
|
}
|
|
2899
2903
|
|
|
2900
2904
|
if (msg.type === "STREAM_ERROR") {
|
|
@@ -2907,7 +2911,7 @@ function processInput() {
|
|
|
2907
2911
|
})
|
|
2908
2912
|
.finally(() => stopActiveStream(msg.streamId));
|
|
2909
2913
|
}
|
|
2910
|
-
|
|
2914
|
+
continue;
|
|
2911
2915
|
}
|
|
2912
2916
|
|
|
2913
2917
|
|
|
@@ -2920,13 +2924,13 @@ function processInput() {
|
|
|
2920
2924
|
if (topLevelResponse && request?.context) {
|
|
2921
2925
|
completeOwnedRequest(request.context, request.id, "cleanup-settled");
|
|
2922
2926
|
}
|
|
2923
|
-
|
|
2927
|
+
continue;
|
|
2924
2928
|
}
|
|
2925
2929
|
handleFrameContextFailure(pending.request, msg);
|
|
2926
2930
|
updateFrameContextFromResult(pending.request, pending.tool, msg);
|
|
2927
2931
|
if (pending.resolve || pending.onComplete) {
|
|
2928
2932
|
pendingToolRequests.resolve(msg.id, msg);
|
|
2929
|
-
|
|
2933
|
+
continue;
|
|
2930
2934
|
}
|
|
2931
2935
|
pendingToolRequests.delete(msg.id);
|
|
2932
2936
|
{
|
|
@@ -2935,7 +2939,11 @@ function processInput() {
|
|
|
2935
2939
|
const tabId = storedTabId || msg._resolvedTabId;
|
|
2936
2940
|
const failAutoScreenshot = (message) => pending.autoScreenshotOutput
|
|
2937
2941
|
? sendToolResponse(socket, originalId, null, `Auto-screenshot failed: ${message}`)
|
|
2938
|
-
: sendToolResponse(socket, originalId, {
|
|
2942
|
+
: sendToolResponse(socket, originalId, {
|
|
2943
|
+
...msg,
|
|
2944
|
+
screenshotError: message,
|
|
2945
|
+
autoScreenshotError: message,
|
|
2946
|
+
}, null);
|
|
2939
2947
|
|
|
2940
2948
|
if (pending.networkExport && Array.isArray(msg.entries)) {
|
|
2941
2949
|
try {
|
|
@@ -3026,7 +3034,7 @@ function processInput() {
|
|
|
3026
3034
|
}
|
|
3027
3035
|
})
|
|
3028
3036
|
.catch((error) => failAutoScreenshot(error.message));
|
|
3029
|
-
|
|
3037
|
+
continue;
|
|
3030
3038
|
} else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
|
|
3031
3039
|
failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
|
|
3032
3040
|
} else if (msg.results && msg.savePath) {
|
package/native/mcp-server.cjs
CHANGED
|
@@ -159,6 +159,27 @@ const TOOL_SCHEMAS = {
|
|
|
159
159
|
timeout: z.number().optional().describe("Max wait time in ms")
|
|
160
160
|
}
|
|
161
161
|
},
|
|
162
|
+
"wait.ready": {
|
|
163
|
+
desc: "Wait until the page is ready, or fail fast with a typed state (login, challenge, not-found, error)",
|
|
164
|
+
schema: {
|
|
165
|
+
selector: z.string().optional().describe("Visible CSS selector that marks a ready page"),
|
|
166
|
+
text: z.string().optional().describe("Page text that marks a ready page"),
|
|
167
|
+
urlPrefix: z.string().optional().describe("Expected URL prefix; anything else is a bounce"),
|
|
168
|
+
emptyText: z.string().optional().describe("Text of an explicit no-results render (state 'empty')"),
|
|
169
|
+
accept: z.string().optional().describe("Negative states to return instead of fail, comma-separated"),
|
|
170
|
+
timeout: z.number().optional().describe("Max wait time in ms (default 20000, max 120000)"),
|
|
171
|
+
interval: z.number().optional().describe("Poll interval in ms (default 400)")
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
"page.readiness": {
|
|
175
|
+
desc: "Classify the page once: ready, empty, loading, login, challenge, not-found, error",
|
|
176
|
+
schema: {
|
|
177
|
+
selector: z.string().optional().describe("Visible CSS selector that marks a ready page"),
|
|
178
|
+
text: z.string().optional().describe("Page text that marks a ready page"),
|
|
179
|
+
urlPrefix: z.string().optional().describe("Expected URL prefix"),
|
|
180
|
+
emptyText: z.string().optional().describe("Text of an explicit no-results render")
|
|
181
|
+
}
|
|
182
|
+
},
|
|
162
183
|
"wait.load": {
|
|
163
184
|
desc: "Wait for page to fully load",
|
|
164
185
|
schema: { timeout: z.number().optional().describe("Max wait time in ms") }
|
|
@@ -242,6 +263,10 @@ const TOOL_SCHEMAS = {
|
|
|
242
263
|
desc: "List all frames in page",
|
|
243
264
|
schema: {}
|
|
244
265
|
},
|
|
266
|
+
"frame.diagnose": {
|
|
267
|
+
desc: "Compare DOM iframes, extension frames (with content-script reachability) and the CDP frame tree, with warnings",
|
|
268
|
+
schema: {}
|
|
269
|
+
},
|
|
245
270
|
"frame.js": {
|
|
246
271
|
desc: "Execute JS in specific frame",
|
|
247
272
|
schema: {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
3
|
+
const proto = Object.getPrototypeOf(value);
|
|
4
|
+
return proto === Object.prototype || proto === null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function parseScriptOptions(input) {
|
|
8
|
+
if (input === undefined || input === null || input === "") return {};
|
|
9
|
+
if (input === true) throw new Error("--options needs a JSON object value");
|
|
10
|
+
let value = input;
|
|
11
|
+
if (typeof input === "string") {
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(input);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new Error(`--options is not valid JSON: ${error.message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (!isPlainObject(value)) {
|
|
19
|
+
throw new Error("--options must be a JSON object, e.g. '{\"limit\": 20}'");
|
|
20
|
+
}
|
|
21
|
+
// Round-trip so functions, undefined and prototypes cannot leak into the page.
|
|
22
|
+
return JSON.parse(JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function applyOptionsPrelude(code, options) {
|
|
26
|
+
const normalized = parseScriptOptions(options);
|
|
27
|
+
const prelude = `const SURF_OPTIONS = Object.freeze(JSON.parse(${JSON.stringify(JSON.stringify(normalized))}));\n`;
|
|
28
|
+
const strict = code.match(/^\s*(["'])use strict\1\s*;/);
|
|
29
|
+
if (!strict) return `${prelude}${code}`;
|
|
30
|
+
return `${strict[0]}\n${prelude}${code.slice(strict[0].length)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { applyOptionsPrelude, parseScriptOptions };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native messaging framing: every message from the extension arrives as a
|
|
3
|
+
* 4-byte little-endian length followed by that many bytes of UTF-8 JSON.
|
|
4
|
+
*
|
|
5
|
+
* One stdin chunk routinely carries several complete frames (a TARGET_EVENT
|
|
6
|
+
* followed by the reply to a tool request is the common case), so a reader
|
|
7
|
+
* must take every complete frame out of the buffer before waiting for more
|
|
8
|
+
* input. Leaving one behind stalls that reply until the extension sends
|
|
9
|
+
* something else.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HEADER_BYTES = 4;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split `buffer` into complete frames and the unread remainder.
|
|
16
|
+
*
|
|
17
|
+
* @param {Buffer} buffer
|
|
18
|
+
* @returns {{ frames: string[], rest: Buffer }} decoded frame payloads in
|
|
19
|
+
* arrival order, and the bytes of any trailing partial frame.
|
|
20
|
+
*/
|
|
21
|
+
function takeFrames(buffer) {
|
|
22
|
+
const frames = [];
|
|
23
|
+
let offset = 0;
|
|
24
|
+
while (buffer.length - offset >= HEADER_BYTES) {
|
|
25
|
+
const length = buffer.readUInt32LE(offset);
|
|
26
|
+
if (buffer.length - offset < HEADER_BYTES + length) break;
|
|
27
|
+
frames.push(buffer.subarray(offset + HEADER_BYTES, offset + HEADER_BYTES + length).toString("utf8"));
|
|
28
|
+
offset += HEADER_BYTES + length;
|
|
29
|
+
}
|
|
30
|
+
return { frames, rest: offset === 0 ? buffer : buffer.subarray(offset) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { takeFrames };
|
package/native/tool-scope.cjs
CHANGED
|
@@ -45,8 +45,8 @@ const TAB_TOOLS = new Set([
|
|
|
45
45
|
"scroll", "scroll.top", "scroll.bottom", "scroll.to", "scroll.info", "scroll_to_position",
|
|
46
46
|
"search", "locate.role", "locate.text", "locate.label", "element.styles",
|
|
47
47
|
"js", "javascript_tool", "eval",
|
|
48
|
-
"wait.element", "wait.url", "wait.network", "wait.dom", "wait.load", "health",
|
|
49
|
-
"frame.list", "frame.switch", "frame.main", "frame.js",
|
|
48
|
+
"wait.element", "wait.url", "wait.network", "wait.dom", "wait.load", "wait.ready", "page.readiness", "health",
|
|
49
|
+
"frame.list", "frame.diagnose", "frame.switch", "frame.main", "frame.js",
|
|
50
50
|
"dialog.accept", "dialog.dismiss", "dialog.info",
|
|
51
51
|
"console", "network", "network.get", "network.body", "network.curl", "network.path",
|
|
52
52
|
"network.origins", "network.clear", "network.stats", "network.export",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "surf-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chrome",
|
|
@@ -68,14 +68,14 @@
|
|
|
68
68
|
"@biomejs/biome": "^2.5.4",
|
|
69
69
|
"@types/chrome": "^0.2.2",
|
|
70
70
|
"@types/node": "^26.1.2",
|
|
71
|
-
"@vitest/coverage-v8": "^
|
|
72
|
-
"@vitest/ui": "^
|
|
73
|
-
"pi-subagents": "^0.
|
|
74
|
-
"puppeteer": "25.
|
|
71
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
72
|
+
"@vitest/ui": "^5.0.0",
|
|
73
|
+
"pi-subagents": "^0.66.0",
|
|
74
|
+
"puppeteer": "25.10.0",
|
|
75
75
|
"typebox": "^1.3.11",
|
|
76
76
|
"typescript": "^7.0.2",
|
|
77
77
|
"vite": "^8.1.4",
|
|
78
|
-
"vitest": "^
|
|
78
|
+
"vitest": "^5.0.0"
|
|
79
79
|
},
|
|
80
80
|
"pi": {
|
|
81
81
|
"extensions": [
|
package/skills/surf/SKILL.md
CHANGED
|
@@ -7,6 +7,8 @@ description: Control Chrome browser via CLI for testing, automation, and debuggi
|
|
|
7
7
|
|
|
8
8
|
Control Chrome browser via CLI or Unix socket.
|
|
9
9
|
|
|
10
|
+
Ordinary socket-backed CLI commands report top-level host tool-response errors on stderr with a supplied `[code]` on the first line and exit 1. `--json` additionally writes `{error:{code,message,details?}}` on stdout (missing code becomes `"error"`). `--soft-fail` instead keeps the original stderr warning, empty stdout and exit 0, even with `--json`. This does not cover local validation, transport/parser failures or compound-command errors: do not assume every failure produces JSON. Connection failures remain stderr-only and exit 1, including with `--soft-fail`.
|
|
11
|
+
|
|
10
12
|
## Native Host / Socket Notes
|
|
11
13
|
|
|
12
14
|
For WSL2 with Windows Chrome, run `surf install <extension-id>` inside WSL2. Surf detects WSL2 and writes the Windows-side native messaging manifest plus a wrapper that launches the WSL host. Use `surf install <extension-id> --target linux` only for Linux browsers running inside WSLg.
|
|
@@ -34,9 +36,18 @@ surf --remote 100.101.102.103:4321 \
|
|
|
34
36
|
--remote-credential ~/.config/surf/agent-macbook.json \
|
|
35
37
|
page.read
|
|
36
38
|
|
|
39
|
+
# TLS is client-side and requires a TLS-terminating reverse proxy in front of SURF_LISTEN
|
|
40
|
+
surf --remote surf.example.com:443 --remote-tls \
|
|
41
|
+
--remote-tls-ca ~/.config/surf/private-ca.pem \
|
|
42
|
+
--remote-credential ~/.config/surf/agent-macbook.json page.read
|
|
43
|
+
|
|
37
44
|
surf remote revoke agent-macbook # Run on the browser host
|
|
38
45
|
```
|
|
39
46
|
|
|
47
|
+
Environment equivalents are `SURF_REMOTE_TLS=1`, `SURF_REMOTE_TLS_CA`, and
|
|
48
|
+
`SURF_REMOTE_TLS_SERVER_NAME`. A custom CA replaces system roots; Ed25519 credentials remain
|
|
49
|
+
mandatory after TLS validation.
|
|
50
|
+
|
|
40
51
|
Remote paths are client-local by default. `local:./file` is explicit client-local syntax; only `remote:/absolute/path` accesses the browser host directly. Remote transfer supports one upload or ChatGPT/Gemini input and one screenshot, network-export, or Gemini image output. Limits are 256 MiB per file, 512 MiB and 32 files per connection, and 256 KiB decoded chunks. `record`, `aistudio.build`, smoke screenshot directories, directories, and multi-file inputs are not supported remotely. Successful action screenshots and failure `--auto-capture` diagnostics are transferred back to client-local paths.
|
|
41
52
|
|
|
42
53
|
## CLI Quick Reference
|
|
@@ -321,6 +332,7 @@ surf page.text # Plain text content only
|
|
|
321
332
|
surf page.html --strip-scripts # Rendered HTML without scripts
|
|
322
333
|
surf page.save --selector "#artifact" --strip-scripts --output page.html # Save one static element
|
|
323
334
|
surf page.state # Modals, loading state, scroll info
|
|
335
|
+
surf frame.diagnose # Why a selector misses: DOM iframes (incl. open shadow roots) vs extension frames vs CDP tree, with warnings; out-of-process frames need frame.switch, not frame.js
|
|
324
336
|
```
|
|
325
337
|
|
|
326
338
|
### Export Rendered HTML
|
|
@@ -398,8 +410,14 @@ surf wait.network # Wait for network idle
|
|
|
398
410
|
surf wait.url "/success" # Wait for URL pattern
|
|
399
411
|
surf wait.dom --stable 100 # Wait for DOM stability
|
|
400
412
|
surf wait.load # Wait for page load complete
|
|
413
|
+
surf wait.ready --selector ".results" # Ready, or fail fast: login / challenge / not-found / error
|
|
414
|
+
surf wait.ready --url-prefix "https://app.example.com/" --empty-text "No results" # empty vs blocked
|
|
415
|
+
surf wait.ready --accept login --json # Return the negative state instead of failing
|
|
416
|
+
surf page.readiness --json # Classify the current page once (state + evidence)
|
|
401
417
|
```
|
|
402
418
|
|
|
419
|
+
Typed readiness states replace "the selector never appeared": exit codes carry `page_login`, `page_challenge`, `page_not_found`, `page_error` or `page_timeout`. Detection uses visible UI (a rendered password field, a login route, the page's wording, a URL outside `--url-prefix`), not site selectors.
|
|
420
|
+
|
|
403
421
|
## Dialog Handling
|
|
404
422
|
|
|
405
423
|
```bash
|