mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1904f8141 | ||
|
|
e069abaf2e | ||
|
|
f5dab955e2 | ||
|
|
71f7e6d4b6 | ||
|
|
22ca308342 | ||
|
|
d660da87f3 | ||
|
|
a0773f3551 | ||
|
|
1c61857b64 | ||
|
|
8845325d06 | ||
|
|
f06a514bd2 | ||
|
|
1e53ee63ae | ||
|
|
120dc441bb | ||
|
|
710b1219f4 | ||
|
|
177ecd3ebf | ||
|
|
0e6b9c4542 | ||
|
|
506adbe708 | ||
|
|
984d017b0f | ||
|
|
5df93143c5 | ||
|
|
7163f2f9b0 | ||
|
|
e9f0584af3 | ||
|
|
2997f495db | ||
|
|
f96bd72c3c | ||
|
|
c48a53269e | ||
|
|
3bbd6ab19f | ||
|
|
60fbf30e24 |
@@ -325,3 +325,180 @@ jobs:
|
||||
|
||||
echo "Chrome Web Store submission created successfully:"
|
||||
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
||||
|
||||
publish-edge-addons:
|
||||
needs: [resolve, build-extension]
|
||||
runs-on: ubuntu-latest
|
||||
environment: edge-addons
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: extension-zip
|
||||
path: dist
|
||||
|
||||
- name: Validate Microsoft Edge Add-ons configuration
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo "::error::Environment secret EDGE_ADDONS_API_KEY is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CLIENT_ID" ]; then
|
||||
echo "::error::Environment variable EDGE_ADDONS_CLIENT_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PRODUCT_ID" ]; then
|
||||
echo "::error::Environment variable EDGE_ADDONS_PRODUCT_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$PRODUCT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||
echo "::error::EDGE_ADDONS_PRODUCT_ID must be the Partner Center product GUID, not the public Edge Add-ons extension ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
|
||||
if [ -z "$ARCHIVE" ]; then
|
||||
echo "::error::No extension zip found in the downloaded artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload and publish Microsoft Edge Add-ons extension
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API_ROOT="https://api.addons.microsoftedge.microsoft.com/v1"
|
||||
UPLOAD_HEADERS="$RUNNER_TEMP/edge-upload-headers.txt"
|
||||
UPLOAD_RESPONSE="$RUNNER_TEMP/edge-upload-response.json"
|
||||
STATUS_RESPONSE="$RUNNER_TEMP/edge-status-response.json"
|
||||
PUBLISH_HEADERS="$RUNNER_TEMP/edge-publish-headers.txt"
|
||||
PUBLISH_RESPONSE="$RUNNER_TEMP/edge-publish-response.json"
|
||||
|
||||
operation_id_from_headers() {
|
||||
local headers="$1"
|
||||
local location
|
||||
location="$(awk '
|
||||
tolower($1) == "location:" {
|
||||
sub(/\r$/, "")
|
||||
sub(/^[^:]*:[[:space:]]*/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$headers")"
|
||||
location="${location%%\?*}"
|
||||
printf '%s' "${location##*/}"
|
||||
}
|
||||
|
||||
poll_operation() {
|
||||
local operation_name="$1"
|
||||
local status_url="$2"
|
||||
local status="InProgress"
|
||||
local http_status
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
sleep 5
|
||||
http_status="$(curl --silent --show-error \
|
||||
--output "$STATUS_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
"$status_url")"
|
||||
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name status check failed with HTTP $http_status"
|
||||
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status="$(jq -r '.status // empty' "$STATUS_RESPONSE")"
|
||||
echo "$operation_name status check $attempt/30: $status"
|
||||
case "$status" in
|
||||
Succeeded)
|
||||
return 0
|
||||
;;
|
||||
InProgress)
|
||||
;;
|
||||
Failed)
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name failed"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::Microsoft Edge Add-ons returned an unknown $operation_name status: $status"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name did not complete after 30 checks"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$UPLOAD_RESPONSE" \
|
||||
--dump-header "$UPLOAD_HEADERS" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
--header "Content-Type: application/zip" \
|
||||
--data-binary "@$ARCHIVE" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package")"
|
||||
if [ "$HTTP_STATUS" != "202" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons upload failed with HTTP $HTTP_STATUS"
|
||||
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_OPERATION_ID="$(operation_id_from_headers "$UPLOAD_HEADERS")"
|
||||
if [ -z "$UPLOAD_OPERATION_ID" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons upload response has no Location operation ID"
|
||||
cat "$UPLOAD_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
poll_operation \
|
||||
"package upload" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package/operations/$UPLOAD_OPERATION_ID"
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$PUBLISH_RESPONSE" \
|
||||
--dump-header "$PUBLISH_HEADERS" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{\"notes\":\"Automated BrowserSkill extension v${RELEASE_VERSION} release from GitHub Actions.\"}" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions")"
|
||||
if [ "$HTTP_STATUS" != "202" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons publish failed with HTTP $HTTP_STATUS"
|
||||
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PUBLISH_OPERATION_ID="$(operation_id_from_headers "$PUBLISH_HEADERS")"
|
||||
if [ -z "$PUBLISH_OPERATION_ID" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons publish response has no Location operation ID"
|
||||
cat "$PUBLISH_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
poll_operation \
|
||||
"submission" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/operations/$PUBLISH_OPERATION_ID"
|
||||
|
||||
echo "Microsoft Edge Add-ons submission created successfully:"
|
||||
jq '{status, message, errorCode, errors}' "$STATUS_RESPONSE"
|
||||
|
||||
@@ -247,6 +247,41 @@ describe("App", () => {
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats protocol drift as still connected and prompts an upgrade", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "version_skew",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "version_skew",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("已连接")).toBeTruthy();
|
||||
expect(screen.getByText("可升级")).toBeTruthy();
|
||||
expect(screen.queryByText("协议不一致")).toBeNull();
|
||||
expect(screen.queryByText("Action needed")).toBeNull();
|
||||
expect(screen.queryByText("兼容")).toBeNull();
|
||||
const warning = screen.getByText(/协议版本不同,请及时升级/);
|
||||
expect(warning.textContent).toContain("CLI 协议");
|
||||
expect(warning.textContent).toContain("扩展协议");
|
||||
|
||||
openRecordView();
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
fireEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control hints toggle", () => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export function App() {
|
||||
}, [copiedTick]);
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const connectionLive = statusState === "connected" || isSkewed;
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||
const extensionVersion = snapshot.extensionVersion || "—";
|
||||
@@ -90,7 +91,7 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
@@ -206,12 +207,12 @@ export function App() {
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
cliProtocol: daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type SurfaceCaptureInput, SurfaceCaptureStore } from "../surface-capture-store";
|
||||
|
||||
function input(): SurfaceCaptureInput {
|
||||
return {
|
||||
sessionId: "aa11",
|
||||
tabId: 7,
|
||||
navigationIdentity: "navigation",
|
||||
surface: {
|
||||
ref: "e3",
|
||||
frameId: "main",
|
||||
backendNodeId: 99,
|
||||
observationGeneration: 2,
|
||||
},
|
||||
topViewportRect: { x: 10, y: 20, w: 100, h: 50 },
|
||||
imageWidth: 200,
|
||||
imageHeight: 100,
|
||||
viewportSignature: "viewport",
|
||||
frameProjectionSignature: "frame",
|
||||
};
|
||||
}
|
||||
|
||||
describe("SurfaceCaptureStore", () => {
|
||||
it("creates metadata-only, short-lived captures and consumes them once", () => {
|
||||
let now = 1_000;
|
||||
const store = new SurfaceCaptureStore({
|
||||
now: () => now,
|
||||
ttlMs: 500,
|
||||
createId: () => "sc_test",
|
||||
});
|
||||
const capture = store.create(input());
|
||||
|
||||
expect(capture).toMatchObject({
|
||||
id: "sc_test",
|
||||
createdAt: 1_000,
|
||||
expiresAt: 1_500,
|
||||
consumed: false,
|
||||
});
|
||||
expect(capture).not.toHaveProperty("imageBase64");
|
||||
expect(store.consume("sc_test")).toMatchObject({ ok: true });
|
||||
expect(store.consume("sc_test")).toEqual({ ok: false, reason: "consumed" });
|
||||
|
||||
now = 1_600;
|
||||
expect(store.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects expired and unknown captures", () => {
|
||||
let now = 1_000;
|
||||
const store = new SurfaceCaptureStore({
|
||||
now: () => now,
|
||||
ttlMs: 10,
|
||||
createId: () => "sc_expired",
|
||||
});
|
||||
store.create(input());
|
||||
now = 1_010;
|
||||
|
||||
expect(store.consume("sc_expired")).toEqual({ ok: false, reason: "expired" });
|
||||
expect(store.consume("sc_missing")).toEqual({ ok: false, reason: "not_found" });
|
||||
});
|
||||
|
||||
it("clears every capture on session cleanup", () => {
|
||||
const store = new SurfaceCaptureStore({ createId: () => "sc_clear" });
|
||||
store.create(input());
|
||||
store.clear();
|
||||
expect(store.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
|
||||
import { RefStore } from "./ref-store";
|
||||
import { SurfaceCaptureStore } from "./surface-capture-store";
|
||||
|
||||
export interface SessionContext {
|
||||
sessionId: string;
|
||||
agentWindowId: number;
|
||||
refStore: RefStore;
|
||||
surfaceCaptures: SurfaceCaptureStore;
|
||||
borrowedTabs: Map<number, BorrowedTab>;
|
||||
createdAtMs: number;
|
||||
}
|
||||
@@ -186,6 +188,7 @@ export class SessionManager {
|
||||
sessionId,
|
||||
agentWindowId: windowId,
|
||||
refStore: new RefStore(),
|
||||
surfaceCaptures: new SurfaceCaptureStore({ now: this.now }),
|
||||
borrowedTabs: new Map(),
|
||||
createdAtMs: this.now(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Rect } from "@browser-skill/vom";
|
||||
|
||||
export const SURFACE_CAPTURE_TTL_MS = 30_000;
|
||||
|
||||
export interface SurfaceCapture {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
tabId: number;
|
||||
navigationIdentity: string;
|
||||
surface: {
|
||||
ref: string;
|
||||
frameId?: string;
|
||||
backendNodeId: number;
|
||||
observationGeneration: number;
|
||||
};
|
||||
topViewportRect: Rect;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
viewportSignature: string;
|
||||
frameProjectionSignature: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
export type SurfaceCaptureInput = Omit<
|
||||
SurfaceCapture,
|
||||
"id" | "createdAt" | "expiresAt" | "consumed"
|
||||
>;
|
||||
|
||||
export type SurfaceCaptureConsumeResult =
|
||||
| { ok: true; capture: SurfaceCapture }
|
||||
| { ok: false; reason: "not_found" | "expired" | "consumed" };
|
||||
|
||||
export interface SurfaceCaptureStoreOptions {
|
||||
now?: () => number;
|
||||
ttlMs?: number;
|
||||
createId?: () => string;
|
||||
}
|
||||
|
||||
function randomCaptureId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
||||
return `sc_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Per-session, metadata-only store for short-lived screenshot coordinate transactions. */
|
||||
export class SurfaceCaptureStore {
|
||||
private readonly captures = new Map<string, SurfaceCapture>();
|
||||
private readonly now: () => number;
|
||||
private readonly ttlMs: number;
|
||||
private readonly createId: () => string;
|
||||
|
||||
constructor(options: SurfaceCaptureStoreOptions = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.ttlMs = options.ttlMs ?? SURFACE_CAPTURE_TTL_MS;
|
||||
this.createId = options.createId ?? randomCaptureId;
|
||||
}
|
||||
|
||||
create(input: SurfaceCaptureInput): SurfaceCapture {
|
||||
this.purgeExpired();
|
||||
const createdAt = this.now();
|
||||
const capture: SurfaceCapture = {
|
||||
...input,
|
||||
id: this.createId(),
|
||||
createdAt,
|
||||
expiresAt: createdAt + this.ttlMs,
|
||||
consumed: false,
|
||||
};
|
||||
this.captures.set(capture.id, capture);
|
||||
return capture;
|
||||
}
|
||||
|
||||
consume(id: string): SurfaceCaptureConsumeResult {
|
||||
const capture = this.captures.get(id);
|
||||
if (!capture) return { ok: false, reason: "not_found" };
|
||||
if (capture.expiresAt <= this.now()) {
|
||||
this.captures.delete(id);
|
||||
return { ok: false, reason: "expired" };
|
||||
}
|
||||
if (capture.consumed) return { ok: false, reason: "consumed" };
|
||||
capture.consumed = true;
|
||||
return { ok: true, capture };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.captures.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
this.purgeExpired();
|
||||
return this.captures.size;
|
||||
}
|
||||
|
||||
private purgeExpired(): void {
|
||||
const now = this.now();
|
||||
for (const [id, capture] of this.captures) {
|
||||
if (capture.expiresAt <= now) this.captures.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fillEditable } from "../editable-input";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
interface FakeOptions {
|
||||
initialValue?: string;
|
||||
finalValue: string;
|
||||
remount?: boolean;
|
||||
}
|
||||
|
||||
function editableState(value: string, connected = true, focused = true) {
|
||||
return {
|
||||
result: {
|
||||
value: { supported: true, connected, focused, value, kind: "input" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEditableCdp(options: FakeOptions) {
|
||||
const sent: Array<{ method: string; params?: object }> = [];
|
||||
let inserted = false;
|
||||
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object): Promise<T> => {
|
||||
sent.push({ method, params });
|
||||
if (method === "Input.insertText") {
|
||||
inserted = true;
|
||||
return {} as T;
|
||||
}
|
||||
if (method === "Input.dispatchKeyEvent") {
|
||||
if ((params as { type?: string }).type === "rawKeyDown") inserted = true;
|
||||
return {} as T;
|
||||
}
|
||||
if (method !== "Runtime.callFunctionOn") throw new Error(`unexpected ${method}`);
|
||||
|
||||
const call = params as { objectId?: string; functionDeclaration?: string };
|
||||
const fn = call.functionDeclaration ?? "";
|
||||
if (fn.includes("bsk-editable-selection")) {
|
||||
return { result: { value: { selected: true } } } as T;
|
||||
}
|
||||
if (fn.includes("bsk-editable-sync")) return { result: { value: undefined } } as T;
|
||||
if (fn.includes("bsk-editable-live")) {
|
||||
return {
|
||||
result: options.remount && inserted ? { objectId: "replacement" } : { objectId: "anchor" },
|
||||
} as T;
|
||||
}
|
||||
if (fn.includes("bsk-editable-state")) {
|
||||
if (call.objectId === "replacement") return editableState(options.finalValue) as T;
|
||||
if (options.remount && inserted) {
|
||||
return editableState(options.finalValue, false, false) as T;
|
||||
}
|
||||
return editableState(inserted ? options.finalValue : (options.initialValue ?? "")) as T;
|
||||
}
|
||||
throw new Error("unexpected Runtime.callFunctionOn function");
|
||||
});
|
||||
return { cdp: { send } as CdpRunner, sent };
|
||||
}
|
||||
|
||||
describe("fillEditable", () => {
|
||||
it("replaces text through the browser input path without synthetic events or value setters", async () => {
|
||||
const fake = makeEditableCdp({ initialValue: "old", finalValue: "新值" });
|
||||
|
||||
const result = await fillEditable(fake.cdp, 4, "anchor", "新值", true);
|
||||
|
||||
expect(result).toMatchObject({ value: "新值", replacedNode: false });
|
||||
expect(fake.sent).toContainEqual({ method: "Input.insertText", params: { text: "新值" } });
|
||||
const declarations = fake.sent
|
||||
.map((call) => (call.params as { functionDeclaration?: string })?.functionDeclaration ?? "")
|
||||
.join("\n");
|
||||
expect(declarations).not.toMatch(/dispatchEvent|\.value\s*=/);
|
||||
});
|
||||
|
||||
it("reacquires the focused editable after a framework remount", async () => {
|
||||
const fake = makeEditableCdp({ finalValue: "persisted", remount: true });
|
||||
|
||||
const result = await fillEditable(fake.cdp, 4, "anchor", "persisted", true);
|
||||
|
||||
expect(result).toMatchObject({ value: "persisted", replacedNode: true });
|
||||
});
|
||||
|
||||
it("reports a page rollback after the bounded settlement", async () => {
|
||||
const fake = makeEditableCdp({ finalValue: "" });
|
||||
|
||||
const result = await fillEditable(fake.cdp, 4, "anchor", "rejected", true);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
data: {
|
||||
reason: "input_not_applied",
|
||||
expected_value_length: 8,
|
||||
actual_value_length: 0,
|
||||
phase: "readback",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("clears to an empty value with a browser Backspace edit", async () => {
|
||||
const fake = makeEditableCdp({ initialValue: "old", finalValue: "" });
|
||||
|
||||
const result = await fillEditable(fake.cdp, 4, "anchor", "", true);
|
||||
|
||||
expect(result).toMatchObject({ value: "" });
|
||||
expect(fake.sent.some((call) => call.method === "Input.insertText")).toBe(false);
|
||||
expect(fake.sent.filter((call) => call.method === "Input.dispatchKeyEvent")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
handleHover,
|
||||
handlePress,
|
||||
handleSelect,
|
||||
handleType,
|
||||
modifiersBitfield,
|
||||
parseKeySpec,
|
||||
resolveKeyDescriptor,
|
||||
} from "../interaction";
|
||||
import { captureSurfaceEnvironment } from "../surface-target";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
@@ -33,6 +35,15 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
||||
if (!h && method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||
}
|
||||
if (
|
||||
!h &&
|
||||
method === "Runtime.evaluate" &&
|
||||
String((params as { expression?: string })?.expression ?? "").includes(
|
||||
"bsk-focus-target-probe",
|
||||
)
|
||||
) {
|
||||
return { result: { value: { hasFocus: true, activeTag: "input" } } };
|
||||
}
|
||||
if (!h) throw new Error(`unexpected CDP call ${method}`);
|
||||
return h(params);
|
||||
};
|
||||
@@ -44,6 +55,10 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
||||
params?: object,
|
||||
) => Promise<T>,
|
||||
trackSessionTab: vi.fn(),
|
||||
getFrameGraph: vi.fn(async (tabId) => ({
|
||||
rootFrameId: "main",
|
||||
frames: [{ frameId: "main", target: { tabId } }],
|
||||
})),
|
||||
};
|
||||
const tabsApi = {
|
||||
get: vi.fn(
|
||||
@@ -54,6 +69,84 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
||||
return { cdp, tabsApi, sent };
|
||||
}
|
||||
|
||||
function fillRuntimeReadback(value: string) {
|
||||
return (params: unknown) => {
|
||||
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
||||
if (fn.includes("bsk-editable-state")) {
|
||||
return {
|
||||
result: {
|
||||
value: {
|
||||
supported: true,
|
||||
connected: true,
|
||||
focused: true,
|
||||
value,
|
||||
kind: "input",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (fn.includes("bsk-editable-selection")) {
|
||||
return { result: { value: { selected: true } } };
|
||||
}
|
||||
return { result: { type: "undefined" } };
|
||||
};
|
||||
}
|
||||
|
||||
async function makeSurfaceClickFixture(onMouse?: (params: Record<string, unknown>) => void) {
|
||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const context = await manager.start("aa11");
|
||||
context.refStore.set("e3", 99, {
|
||||
tabId: 4,
|
||||
kind: "surface",
|
||||
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||
});
|
||||
const fake = makeFakeCdp({
|
||||
"Page.getFrameTree": () => ({
|
||||
frameTree: {
|
||||
frame: { id: "main", loaderId: "loader-1", url: "https://fixture.test/" },
|
||||
},
|
||||
}),
|
||||
"Page.getLayoutMetrics": () => ({
|
||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY: 0 },
|
||||
cssVisualViewport: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
scale: 1,
|
||||
},
|
||||
}),
|
||||
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
|
||||
"Runtime.evaluate": () => ({
|
||||
result: { value: { overlayHostPresent: false, overlayHostConnected: false } },
|
||||
}),
|
||||
"Input.dispatchMouseEvent": (params) => {
|
||||
onMouse?.(params as Record<string, unknown>);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const environment = await captureSurfaceEnvironment(fake.cdp, 4, undefined);
|
||||
if ("code" in environment) throw new Error(environment.message);
|
||||
const entry = context.refStore.resolveEntry("e3");
|
||||
if (!entry) throw new Error("missing Surface ref");
|
||||
const capture = context.surfaceCaptures.create({
|
||||
sessionId: "aa11",
|
||||
tabId: 4,
|
||||
navigationIdentity: environment.navigationIdentity,
|
||||
surface: {
|
||||
ref: "e3",
|
||||
backendNodeId: 99,
|
||||
observationGeneration: entry.generation,
|
||||
},
|
||||
topViewportRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||
imageWidth: 200,
|
||||
imageHeight: 80,
|
||||
viewportSignature: environment.viewportSignature,
|
||||
frameProjectionSignature: environment.frameProjectionSignature,
|
||||
});
|
||||
return { manager, context, fake, capture };
|
||||
}
|
||||
|
||||
describe("modifiersBitfield", () => {
|
||||
it("matches CDP's expected bit layout", () => {
|
||||
expect(modifiersBitfield([])).toBe(0);
|
||||
@@ -190,6 +283,74 @@ describe("handleClick", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"left",
|
||||
"middle",
|
||||
"right",
|
||||
] as const)("uses the common click executor for a %s-button Surface click", async (button) => {
|
||||
const { manager, fake, capture } = await makeSurfaceClickFixture();
|
||||
const result = await handleClick(
|
||||
manager,
|
||||
{
|
||||
session_id: "aa11",
|
||||
ref: "@e3",
|
||||
capture_id: capture.id,
|
||||
image_x: 50,
|
||||
image_y: 20,
|
||||
button,
|
||||
click_count: 2,
|
||||
modifiers: ["ctrl", "shift"],
|
||||
},
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||
expect(result).toMatchObject({
|
||||
used_ref: "e3",
|
||||
capture_id: capture.id,
|
||||
image_x: 50,
|
||||
image_y: 20,
|
||||
x: 35,
|
||||
y: 30,
|
||||
});
|
||||
const mouse = fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent");
|
||||
expect(mouse).toHaveLength(3);
|
||||
expect(mouse[1].params).toMatchObject({
|
||||
type: "mousePressed",
|
||||
button,
|
||||
clickCount: 2,
|
||||
modifiers: 2 | 8,
|
||||
});
|
||||
expect(mouse[2].params).toMatchObject({
|
||||
type: "mouseReleased",
|
||||
button,
|
||||
clickCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes a Surface capture when the common click executor fails", async () => {
|
||||
let mouseCalls = 0;
|
||||
const { manager, fake, capture } = await makeSurfaceClickFixture((params) => {
|
||||
mouseCalls += 1;
|
||||
if (params.type === "mousePressed") throw new Error("input failed");
|
||||
});
|
||||
const params = {
|
||||
session_id: "aa11",
|
||||
ref: "@e3",
|
||||
capture_id: capture.id,
|
||||
image_x: 50,
|
||||
image_y: 20,
|
||||
};
|
||||
|
||||
expect(
|
||||
await handleClick(manager, params, { cdp: fake.cdp, tabsApi: fake.tabsApi }),
|
||||
).toMatchObject({ code: "cdp_failed" });
|
||||
expect(mouseCalls).toBe(2);
|
||||
expect(
|
||||
await handleClick(manager, params, { cdp: fake.cdp, tabsApi: fake.tabsApi }),
|
||||
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
||||
});
|
||||
|
||||
it("resolves frame refs in their CDP session and dispatches input in top coordinates", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
@@ -609,7 +770,7 @@ describe("handleFill", () => {
|
||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||
"DOM.focus": () => ({}),
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
||||
"Runtime.callFunctionOn": fillRuntimeReadback("hello"),
|
||||
"Input.insertText": () => ({}),
|
||||
});
|
||||
const res = await handleFill(
|
||||
@@ -623,13 +784,13 @@ describe("handleFill", () => {
|
||||
expect(res.used_ref).toBe("e1");
|
||||
const insert = fake.sent.find((c) => c.method === "Input.insertText");
|
||||
expect(insert?.params).toEqual({ text: "hello" });
|
||||
// clear_before defaults to true → callFunctionOn invoked once to
|
||||
// clear, once to fire input/change after typing.
|
||||
// clear_before defaults to true, so the edit transaction prepares a
|
||||
// replacement selection and performs a settled live-value readback.
|
||||
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
|
||||
expect(callFns.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("clear_before=false skips the wipe call", async () => {
|
||||
it("clear_before=false skips replacement selection", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 1, { tabId: 4 });
|
||||
@@ -642,9 +803,20 @@ describe("handleFill", () => {
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
||||
"Runtime.callFunctionOn": (p) => {
|
||||
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
|
||||
// No "this.value = ''" clearing on a no-clear path; only the
|
||||
// post-input dispatchEvent.
|
||||
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
|
||||
if (fn.includes("bsk-editable-state")) {
|
||||
return {
|
||||
result: {
|
||||
value: {
|
||||
supported: true,
|
||||
connected: true,
|
||||
focused: true,
|
||||
value: "existingx",
|
||||
kind: "textarea",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
expect(fn).not.toMatch(/dispatchEvent|\.value\s*=/);
|
||||
return { result: { type: "undefined" } };
|
||||
},
|
||||
"Input.insertText": () => ({}),
|
||||
@@ -655,7 +827,7 @@ describe("handleFill", () => {
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.value_length).toBe(1);
|
||||
expect(res.value_length).toBe(9);
|
||||
});
|
||||
|
||||
it("treats contenteditable=true as fillable", async () => {
|
||||
@@ -673,7 +845,7 @@ describe("handleFill", () => {
|
||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||
"DOM.focus": () => ({}),
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
||||
"Runtime.callFunctionOn": fillRuntimeReadback("rich"),
|
||||
"Input.insertText": () => ({}),
|
||||
});
|
||||
const res = await handleFill(
|
||||
@@ -684,7 +856,103 @@ describe("handleFill", () => {
|
||||
expect("code" in res).toBe(false);
|
||||
});
|
||||
|
||||
it("stops before Input.insertText when abort fires after clearing", async () => {
|
||||
it("routes OOPIF focus, text, DOM events, and readback through the node target", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 77, {
|
||||
tabId: 4,
|
||||
frameId: "child-frame",
|
||||
cdpSessionId: "child-session",
|
||||
});
|
||||
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child-frame",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 12,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const childCalls: string[] = [];
|
||||
fake.cdp.sendToTarget = vi.fn(async (target, method, params) => {
|
||||
expect(target).toEqual({ tabId: 4, sessionId: "child-session" });
|
||||
childCalls.push(method);
|
||||
if (method === "DOM.describeNode") {
|
||||
return { node: { backendNodeId: 77, nodeName: "INPUT", attributes: [] } };
|
||||
}
|
||||
if (method === "DOM.scrollIntoViewIfNeeded" || method === "DOM.focus") return {};
|
||||
if (method === "DOM.resolveNode") return { object: { objectId: "child-input" } };
|
||||
if (method === "Input.insertText") {
|
||||
expect(params).toEqual({ text: "中文输入" });
|
||||
return {};
|
||||
}
|
||||
if (method === "Runtime.callFunctionOn") {
|
||||
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
||||
if (fn.includes("bsk-editable-state")) {
|
||||
return {
|
||||
result: {
|
||||
value: {
|
||||
supported: true,
|
||||
connected: true,
|
||||
focused: true,
|
||||
value: "中文输入",
|
||||
kind: "input",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (fn.includes("bsk-editable-selection")) {
|
||||
return { result: { value: { selected: true } } };
|
||||
}
|
||||
return { result: { type: "undefined" } };
|
||||
}
|
||||
throw new Error(`unexpected child call ${method}`);
|
||||
}) as CdpRunner["sendToTarget"];
|
||||
|
||||
const res = await handleFill(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e1", value: "中文输入" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
if ("code" in res) throw new Error(JSON.stringify(res));
|
||||
expect(res.value_length).toBe(4);
|
||||
expect(childCalls).toContain("DOM.focus");
|
||||
expect(childCalls).toContain("Input.insertText");
|
||||
expect(childCalls.filter((method) => method === "Runtime.callFunctionOn")).toHaveLength(4);
|
||||
expect(fake.sent.some((call) => call.method === "Input.insertText")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns input_not_applied when a controlled input resets the value", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 8, { tabId: 4 });
|
||||
const fake = makeFakeCdp({
|
||||
"DOM.describeNode": () => ({ node: { backendNodeId: 8, nodeName: "INPUT" } }),
|
||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||
"DOM.focus": () => ({}),
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "controlled" } }),
|
||||
"Input.insertText": () => ({}),
|
||||
"Runtime.callFunctionOn": fillRuntimeReadback(""),
|
||||
});
|
||||
|
||||
const res = await handleFill(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e1", value: "rejected" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
data: { reason: "input_not_applied", expected_value_length: 8, actual_value_length: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("stops before Input.insertText when abort fires after preparing replacement", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 555, { tabId: 4 });
|
||||
@@ -696,8 +964,25 @@ describe("handleFill", () => {
|
||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||
"DOM.focus": () => ({}),
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
||||
"Runtime.callFunctionOn": () => {
|
||||
abort.abort();
|
||||
"Runtime.callFunctionOn": (params) => {
|
||||
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
||||
if (fn.includes("bsk-editable-state")) {
|
||||
return {
|
||||
result: {
|
||||
value: {
|
||||
supported: true,
|
||||
connected: true,
|
||||
focused: true,
|
||||
value: "",
|
||||
kind: "input",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (fn.includes("bsk-editable-selection")) {
|
||||
abort.abort();
|
||||
return { result: { value: { selected: true } } };
|
||||
}
|
||||
return { result: { type: "undefined" } };
|
||||
},
|
||||
"Input.insertText": () => ({}),
|
||||
@@ -740,6 +1025,204 @@ describe("handleFill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleType", () => {
|
||||
it("requires an explicit target mode instead of silently using focus", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const fake = makeFakeCdp({});
|
||||
|
||||
const missing = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", text: "x" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
expect(missing).toMatchObject({
|
||||
code: "invalid_params",
|
||||
message: expect.stringContaining("focused=true"),
|
||||
});
|
||||
|
||||
const mixed = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e1", focused: true, text: "x" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
expect(mixed).toMatchObject({
|
||||
code: "invalid_params",
|
||||
message: expect.stringContaining("not both"),
|
||||
});
|
||||
expect(fake.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("routes committed IME text through the explicit OOPIF target without DOM readback", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 77, {
|
||||
tabId: 4,
|
||||
frameId: "child-frame",
|
||||
cdpSessionId: "child-session",
|
||||
});
|
||||
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child-frame",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 12,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const childCalls: Array<{ method: string; params?: object }> = [];
|
||||
fake.cdp.sendToTarget = vi.fn(async (_target, method, params) => {
|
||||
childCalls.push({ method, params });
|
||||
if (method === "DOM.describeNode") {
|
||||
return { node: { backendNodeId: 77, nodeName: "TEXTAREA", attributes: [] } };
|
||||
}
|
||||
return {};
|
||||
}) as CdpRunner["sendToTarget"];
|
||||
|
||||
const result = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e1", text: "测试" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||
expect(result).toMatchObject({
|
||||
tab_id: 4,
|
||||
used_ref: "e1",
|
||||
text_length: 2,
|
||||
verification: "dispatched",
|
||||
});
|
||||
expect(childCalls.map((call) => call.method)).toEqual([
|
||||
"DOM.describeNode",
|
||||
"DOM.scrollIntoViewIfNeeded",
|
||||
"DOM.focus",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.insertText",
|
||||
"Input.dispatchKeyEvent",
|
||||
]);
|
||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
||||
expect(childCalls.some((call) => call.method === "Runtime.callFunctionOn")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the focused OOPIF only when focused mode resolves a text-entry target", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const fake = makeFakeCdp({
|
||||
"Runtime.evaluate": () => ({
|
||||
result: { value: { hasFocus: true, activeTag: "iframe", textEntry: false } },
|
||||
}),
|
||||
});
|
||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child-frame",
|
||||
parentFrameId: "main",
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const childCalls: string[] = [];
|
||||
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
||||
childCalls.push(method);
|
||||
if (method === "Runtime.evaluate") {
|
||||
return {
|
||||
result: { value: { hasFocus: true, activeTag: "textarea", textEntry: true } },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}) as CdpRunner["sendToTarget"];
|
||||
|
||||
const result = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", focused: true, text: "A" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||
expect(result).toMatchObject({ text_length: 1, verification: "dispatched" });
|
||||
expect(childCalls).toEqual([
|
||||
"Runtime.evaluate",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
]);
|
||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses focused mode when focus is not owned by a text-entry target", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const fake = makeFakeCdp({
|
||||
"Runtime.evaluate": () => ({
|
||||
result: { value: { hasFocus: true, activeTag: "button", textEntry: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", focused: true, text: "x" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
data: { reason: "focus_target_unresolved", candidate_count: 0 },
|
||||
});
|
||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects explicit non-text input controls", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 77, { tabId: 4 });
|
||||
const fake = makeFakeCdp({
|
||||
"DOM.describeNode": () => ({
|
||||
node: { backendNodeId: 77, nodeName: "INPUT", attributes: ["type", "checkbox"] },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e1", text: "x" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "invalid_params",
|
||||
data: { reason: "target_not_fillable" },
|
||||
});
|
||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a Surface ref instead of treating it as a text target", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 77, {
|
||||
tabId: 4,
|
||||
kind: "surface",
|
||||
capabilities: ["screenshot"],
|
||||
});
|
||||
const fake = makeFakeCdp({});
|
||||
|
||||
const result = await handleType(
|
||||
sm,
|
||||
{ session_id: "aa11", ref: "@e1", text: "x" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "ref_capability_denied", required_capability: "interact" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKeySpec", () => {
|
||||
it("splits compound expressions into modifiers + base key", () => {
|
||||
expect(parseKeySpec("Ctrl+A")).toEqual({ key: "A", modifiers: ["ctrl"] });
|
||||
@@ -878,6 +1361,125 @@ describe("handlePress", () => {
|
||||
expect(res.key).toBe("Enter");
|
||||
});
|
||||
|
||||
it("routes ref-targeted key events through the OOPIF session", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.refStore.set("e1", 555, {
|
||||
tabId: 4,
|
||||
frameId: "child-frame",
|
||||
cdpSessionId: "child-session",
|
||||
});
|
||||
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child-frame",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 12,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const childCalls: string[] = [];
|
||||
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
||||
childCalls.push(method);
|
||||
return {};
|
||||
}) as CdpRunner["sendToTarget"];
|
||||
|
||||
const res = await handlePress(
|
||||
sm,
|
||||
{ session_id: "aa11", key: "Enter", ref: "@e1" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expectPressOk(res);
|
||||
expect(childCalls).toEqual([
|
||||
"DOM.scrollIntoViewIfNeeded",
|
||||
"DOM.focus",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
]);
|
||||
expect(fake.sent.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
|
||||
});
|
||||
|
||||
it("routes an untargeted press to the uniquely focused OOPIF session", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const fake = makeFakeCdp({
|
||||
"Runtime.evaluate": () => ({
|
||||
result: { value: { hasFocus: true, activeTag: "iframe" } },
|
||||
}),
|
||||
});
|
||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child-frame",
|
||||
parentFrameId: "main",
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const childCalls: string[] = [];
|
||||
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
||||
childCalls.push(method);
|
||||
if (method === "Runtime.evaluate") {
|
||||
return { result: { value: { hasFocus: true, activeTag: "textarea" } } };
|
||||
}
|
||||
return {};
|
||||
}) as CdpRunner["sendToTarget"];
|
||||
|
||||
const res = await handlePress(
|
||||
sm,
|
||||
{ session_id: "aa11", key: "a" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expectPressOk(res);
|
||||
expect(childCalls).toEqual([
|
||||
"Runtime.evaluate",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses an untargeted press when focus ownership is ambiguous", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const fake = makeFakeCdp({
|
||||
"Runtime.evaluate": () => ({
|
||||
result: { value: { hasFocus: true, activeTag: "input" } },
|
||||
}),
|
||||
});
|
||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{ frameId: "child", target: { tabId: 4, sessionId: "child-session" } },
|
||||
],
|
||||
}));
|
||||
fake.cdp.sendToTarget = vi.fn(async () => ({
|
||||
result: { value: { hasFocus: true, activeTag: "textarea" } },
|
||||
})) as CdpRunner["sendToTarget"];
|
||||
|
||||
const res = await handlePress(
|
||||
sm,
|
||||
{ session_id: "aa11", key: "Enter" },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
data: { reason: "focus_target_unresolved", candidate_count: 2 },
|
||||
});
|
||||
expect(fake.sent.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
|
||||
});
|
||||
|
||||
it("stops before focus when abort fires after press scroll", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
const ctx = await sm.start("aa11");
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { dispatchMouseClick } from "../mouse-input";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
function fakeCdp(onSend?: (method: string, params: Record<string, unknown>) => void): {
|
||||
cdp: CdpRunner;
|
||||
events: Record<string, unknown>[];
|
||||
} {
|
||||
const events: Record<string, unknown>[] = [];
|
||||
return {
|
||||
events,
|
||||
cdp: {
|
||||
send: vi.fn(async (_tabId, method, params) => {
|
||||
const event = params as Record<string, unknown>;
|
||||
events.push(event);
|
||||
onSend?.(method, event);
|
||||
return {} as never;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("dispatchMouseClick", () => {
|
||||
it("emits one move, press, and release sequence", async () => {
|
||||
const { cdp, events } = fakeCdp();
|
||||
|
||||
expect(
|
||||
await dispatchMouseClick(
|
||||
cdp,
|
||||
4,
|
||||
{ x: 12, y: 34 },
|
||||
{
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
modifiers: 0,
|
||||
moveSettleMs: 0,
|
||||
},
|
||||
),
|
||||
).toBe("completed");
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "mouseMoved",
|
||||
x: 12,
|
||||
y: 34,
|
||||
modifiers: 0,
|
||||
},
|
||||
{
|
||||
type: "mousePressed",
|
||||
x: 12,
|
||||
y: 34,
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
modifiers: 0,
|
||||
},
|
||||
{
|
||||
type: "mouseReleased",
|
||||
x: 12,
|
||||
y: 34,
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
modifiers: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases a pressed button when cancellation arrives", async () => {
|
||||
const controller = new AbortController();
|
||||
const { cdp, events } = fakeCdp((_method, event) => {
|
||||
if (event.type === "mousePressed") controller.abort();
|
||||
});
|
||||
|
||||
expect(
|
||||
await dispatchMouseClick(
|
||||
cdp,
|
||||
4,
|
||||
{ x: 1, y: 2 },
|
||||
{
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
modifiers: 0,
|
||||
signal: controller.signal,
|
||||
moveSettleMs: 0,
|
||||
},
|
||||
),
|
||||
).toBe("cancelled");
|
||||
expect(events.at(-1)).toMatchObject({ type: "mouseReleased" });
|
||||
});
|
||||
|
||||
it("settles pointer-move hit testing before pressing", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { cdp, events } = fakeCdp();
|
||||
const click = dispatchMouseClick(
|
||||
cdp,
|
||||
4,
|
||||
{ x: 1, y: 2 },
|
||||
{
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
modifiers: 0,
|
||||
moveSettleMs: 32,
|
||||
},
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(31);
|
||||
expect(events.map((event) => event.type)).toEqual(["mouseMoved"]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await click;
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"mouseMoved",
|
||||
"mousePressed",
|
||||
"mouseReleased",
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,9 @@ function makeFakeCdp(handlers: Record<string, (params?: object) => unknown>) {
|
||||
if (!handler && method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||
}
|
||||
if (!handler && method === "Page.getFrameTree") {
|
||||
return { frameTree: { frame: { id: "main", loaderId: "loader-1", url: "https://test/" } } };
|
||||
}
|
||||
if (!handler) throw new Error(`unexpected CDP call ${method}`);
|
||||
return handler(params);
|
||||
});
|
||||
@@ -334,6 +337,11 @@ describe("handleScreenshot", () => {
|
||||
};
|
||||
expect(clip.clip).toMatchObject({ width: 4096, height: 4096 });
|
||||
expect(clip.clip?.scale).toBeCloseTo(Math.sqrt(4_000_000 / (4096 * 4096)));
|
||||
expect(res.capture).toMatchObject({
|
||||
surface_ref: "@e5",
|
||||
coordinate_space: "capture-image-pixel",
|
||||
});
|
||||
expect(ctx.surfaceCaptures.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("crops a visual surface screenshot to its observation-time visible region", async () => {
|
||||
@@ -368,6 +376,7 @@ describe("handleScreenshot", () => {
|
||||
clip?: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
expect(clip.clip).toMatchObject({ x: 25, y: 30, width: 50, height: 40 });
|
||||
expect(res.capture?.id).toMatch(/^sc_/);
|
||||
});
|
||||
|
||||
it("returns not_found for unknown ref", async () => {
|
||||
|
||||
@@ -252,12 +252,23 @@ describe("handleSessionStop with auto-return", () => {
|
||||
expect(aw.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the RefStore before window teardown", async () => {
|
||||
it("clears refs and Surface captures before window teardown", async () => {
|
||||
const aw = fakeAgentWindow([100]);
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
const ctx = await sm.start("aa11");
|
||||
// Insert a fake ref so we can verify clear() ran.
|
||||
ctx.refStore.set("e1", 123, { tabId: 7 });
|
||||
ctx.surfaceCaptures.create({
|
||||
sessionId: "aa11",
|
||||
tabId: 7,
|
||||
navigationIdentity: "navigation",
|
||||
surface: { ref: "e1", backendNodeId: 123, observationGeneration: 0 },
|
||||
topViewportRect: { x: 0, y: 0, w: 100, h: 50 },
|
||||
imageWidth: 100,
|
||||
imageHeight: 50,
|
||||
viewportSignature: "viewport",
|
||||
frameProjectionSignature: "frame",
|
||||
});
|
||||
const state: FakeState = {
|
||||
tabs: new Map(),
|
||||
windowsClosed: new Set(),
|
||||
@@ -266,5 +277,6 @@ describe("handleSessionStop with auto-return", () => {
|
||||
const { tabs, windows } = makeApis(state);
|
||||
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
|
||||
expect(ctx.refStore.isEmpty()).toBe(true);
|
||||
expect(ctx.surfaceCaptures.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,14 @@ describe("lookupSnapshotRef", () => {
|
||||
refKey: "e3",
|
||||
kind: "dom",
|
||||
capabilities: ["interact", "screenshot"],
|
||||
generation: 0,
|
||||
});
|
||||
expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({
|
||||
backendNodeId: 1234,
|
||||
refKey: "e3",
|
||||
kind: "dom",
|
||||
capabilities: ["interact", "screenshot"],
|
||||
generation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +70,7 @@ describe("resolveSnapshotRef", () => {
|
||||
cdpSessionId: "child-session",
|
||||
kind: "dom" as const,
|
||||
capabilities: ["interact", "screenshot"] as const,
|
||||
generation: 0,
|
||||
};
|
||||
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
|
||||
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
|
||||
@@ -115,6 +118,7 @@ describe("resolveSnapshotRef", () => {
|
||||
refKey: "e3",
|
||||
kind: "dom",
|
||||
capabilities: ["interact", "screenshot"],
|
||||
generation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapImagePointToViewport,
|
||||
pointInRegion,
|
||||
sameRect,
|
||||
surfaceVisibleRect,
|
||||
} from "../surface-coordinate";
|
||||
|
||||
describe("surface coordinate mapping", () => {
|
||||
it("maps capture image pixels through the actual screenshot dimensions", () => {
|
||||
expect(mapImagePointToViewport({ x: 100, y: 50, w: 400, h: 200 }, 800, 400, 200, 100)).toEqual({
|
||||
x: 200,
|
||||
y: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid image coordinates and dimensions", () => {
|
||||
const rect = { x: 0, y: 0, w: 100, h: 50 };
|
||||
expect(mapImagePointToViewport(rect, 100, 50, -1, 0)).toBeNull();
|
||||
expect(mapImagePointToViewport(rect, 100, 50, 100, 0)).toBeNull();
|
||||
expect(mapImagePointToViewport(rect, 100, 50, 0, 50)).toBeNull();
|
||||
expect(mapImagePointToViewport(rect, 100, 50, Number.NaN, 0)).toBeNull();
|
||||
expect(mapImagePointToViewport(rect, 100, 50, Number.POSITIVE_INFINITY, 0)).toBeNull();
|
||||
expect(mapImagePointToViewport(rect, 0, 50, 0, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("intersects live and observed visible regions and compares them strictly", () => {
|
||||
const clipped = surfaceVisibleRect(
|
||||
{ x: 0, y: 0, width: 200, height: 100 },
|
||||
{ x: 25, y: 30, w: 50, h: 40 },
|
||||
);
|
||||
expect(clipped).toEqual({ x: 25, y: 30, w: 50, h: 40 });
|
||||
expect(sameRect(clipped as NonNullable<typeof clipped>, { x: 25, y: 30, w: 50, h: 40 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(sameRect(clipped as NonNullable<typeof clipped>, { x: 26, y: 30, w: 50, h: 40 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the projected visible region rather than only its bounding box", () => {
|
||||
const triangle = [
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 0, y: 100 },
|
||||
],
|
||||
];
|
||||
expect(pointInRegion({ x: 20, y: 20 }, triangle)).toBe(true);
|
||||
expect(pointInRegion({ x: 90, y: 90 }, triangle)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import {
|
||||
captureSurfaceEnvironment,
|
||||
resolveSurfacePointerTarget,
|
||||
SURFACE_POINTER_MOVE_SETTLE_MS,
|
||||
} from "../surface-target";
|
||||
|
||||
function fakeAgentWindow() {
|
||||
return {
|
||||
create: vi.fn(async () => 100),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBrowser() {
|
||||
let loaderId = "loader-1";
|
||||
let pageY = 0;
|
||||
let quad = [10, 20, 110, 20, 110, 60, 10, 60];
|
||||
const sent: Array<{ method: string; params?: object }> = [];
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||
sent.push({ method, params });
|
||||
if (method === "Page.getFrameTree") {
|
||||
return {
|
||||
frameTree: { frame: { id: "main", loaderId, url: "https://fixture.test/" } },
|
||||
} as never;
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY },
|
||||
cssVisualViewport: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
pageX: 0,
|
||||
pageY,
|
||||
scale: 1,
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
if (method === "DOM.getContentQuads") return { quads: [quad] } as never;
|
||||
throw new Error(`unexpected CDP call ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
trackSessionTab: vi.fn(),
|
||||
};
|
||||
return {
|
||||
cdp,
|
||||
sent,
|
||||
setLoaderId: (value: string) => {
|
||||
loaderId = value;
|
||||
},
|
||||
setPageY: (value: number) => {
|
||||
pageY = value;
|
||||
},
|
||||
setQuad: (value: number[]) => {
|
||||
quad = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setupCapture() {
|
||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const context = await manager.start("aa11");
|
||||
context.refStore.set("e3", 99, {
|
||||
tabId: 4,
|
||||
kind: "surface",
|
||||
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||
});
|
||||
const browser = fakeBrowser();
|
||||
const environment = await captureSurfaceEnvironment(browser.cdp, 4, undefined);
|
||||
if ("code" in environment) throw new Error(environment.message);
|
||||
const entry = context.refStore.resolveEntry("e3");
|
||||
if (!entry) throw new Error("missing ref");
|
||||
const capture = context.surfaceCaptures.create({
|
||||
sessionId: "aa11",
|
||||
tabId: 4,
|
||||
navigationIdentity: environment.navigationIdentity,
|
||||
surface: {
|
||||
ref: "e3",
|
||||
backendNodeId: 99,
|
||||
observationGeneration: entry.generation,
|
||||
},
|
||||
topViewportRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||
imageWidth: 200,
|
||||
imageHeight: 80,
|
||||
viewportSignature: environment.viewportSignature,
|
||||
frameProjectionSignature: environment.frameProjectionSignature,
|
||||
});
|
||||
return { manager, context, browser, capture };
|
||||
}
|
||||
|
||||
function pointParams(captureId: string) {
|
||||
return {
|
||||
ref: "@e3",
|
||||
captureId,
|
||||
imageX: 50,
|
||||
imageY: 20,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Surface screenshot-bound pointer target", () => {
|
||||
it("maps one fresh capture coordinate without executing an action", async () => {
|
||||
const { context, browser, capture } = await setupCapture();
|
||||
const result = await resolveSurfacePointerTarget(
|
||||
context,
|
||||
{ tabId: 4 },
|
||||
pointParams(capture.id),
|
||||
{ cdp: browser.cdp },
|
||||
);
|
||||
|
||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||
expect(result).toMatchObject({
|
||||
usedRef: "e3",
|
||||
point: { x: 35, y: 30 },
|
||||
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
||||
capture: { id: capture.id, imageX: 50, imageY: 20 },
|
||||
});
|
||||
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||
|
||||
const repeated = await resolveSurfacePointerTarget(
|
||||
context,
|
||||
{ tabId: 4 },
|
||||
pointParams(capture.id),
|
||||
{ cdp: browser.cdp },
|
||||
);
|
||||
expect(repeated).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "surface_capture_consumed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a new observation generation before sending input", async () => {
|
||||
const { context, browser, capture } = await setupCapture();
|
||||
context.refStore.replace([
|
||||
[
|
||||
"e3",
|
||||
{
|
||||
backendNodeId: 99,
|
||||
tabId: 4,
|
||||
kind: "surface" as const,
|
||||
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await resolveSurfacePointerTarget(
|
||||
context,
|
||||
{ tabId: 4 },
|
||||
pointParams(capture.id),
|
||||
{ cdp: browser.cdp },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "surface_capture_stale" },
|
||||
});
|
||||
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects navigation, scroll, and visible geometry changes", async () => {
|
||||
for (const mutate of [
|
||||
(browser: ReturnType<typeof fakeBrowser>) => browser.setLoaderId("loader-2"),
|
||||
(browser: ReturnType<typeof fakeBrowser>) => browser.setPageY(20),
|
||||
(browser: ReturnType<typeof fakeBrowser>) =>
|
||||
browser.setQuad([11, 20, 111, 20, 111, 60, 11, 60]),
|
||||
]) {
|
||||
const { context, browser, capture } = await setupCapture();
|
||||
mutate(browser);
|
||||
const result = await resolveSurfacePointerTarget(
|
||||
context,
|
||||
{ tabId: 4 },
|
||||
pointParams(capture.id),
|
||||
{ cdp: browser.cdp },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "surface_capture_stale" },
|
||||
});
|
||||
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes the capture when coordinate validation fails", async () => {
|
||||
const invalid = await setupCapture();
|
||||
const outside = await resolveSurfacePointerTarget(
|
||||
invalid.context,
|
||||
{ tabId: 4 },
|
||||
{ ...pointParams(invalid.capture.id), imageX: 200 },
|
||||
{ cdp: invalid.browser.cdp },
|
||||
);
|
||||
expect(outside).toMatchObject({
|
||||
code: "invalid_params",
|
||||
data: { reason: "surface_coordinate_invalid" },
|
||||
});
|
||||
expect(
|
||||
await resolveSurfacePointerTarget(
|
||||
invalid.context,
|
||||
{ tabId: 4 },
|
||||
pointParams(invalid.capture.id),
|
||||
{ cdp: invalid.browser.cdp },
|
||||
),
|
||||
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
||||
});
|
||||
|
||||
it("maps image coordinates through an OOPIF frame projection", async () => {
|
||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const context = await manager.start("aa11");
|
||||
context.refStore.set("e3", 99, {
|
||||
tabId: 4,
|
||||
frameId: "child",
|
||||
cdpSessionId: "child-session",
|
||||
kind: "surface",
|
||||
visibleRect: { x: 120, y: 110, w: 100, h: 40 },
|
||||
});
|
||||
const graph = {
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 77,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const sent: Array<{ method: string; params?: object }> = [];
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||
sent.push({ method, params });
|
||||
if (method === "Page.getFrameTree") {
|
||||
return {
|
||||
frameTree: {
|
||||
frame: { id: "main", loaderId: "loader-1", url: "https://fixture.test/" },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY: 0 },
|
||||
cssVisualViewport: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
scale: 1,
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
if (method === "DOM.getBoxModel") {
|
||||
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } } as never;
|
||||
}
|
||||
if (method === "Input.dispatchMouseEvent") return {} as never;
|
||||
throw new Error(`unexpected root CDP call ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[20, 10, 120, 10, 120, 50, 20, 50]] } as never;
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } } as never;
|
||||
}
|
||||
throw new Error(`unexpected child CDP call ${method}`);
|
||||
}) as CdpRunner["sendToTarget"],
|
||||
getFrameGraph: vi.fn(async () => graph),
|
||||
trackSessionTab: vi.fn(),
|
||||
};
|
||||
const environment = await captureSurfaceEnvironment(cdp, 4, "child");
|
||||
if ("code" in environment) throw new Error(environment.message);
|
||||
const entry = context.refStore.resolveEntry("e3");
|
||||
if (!entry) throw new Error("missing ref");
|
||||
const capture = context.surfaceCaptures.create({
|
||||
sessionId: "aa11",
|
||||
tabId: 4,
|
||||
navigationIdentity: environment.navigationIdentity,
|
||||
surface: {
|
||||
ref: "e3",
|
||||
frameId: "child",
|
||||
backendNodeId: 99,
|
||||
observationGeneration: entry.generation,
|
||||
},
|
||||
topViewportRect: { x: 120, y: 110, w: 100, h: 40 },
|
||||
imageWidth: 200,
|
||||
imageHeight: 80,
|
||||
viewportSignature: environment.viewportSignature,
|
||||
frameProjectionSignature: environment.frameProjectionSignature,
|
||||
});
|
||||
|
||||
const result = await resolveSurfacePointerTarget(
|
||||
context,
|
||||
{ tabId: 4 },
|
||||
{
|
||||
ref: "@e3",
|
||||
captureId: capture.id,
|
||||
imageX: 100,
|
||||
imageY: 40,
|
||||
},
|
||||
{ cdp },
|
||||
);
|
||||
|
||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
||||
expect(result).toMatchObject({ point: { x: 170, y: 130 } });
|
||||
expect(sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { dispatchTextInput } from "../text-input";
|
||||
|
||||
function makeCdp(onSend?: (method: string, params?: object) => void) {
|
||||
const sent: Array<{ method: string; params?: object }> = [];
|
||||
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object): Promise<T> => {
|
||||
sent.push({ method, params });
|
||||
onSend?.(method, params);
|
||||
return {} as T;
|
||||
});
|
||||
return { cdp: { send } as CdpRunner, sent };
|
||||
}
|
||||
|
||||
describe("dispatchTextInput", () => {
|
||||
it("types ASCII through complete keydown, char, and keyup sequences", async () => {
|
||||
const fake = makeCdp();
|
||||
|
||||
const result = await dispatchTextInput(fake.cdp, 4, "Ab");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(
|
||||
fake.sent.map((call) => `${call.method}:${(call.params as { type?: string }).type}`),
|
||||
).toEqual([
|
||||
"Input.dispatchKeyEvent:rawKeyDown",
|
||||
"Input.dispatchKeyEvent:char",
|
||||
"Input.dispatchKeyEvent:keyUp",
|
||||
"Input.dispatchKeyEvent:rawKeyDown",
|
||||
"Input.dispatchKeyEvent:char",
|
||||
"Input.dispatchKeyEvent:keyUp",
|
||||
]);
|
||||
expect(fake.sent[0].params).toMatchObject({ key: "A", code: "KeyA", modifiers: 8 });
|
||||
expect(fake.sent[1].params).toMatchObject({ text: "A" });
|
||||
});
|
||||
|
||||
it("types non-ASCII through the committed IME insertion path", async () => {
|
||||
const fake = makeCdp();
|
||||
|
||||
const result = await dispatchTextInput(fake.cdp, 4, "测试");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fake.sent).toEqual([
|
||||
{
|
||||
method: "Input.dispatchKeyEvent",
|
||||
params: {
|
||||
type: "rawKeyDown",
|
||||
key: "Process",
|
||||
code: "",
|
||||
windowsVirtualKeyCode: 229,
|
||||
nativeVirtualKeyCode: 229,
|
||||
modifiers: 0,
|
||||
},
|
||||
},
|
||||
{ method: "Input.insertText", params: { text: "测试" } },
|
||||
{
|
||||
method: "Input.dispatchKeyEvent",
|
||||
params: {
|
||||
type: "keyUp",
|
||||
key: "Process",
|
||||
code: "",
|
||||
windowsVirtualKeyCode: 229,
|
||||
nativeVirtualKeyCode: 229,
|
||||
modifiers: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves text order across keyboard and IME segments", async () => {
|
||||
const fake = makeCdp();
|
||||
|
||||
const result = await dispatchTextInput(fake.cdp, 4, "A测试1");
|
||||
|
||||
expect(result).toBeNull();
|
||||
const meaningful = fake.sent.flatMap((call) => {
|
||||
if (call.method === "Input.insertText") {
|
||||
return [`ime:${(call.params as { text: string }).text}`];
|
||||
}
|
||||
if (
|
||||
call.method === "Input.dispatchKeyEvent" &&
|
||||
(call.params as { type?: string }).type === "char"
|
||||
) {
|
||||
return [`key:${(call.params as { text: string }).text}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
expect(meaningful).toEqual(["key:A", "ime:测试", "key:1"]);
|
||||
});
|
||||
|
||||
it("reports cancellation observed after an accepted IME insertion", async () => {
|
||||
const abort = new AbortController();
|
||||
const fake = makeCdp((method) => {
|
||||
if (method === "Input.insertText") abort.abort();
|
||||
});
|
||||
|
||||
const result = await dispatchTextInput(fake.cdp, 4, "测试", abort.signal);
|
||||
|
||||
expect(result).toMatchObject({ code: "cancelled" });
|
||||
expect(fake.sent.map((call) => call.method)).toEqual([
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.insertText",
|
||||
"Input.dispatchKeyEvent",
|
||||
]);
|
||||
expect(fake.sent.at(-1)?.params).toMatchObject({ type: "keyUp", key: "Process" });
|
||||
});
|
||||
|
||||
it("releases the IME process key without committing when cancellation follows keydown", async () => {
|
||||
const abort = new AbortController();
|
||||
const fake = makeCdp((method, params) => {
|
||||
if (
|
||||
method === "Input.dispatchKeyEvent" &&
|
||||
(params as { type?: string }).type === "rawKeyDown"
|
||||
) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const result = await dispatchTextInput(fake.cdp, 4, "测试", abort.signal);
|
||||
|
||||
expect(result).toMatchObject({ code: "cancelled" });
|
||||
expect(fake.sent.map((call) => call.method)).toEqual([
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
]);
|
||||
expect(fake.sent[0].params).toMatchObject({ type: "rawKeyDown", key: "Process" });
|
||||
expect(fake.sent[1].params).toMatchObject({ type: "keyUp", key: "Process" });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ScreenshotParams,
|
||||
SelectParams,
|
||||
SnapshotParams,
|
||||
TypeParams,
|
||||
WaitForNavigationParams,
|
||||
} from "@/transport/types";
|
||||
import { isRequestFrame } from "@/transport/types";
|
||||
@@ -35,7 +36,14 @@ import { handleConsole } from "./console";
|
||||
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
|
||||
import { handleEvaluate } from "./evaluate";
|
||||
import { handleRequestHelp } from "./human-loop";
|
||||
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
|
||||
import {
|
||||
handleClick,
|
||||
handleFill,
|
||||
handleHover,
|
||||
handlePress,
|
||||
handleSelect,
|
||||
handleType,
|
||||
} from "./interaction";
|
||||
import {
|
||||
handleNavigate,
|
||||
handleNavigateBack,
|
||||
@@ -491,6 +499,17 @@ export class ToolDispatcher {
|
||||
),
|
||||
signal,
|
||||
);
|
||||
case "tool.type":
|
||||
return this.withHoverReleaseForRequest(
|
||||
req.params as TypeParams,
|
||||
() =>
|
||||
handleType(
|
||||
this.sessions,
|
||||
req.params as TypeParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||
),
|
||||
signal,
|
||||
);
|
||||
case "tool.press":
|
||||
return this.withHoverReleaseForRequest(
|
||||
req.params as PressParams,
|
||||
@@ -717,6 +736,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
||||
case "tool.click":
|
||||
case "tool.hover":
|
||||
case "tool.fill":
|
||||
case "tool.type":
|
||||
case "tool.press":
|
||||
case "tool.select":
|
||||
case "tool.evaluate":
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Browser-native editing transaction for input, textarea, and contenteditable
|
||||
// targets. The CDP target/session is selected by interaction.ts; this module
|
||||
// owns the shorter-lived editable identity inside that target.
|
||||
|
||||
import type { RpcError } from "@/transport/types";
|
||||
import { rpcError } from "./errors";
|
||||
import type { CdpRunner } from "./shared";
|
||||
|
||||
interface EditableState {
|
||||
supported: boolean;
|
||||
connected: boolean;
|
||||
focused: boolean;
|
||||
value: string;
|
||||
kind: "input" | "textarea" | "contenteditable" | "unsupported";
|
||||
}
|
||||
|
||||
export interface EditableFillResult {
|
||||
value: string;
|
||||
replacedNode: boolean;
|
||||
}
|
||||
|
||||
function cancelled(signal: AbortSignal | undefined): RpcError | null {
|
||||
return signal?.aborted ? { code: "cancelled", message: "fill aborted" } : null;
|
||||
}
|
||||
|
||||
async function inspectEditable(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
objectId: string,
|
||||
): Promise<EditableState> {
|
||||
const reply = await cdp.send<{
|
||||
result?: { value?: EditableState };
|
||||
}>(tabId, "Runtime.callFunctionOn", {
|
||||
objectId,
|
||||
functionDeclaration: `function() {
|
||||
/* bsk-editable-state */
|
||||
const doc = this.ownerDocument;
|
||||
const focused = !!doc && doc.activeElement === this;
|
||||
if (this instanceof HTMLInputElement) {
|
||||
return { supported: true, connected: this.isConnected, focused,
|
||||
value: this.value, kind: 'input' };
|
||||
}
|
||||
if (this instanceof HTMLTextAreaElement) {
|
||||
return { supported: true, connected: this.isConnected, focused,
|
||||
value: this.value, kind: 'textarea' };
|
||||
}
|
||||
if (this.isContentEditable) {
|
||||
return { supported: true, connected: this.isConnected, focused,
|
||||
value: this.innerText ?? this.textContent ?? '', kind: 'contenteditable' };
|
||||
}
|
||||
return { supported: false, connected: this.isConnected === true, focused,
|
||||
value: '', kind: 'unsupported' };
|
||||
}`,
|
||||
returnByValue: true,
|
||||
});
|
||||
const state = reply.result?.value;
|
||||
if (!state || typeof state.value !== "string") {
|
||||
throw new Error("editable state probe returned no value");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the active editable from the anchor's own document. Using the
|
||||
* anchor's execution context also works for same-process nested frames, where
|
||||
* a target-level Runtime.evaluate would otherwise see the top document.
|
||||
*/
|
||||
async function focusedEditableFromAnchor(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
anchorObjectId: string,
|
||||
): Promise<string | null> {
|
||||
const reply = await cdp.send<{
|
||||
result?: { objectId?: string; subtype?: string };
|
||||
}>(tabId, "Runtime.callFunctionOn", {
|
||||
objectId: anchorObjectId,
|
||||
functionDeclaration: `function() {
|
||||
/* bsk-editable-live */
|
||||
const active = this.ownerDocument?.activeElement;
|
||||
if (!active) return null;
|
||||
const editable = active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement || active.isContentEditable;
|
||||
return editable ? active : null;
|
||||
}`,
|
||||
returnByValue: false,
|
||||
});
|
||||
if (reply.result?.subtype === "null") return null;
|
||||
return typeof reply.result?.objectId === "string" ? reply.result.objectId : null;
|
||||
}
|
||||
|
||||
async function resolveLiveEditable(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
anchorObjectId: string,
|
||||
): Promise<{ objectId: string; state: EditableState; replaced: boolean } | RpcError> {
|
||||
const anchor = await inspectEditable(cdp, tabId, anchorObjectId);
|
||||
if (anchor.supported && anchor.connected && anchor.focused) {
|
||||
return { objectId: anchorObjectId, state: anchor, replaced: false };
|
||||
}
|
||||
|
||||
const focusedObjectId = await focusedEditableFromAnchor(cdp, tabId, anchorObjectId);
|
||||
if (!focusedObjectId) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"input_not_applied",
|
||||
"the target editable lost focus or detached before input",
|
||||
{ phase: "resolve_live_editable" },
|
||||
);
|
||||
}
|
||||
const focused = await inspectEditable(cdp, tabId, focusedObjectId);
|
||||
if (!focused.supported || !focused.connected || !focused.focused) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"input_not_applied",
|
||||
"the replacement editable was not live and focused",
|
||||
{ phase: "resolve_live_editable" },
|
||||
);
|
||||
}
|
||||
return { objectId: focusedObjectId, state: focused, replaced: true };
|
||||
}
|
||||
|
||||
async function selectEditableContents(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
objectId: string,
|
||||
): Promise<void> {
|
||||
const reply = await cdp.send<{
|
||||
result?: { value?: { selected?: boolean } };
|
||||
}>(tabId, "Runtime.callFunctionOn", {
|
||||
objectId,
|
||||
functionDeclaration: `function() {
|
||||
/* bsk-editable-selection */
|
||||
if (!this.isConnected || this.ownerDocument?.activeElement !== this) {
|
||||
return { selected: false };
|
||||
}
|
||||
if (this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement) {
|
||||
try {
|
||||
this.setSelectionRange(0, this.value.length);
|
||||
} catch {
|
||||
if (typeof this.select !== 'function') return { selected: false };
|
||||
this.select();
|
||||
}
|
||||
return { selected: true };
|
||||
}
|
||||
if (this.isContentEditable) {
|
||||
const selection = this.ownerDocument.getSelection();
|
||||
if (!selection) return { selected: false };
|
||||
const range = this.ownerDocument.createRange();
|
||||
range.selectNodeContents(this);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return { selected: true };
|
||||
}
|
||||
return { selected: false };
|
||||
}`,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (reply.result?.value?.selected !== true) {
|
||||
throw new Error("could not select the live editable contents");
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForEditableSettlement(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
anchorObjectId: string,
|
||||
): Promise<void> {
|
||||
await cdp.send(tabId, "Runtime.callFunctionOn", {
|
||||
objectId: anchorObjectId,
|
||||
functionDeclaration: `function() {
|
||||
/* bsk-editable-sync */
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, 100);
|
||||
const view = this.ownerDocument?.defaultView;
|
||||
if (!view || typeof view.requestAnimationFrame !== 'function') { finish(); return; }
|
||||
view.requestAnimationFrame(() => view.requestAnimationFrame(finish));
|
||||
});
|
||||
}`,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSelection(cdp: CdpRunner, tabId: number): Promise<void> {
|
||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
||||
type: "rawKeyDown",
|
||||
key: "Backspace",
|
||||
code: "Backspace",
|
||||
windowsVirtualKeyCode: 8,
|
||||
});
|
||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
key: "Backspace",
|
||||
code: "Backspace",
|
||||
windowsVirtualKeyCode: 8,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace or append text through Chromium's editing pipeline. No value setter
|
||||
* or synthetic DOM events are used: controlled components observe the same
|
||||
* beforeinput/input path as a real browser edit. The live editable is resolved
|
||||
* again after settlement so a framework may remount it without producing a
|
||||
* false failure or a stale-object success.
|
||||
*/
|
||||
export async function fillEditable(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
anchorObjectId: string,
|
||||
value: string,
|
||||
clearBefore: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditableFillResult | RpcError> {
|
||||
try {
|
||||
const abortedBefore = cancelled(signal);
|
||||
if (abortedBefore) return abortedBefore;
|
||||
|
||||
const initial = await resolveLiveEditable(cdp, tabId, anchorObjectId);
|
||||
if ("code" in initial) return initial;
|
||||
let replacedNode = initial.replaced;
|
||||
|
||||
if (clearBefore) await selectEditableContents(cdp, tabId, initial.objectId);
|
||||
const abortedAfterSelection = cancelled(signal);
|
||||
if (abortedAfterSelection) return abortedAfterSelection;
|
||||
|
||||
if (value.length > 0) {
|
||||
await cdp.send(tabId, "Input.insertText", { text: value });
|
||||
} else if (clearBefore) {
|
||||
await deleteSelection(cdp, tabId);
|
||||
}
|
||||
|
||||
const abortedAfterInput = cancelled(signal);
|
||||
if (abortedAfterInput) return abortedAfterInput;
|
||||
await waitForEditableSettlement(cdp, tabId, initial.objectId);
|
||||
|
||||
const abortedAfterSettlement = cancelled(signal);
|
||||
if (abortedAfterSettlement) return abortedAfterSettlement;
|
||||
|
||||
let finalObjectId = initial.objectId;
|
||||
let finalState = await inspectEditable(cdp, tabId, finalObjectId);
|
||||
if (!finalState.connected || !finalState.focused) {
|
||||
const live = await resolveLiveEditable(cdp, tabId, initial.objectId);
|
||||
if ("code" in live) return live;
|
||||
finalObjectId = live.objectId;
|
||||
finalState = live.state;
|
||||
replacedNode ||= live.replaced || finalObjectId !== initial.objectId;
|
||||
}
|
||||
|
||||
const applied = clearBefore
|
||||
? finalState.value === value
|
||||
: value.length === 0 || finalState.value.includes(value);
|
||||
if (!finalState.supported || !applied) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"input_not_applied",
|
||||
"the page did not retain the requested input value",
|
||||
{
|
||||
expected_value_length: value.length,
|
||||
actual_value_length: finalState.value.length,
|
||||
phase: "readback",
|
||||
editable_replaced: replacedNode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { value: finalState.value, replacedNode };
|
||||
} catch (err) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"input_not_applied",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
{ phase: "edit_transaction" },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// DOM interaction tools — `tool.click`, `tool.fill`, `tool.press`, and
|
||||
// `tool.select`.
|
||||
// DOM interaction tools — `tool.click`, `tool.fill`, `tool.type`,
|
||||
// `tool.press`, and `tool.select`.
|
||||
//
|
||||
// All interaction tools:
|
||||
// 1. Resolve target tab (sandbox: must be inside Agent Window).
|
||||
@@ -11,7 +11,7 @@
|
||||
// commands.
|
||||
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||
import type {
|
||||
ClickParams,
|
||||
@@ -27,11 +27,15 @@ import type {
|
||||
RpcError,
|
||||
SelectParams,
|
||||
SelectResult,
|
||||
TypeParams,
|
||||
TypeResult,
|
||||
} from "@/transport/types";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import { fillEditable } from "./editable-input";
|
||||
import { backendNodeToObject } from "./element-geometry";
|
||||
import { rpcError } from "./errors";
|
||||
import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry";
|
||||
import { dispatchMouseClick } from "./mouse-input";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
@@ -43,6 +47,8 @@ import {
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
import { resolveSurfacePointerTarget } from "./surface-target";
|
||||
import { dispatchTextInput } from "./text-input";
|
||||
|
||||
export interface InteractionDeps {
|
||||
cdp: CdpRunner;
|
||||
@@ -215,15 +221,146 @@ async function resolveBackendNode(
|
||||
}
|
||||
}
|
||||
|
||||
interface InputDispatchContext {
|
||||
target: CdpTarget;
|
||||
cdp: CdpRunner;
|
||||
backendNodeId?: number;
|
||||
frameId?: string;
|
||||
usedRef?: string;
|
||||
usedSelector?: string;
|
||||
}
|
||||
|
||||
function inputContextForNode(
|
||||
cdp: CdpRunner,
|
||||
node: Awaited<ReturnType<typeof resolveBackendNode>>,
|
||||
): (InputDispatchContext & { backendNodeId: number }) | RpcError {
|
||||
if (isRpcError(node)) return node;
|
||||
return {
|
||||
target: node.cdpTarget,
|
||||
cdp: cdpRunnerForTarget(cdp, node.cdpTarget),
|
||||
backendNodeId: node.backendNodeId,
|
||||
frameId: node.frameId,
|
||||
usedRef: node.usedRef,
|
||||
usedSelector: node.usedSelector,
|
||||
};
|
||||
}
|
||||
|
||||
interface FocusProbe {
|
||||
hasFocus: boolean;
|
||||
activeTag: string;
|
||||
textEntry: boolean;
|
||||
}
|
||||
|
||||
/** Resolve keyboard input to the CDP target that currently owns focus. */
|
||||
async function resolveFocusedInputContext(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
mode: "press" | "type",
|
||||
): Promise<InputDispatchContext | RpcError> {
|
||||
if (!cdp.getFrameGraph) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"focus_target_unresolved",
|
||||
`${mode} cannot determine the focused frame target`,
|
||||
);
|
||||
}
|
||||
|
||||
let targets: CdpTarget[];
|
||||
try {
|
||||
const graph = await cdp.getFrameGraph(tabId);
|
||||
const unique = new Map<string, CdpTarget>();
|
||||
unique.set(cdpTargetKey({ tabId }), { tabId });
|
||||
for (const frame of graph.frames) unique.set(cdpTargetKey(frame.target), frame.target);
|
||||
targets = [...unique.values()];
|
||||
} catch (err) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"focus_target_unresolved",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
const focused: Array<{ target: CdpTarget; probe: FocusProbe }> = [];
|
||||
await Promise.all(
|
||||
targets.map(async (candidate) => {
|
||||
try {
|
||||
const reply = await cdpRunnerForTarget(cdp, candidate).send<{
|
||||
result?: { value?: FocusProbe };
|
||||
}>(tabId, "Runtime.evaluate", {
|
||||
expression: `(() => {
|
||||
/* bsk-focus-target-probe */
|
||||
const active = document.activeElement;
|
||||
const inputType = active instanceof HTMLInputElement ? active.type.toLowerCase() : '';
|
||||
const nonTextInputTypes = new Set([
|
||||
'button', 'checkbox', 'color', 'date', 'datetime-local', 'file',
|
||||
'hidden', 'image', 'month', 'radio', 'range', 'reset', 'submit',
|
||||
'time', 'week'
|
||||
]);
|
||||
const role = active instanceof Element ? active.getAttribute('role') : null;
|
||||
return {
|
||||
hasFocus: document.hasFocus(),
|
||||
activeTag: active instanceof Element ? active.tagName.toLowerCase() : '',
|
||||
textEntry:
|
||||
(active instanceof HTMLInputElement && !nonTextInputTypes.has(inputType)) ||
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
(active instanceof HTMLElement && active.isContentEditable) ||
|
||||
role === 'textbox' || role === 'searchbox'
|
||||
};
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
});
|
||||
const probe = reply.result?.value;
|
||||
if (probe?.hasFocus) focused.push({ target: candidate, probe });
|
||||
} catch {
|
||||
// A target may navigate or detach while the graph is being probed.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// A top document whose active element is an iframe delegates focus to
|
||||
// that frame. Removing those delegating targets leaves the deepest OOPIF
|
||||
// target while same-process frames continue to use the shared root target.
|
||||
const owners = focused.filter(({ probe }) => {
|
||||
if (probe.activeTag === "iframe" || probe.activeTag === "frame") return false;
|
||||
return mode !== "type" || probe.textEntry;
|
||||
});
|
||||
if (owners.length !== 1) {
|
||||
return rpcError(
|
||||
"cdp_failed",
|
||||
"focus_target_unresolved",
|
||||
owners.length === 0
|
||||
? `${mode} found no unique focused${mode === "type" ? " text-entry" : " frame"} target`
|
||||
: `${mode} found multiple focused frame targets`,
|
||||
{ candidate_count: owners.length },
|
||||
);
|
||||
}
|
||||
const target = owners[0].target;
|
||||
return { target, cdp: cdpRunnerForTarget(cdp, target) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool.click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResolvedClickPointerTarget {
|
||||
point: { x: number; y: number };
|
||||
usedRef?: string;
|
||||
usedSelector?: string;
|
||||
moveSettleMs: number;
|
||||
capture?: {
|
||||
id: string;
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleClick(
|
||||
manager: SessionManager,
|
||||
params: ClickParams,
|
||||
deps: InteractionDeps = getDefaultDeps(),
|
||||
): Promise<ClickResult | RpcError> {
|
||||
const hasSurfacePointParams =
|
||||
params.capture_id !== undefined || params.image_x !== undefined || params.image_y !== undefined;
|
||||
const ctxOrErr = lookupSession(manager, params, "click");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
@@ -235,39 +372,67 @@ export async function handleClick(
|
||||
if (denied) return denied;
|
||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
|
||||
if (isRpcError(node)) return node;
|
||||
const button: MouseButton = params.button ?? "left";
|
||||
const clickCount = params.click_count ?? 1;
|
||||
if (!Number.isSafeInteger(clickCount) || clickCount < 1) {
|
||||
return { code: "invalid_params", message: "click_count must be a positive integer" };
|
||||
}
|
||||
const modifiers = modifiersBitfield(params.modifiers);
|
||||
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "click aborted" };
|
||||
}
|
||||
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
const geometry = await resolveNodeGeometry(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
{
|
||||
target: node.cdpTarget,
|
||||
backendNodeId: node.backendNodeId,
|
||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
||||
},
|
||||
{ scrollIntoView: true },
|
||||
);
|
||||
if (isRpcError(geometry)) return geometry;
|
||||
const centre = geometry.actionPoint;
|
||||
const pointerTarget: ResolvedClickPointerTarget | RpcError = hasSurfacePointParams
|
||||
? await resolveSurfacePointerTarget(
|
||||
ctx,
|
||||
target,
|
||||
{
|
||||
ref: params.ref,
|
||||
selector: params.selector,
|
||||
captureId: params.capture_id,
|
||||
imageX: params.image_x,
|
||||
imageY: params.image_y,
|
||||
},
|
||||
{
|
||||
cdp: deps.cdp,
|
||||
signal: deps.signal,
|
||||
},
|
||||
)
|
||||
: await (async () => {
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
|
||||
if (isRpcError(node)) return node;
|
||||
const geometry = await resolveNodeGeometry(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
{
|
||||
target: node.cdpTarget,
|
||||
backendNodeId: node.backendNodeId,
|
||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
||||
},
|
||||
{ scrollIntoView: true },
|
||||
);
|
||||
if (isRpcError(geometry)) return geometry;
|
||||
return {
|
||||
point: geometry.actionPoint,
|
||||
usedRef: node.usedRef,
|
||||
usedSelector: node.usedSelector,
|
||||
moveSettleMs: 0,
|
||||
};
|
||||
})();
|
||||
if (isRpcError(pointerTarget)) return pointerTarget;
|
||||
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "click aborted" };
|
||||
}
|
||||
|
||||
const button: MouseButton = params.button ?? "left";
|
||||
const clickCount = params.click_count ?? 1;
|
||||
if (clickCount < 1) {
|
||||
return { code: "invalid_params", message: "click_count must be greater than zero" };
|
||||
}
|
||||
const modifiers = modifiersBitfield(params.modifiers);
|
||||
|
||||
const overlayBlocking = await checkOverlayAtPoint(deps.cdp, target.tabId, centre.x, centre.y);
|
||||
const overlayBlocking = await checkOverlayAtPoint(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
pointerTarget.point.x,
|
||||
pointerTarget.point.y,
|
||||
);
|
||||
let automationBypassEnabled = false;
|
||||
if (overlayBlocking && deps.bypassOverlay) {
|
||||
try {
|
||||
@@ -279,47 +444,16 @@ export async function handleClick(
|
||||
}
|
||||
|
||||
try {
|
||||
// Move first so hover state activates, then press → release.
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
modifiers,
|
||||
});
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "click aborted" };
|
||||
}
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mousePressed",
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
const dispatch = await dispatchMouseClick(deps.cdp, target.tabId, pointerTarget.point, {
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
signal: deps.signal,
|
||||
moveSettleMs: pointerTarget.moveSettleMs,
|
||||
});
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
try {
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseReleased",
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.debug("[bsk interaction] best-effort mouseReleased after abort failed", err);
|
||||
}
|
||||
if (dispatch === "cancelled") {
|
||||
return { code: "cancelled", message: "click aborted" };
|
||||
}
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseReleased",
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
@@ -337,10 +471,17 @@ export async function handleClick(
|
||||
|
||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||
tab_id: target.tabId,
|
||||
used_ref: node.usedRef,
|
||||
used_selector: node.usedSelector,
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
used_ref: pointerTarget.usedRef,
|
||||
used_selector: pointerTarget.usedSelector,
|
||||
x: pointerTarget.point.x,
|
||||
y: pointerTarget.point.y,
|
||||
...(pointerTarget.capture
|
||||
? {
|
||||
capture_id: pointerTarget.capture.id,
|
||||
image_x: pointerTarget.capture.imageX,
|
||||
image_y: pointerTarget.capture.imageY,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -560,6 +701,47 @@ function isFillable(node: DescribedNode): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `type` also supports custom text-entry widgets that expose an ARIA role. */
|
||||
function isTypeTarget(node: DescribedNode): boolean {
|
||||
const tag = (node.nodeName ?? "").toUpperCase();
|
||||
const attrs = node.attributes ?? [];
|
||||
const attributes = new Map<string, string>();
|
||||
for (let i = 0; i + 1 < attrs.length; i += 2) {
|
||||
attributes.set(attrs[i].toLowerCase(), attrs[i + 1].toLowerCase());
|
||||
}
|
||||
if (tag === "TEXTAREA") return true;
|
||||
if (tag === "INPUT") {
|
||||
const type = attributes.get("type") ?? "text";
|
||||
return !new Set([
|
||||
"button",
|
||||
"checkbox",
|
||||
"color",
|
||||
"date",
|
||||
"datetime-local",
|
||||
"file",
|
||||
"hidden",
|
||||
"image",
|
||||
"month",
|
||||
"radio",
|
||||
"range",
|
||||
"reset",
|
||||
"submit",
|
||||
"time",
|
||||
"week",
|
||||
]).has(type);
|
||||
}
|
||||
const contentEditable = attributes.get("contenteditable");
|
||||
if (
|
||||
contentEditable === "" ||
|
||||
contentEditable === "true" ||
|
||||
contentEditable === "plaintext-only"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const role = attributes.get("role");
|
||||
return role === "textbox" || role === "searchbox";
|
||||
}
|
||||
|
||||
type SelectMutationResult =
|
||||
| {
|
||||
ok: true;
|
||||
@@ -598,8 +780,9 @@ export async function handleFill(
|
||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
|
||||
if (isRpcError(node)) return node;
|
||||
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||
const input = inputContextForNode(deps.cdp, node);
|
||||
if (isRpcError(input)) return input;
|
||||
const nodeCdp = input.cdp;
|
||||
|
||||
try {
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
@@ -607,7 +790,7 @@ export async function handleFill(
|
||||
target.tabId,
|
||||
"DOM.describeNode",
|
||||
{
|
||||
backendNodeId: node.backendNodeId,
|
||||
backendNodeId: input.backendNodeId,
|
||||
},
|
||||
);
|
||||
if (!described.node || !isFillable(described.node)) {
|
||||
@@ -620,15 +803,15 @@ export async function handleFill(
|
||||
const scrollErr = await scrollElementAndFramesIntoView(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
node.cdpTarget,
|
||||
node.backendNodeId,
|
||||
node.frameId,
|
||||
input.target,
|
||||
input.backendNodeId,
|
||||
input.frameId,
|
||||
);
|
||||
if (scrollErr) return scrollErr;
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "fill aborted" };
|
||||
}
|
||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: input.backendNodeId });
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
@@ -640,62 +823,118 @@ export async function handleFill(
|
||||
return { code: "cancelled", message: "fill aborted" };
|
||||
}
|
||||
|
||||
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
|
||||
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, input.backendNodeId);
|
||||
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
||||
const objectId = objectIdOrErr;
|
||||
const clearBefore = params.clear_before ?? true;
|
||||
|
||||
try {
|
||||
if (clearBefore) {
|
||||
// Clear input/textarea value or wipe contenteditable innerText,
|
||||
// then fire `input` so frameworks observe the empty state.
|
||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
||||
objectId,
|
||||
functionDeclaration: `function() {
|
||||
if (this.isContentEditable) { this.textContent = ''; }
|
||||
else {
|
||||
const proto = this instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
if (descriptor && descriptor.set) descriptor.set.call(this, '');
|
||||
else this.value = '';
|
||||
}
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}`,
|
||||
returnByValue: true,
|
||||
});
|
||||
}
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "fill aborted" };
|
||||
}
|
||||
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
||||
// box, much more reliably than per-key `dispatchKeyEvent`.
|
||||
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "fill aborted" };
|
||||
}
|
||||
// Fire `input` + `change` so React / Vue controlled inputs commit.
|
||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
||||
objectId,
|
||||
functionDeclaration: `function() {
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}`,
|
||||
returnByValue: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const fillResult = await fillEditable(
|
||||
nodeCdp,
|
||||
target.tabId,
|
||||
objectId,
|
||||
params.value,
|
||||
clearBefore,
|
||||
deps.signal,
|
||||
);
|
||||
if (isRpcError(fillResult)) return fillResult;
|
||||
|
||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||
tab_id: target.tabId,
|
||||
used_ref: node.usedRef,
|
||||
used_selector: node.usedSelector,
|
||||
value_length: params.value.length,
|
||||
used_ref: input.usedRef,
|
||||
used_selector: input.usedSelector,
|
||||
value_length: fillResult.value.length,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool.type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleType(
|
||||
manager: SessionManager,
|
||||
params: TypeParams,
|
||||
deps: InteractionDeps = getDefaultDeps(),
|
||||
): Promise<TypeResult | RpcError> {
|
||||
if (!params || typeof params.text !== "string") {
|
||||
return { code: "invalid_params", message: "type requires a text string" };
|
||||
}
|
||||
if (params.focused !== undefined && typeof params.focused !== "boolean") {
|
||||
return { code: "invalid_params", message: "type focused must be a boolean" };
|
||||
}
|
||||
const hasExplicitTarget =
|
||||
(typeof params.ref === "string" && params.ref.length > 0) ||
|
||||
(typeof params.selector === "string" && params.selector.length > 0);
|
||||
if (params.focused === true && hasExplicitTarget) {
|
||||
return {
|
||||
code: "invalid_params",
|
||||
message: "type: pass a ref/selector or focused=true, not both",
|
||||
};
|
||||
}
|
||||
if (params.focused !== true && !hasExplicitTarget) {
|
||||
return {
|
||||
code: "invalid_params",
|
||||
message: "type requires a ref/selector or focused=true",
|
||||
};
|
||||
}
|
||||
const ctxOrErr = lookupSession(manager, params, "type");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "type aborted" };
|
||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
const denied = enforceAgentWindow(ctx, target, "type");
|
||||
if (denied) return denied;
|
||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||
|
||||
let input: InputDispatchContext | RpcError;
|
||||
if (hasExplicitTarget) {
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "type");
|
||||
const explicitInput = inputContextForNode(deps.cdp, node);
|
||||
if (isRpcError(explicitInput)) return explicitInput;
|
||||
input = explicitInput;
|
||||
try {
|
||||
const described = await explicitInput.cdp.send<{ node?: DescribedNode }>(
|
||||
target.tabId,
|
||||
"DOM.describeNode",
|
||||
{ backendNodeId: explicitInput.backendNodeId },
|
||||
);
|
||||
if (!described.node || !isTypeTarget(described.node)) {
|
||||
return rpcError(
|
||||
"invalid_params",
|
||||
"target_not_fillable",
|
||||
`element ${described.node?.nodeName ?? "?"} not text-editable`,
|
||||
);
|
||||
}
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
const scrollErr = await scrollElementAndFramesIntoView(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
explicitInput.target,
|
||||
explicitInput.backendNodeId,
|
||||
explicitInput.frameId,
|
||||
);
|
||||
if (scrollErr) return scrollErr;
|
||||
if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "type aborted" };
|
||||
await explicitInput.cdp.send(target.tabId, "DOM.focus", {
|
||||
backendNodeId: explicitInput.backendNodeId,
|
||||
});
|
||||
} catch (err) {
|
||||
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
} else {
|
||||
input = await resolveFocusedInputContext(deps.cdp, target.tabId, "type");
|
||||
if (isRpcError(input)) return input;
|
||||
}
|
||||
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
const dispatchError = await dispatchTextInput(input.cdp, target.tabId, params.text, deps.signal);
|
||||
if (dispatchError) return dispatchError;
|
||||
|
||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||
tab_id: target.tabId,
|
||||
used_ref: input.usedRef,
|
||||
used_selector: input.usedSelector,
|
||||
text_length: params.text.length,
|
||||
verification: "dispatched",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -870,31 +1109,38 @@ export async function handlePress(
|
||||
};
|
||||
}
|
||||
|
||||
let input: InputDispatchContext | RpcError;
|
||||
// Optional focus before key dispatch.
|
||||
if (params.ref || params.selector) {
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
|
||||
if (isRpcError(node)) return node;
|
||||
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||
const explicitInput = inputContextForNode(deps.cdp, node);
|
||||
if (isRpcError(explicitInput)) return explicitInput;
|
||||
input = explicitInput;
|
||||
try {
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
const scrollErr = await scrollElementAndFramesIntoView(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
node.cdpTarget,
|
||||
node.backendNodeId,
|
||||
node.frameId,
|
||||
explicitInput.target,
|
||||
explicitInput.backendNodeId,
|
||||
explicitInput.frameId,
|
||||
);
|
||||
if (scrollErr) return scrollErr;
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
return { code: "cancelled", message: "press aborted" };
|
||||
}
|
||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||
await explicitInput.cdp.send(target.tabId, "DOM.focus", {
|
||||
backendNodeId: explicitInput.backendNodeId,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
input = await resolveFocusedInputContext(deps.cdp, target.tabId, "press");
|
||||
if (isRpcError(input)) return input;
|
||||
}
|
||||
|
||||
if (throwIfAborted(deps.signal)) {
|
||||
@@ -908,7 +1154,7 @@ export async function handlePress(
|
||||
try {
|
||||
let cancelled = false;
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||
type: "rawKeyDown",
|
||||
key: descriptor.key,
|
||||
code: descriptor.code,
|
||||
@@ -922,7 +1168,7 @@ export async function handlePress(
|
||||
typeof descriptor.text === "string" &&
|
||||
descriptor.text.length > 0
|
||||
) {
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||
type: "char",
|
||||
key: descriptor.key,
|
||||
code: descriptor.code,
|
||||
@@ -939,7 +1185,7 @@ export async function handlePress(
|
||||
cancelled = true;
|
||||
}
|
||||
}
|
||||
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
key: descriptor.key,
|
||||
code: descriptor.code,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { MouseButton } from "@/transport/types";
|
||||
import type { Point } from "./geometry";
|
||||
import type { CdpRunner } from "./shared";
|
||||
|
||||
interface MouseClickDispatchOptions {
|
||||
button: MouseButton;
|
||||
clickCount: number;
|
||||
modifiers: number;
|
||||
signal?: AbortSignal;
|
||||
moveSettleMs?: number;
|
||||
}
|
||||
|
||||
type MouseClickDispatchResult = "completed" | "cancelled";
|
||||
|
||||
function waitForSettle(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
|
||||
if (ms <= 0) return Promise.resolve(signal?.aborted !== true);
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (completed: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(completed);
|
||||
};
|
||||
const timer = setTimeout(() => finish(true), ms);
|
||||
const onAbort = () => finish(false);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one coherent CDP mouse click in top-viewport CSS coordinates.
|
||||
*
|
||||
* Keeping the sequence here prevents DOM and Surface interaction paths from
|
||||
* duplicating cancellation and best-effort release behavior. Callers that
|
||||
* target asynchronously hit-tested content may request a settle interval.
|
||||
*/
|
||||
export async function dispatchMouseClick(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
point: Point,
|
||||
options: MouseClickDispatchOptions,
|
||||
): Promise<MouseClickDispatchResult> {
|
||||
const { button, clickCount, modifiers, signal, moveSettleMs = 0 } = options;
|
||||
let pressed = false;
|
||||
const release = () =>
|
||||
cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseReleased",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
|
||||
try {
|
||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
modifiers,
|
||||
});
|
||||
if (!(await waitForSettle(moveSettleMs, signal))) return "cancelled";
|
||||
|
||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mousePressed",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
pressed = true;
|
||||
|
||||
if (signal?.aborted) {
|
||||
await release();
|
||||
pressed = false;
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
await release();
|
||||
pressed = false;
|
||||
return "completed";
|
||||
} catch (error) {
|
||||
if (pressed) {
|
||||
try {
|
||||
await release();
|
||||
} catch (releaseError) {
|
||||
console.debug("[bsk mouse-input] best-effort mouse release failed", releaseError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
type ToolEffect,
|
||||
} from "./shared";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
import { surfaceVisibleRect } from "./surface-coordinate";
|
||||
import { captureSurfaceEnvironment } from "./surface-target";
|
||||
import {
|
||||
type CapturedNode,
|
||||
type CapturedViewModel,
|
||||
@@ -194,7 +196,9 @@ async function captureElementScreenshot(
|
||||
visualSurface = false,
|
||||
observedVisibleRect?: Rect,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
|
||||
): Promise<
|
||||
{ image_base64: string; width: number; height: number; topViewportRect: Rect } | RpcError
|
||||
> {
|
||||
if (signal?.aborted) return cancelled("screenshot");
|
||||
const geometry = await resolveNodeGeometry(
|
||||
cdp,
|
||||
@@ -204,24 +208,19 @@ async function captureElementScreenshot(
|
||||
);
|
||||
if (isRpcError(geometry)) return geometry;
|
||||
if (signal?.aborted) return cancelled("screenshot");
|
||||
const liveRect = geometry.topBounds;
|
||||
const rect =
|
||||
visualSurface && observedVisibleRect
|
||||
? (() => {
|
||||
const x = Math.max(liveRect.x, observedVisibleRect.x);
|
||||
const y = Math.max(liveRect.y, observedVisibleRect.y);
|
||||
const right = Math.min(
|
||||
liveRect.x + liveRect.width,
|
||||
observedVisibleRect.x + observedVisibleRect.w,
|
||||
);
|
||||
const bottom = Math.min(
|
||||
liveRect.y + liveRect.height,
|
||||
observedVisibleRect.y + observedVisibleRect.h,
|
||||
);
|
||||
return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null;
|
||||
})()
|
||||
: liveRect;
|
||||
if (!rect) return { code: "permission_denied", message: "surface is no longer visible" };
|
||||
const topViewportRect = surfaceVisibleRect(
|
||||
geometry.topBounds,
|
||||
visualSurface ? observedVisibleRect : undefined,
|
||||
);
|
||||
if (!topViewportRect) {
|
||||
return { code: "permission_denied", message: "surface is no longer visible" };
|
||||
}
|
||||
const rect = {
|
||||
x: topViewportRect.x,
|
||||
y: topViewportRect.y,
|
||||
width: topViewportRect.w,
|
||||
height: topViewportRect.h,
|
||||
};
|
||||
const scale = visualSurface
|
||||
? Math.min(
|
||||
1,
|
||||
@@ -251,7 +250,7 @@ async function captureElementScreenshot(
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
};
|
||||
return { image_base64, width: dims.width, height: dims.height };
|
||||
return { image_base64, width: dims.width, height: dims.height, topViewportRect };
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
@@ -374,12 +373,46 @@ export async function handleScreenshot(
|
||||
);
|
||||
if (isRpcError(captured)) return captured;
|
||||
if (signal?.aborted) return cancelled("screenshot");
|
||||
const capture =
|
||||
node.kind === "surface"
|
||||
? await (async () => {
|
||||
const environment = await captureSurfaceEnvironment(cdp, target.tabId, node.frameId);
|
||||
if (isRpcError(environment)) return environment;
|
||||
return ctx.surfaceCaptures.create({
|
||||
sessionId: ctx.sessionId,
|
||||
tabId: target.tabId,
|
||||
navigationIdentity: environment.navigationIdentity,
|
||||
surface: {
|
||||
ref: node.refKey,
|
||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
||||
backendNodeId: node.backendNodeId,
|
||||
observationGeneration: node.generation,
|
||||
},
|
||||
topViewportRect: captured.topViewportRect,
|
||||
imageWidth: captured.width,
|
||||
imageHeight: captured.height,
|
||||
viewportSignature: environment.viewportSignature,
|
||||
frameProjectionSignature: environment.frameProjectionSignature,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (capture && isRpcError(capture)) return capture;
|
||||
return withShotDialogs({
|
||||
image_base64: captured.image_base64,
|
||||
width: captured.width,
|
||||
height: captured.height,
|
||||
format: "png",
|
||||
tab_id: target.tabId,
|
||||
...(capture
|
||||
? {
|
||||
capture: {
|
||||
id: capture.id,
|
||||
surface_ref: `@${capture.surface.ref}`,
|
||||
coordinate_space: "capture-image-pixel" as const,
|
||||
expires_at: capture.expiresAt,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ export async function handleSessionStop(
|
||||
|
||||
// Step 2: clear the per-session RefStore (review M6/M7 parity).
|
||||
ctx.refStore.clear();
|
||||
ctx.surfaceCaptures.clear();
|
||||
|
||||
clearRecordingForSession(params.session_id);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SnapshotRefLookup {
|
||||
visibleRect?: Rect;
|
||||
kind: RefTargetKind;
|
||||
capabilities: RefCapability[];
|
||||
generation: number;
|
||||
}
|
||||
|
||||
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
|
||||
@@ -44,6 +45,7 @@ export function lookupSnapshotRef(
|
||||
...(entry.visibleRect ? { visibleRect: entry.visibleRect } : {}),
|
||||
kind: entry.kind,
|
||||
capabilities: entry.capabilities,
|
||||
generation: entry.generation,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Rect } from "@browser-skill/vom";
|
||||
import type { Point, Region, ViewportRect } from "./geometry";
|
||||
|
||||
const RECT_EPSILON = 0.01;
|
||||
|
||||
export function surfaceVisibleRect(
|
||||
liveRect: ViewportRect,
|
||||
observedRect: Rect | undefined,
|
||||
): Rect | null {
|
||||
const observed = observedRect ?? {
|
||||
x: liveRect.x,
|
||||
y: liveRect.y,
|
||||
w: liveRect.width,
|
||||
h: liveRect.height,
|
||||
};
|
||||
const x = Math.max(liveRect.x, observed.x);
|
||||
const y = Math.max(liveRect.y, observed.y);
|
||||
const right = Math.min(liveRect.x + liveRect.width, observed.x + observed.w);
|
||||
const bottom = Math.min(liveRect.y + liveRect.height, observed.y + observed.h);
|
||||
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : null;
|
||||
}
|
||||
|
||||
export function sameRect(a: Rect, b: Rect): boolean {
|
||||
return (
|
||||
Math.abs(a.x - b.x) <= RECT_EPSILON &&
|
||||
Math.abs(a.y - b.y) <= RECT_EPSILON &&
|
||||
Math.abs(a.w - b.w) <= RECT_EPSILON &&
|
||||
Math.abs(a.h - b.h) <= RECT_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function mapImagePointToViewport(
|
||||
rect: Rect,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
imageX: number,
|
||||
imageY: number,
|
||||
): Point | null {
|
||||
if (
|
||||
!Number.isFinite(imageX) ||
|
||||
!Number.isFinite(imageY) ||
|
||||
!Number.isSafeInteger(imageWidth) ||
|
||||
!Number.isSafeInteger(imageHeight) ||
|
||||
imageWidth <= 0 ||
|
||||
imageHeight <= 0 ||
|
||||
imageX < 0 ||
|
||||
imageY < 0 ||
|
||||
imageX >= imageWidth ||
|
||||
imageY >= imageHeight ||
|
||||
!Number.isFinite(rect.x) ||
|
||||
!Number.isFinite(rect.y) ||
|
||||
!Number.isFinite(rect.w) ||
|
||||
!Number.isFinite(rect.h) ||
|
||||
rect.w <= 0 ||
|
||||
rect.h <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: rect.x + (imageX / imageWidth) * rect.w,
|
||||
y: rect.y + (imageY / imageHeight) * rect.h,
|
||||
};
|
||||
}
|
||||
|
||||
function pointInPolygon(point: Point, polygon: Point[]): boolean {
|
||||
for (let index = 0; index < polygon.length; index += 1) {
|
||||
const a = polygon[index];
|
||||
const b = polygon[(index + 1) % polygon.length];
|
||||
const cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x);
|
||||
const withinX = point.x >= Math.min(a.x, b.x) - 1e-9 && point.x <= Math.max(a.x, b.x) + 1e-9;
|
||||
const withinY = point.y >= Math.min(a.y, b.y) - 1e-9 && point.y <= Math.max(a.y, b.y) + 1e-9;
|
||||
if (Math.abs(cross) <= 1e-9 && withinX && withinY) return true;
|
||||
}
|
||||
let inside = false;
|
||||
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
||||
const a = polygon[index];
|
||||
const b = polygon[previous];
|
||||
const intersects =
|
||||
a.y > point.y !== b.y > point.y &&
|
||||
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function pointInRegion(point: Point, region: Region): boolean {
|
||||
return region.some((polygon) => polygon.length >= 3 && pointInPolygon(point, polygon));
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
||||
import type { SessionContext } from "@/session-manager/manager";
|
||||
import type { RpcError } from "@/transport/types";
|
||||
import { rpcError } from "./errors";
|
||||
import { resolveNodeGeometry } from "./frame-geometry";
|
||||
import { type CdpRunner, isRpcError, normaliseRef } from "./shared";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
import {
|
||||
mapImagePointToViewport,
|
||||
pointInRegion,
|
||||
sameRect,
|
||||
surfaceVisibleRect,
|
||||
} from "./surface-coordinate";
|
||||
|
||||
export interface SurfaceCaptureEnvironment {
|
||||
navigationIdentity: string;
|
||||
viewportSignature: string;
|
||||
frameProjectionSignature: string;
|
||||
}
|
||||
|
||||
export interface SurfaceTargetResolverDeps {
|
||||
cdp: CdpRunner;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SurfacePointerTargetInput {
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
captureId?: string;
|
||||
imageX?: number;
|
||||
imageY?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedSurfacePointerTarget {
|
||||
point: { x: number; y: number };
|
||||
usedRef: string;
|
||||
moveSettleMs: number;
|
||||
capture: {
|
||||
id: string;
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Canvas-style controls commonly update their pointer hit-test on the next
|
||||
// animation frame. Leave a scheduling boundary after moving and before pressing.
|
||||
export const SURFACE_POINTER_MOVE_SETTLE_MS = 32;
|
||||
|
||||
function stale(message: string): RpcError {
|
||||
return rpcError("permission_denied", "surface_capture_stale", message);
|
||||
}
|
||||
|
||||
function finiteObject(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(finiteObject);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(
|
||||
([, entry]) => entry !== undefined && (typeof entry !== "number" || Number.isFinite(entry)),
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, entry]) => [key, finiteObject(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
function framePathSignature(graph: CdpFrameGraph, frameId: string | undefined): string | null {
|
||||
if (!frameId) return "top";
|
||||
const frames = new Map(graph.frames.map((frame) => [frame.frameId, frame]));
|
||||
const path: Array<{
|
||||
frameId: string;
|
||||
parentFrameId?: string;
|
||||
ownerBackendNodeId?: number;
|
||||
targetSessionId?: string;
|
||||
}> = [];
|
||||
const seen = new Set<string>();
|
||||
let current = frames.get(frameId);
|
||||
while (current) {
|
||||
if (seen.has(current.frameId)) return null;
|
||||
seen.add(current.frameId);
|
||||
path.push({
|
||||
frameId: current.frameId,
|
||||
...(current.parentFrameId ? { parentFrameId: current.parentFrameId } : {}),
|
||||
...(current.ownerBackendNodeId !== undefined
|
||||
? { ownerBackendNodeId: current.ownerBackendNodeId }
|
||||
: {}),
|
||||
...(current.target.sessionId ? { targetSessionId: current.target.sessionId } : {}),
|
||||
});
|
||||
if (!current.parentFrameId) return JSON.stringify(path);
|
||||
const parent = frames.get(current.parentFrameId);
|
||||
if (!parent) return null;
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function captureSurfaceEnvironment(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
frameId: string | undefined,
|
||||
): Promise<SurfaceCaptureEnvironment | RpcError> {
|
||||
try {
|
||||
const [tree, metrics, graph] = await Promise.all([
|
||||
cdp.send<{
|
||||
frameTree?: { frame?: { id?: string; loaderId?: string; url?: string } };
|
||||
}>(tabId, "Page.getFrameTree", {}),
|
||||
cdp.send<Record<string, unknown>>(tabId, "Page.getLayoutMetrics", {}),
|
||||
frameId && cdp.getFrameGraph ? cdp.getFrameGraph(tabId) : Promise.resolve(null),
|
||||
]);
|
||||
const root = tree.frameTree?.frame;
|
||||
if (!root?.id || !root.loaderId) {
|
||||
return { code: "cdp_failed", message: "could not identify the current navigation" };
|
||||
}
|
||||
const frameProjectionSignature = frameId
|
||||
? graph
|
||||
? framePathSignature(graph, frameId)
|
||||
: null
|
||||
: "top";
|
||||
if (!frameProjectionSignature) {
|
||||
return { code: "cdp_failed", message: `could not identify frame projection for ${frameId}` };
|
||||
}
|
||||
return {
|
||||
navigationIdentity: JSON.stringify({ id: root.id, loaderId: root.loaderId, url: root.url }),
|
||||
viewportSignature: JSON.stringify(
|
||||
finiteObject({
|
||||
cssLayoutViewport: metrics.cssLayoutViewport,
|
||||
cssVisualViewport: metrics.cssVisualViewport,
|
||||
}),
|
||||
),
|
||||
frameProjectionSignature,
|
||||
};
|
||||
} catch (error) {
|
||||
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function validatePointParams(input: SurfacePointerTargetInput): RpcError | null {
|
||||
if (!input.captureId || typeof input.captureId !== "string") {
|
||||
return { code: "invalid_params", message: "surface point click requires capture_id" };
|
||||
}
|
||||
if (typeof input.imageX !== "number" || !Number.isFinite(input.imageX)) {
|
||||
return { code: "invalid_params", message: "image_x must be a finite number" };
|
||||
}
|
||||
if (typeof input.imageY !== "number" || !Number.isFinite(input.imageY)) {
|
||||
return { code: "invalid_params", message: "image_y must be a finite number" };
|
||||
}
|
||||
if (!input.ref || input.selector) {
|
||||
return {
|
||||
code: "invalid_params",
|
||||
message: "surface point click requires one Surface ref and does not accept a selector",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function captureConsumeError(reason: "not_found" | "expired" | "consumed"): RpcError {
|
||||
const code = reason === "not_found" ? "not_found" : "permission_denied";
|
||||
return rpcError(code, `surface_capture_${reason}`, `surface capture is ${reason}`);
|
||||
}
|
||||
|
||||
function aborted(signal: AbortSignal | undefined): RpcError | null {
|
||||
return signal?.aborted
|
||||
? { code: "cancelled", message: "surface target resolution aborted" }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Resolve one fresh Surface capture coordinate into a validated top-viewport pointer target. */
|
||||
export async function resolveSurfacePointerTarget(
|
||||
ctx: SessionContext,
|
||||
target: { tabId: number; url?: string },
|
||||
input: SurfacePointerTargetInput,
|
||||
deps: SurfaceTargetResolverDeps,
|
||||
): Promise<ResolvedSurfacePointerTarget | RpcError> {
|
||||
const invalid = validatePointParams(input);
|
||||
if (invalid) return invalid;
|
||||
const earlyAbort = aborted(deps.signal);
|
||||
if (earlyAbort) return earlyAbort;
|
||||
|
||||
const consumed = ctx.surfaceCaptures.consume(input.captureId as string);
|
||||
if (!consumed.ok) return captureConsumeError(consumed.reason);
|
||||
const capture = consumed.capture;
|
||||
const postConsumeAbort = aborted(deps.signal);
|
||||
if (postConsumeAbort) return postConsumeAbort;
|
||||
|
||||
if (capture.sessionId !== ctx.sessionId || capture.tabId !== target.tabId) {
|
||||
return stale("surface capture belongs to a different session or tab");
|
||||
}
|
||||
if (normaliseRef(input.ref as string) !== capture.surface.ref) {
|
||||
return stale("surface ref does not match the capture");
|
||||
}
|
||||
const node = resolveSnapshotRef(ctx, input.ref as string, target.tabId, "screenshot");
|
||||
if (isRpcError(node)) return stale(node.message);
|
||||
if (
|
||||
node.kind !== "surface" ||
|
||||
node.backendNodeId !== capture.surface.backendNodeId ||
|
||||
node.frameId !== capture.surface.frameId ||
|
||||
node.generation !== capture.surface.observationGeneration
|
||||
) {
|
||||
return stale("surface observation generation or node identity changed");
|
||||
}
|
||||
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
try {
|
||||
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
||||
} catch (error) {
|
||||
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
const environment = await captureSurfaceEnvironment(deps.cdp, target.tabId, node.frameId);
|
||||
if (isRpcError(environment)) return environment;
|
||||
if (
|
||||
environment.navigationIdentity !== capture.navigationIdentity ||
|
||||
environment.viewportSignature !== capture.viewportSignature ||
|
||||
environment.frameProjectionSignature !== capture.frameProjectionSignature
|
||||
) {
|
||||
return stale("navigation, viewport, zoom, scroll, or frame projection changed");
|
||||
}
|
||||
|
||||
const geometry = await resolveNodeGeometry(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
{
|
||||
target: {
|
||||
tabId: target.tabId,
|
||||
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
|
||||
},
|
||||
backendNodeId: node.backendNodeId,
|
||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
||||
},
|
||||
{ scrollIntoView: false },
|
||||
);
|
||||
if (isRpcError(geometry)) return stale(geometry.message);
|
||||
const currentRect = surfaceVisibleRect(geometry.topBounds, node.visibleRect);
|
||||
if (!currentRect || !sameRect(currentRect, capture.topViewportRect)) {
|
||||
return stale("surface visible region changed");
|
||||
}
|
||||
const point = mapImagePointToViewport(
|
||||
capture.topViewportRect,
|
||||
capture.imageWidth,
|
||||
capture.imageHeight,
|
||||
input.imageX as number,
|
||||
input.imageY as number,
|
||||
);
|
||||
if (!point || !pointInRegion(point, geometry.topVisibleRegions)) {
|
||||
return rpcError(
|
||||
"invalid_params",
|
||||
"surface_coordinate_invalid",
|
||||
"image coordinate is outside the captured Surface",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
point,
|
||||
usedRef: capture.surface.ref,
|
||||
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
||||
capture: {
|
||||
id: capture.id,
|
||||
imageX: input.imageX as number,
|
||||
imageY: input.imageY as number,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Browser-native text entry for live editing sessions. Unlike fill, this
|
||||
// module does not assume that the focused editable retains a DOM value: a
|
||||
// Canvas editor may use an input/textarea only as a transient keyboard/IME
|
||||
// sink and clear it after consuming each event.
|
||||
|
||||
import type { RpcError } from "@/transport/types";
|
||||
import type { CdpRunner } from "./shared";
|
||||
|
||||
function cancelled(signal: AbortSignal | undefined): RpcError | null {
|
||||
return signal?.aborted ? { code: "cancelled", message: "type aborted" } : null;
|
||||
}
|
||||
|
||||
interface KeyDescriptor {
|
||||
key: string;
|
||||
code: string;
|
||||
windowsVirtualKeyCode: number;
|
||||
nativeVirtualKeyCode?: number;
|
||||
modifiers: number;
|
||||
}
|
||||
|
||||
function asciiKeyDescriptor(character: string): KeyDescriptor {
|
||||
if (/^[a-z]$/.test(character)) {
|
||||
return {
|
||||
key: character,
|
||||
code: `Key${character.toUpperCase()}`,
|
||||
windowsVirtualKeyCode: character.toUpperCase().charCodeAt(0),
|
||||
modifiers: 0,
|
||||
};
|
||||
}
|
||||
if (/^[A-Z]$/.test(character)) {
|
||||
return {
|
||||
key: character,
|
||||
code: `Key${character}`,
|
||||
windowsVirtualKeyCode: character.charCodeAt(0),
|
||||
modifiers: 8,
|
||||
};
|
||||
}
|
||||
if (/^[0-9]$/.test(character)) {
|
||||
return {
|
||||
key: character,
|
||||
code: `Digit${character}`,
|
||||
windowsVirtualKeyCode: character.charCodeAt(0),
|
||||
modifiers: 0,
|
||||
};
|
||||
}
|
||||
if (character === " ") {
|
||||
return { key: " ", code: "Space", windowsVirtualKeyCode: 32, modifiers: 0 };
|
||||
}
|
||||
return {
|
||||
key: character,
|
||||
code: "",
|
||||
windowsVirtualKeyCode: character.charCodeAt(0),
|
||||
modifiers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one complete browser key transaction and always release the key. The
|
||||
* commit callback owns the text-producing phase: a `char` event for a
|
||||
* keyboard character, or `Input.insertText` for committed IME text.
|
||||
*/
|
||||
async function dispatchKeyTransaction(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
descriptor: KeyDescriptor,
|
||||
commit: () => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcError | null> {
|
||||
let keyDown = false;
|
||||
try {
|
||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
||||
type: "rawKeyDown",
|
||||
...descriptor,
|
||||
});
|
||||
keyDown = true;
|
||||
const abortedAfterDown = cancelled(signal);
|
||||
if (!abortedAfterDown) await commit();
|
||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
...descriptor,
|
||||
});
|
||||
keyDown = false;
|
||||
return abortedAfterDown ?? cancelled(signal);
|
||||
} catch (err) {
|
||||
if (keyDown) {
|
||||
try {
|
||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
...descriptor,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup keeps the page from observing a stuck key.
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function typeAsciiCharacter(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
character: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcError | null> {
|
||||
const descriptor = asciiKeyDescriptor(character);
|
||||
return dispatchKeyTransaction(
|
||||
cdp,
|
||||
tabId,
|
||||
descriptor,
|
||||
async () => {
|
||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
||||
type: "char",
|
||||
...descriptor,
|
||||
text: character,
|
||||
unmodifiedText: character,
|
||||
});
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
async function typeImeSegment(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
text: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcError | null> {
|
||||
const abortedBefore = cancelled(signal);
|
||||
if (abortedBefore) return abortedBefore;
|
||||
|
||||
// A committed IME value is still part of a keyboard transaction. The
|
||||
// Process key (VK_PROCESSKEY / 229) is the platform-neutral signal that an
|
||||
// IME owns the keystroke. It lets selection-based editors enter text mode
|
||||
// before insertText commits the Unicode value, without inventing a visible
|
||||
// ASCII prefix or leaving a composition transaction open.
|
||||
const processKey: KeyDescriptor = {
|
||||
key: "Process",
|
||||
code: "",
|
||||
windowsVirtualKeyCode: 229,
|
||||
nativeVirtualKeyCode: 229,
|
||||
modifiers: 0,
|
||||
};
|
||||
return dispatchKeyTransaction(
|
||||
cdp,
|
||||
tabId,
|
||||
processKey,
|
||||
() => cdp.send(tabId, "Input.insertText", { text }).then(() => undefined),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch text through the browser's keyboard/IME pipelines. ASCII runs are
|
||||
* delivered as physical-key-shaped events; non-ASCII runs use CDP's committed
|
||||
* IME/software-keyboard insertion path. Successful return means the browser
|
||||
* accepted the input commands, not that a DOM value retained the text.
|
||||
*/
|
||||
export async function dispatchTextInput(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
text: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcError | null> {
|
||||
const abortedBefore = cancelled(signal);
|
||||
if (abortedBefore) return abortedBefore;
|
||||
|
||||
const segments: Array<{ ime: boolean; text: string }> = [];
|
||||
for (const character of text) {
|
||||
const ime = character.codePointAt(0)! > 0x7f;
|
||||
const previous = segments.at(-1);
|
||||
if (previous?.ime === ime) previous.text += character;
|
||||
else segments.push({ ime, text: character });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const segment of segments) {
|
||||
if (segment.ime) {
|
||||
const error = await typeImeSegment(cdp, tabId, segment.text, signal);
|
||||
if (error) return error;
|
||||
continue;
|
||||
}
|
||||
for (const character of segment.text) {
|
||||
const error = await typeAsciiCharacter(cdp, tabId, character, signal);
|
||||
if (error) return error;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export type RpcErrorReason =
|
||||
| "ref_not_found"
|
||||
| "selector_not_found"
|
||||
| "target_not_fillable"
|
||||
| "focus_target_unresolved"
|
||||
| "input_not_applied"
|
||||
| "target_not_select"
|
||||
| "option_not_found"
|
||||
| "single_select_value_count"
|
||||
@@ -34,6 +36,11 @@ export type RpcErrorReason =
|
||||
| "restricted_tab_url"
|
||||
| "borrow_conflict"
|
||||
| "screenshot_capture_failed"
|
||||
| "surface_capture_not_found"
|
||||
| "surface_capture_expired"
|
||||
| "surface_capture_consumed"
|
||||
| "surface_capture_stale"
|
||||
| "surface_coordinate_invalid"
|
||||
| "cleanup_failed";
|
||||
|
||||
export interface RpcErrorData {
|
||||
@@ -299,9 +306,17 @@ export interface ScreenshotResult {
|
||||
height: number;
|
||||
format: string;
|
||||
tab_id: number;
|
||||
capture?: SurfaceCaptureInfo;
|
||||
dialogs?: JavaScriptDialogInfo[];
|
||||
}
|
||||
|
||||
export interface SurfaceCaptureInfo {
|
||||
id: string;
|
||||
surface_ref: string;
|
||||
coordinate_space: "capture-image-pixel";
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface SnapshotParams {
|
||||
session_id: string;
|
||||
tab_id?: number;
|
||||
@@ -415,6 +430,9 @@ export interface ClickParams {
|
||||
click_count?: number;
|
||||
modifiers?: KeyModifier[];
|
||||
timeout_ms?: number;
|
||||
capture_id?: string;
|
||||
image_x?: number;
|
||||
image_y?: number;
|
||||
}
|
||||
|
||||
export interface ClickResult {
|
||||
@@ -423,6 +441,9 @@ export interface ClickResult {
|
||||
used_selector?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
capture_id?: string;
|
||||
image_x?: number;
|
||||
image_y?: number;
|
||||
dialogs?: JavaScriptDialogInfo[];
|
||||
}
|
||||
|
||||
@@ -463,6 +484,26 @@ export interface FillResult {
|
||||
dialogs?: JavaScriptDialogInfo[];
|
||||
}
|
||||
|
||||
export interface TypeParams {
|
||||
session_id: string;
|
||||
text: string;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
/** Explicitly dispatch to the unique focused text-entry target. */
|
||||
focused?: boolean;
|
||||
tab_id?: number;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface TypeResult {
|
||||
tab_id: number;
|
||||
used_ref?: string;
|
||||
used_selector?: string;
|
||||
text_length: number;
|
||||
verification: "dispatched";
|
||||
dialogs?: JavaScriptDialogInfo[];
|
||||
}
|
||||
|
||||
export interface PressParams {
|
||||
session_id: string;
|
||||
key: string;
|
||||
|
||||
@@ -77,6 +77,14 @@ Expected Surface labels:
|
||||
|
||||
The cross-origin frame uses `http://localhost:4173` while the parent uses `http://127.0.0.1:4173`. With Chromium site isolation this exercises the OOPIF path without an external service.
|
||||
|
||||
Each frame Canvas also exposes a `pointer status` live region. A successful
|
||||
screenshot-bound point click must report
|
||||
`down button=0 buttons=1 hoverReady=true; click received` and turn the Canvas
|
||||
yellow. The hover flag is set on the animation frame after `pointermove`, so the
|
||||
fixture verifies both child-document delivery and the scheduling boundary
|
||||
required before `pointerdown`, rather than merely validating projected
|
||||
coordinates or a successful CDP response.
|
||||
|
||||
### `viewport.html`
|
||||
|
||||
Run with a `900x700` Agent Window.
|
||||
@@ -85,6 +93,40 @@ Run with a `900x700` Agent Window.
|
||||
- `fixture-below-fold` is absent initially.
|
||||
- After scrolling it into view and observing again, `fixture-below-fold` must be represented with a fresh ref.
|
||||
|
||||
### `input-frames.html`
|
||||
|
||||
This page exercises generic frame-aware input routing. It contains a top-level
|
||||
input plus same-origin, cross-origin (OOPIF), and nested frame inputs. Each
|
||||
frame includes an input, textarea, contenteditable editor, an input that
|
||||
replaces its DOM node after accepted input, an input that rejects synthetic
|
||||
DOM events, a transient keyboard/IME input proxy that consumes and clears its
|
||||
DOM value, and a controlled input that resets its value on the next animation
|
||||
frame.
|
||||
|
||||
- `fill` must route focus, text insertion, DOM events, and readback through the
|
||||
ref's owning CDP target.
|
||||
- `press --ref` must focus and dispatch every key event through that same target.
|
||||
- Untargeted `press` must follow the actual focused target or refuse when focus
|
||||
ownership cannot be determined uniquely.
|
||||
- The remounting input must retain the value through its replacement node.
|
||||
- The trusted input must be filled without synthetic `input` / `change` events.
|
||||
- `type` must deliver ASCII key events and committed non-ASCII IME/software
|
||||
keyboard insertion to the input proxy. Its DOM value remains empty while
|
||||
`proxy output` retains the consumed text; the result reports
|
||||
`verification=dispatched`, not a retained DOM value.
|
||||
- `type` must reject an omitted target unless `focused=true` is explicit, and
|
||||
focused mode must reject focus that is not owned by a text-entry target.
|
||||
- The rejecting input must return `input_not_applied`, never a successful
|
||||
requested-value length.
|
||||
|
||||
### `canvas-input.html`
|
||||
|
||||
This is the PR3 end-to-end fixture. Its cross-origin frame contains a
|
||||
visual-only three-row Canvas. A screenshot-bound click on row 3 exposes and
|
||||
focuses a standard textbox; ordinary `fill` must then write and read back its
|
||||
real value through the textbox's owning target. The fixture deliberately does
|
||||
not provide a Canvas-specific fill action.
|
||||
|
||||
### `dynamic.html`
|
||||
|
||||
Use the normal DOM buttons and re-observe after each action:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Canvas to standard input fixture</title>
|
||||
<style>
|
||||
body { padding: 16px; font: 14px system-ui, sans-serif; }
|
||||
iframe { width: 300px; height: 220px; border: 2px solid #5d6d7e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Canvas to standard input fixture</h1>
|
||||
<p>Select row 3 in the visual-only Canvas, then fill the exposed standard textbox.</p>
|
||||
<iframe
|
||||
title="cross-origin Canvas editor"
|
||||
src="http://localhost:4173/frame.html?label=canvas-input-grid&interactive=1&editor=1"
|
||||
></iframe>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,7 @@
|
||||
body { padding: 12px; background: #fdfefe; }
|
||||
canvas { display: block; width: 220px; height: 90px; background: #d5f5e3; border: 2px solid #1e8449; }
|
||||
iframe { width: 280px; height: 150px; margin-top: 10px; border: 2px solid #85929e; }
|
||||
#pointer-status { margin: 8px 0 0; min-height: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,11 +17,62 @@
|
||||
const params = new URLSearchParams(location.search);
|
||||
const canvas = document.querySelector("#frame-canvas");
|
||||
if (params.get("canvas") === "0") canvas.remove();
|
||||
else canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default");
|
||||
else {
|
||||
canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default");
|
||||
if (params.get("editor") === "1") {
|
||||
const context = canvas.getContext("2d");
|
||||
context.font = "14px system-ui, sans-serif";
|
||||
context.textBaseline = "middle";
|
||||
context.strokeStyle = "#1e8449";
|
||||
context.fillStyle = "#145a32";
|
||||
for (let row = 0; row < 3; row += 1) {
|
||||
const top = row * 30;
|
||||
context.strokeRect(0.5, top + 0.5, 219, 29);
|
||||
context.fillText(`Row ${row + 1}`, 12, top + 15);
|
||||
}
|
||||
}
|
||||
if (params.get("interactive") === "1") {
|
||||
const status = document.createElement("p");
|
||||
status.id = "pointer-status";
|
||||
status.setAttribute("aria-live", "polite");
|
||||
status.textContent = "pointer status: idle";
|
||||
canvas.insertAdjacentElement("afterend", status);
|
||||
let hoverReady = false;
|
||||
canvas.addEventListener("pointermove", () => {
|
||||
hoverReady = false;
|
||||
requestAnimationFrame(() => {
|
||||
hoverReady = true;
|
||||
});
|
||||
});
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
status.textContent = `pointer status: down button=${event.button} buttons=${event.buttons} hoverReady=${hoverReady}`;
|
||||
if (event.buttons === 1 && hoverReady) canvas.style.background = "#f9e79f";
|
||||
});
|
||||
canvas.addEventListener("click", () => {
|
||||
status.textContent += "; click received";
|
||||
});
|
||||
if (params.get("editor") === "1") {
|
||||
canvas.addEventListener("click", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasY = ((event.clientY - rect.top) / rect.height) * canvas.height;
|
||||
const row = Math.min(2, Math.max(0, Math.floor(canvasY / 30)));
|
||||
status.textContent += `; selected row=${row + 1}`;
|
||||
document.querySelector("#canvas-cell-editor")?.remove();
|
||||
if (row !== 2) return;
|
||||
const editor = document.createElement("input");
|
||||
editor.id = "canvas-cell-editor";
|
||||
editor.setAttribute("aria-label", "selected canvas cell editor");
|
||||
editor.placeholder = "Type into selected row 3";
|
||||
canvas.insertAdjacentElement("afterend", editor);
|
||||
editor.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.get("nested") === "1") {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.title = "nested fixture frame";
|
||||
iframe.src = "frame.html?label=fixture-nested-frame";
|
||||
iframe.src = "frame.html?label=fixture-nested-frame&interactive=1";
|
||||
document.body.append(iframe);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<body>
|
||||
<h1>Frame matrix</h1>
|
||||
<div class="frames">
|
||||
<iframe title="same-origin fixture" src="frame.html?label=fixture-same-origin-frame"></iframe>
|
||||
<iframe title="cross-origin fixture" src="http://localhost:4173/frame.html?label=fixture-cross-origin-frame"></iframe>
|
||||
<iframe title="same-origin fixture" src="frame.html?label=fixture-same-origin-frame&interactive=1"></iframe>
|
||||
<iframe title="cross-origin fixture" src="http://localhost:4173/frame.html?label=fixture-cross-origin-frame&interactive=1"></iframe>
|
||||
<iframe title="nested fixture" src="frame.html?canvas=0&nested=1"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body { margin: 0; padding: 12px; font: 14px system-ui, sans-serif; }
|
||||
label, textarea, input, [contenteditable] { display: block; margin: 8px 0; }
|
||||
input, textarea, [contenteditable] { width: 240px; padding: 6px; border: 1px solid #85929e; }
|
||||
iframe { width: 280px; height: 190px; border: 2px solid #85929e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label>Frame input <input aria-label="frame input" /></label>
|
||||
<label>Frame textarea <textarea aria-label="frame textarea"></textarea></label>
|
||||
<div contenteditable="true" aria-label="frame editor"></div>
|
||||
<label>Remounting input <span id="remount-host"><input aria-label="remounting input" /></span></label>
|
||||
<label>Trusted input <input id="trusted" aria-label="trusted input" /></label>
|
||||
<label>Input proxy <input id="proxy" aria-label="input proxy" /></label>
|
||||
<output id="proxy-output" aria-label="proxy output">proxy output: empty</output>
|
||||
<label>Rejecting input <input id="rejecting" aria-label="rejecting input" /></label>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const remountHost = document.querySelector("#remount-host");
|
||||
const armRemount = (input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const replacement = document.createElement("input");
|
||||
replacement.setAttribute("aria-label", "remounting input");
|
||||
replacement.value = input.value;
|
||||
armRemount(replacement);
|
||||
input.replaceWith(replacement);
|
||||
replacement.focus();
|
||||
}, { once: true });
|
||||
};
|
||||
armRemount(remountHost.querySelector("input"));
|
||||
const trusted = document.querySelector("#trusted");
|
||||
trusted.addEventListener("input", (event) => {
|
||||
if (!event.isTrusted) requestAnimationFrame(() => { trusted.value = ""; });
|
||||
});
|
||||
const proxy = document.querySelector("#proxy");
|
||||
const proxyOutput = document.querySelector("#proxy-output");
|
||||
let proxyValue = "";
|
||||
proxy.addEventListener("input", (event) => {
|
||||
if (event.isComposing) return;
|
||||
proxyValue += event.data ?? proxy.value;
|
||||
proxy.value = "";
|
||||
proxyOutput.textContent = `proxy output: ${proxyValue || "empty"}`;
|
||||
});
|
||||
const rejecting = document.querySelector("#rejecting");
|
||||
rejecting.addEventListener("input", () => requestAnimationFrame(() => { rejecting.value = ""; }));
|
||||
if (params.get("nested") === "1") {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.title = "nested input frame";
|
||||
iframe.src = "input-frame.html";
|
||||
document.body.append(iframe);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Frame input routing matrix</title>
|
||||
<style>
|
||||
body { padding: 16px; font: 14px system-ui, sans-serif; }
|
||||
.frames { display: grid; grid-template-columns: repeat(3, 320px); gap: 16px; }
|
||||
iframe { width: 300px; height: 430px; border: 2px solid #5d6d7e; }
|
||||
input { padding: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Frame input routing matrix</h1>
|
||||
<label>Top input <input aria-label="top input" /></label>
|
||||
<div class="frames">
|
||||
<iframe title="same-origin inputs" src="input-frame.html"></iframe>
|
||||
<iframe title="cross-origin inputs" src="http://localhost:4173/input-frame.html"></iframe>
|
||||
<iframe title="nested inputs" src="input-frame.html?nested=1"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
||||
## Prerequisites
|
||||
|
||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||
|
||||
## Mandatory workflow
|
||||
@@ -70,17 +70,46 @@ bsk navigate <url> --session <id>
|
||||
bsk observe --session <id> → primary semantic VOM view; reveals hover/focus surfaces
|
||||
bsk snapshot --session <id> → static aria tree fallback when VOM is insufficient
|
||||
bsk hover @e3 --session <id> → reveal hover-triggered menus before re-observing/clicking
|
||||
bsk click @e4 --session <id> → or bsk fill, bsk select, bsk press
|
||||
bsk click @e4 --session <id> → or bsk fill, bsk type, bsk select, bsk press
|
||||
bsk observe --session <id> → again after navigation / DOM change
|
||||
```
|
||||
|
||||
### Choosing `fill`, `type`, and `press`
|
||||
|
||||
Choose by the intended postcondition, not by whether observation happens to
|
||||
render `textbox [empty]`:
|
||||
|
||||
- `fill` sets a field's retained value. Use it when success means the target
|
||||
value must equal the requested text; it replaces existing content by default.
|
||||
- `type` inserts text at the active caret/selection in a live editing session.
|
||||
Use it when the application must consume keyboard/IME text; it never clears
|
||||
first and reports dispatch only.
|
||||
- `press` sends a discrete key or shortcut such as `Enter`, `Tab`, or `Ctrl+A`.
|
||||
Do not encode special keys inside `type --text`.
|
||||
|
||||
`type` requires an explicit targeting mode: pass a ref/selector when one is
|
||||
available, or pass `--focused` only after an immediately preceding action in
|
||||
the same tab deliberately established a text-entry focus. Never use `fill`
|
||||
as a probe and never automatically retry a failed `fill` with `type`: an
|
||||
application may already have consumed the text before clearing its proxy DOM
|
||||
value. Re-observe first. After `type`, verify the application output with one
|
||||
observation or screenshot; `verification=dispatched` is not business success.
|
||||
|
||||
**Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page.
|
||||
|
||||
Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`).
|
||||
|
||||
When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover <that-ref> --session <id>`, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action.
|
||||
|
||||
When `bsk observe` renders `@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered canvas content that is not represented by the text observation, not an interactable DOM control. Keep using any reliable DOM/AX text and controls that appear alongside it. If you can actually inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop. If you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its contents, and never guess coordinates; tell the user that they need to switch to a model with image-understanding capability. Do not pass a visual surface ref to click, fill, hover, or select; those commands intentionally reject screenshot-only refs.
|
||||
When `bsk observe` renders `@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered canvas content that is not represented by the text observation, not an interactable DOM control. Keep using any reliable DOM/AX text and controls that appear alongside it. If you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its contents, and never guess coordinates; tell the user that they need to switch to a model with image-understanding capability.
|
||||
|
||||
If you can actually inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop and its short-lived, single-use Surface capture id. Only when the task requires a point action and the image provides a clear target, bind that exact screenshot to the same general click command:
|
||||
|
||||
```bash
|
||||
bsk click @eN --capture <capture-id> --image-x <px> --image-y <px> --session <id>
|
||||
```
|
||||
|
||||
Coordinates are pixels in the returned capture image, not viewport CSS coordinates. Re-observing, navigating, scrolling, resizing, zooming, changing Frame projection, expiry, or reusing the capture makes the action fail; take a new Surface screenshot instead of adjusting or retrying coordinates. A point click does not reveal Canvas semantics and must not be followed by an assumed fill/type/press workflow. Re-observe first: if the application exposes a real DOM editable, use its new ref; use `type --focused` only when the observation and immediately preceding click establish a focused text-entry session without a usable ref. Without all three capture arguments, visual Surface refs remain screenshot-only and click/fill/type/hover/select reject them.
|
||||
|
||||
## Observation priority
|
||||
|
||||
@@ -176,7 +205,7 @@ bsk emulate --session <id> --off
|
||||
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
|
||||
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
|
||||
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
|
||||
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
|
||||
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional); Surface crops return a short-lived capture id |
|
||||
|
||||
### Console & network debugging (read-only; require `--session`)
|
||||
|
||||
@@ -202,9 +231,11 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk click <ref-or-selector>` | Click element (`--button`, `--click-count`, `--modifiers`) |
|
||||
| `bsk click <ref-or-selector>` | Click a DOM element (`--button`, `--click-count`, `--modifiers`), or perform one screenshot-bound Surface point click with `--capture`, `--image-x`, and `--image-y` |
|
||||
| `bsk hover <ref-or-selector>` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) |
|
||||
| `bsk fill <ref-or-selector> --value <text>` | Clear and type into input |
|
||||
| `bsk fill <ref-or-selector> --value <text>` | Replace and verify a field's retained value |
|
||||
| `bsk type <ref-or-selector> --text <text>` | Insert keyboard/IME text into an explicitly targeted editing session; reports dispatch only |
|
||||
| `bsk type --focused --text <text>` | Insert into the unique focused text-entry session after an intentional focus-producing action |
|
||||
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
|
||||
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
|
||||
|
||||
@@ -212,7 +243,7 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines); JS throw → stderr, **exit 0** |
|
||||
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
|
||||
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
|
||||
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
|
||||
|
||||
@@ -296,12 +327,12 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|------|---------|------------|
|
||||
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue |
|
||||
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue; for `evaluate`, check `--json` `.ok` to distinguish JS success from RPC success |
|
||||
| `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot |
|
||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
|
||||
|
||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||
|
||||
|
||||
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// `bsk doctor` check: every connected browser should use the same
|
||||
/// protocol version as the daemon. Minor protocol drift is accepted by
|
||||
/// the daemon but flagged here so the user can see who needs updating
|
||||
/// (M10.4).
|
||||
/// `bsk doctor` check: connected browsers should speak a protocol the
|
||||
/// daemon accepts. A different protocol string is still a live
|
||||
/// connection — report Ok so agents keep working. The detail is an
|
||||
/// upgrade reminder, not a blocker.
|
||||
///
|
||||
/// Review M2 (round-1 minor): when no browsers are connected, the
|
||||
/// check has nothing to compare against, so it now reports
|
||||
@@ -396,13 +396,12 @@ fn check_browsers_protocol_compatible(status: Option<&StatusResult>) -> CheckRes
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
CheckResult::fail(
|
||||
CheckResult::ok(
|
||||
name,
|
||||
format!(
|
||||
"{} browser(s) have protocol minor drift from the daemon: {stale}",
|
||||
"{} browser(s) report a different protocol version (still usable — continue, and upgrade soon): {stale}",
|
||||
status.version_skew_browsers.len()
|
||||
),
|
||||
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -581,7 +580,7 @@ mod m2_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsers_check_reports_fail_when_skew_present() {
|
||||
fn browsers_check_reports_ok_when_skew_present() {
|
||||
let status = fake_status(
|
||||
vec![BrowserStatusEntry {
|
||||
instance_id: "alpha".into(),
|
||||
@@ -605,9 +604,17 @@ mod m2_tests {
|
||||
}],
|
||||
);
|
||||
let check = check_browsers_protocol_compatible(Some(&status));
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(check.detail.contains("alpha"));
|
||||
assert!(check.hint.is_some());
|
||||
assert!(
|
||||
check
|
||||
.detail
|
||||
.contains("still usable — continue, and upgrade soon")
|
||||
);
|
||||
assert!(
|
||||
check.hint.is_none(),
|
||||
"a protocol-version note must not hint to stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -635,7 +642,7 @@ mod m2_tests {
|
||||
}],
|
||||
);
|
||||
let check = check_browsers_protocol_compatible(Some(&status));
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(
|
||||
check
|
||||
.detail
|
||||
|
||||
@@ -11,7 +11,7 @@ use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{
|
||||
ClickParams, ClickResult, FillParams, FillResult, HoverParams, HoverResult, KeyModifier,
|
||||
MouseButton, PressParams, PressResult, SelectParams, SelectResult,
|
||||
MouseButton, PressParams, PressResult, SelectParams, SelectResult, TypeParams, TypeResult,
|
||||
};
|
||||
use clap::{Args, ValueEnum};
|
||||
|
||||
@@ -95,6 +95,27 @@ fn split_target(
|
||||
}
|
||||
}
|
||||
|
||||
fn split_type_target(
|
||||
positional: Option<String>,
|
||||
explicit_ref: Option<String>,
|
||||
explicit_selector: Option<String>,
|
||||
focused: bool,
|
||||
) -> Result<(Option<String>, Option<String>), CliError> {
|
||||
if focused {
|
||||
if positional.is_some() || explicit_ref.is_some() || explicit_selector.is_some() {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"pass a target or --focused, not both"
|
||||
)));
|
||||
}
|
||||
return Ok((None, None));
|
||||
}
|
||||
split_target(positional, explicit_ref, explicit_selector).map_err(|_| {
|
||||
CliError::Local(anyhow::anyhow!(
|
||||
"type requires exactly one of: <target>, --ref, --selector, or --focused"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bsk click
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -136,6 +157,18 @@ pub struct ClickArgs {
|
||||
|
||||
#[arg(long, default_value = "30s", value_parser = parse_timeout_ms)]
|
||||
pub timeout: u32,
|
||||
|
||||
/// Capture id returned by `bsk screenshot --ref <surface>`.
|
||||
#[arg(long)]
|
||||
pub capture: Option<String>,
|
||||
|
||||
/// X coordinate in capture-image pixels. Requires `--capture` and `--image-y`.
|
||||
#[arg(long = "image-x", allow_hyphen_values = true)]
|
||||
pub image_x: Option<f64>,
|
||||
|
||||
/// Y coordinate in capture-image pixels. Requires `--capture` and `--image-x`.
|
||||
#[arg(long = "image-y", allow_hyphen_values = true)]
|
||||
pub image_y: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
|
||||
@@ -147,6 +180,14 @@ pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
|
||||
)?;
|
||||
let modifiers = parse_modifiers(&args.modifiers)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!("--modifiers: {e}")))?;
|
||||
let point_arg_count = usize::from(args.capture.is_some())
|
||||
+ usize::from(args.image_x.is_some())
|
||||
+ usize::from(args.image_y.is_some());
|
||||
if point_arg_count != 0 && point_arg_count != 3 {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"--capture, --image-x, and --image-y must be given together"
|
||||
)));
|
||||
}
|
||||
let params = ClickParams {
|
||||
session_id: args.session.clone(),
|
||||
ref_,
|
||||
@@ -160,6 +201,9 @@ pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
|
||||
Some(modifiers)
|
||||
},
|
||||
timeout_ms: Some(args.timeout),
|
||||
capture_id: args.capture.clone(),
|
||||
image_x: args.image_x,
|
||||
image_y: args.image_y,
|
||||
};
|
||||
let reply: ClickResult = call(
|
||||
info.sock_path,
|
||||
@@ -183,6 +227,14 @@ pub fn dispatch_click(args: ClickArgs, format: Format) -> Result<(), CliError> {
|
||||
"click ok tab={} target={target} at=({}, {})",
|
||||
reply.tab_id, reply.x, reply.y
|
||||
);
|
||||
if let Some(capture_id) = &reply.capture_id {
|
||||
println!(
|
||||
"capture={} image=({}, {}) coordinate_space=capture-image-pixel",
|
||||
capture_id,
|
||||
reply.image_x.unwrap_or_default(),
|
||||
reply.image_y.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
print_dialog_summaries(&reply.dialogs);
|
||||
}
|
||||
}
|
||||
@@ -352,6 +404,95 @@ pub fn dispatch_fill(args: FillArgs, format: Format) -> Result<(), CliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bsk type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[group(id = "type_destination", required = true, multiple = false)]
|
||||
pub struct TypeDestinationArgs {
|
||||
/// Snapshot ref (`@e3`, `e3`) or CSS selector to focus before insertion.
|
||||
#[arg(value_name = "TARGET")]
|
||||
pub target: Option<String>,
|
||||
|
||||
#[arg(long = "ref")]
|
||||
pub ref_: Option<String>,
|
||||
|
||||
#[arg(long = "selector")]
|
||||
pub selector: Option<String>,
|
||||
|
||||
/// Insert into the unique focused text-entry target instead of focusing a target.
|
||||
#[arg(long)]
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct TypeArgs {
|
||||
#[command(flatten)]
|
||||
pub destination: TypeDestinationArgs,
|
||||
|
||||
/// Text to dispatch through the live keyboard/IME editing session.
|
||||
#[arg(long)]
|
||||
pub text: String,
|
||||
|
||||
#[arg(long)]
|
||||
pub session: String,
|
||||
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
#[arg(long, default_value = "30s", value_parser = parse_timeout_ms)]
|
||||
pub timeout: u32,
|
||||
}
|
||||
|
||||
pub fn dispatch_type(args: TypeArgs, format: Format) -> Result<(), CliError> {
|
||||
let (ref_, selector) = split_type_target(
|
||||
args.destination.target.clone(),
|
||||
args.destination.ref_.clone(),
|
||||
args.destination.selector.clone(),
|
||||
args.destination.focused,
|
||||
)?;
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let params = TypeParams {
|
||||
session_id: args.session.clone(),
|
||||
text: args.text.clone(),
|
||||
ref_,
|
||||
selector,
|
||||
focused: args.destination.focused,
|
||||
tab_id: args.tab_id,
|
||||
timeout_ms: Some(args.timeout),
|
||||
};
|
||||
let reply: TypeResult = call(
|
||||
info.sock_path,
|
||||
Method::ToolType,
|
||||
params,
|
||||
"type-1",
|
||||
args.timeout,
|
||||
)?;
|
||||
match format {
|
||||
Format::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&reply)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?
|
||||
);
|
||||
}
|
||||
Format::Human => {
|
||||
let target = if args.destination.focused {
|
||||
"focused".into()
|
||||
} else {
|
||||
format_used_target(reply.used_ref.as_deref(), reply.used_selector.as_deref())
|
||||
};
|
||||
println!(
|
||||
"type dispatched tab={} target={target} length={} verification=dispatched",
|
||||
reply.tab_id, reply.text_length
|
||||
);
|
||||
print_dialog_summaries(&reply.dialogs);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bsk press
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -628,6 +769,52 @@ mod tests {
|
||||
assert_eq!(parsed.args.session, "ohli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_clap_accepts_explicit_target_and_text_flag() {
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "type")]
|
||||
struct Wrapper {
|
||||
#[command(flatten)]
|
||||
args: TypeArgs,
|
||||
}
|
||||
|
||||
let parsed =
|
||||
Wrapper::try_parse_from(["type", "@e138", "--text", "测试", "--session", "ohli"])
|
||||
.expect("type args should parse");
|
||||
assert_eq!(parsed.args.destination.target.as_deref(), Some("@e138"));
|
||||
assert_eq!(parsed.args.text, "测试");
|
||||
assert_eq!(parsed.args.session, "ohli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_targeting_requires_exactly_one_explicit_mode() {
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "type")]
|
||||
struct Wrapper {
|
||||
#[command(flatten)]
|
||||
args: TypeArgs,
|
||||
}
|
||||
|
||||
let parsed =
|
||||
Wrapper::try_parse_from(["type", "--focused", "--text", "测试", "--session", "ohli"])
|
||||
.expect("focused type args should parse");
|
||||
assert!(parsed.args.destination.focused);
|
||||
assert!(parsed.args.destination.target.is_none());
|
||||
|
||||
let focused = split_type_target(None, None, None, true).unwrap();
|
||||
assert_eq!(focused, (None, None));
|
||||
|
||||
let missing = split_type_target(None, None, None, false).unwrap_err();
|
||||
assert!(missing.to_string().contains("--focused"));
|
||||
|
||||
let mixed = split_type_target(Some("@e1".into()), None, None, true).unwrap_err();
|
||||
assert!(mixed.to_string().contains("not both"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_clap_accepts_target_positional_with_repeated_value_flags() {
|
||||
use clap::Parser;
|
||||
|
||||
@@ -43,7 +43,7 @@ use crate::cli::evaluate::EvaluateArgs;
|
||||
use crate::cli::get_html::GetHtmlArgs;
|
||||
use crate::cli::human_loop::RequestHelpArgs;
|
||||
use crate::cli::install_skill::InstallSkillArgs;
|
||||
use crate::cli::interaction::{ClickArgs, FillArgs, HoverArgs, PressArgs, SelectArgs};
|
||||
use crate::cli::interaction::{ClickArgs, FillArgs, HoverArgs, PressArgs, SelectArgs, TypeArgs};
|
||||
use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs};
|
||||
use crate::cli::network::NetworkArgs;
|
||||
use crate::cli::observe::ObserveArgs;
|
||||
@@ -171,6 +171,9 @@ pub enum Command {
|
||||
/// Fill an input / textarea / contenteditable.
|
||||
Fill(FillArgs),
|
||||
|
||||
/// Insert text into an explicit target or focused keyboard/IME editing session.
|
||||
Type(TypeArgs),
|
||||
|
||||
/// Dispatch a keyboard key combo.
|
||||
Press(PressArgs),
|
||||
|
||||
|
||||
@@ -44,12 +44,19 @@ pub mod reason {
|
||||
pub const REF_NOT_FOUND: &str = "ref_not_found";
|
||||
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
|
||||
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
|
||||
pub const FOCUS_TARGET_UNRESOLVED: &str = "focus_target_unresolved";
|
||||
pub const INPUT_NOT_APPLIED: &str = "input_not_applied";
|
||||
pub const TARGET_NOT_SELECT: &str = "target_not_select";
|
||||
pub const OPTION_NOT_FOUND: &str = "option_not_found";
|
||||
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
|
||||
pub const TAB_NOT_ACTIVE: &str = "tab_not_active";
|
||||
pub const BORROW_CONFLICT: &str = "borrow_conflict";
|
||||
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
|
||||
pub const SURFACE_CAPTURE_NOT_FOUND: &str = "surface_capture_not_found";
|
||||
pub const SURFACE_CAPTURE_EXPIRED: &str = "surface_capture_expired";
|
||||
pub const SURFACE_CAPTURE_CONSUMED: &str = "surface_capture_consumed";
|
||||
pub const SURFACE_CAPTURE_STALE: &str = "surface_capture_stale";
|
||||
pub const SURFACE_COORDINATE_INVALID: &str = "surface_coordinate_invalid";
|
||||
pub const SESSION_BUSY: &str = crate::rpc_reason::SESSION_BUSY;
|
||||
pub const RECORD_START_PAGE_UNREACHABLE: &str = "record_start_page_unreachable";
|
||||
}
|
||||
@@ -229,10 +236,24 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::CdpFailed, reason::FOCUS_TARGET_UNRESOLVED) => RenderInfo {
|
||||
summary: "focused frame target could not be determined",
|
||||
hint: Some(
|
||||
"pass an explicit --ref or --selector so the key event can be routed safely",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::CdpFailed, reason::INPUT_NOT_APPLIED) => RenderInfo {
|
||||
summary: "the page did not retain the requested input",
|
||||
hint: Some(
|
||||
"refresh the snapshot and retry; the page may be rejecting or resetting controlled input",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo {
|
||||
summary: "snapshot ref does not support this operation",
|
||||
hint: Some(
|
||||
"visual surface refs are screenshot-only; inspect `bsk screenshot --ref <ref> --session <id>` with image-understanding capability",
|
||||
"visual Surface refs require image understanding; inspect `bsk screenshot --ref <ref> --session <id>`, then bind its capture id and image coordinates to `bsk click`",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
@@ -278,6 +299,41 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::NotFound, reason::SURFACE_CAPTURE_NOT_FOUND) => RenderInfo {
|
||||
summary: "surface capture transaction was not found",
|
||||
hint: Some(
|
||||
"take a fresh surface screenshot and use its capture id with coordinates from that image",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::PermissionDenied, reason::SURFACE_CAPTURE_EXPIRED) => RenderInfo {
|
||||
summary: "surface capture transaction has expired",
|
||||
hint: Some(
|
||||
"take a fresh surface screenshot, then click promptly with that capture id and image coordinates",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::PermissionDenied, reason::SURFACE_CAPTURE_CONSUMED) => RenderInfo {
|
||||
summary: "surface capture transaction was already used",
|
||||
hint: Some(
|
||||
"each capture id is single-use; take a fresh surface screenshot before another point action",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::PermissionDenied, reason::SURFACE_CAPTURE_STALE) => RenderInfo {
|
||||
summary: "page state changed since the surface screenshot",
|
||||
hint: Some(
|
||||
"take a fresh surface screenshot and choose coordinates from the new image before retrying",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(ErrorCode::InvalidParams, reason::SURFACE_COORDINATE_INVALID) => RenderInfo {
|
||||
summary: "surface image coordinate is invalid",
|
||||
hint: Some(
|
||||
"use finite pixel coordinates inside the captured image; its top-left corner is (0, 0)",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(
|
||||
ErrorCode::CdpFailed | ErrorCode::Timeout | ErrorCode::InvalidParams,
|
||||
reason::RECORD_START_PAGE_UNREACHABLE,
|
||||
@@ -391,7 +447,7 @@ mod tests {
|
||||
let info = info_for_error(ErrorCode::PermissionDenied, Some(&data));
|
||||
assert_eq!(info.summary, "snapshot ref does not support this operation");
|
||||
assert!(
|
||||
info.hint.unwrap().contains("screenshot-only"),
|
||||
info.hint.unwrap().contains("capture id"),
|
||||
"expected visual-surface-specific hint"
|
||||
);
|
||||
assert_eq!(info.exit_code, 1);
|
||||
@@ -439,6 +495,25 @@ mod tests {
|
||||
assert_eq!(info.exit_code, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_capture_stale_requests_a_fresh_screenshot() {
|
||||
let data = serde_json::json!({ "reason": reason::SURFACE_CAPTURE_STALE });
|
||||
let info = info_for_error(ErrorCode::PermissionDenied, Some(&data));
|
||||
assert_eq!(
|
||||
info.summary,
|
||||
"page state changed since the surface screenshot"
|
||||
);
|
||||
assert!(info.hint.unwrap().contains("fresh surface screenshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_coordinate_invalid_explains_image_pixel_bounds() {
|
||||
let data = serde_json::json!({ "reason": reason::SURFACE_COORDINATE_INVALID });
|
||||
let info = info_for_error(ErrorCode::InvalidParams, Some(&data));
|
||||
assert_eq!(info.summary, "surface image coordinate is invalid");
|
||||
assert!(info.hint.unwrap().contains("top-left corner is (0, 0)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_not_found_overrides_not_found_copy() {
|
||||
let data = serde_json::json!({ "reason": reason::REF_NOT_FOUND });
|
||||
@@ -473,4 +548,20 @@ mod tests {
|
||||
);
|
||||
assert!(info.hint.unwrap().contains("exactly one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_target_unresolved_overrides_cdp_copy() {
|
||||
let data = serde_json::json!({ "reason": reason::FOCUS_TARGET_UNRESOLVED });
|
||||
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
|
||||
assert_eq!(info.summary, "focused frame target could not be determined");
|
||||
assert!(info.hint.unwrap().contains("--ref"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_not_applied_overrides_cdp_copy() {
|
||||
let data = serde_json::json!({ "reason": reason::INPUT_NOT_APPLIED });
|
||||
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
|
||||
assert_eq!(info.summary, "the page did not retain the requested input");
|
||||
assert!(info.hint.unwrap().contains("rejecting"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ fn run(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<(), CliErr
|
||||
.map_err(CliError::Local)?;
|
||||
match format {
|
||||
Format::Json => {
|
||||
let json = serde_json::json!({
|
||||
let mut json = serde_json::json!({
|
||||
"tab_id": reply.tab_id,
|
||||
"width": reply.width,
|
||||
"height": reply.height,
|
||||
@@ -67,6 +67,10 @@ fn run(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<(), CliErr
|
||||
"path": out_path.to_string_lossy(),
|
||||
"byte_size": bytes.len(),
|
||||
});
|
||||
if let Some(capture) = &reply.capture {
|
||||
json["capture"] = serde_json::to_value(capture)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json)
|
||||
@@ -75,6 +79,12 @@ fn run(sock: PathBuf, args: ScreenshotArgs, format: Format) -> Result<(), CliErr
|
||||
}
|
||||
Format::Human => {
|
||||
println!("{}", out_path.display());
|
||||
if let Some(capture) = &reply.capture {
|
||||
println!(
|
||||
"capture={} surface={} coordinate_space={} expires_at={}",
|
||||
capture.id, capture.surface_ref, capture.coordinate_space, capture.expires_at
|
||||
);
|
||||
}
|
||||
print_dialog_summaries(&reply.dialogs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
|
||||
let bold = "\x1b[1m";
|
||||
let reset = "\x1b[0m";
|
||||
eprintln!(
|
||||
"{bold}{yellow}warning:{reset} {} browser(s) have protocol version drift from the daemon{reset}",
|
||||
"{bold}{yellow}note:{reset} {} browser(s) report a different protocol version; still usable — continue, and run `bsk update` soon{reset}",
|
||||
s.version_skew_browsers.len()
|
||||
);
|
||||
for skew in &s.version_skew_browsers {
|
||||
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
|
||||
skew.label.clone()
|
||||
};
|
||||
eprintln!(
|
||||
" {} ({}) — protocol ext {} vs daemon {} (app ext v{}, daemon v{}) — please align protocol versions",
|
||||
" {} ({}) — protocol ext {} vs CLI {} (app ext v{}, CLI v{}) — still usable; please upgrade",
|
||||
skew.instance_id,
|
||||
label,
|
||||
display_protocol(&skew.client_protocol_version),
|
||||
|
||||
@@ -244,6 +244,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolClick
|
||||
| Method::ToolHover
|
||||
| Method::ToolFill
|
||||
| Method::ToolType
|
||||
| Method::ToolPress
|
||||
| Method::ToolSelect
|
||||
| Method::ToolEvaluate
|
||||
|
||||
@@ -95,6 +95,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
Command::Click(args) => cli::interaction::dispatch_click(args, format),
|
||||
Command::Hover(args) => cli::interaction::dispatch_hover(args, format),
|
||||
Command::Fill(args) => cli::interaction::dispatch_fill(args, format),
|
||||
Command::Type(args) => cli::interaction::dispatch_type(args, format),
|
||||
Command::Press(args) => cli::interaction::dispatch_press(args, format),
|
||||
Command::Select(args) => cli::interaction::dispatch_select(args, format),
|
||||
Command::Evaluate(args) => cli::evaluate::dispatch(args, format),
|
||||
|
||||
@@ -188,6 +188,29 @@ fn parses_click_count_alias() {
|
||||
assert_eq!(args.click_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_surface_capture_image_coordinates() {
|
||||
let cli = parse(&[
|
||||
"bsk",
|
||||
"click",
|
||||
"@e1",
|
||||
"--session",
|
||||
"s1",
|
||||
"--capture",
|
||||
"sc_test",
|
||||
"--image-x",
|
||||
"160.5",
|
||||
"--image-y",
|
||||
"-1",
|
||||
]);
|
||||
let Command::Click(args) = cli.command else {
|
||||
panic!("expected click command");
|
||||
};
|
||||
assert_eq!(args.capture.as_deref(), Some("sc_test"));
|
||||
assert_eq!(args.image_x, Some(160.5));
|
||||
assert_eq!(args.image_y, Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hover_with_settle() {
|
||||
let cli = parse(&[
|
||||
|
||||
@@ -238,6 +238,7 @@ async fn screenshot_returns_image_base64_with_dimensions() {
|
||||
height: 600,
|
||||
format: "png".into(),
|
||||
tab_id: 7,
|
||||
capture: None,
|
||||
dialogs: vec![],
|
||||
})
|
||||
.unwrap(),
|
||||
@@ -286,6 +287,7 @@ async fn screenshot_forwards_ref_to_extension() {
|
||||
height: 60,
|
||||
format: "png".into(),
|
||||
tab_id: 7,
|
||||
capture: None,
|
||||
dialogs: vec![],
|
||||
})
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! M7 integration: drive the 7 new tool RPCs (navigate /
|
||||
//! navigate_back / navigate_forward / reload / click / fill / press)
|
||||
//! M7 integration: drive the navigation and interaction tool RPCs
|
||||
//! through the IPC + per-session queue + fake extension and assert
|
||||
//! the wire shapes line up. The extension stub mirrors each method's
|
||||
//! `*Result` so we exercise the daemon's serialise → forward →
|
||||
@@ -16,7 +15,8 @@ use bsk_protocol::tools::{
|
||||
ClickParams, ClickResult, FillParams, FillResult, KeyModifier, MouseButton, NavigateBackParams,
|
||||
NavigateBackResult, NavigateForwardParams, NavigateForwardResult, NavigateParams,
|
||||
NavigateResult, PressParams, PressResult, ReloadParams, ReloadResult, SelectParams,
|
||||
SelectResult, SessionStartParams, SessionStartResult, WaitUntil,
|
||||
SelectResult, SessionStartParams, SessionStartResult, TypeParams, TypeResult, TypeVerification,
|
||||
WaitUntil,
|
||||
};
|
||||
use bsk_protocol::{
|
||||
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
|
||||
@@ -354,6 +354,9 @@ async fn click_round_trips_ref_and_modifiers() {
|
||||
used_selector: None,
|
||||
x: 12.5,
|
||||
y: 34.0,
|
||||
capture_id: None,
|
||||
image_x: None,
|
||||
image_y: None,
|
||||
dialogs: vec![],
|
||||
})
|
||||
.unwrap(),
|
||||
@@ -373,6 +376,9 @@ async fn click_round_trips_ref_and_modifiers() {
|
||||
click_count: Some(1),
|
||||
modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]),
|
||||
timeout_ms: Some(5_000),
|
||||
capture_id: None,
|
||||
image_x: None,
|
||||
image_y: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -427,6 +433,51 @@ async fn fill_round_trips_clear_before_default() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn type_round_trips_dispatched_verification() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||
let _ = do_handshake(&mut ws).await;
|
||||
|
||||
run_extension(ws, |req| {
|
||||
assert_eq!(req.method, Method::ToolType);
|
||||
let p: TypeParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
|
||||
assert_eq!(p.text, "测试");
|
||||
assert!(!p.focused);
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(TypeResult {
|
||||
tab_id: 12,
|
||||
used_ref: Some("e3".into()),
|
||||
used_selector: None,
|
||||
text_length: 2,
|
||||
verification: TypeVerification::Dispatched,
|
||||
dialogs: vec![],
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
|
||||
let session_id = ipc_session_start(&sock).await;
|
||||
let result: TypeResult = ipc_tool_call(
|
||||
&sock,
|
||||
Method::ToolType,
|
||||
TypeParams {
|
||||
session_id,
|
||||
text: "测试".into(),
|
||||
ref_: Some("@e3".into()),
|
||||
selector: None,
|
||||
focused: false,
|
||||
tab_id: None,
|
||||
timeout_ms: Some(5_000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("type ok");
|
||||
assert_eq!(result.text_length, 2);
|
||||
assert_eq!(result.verification, TypeVerification::Dispatched);
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn press_round_trips_compound_key() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
@@ -529,6 +580,7 @@ async fn m7_tools_propagate_extension_errors() {
|
||||
| Method::ToolReload
|
||||
| Method::ToolClick
|
||||
| Method::ToolFill
|
||||
| Method::ToolType
|
||||
| Method::ToolPress
|
||||
| Method::ToolSelect => ResponseBody::Err(RpcError {
|
||||
code: ErrorCode::CdpFailed,
|
||||
@@ -610,6 +662,9 @@ async fn m7_tools_propagate_extension_errors() {
|
||||
click_count: None,
|
||||
modifiers: None,
|
||||
timeout_ms: Some(5_000),
|
||||
capture_id: None,
|
||||
image_x: None,
|
||||
image_y: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -633,6 +688,23 @@ async fn m7_tools_propagate_extension_errors() {
|
||||
.unwrap_err();
|
||||
assert_eq!(fill.code, ErrorCode::CdpFailed);
|
||||
|
||||
let type_result = ipc_tool_call::<_, Value>(
|
||||
&sock,
|
||||
Method::ToolType,
|
||||
TypeParams {
|
||||
session_id: session_id.clone(),
|
||||
text: "hello".into(),
|
||||
ref_: Some("@e1".into()),
|
||||
selector: None,
|
||||
focused: false,
|
||||
tab_id: None,
|
||||
timeout_ms: Some(5_000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(type_result.code, ErrorCode::CdpFailed);
|
||||
|
||||
let press = ipc_tool_call::<_, Value>(
|
||||
&sock,
|
||||
Method::ToolPress,
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"capture_id": {
|
||||
"description": "Short-lived capture id returned by a visual Surface screenshot.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"click_count": {
|
||||
"description": "Number of consecutive mouse presses (double-click = 2).",
|
||||
"type": [
|
||||
@@ -25,6 +32,22 @@
|
||||
"format": "uint32",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"image_x": {
|
||||
"description": "X coordinate in the capture image's pixel coordinate space.",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"image_y": {
|
||||
"description": "Y coordinate in the capture image's pixel coordinate space.",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"modifiers": {
|
||||
"type": [
|
||||
"array",
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"y"
|
||||
],
|
||||
"properties": {
|
||||
"capture_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dialogs": {
|
||||
"description": "Native JS dialogs observed and auto-handled during this call.",
|
||||
"type": "array",
|
||||
@@ -15,6 +21,20 @@
|
||||
"$ref": "#/definitions/JavaScriptDialogInfo"
|
||||
}
|
||||
},
|
||||
"image_x": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"image_y": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"tab_id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
"tab_id"
|
||||
],
|
||||
"properties": {
|
||||
"capture": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SurfaceCaptureInfo"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dialogs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -109,6 +119,31 @@
|
||||
"prompt",
|
||||
"beforeunload"
|
||||
]
|
||||
},
|
||||
"SurfaceCaptureInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"coordinate_space",
|
||||
"expires_at",
|
||||
"id",
|
||||
"surface_ref"
|
||||
],
|
||||
"properties": {
|
||||
"coordinate_space": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"surface_ref": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"text": {
|
||||
"description": "Indented aria-snapshot text. Refs are rendered as `@e<N>` so the agent can copy them into subsequent `tool.click` / `tool.fill` selectors.",
|
||||
"description": "Indented aria-snapshot text. Refs are rendered as `@e<N>` so the agent can copy them into subsequent `tool.click` / `tool.fill` / `tool.type` targets.",
|
||||
"type": "string"
|
||||
},
|
||||
"truncated": {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "TypeParams",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"focused": {
|
||||
"description": "Explicitly dispatch to the unique focused text-entry target instead of focusing a ref or selector.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"ref": {
|
||||
"description": "Target to focus before dispatching text. Exactly one targeting mode must be selected: `ref` / `selector`, or `focused=true`.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"selector": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"tab_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"text": {
|
||||
"description": "Text to dispatch to the live editing session. Unlike `fill`, `type` does not require the focused DOM editable to retain this text.",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "TypeResult",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tab_id",
|
||||
"text_length",
|
||||
"verification"
|
||||
],
|
||||
"properties": {
|
||||
"dialogs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/JavaScriptDialogInfo"
|
||||
}
|
||||
},
|
||||
"tab_id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"text_length": {
|
||||
"description": "UTF-16 code-unit length of text accepted for dispatch.",
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"used_ref": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"used_selector": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"verification": {
|
||||
"$ref": "#/definitions/TypeVerification"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"JavaScriptDialogHandledAction": {
|
||||
"description": "How the extension resolved the dialog so CDP could continue.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"dismissed"
|
||||
]
|
||||
},
|
||||
"JavaScriptDialogInfo": {
|
||||
"description": "One observed + handled JavaScript dialog during a tool call.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"handled",
|
||||
"message",
|
||||
"sequence",
|
||||
"tab_id",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"default_prompt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"handled": {
|
||||
"$ref": "#/definitions/JavaScriptDialogHandledAction"
|
||||
},
|
||||
"has_browser_handler": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"description": "Monotonic per-tab sequence for ordering within a session.",
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"tab_id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/JavaScriptDialogType"
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"JavaScriptDialogType": {
|
||||
"description": "Native JS dialog kind reported by CDP.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"alert",
|
||||
"confirm",
|
||||
"prompt",
|
||||
"beforeunload"
|
||||
]
|
||||
},
|
||||
"TypeVerification": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Chromium accepted the complete keyboard/IME dispatch transaction. The caller must observe the application output to verify business state.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dispatched"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,8 @@ fn main() {
|
||||
dump!(HoverResult, "tool_hover_result");
|
||||
dump!(FillParams, "tool_fill_params");
|
||||
dump!(FillResult, "tool_fill_result");
|
||||
dump!(TypeParams, "tool_type_params");
|
||||
dump!(TypeResult, "tool_type_result");
|
||||
dump!(PressParams, "tool_press_params");
|
||||
dump!(PressResult, "tool_press_result");
|
||||
dump!(SelectParams, "tool_select_params");
|
||||
|
||||
@@ -74,6 +74,8 @@ pub enum Method {
|
||||
ToolHover,
|
||||
#[serde(rename = "tool.fill")]
|
||||
ToolFill,
|
||||
#[serde(rename = "tool.type")]
|
||||
ToolType,
|
||||
#[serde(rename = "tool.press")]
|
||||
ToolPress,
|
||||
#[serde(rename = "tool.select")]
|
||||
@@ -154,6 +156,7 @@ impl Method {
|
||||
| Method::ToolReload
|
||||
| Method::ToolClick
|
||||
| Method::ToolFill
|
||||
| Method::ToolType
|
||||
| Method::ToolPress
|
||||
| Method::ToolSelect
|
||||
| Method::ToolEvaluate
|
||||
@@ -246,6 +249,13 @@ mod tests {
|
||||
assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.emulate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_method_round_trips() {
|
||||
let method: Method = serde_json::from_value(json!("tool.type")).unwrap();
|
||||
assert_eq!(method, Method::ToolType);
|
||||
assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_params_and_result_round_trip() {
|
||||
let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap();
|
||||
@@ -284,6 +294,7 @@ mod tests {
|
||||
assert!(Method::ToolReload.is_mutating());
|
||||
assert!(Method::ToolClick.is_mutating());
|
||||
assert!(Method::ToolFill.is_mutating());
|
||||
assert!(Method::ToolType.is_mutating());
|
||||
assert!(Method::ToolPress.is_mutating());
|
||||
assert!(Method::ToolSelect.is_mutating());
|
||||
assert!(Method::ToolEvaluate.is_mutating());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! DOM interaction tools (`tool.click`, `tool.fill`, `tool.press`,
|
||||
//! `tool.select`).
|
||||
//! DOM interaction tools (`tool.click`, `tool.fill`, `tool.type`,
|
||||
//! `tool.press`, `tool.select`).
|
||||
//!
|
||||
//! Element-targeted tools accept either a snapshot `ref` (`@e<N>` form,
|
||||
//! normalised against the session's RefStore) **or** a CSS selector
|
||||
@@ -67,6 +67,15 @@ pub struct ClickParams {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
/// Short-lived capture id returned by a visual Surface screenshot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub capture_id: Option<String>,
|
||||
/// X coordinate in the capture image's pixel coordinate space.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_x: Option<f64>,
|
||||
/// Y coordinate in the capture image's pixel coordinate space.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_y: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -80,6 +89,12 @@ pub struct ClickResult {
|
||||
/// agents can correlate with a follow-up `tool.screenshot`.
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub capture_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_x: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_y: Option<f64>,
|
||||
/// Native JS dialogs observed and auto-handled during this call.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
@@ -173,6 +188,60 @@ pub struct FillResult {
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TypeParams {
|
||||
pub session_id: String,
|
||||
/// Text to dispatch to the live editing session. Unlike `fill`, `type`
|
||||
/// does not require the focused DOM editable to retain this text.
|
||||
pub text: String,
|
||||
/// Target to focus before dispatching text. Exactly one targeting mode
|
||||
/// must be selected: `ref` / `selector`, or `focused=true`.
|
||||
#[serde(
|
||||
rename = "ref",
|
||||
alias = "ref_",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub ref_: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
/// Explicitly dispatch to the unique focused text-entry target instead
|
||||
/// of focusing a ref or selector.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub focused: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TypeVerification {
|
||||
/// Chromium accepted the complete keyboard/IME dispatch transaction.
|
||||
/// The caller must observe the application output to verify business state.
|
||||
Dispatched,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TypeResult {
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub used_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub used_selector: Option<String>,
|
||||
/// UTF-16 code-unit length of text accepted for dispatch.
|
||||
pub text_length: u32,
|
||||
pub verification: TypeVerification,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// press
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -288,6 +357,9 @@ mod tests {
|
||||
click_count: Some(1),
|
||||
modifiers: Some(vec![KeyModifier::Ctrl]),
|
||||
timeout_ms: Some(5_000),
|
||||
capture_id: Some("sc_test".into()),
|
||||
image_x: Some(160.0),
|
||||
image_y: Some(90.0),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
|
||||
@@ -333,6 +405,50 @@ mod tests {
|
||||
assert_eq!(v, json!("meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_payloads_distinguish_dispatch_from_value_verification() {
|
||||
let params = TypeParams {
|
||||
session_id: "abcd".into(),
|
||||
text: "测试".into(),
|
||||
ref_: Some("@e3".into()),
|
||||
selector: None,
|
||||
focused: false,
|
||||
tab_id: Some(4),
|
||||
timeout_ms: Some(5_000),
|
||||
};
|
||||
let encoded = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(encoded["ref"], json!("@e3"));
|
||||
assert_eq!(encoded["text"], json!("测试"));
|
||||
assert!(encoded.get("focused").is_none());
|
||||
|
||||
let focused = TypeParams {
|
||||
session_id: "abcd".into(),
|
||||
text: "hello".into(),
|
||||
ref_: None,
|
||||
selector: None,
|
||||
focused: true,
|
||||
tab_id: None,
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(focused).unwrap()["focused"],
|
||||
json!(true)
|
||||
);
|
||||
|
||||
let result = TypeResult {
|
||||
tab_id: 4,
|
||||
used_ref: Some("e3".into()),
|
||||
used_selector: None,
|
||||
text_length: 2,
|
||||
verification: TypeVerification::Dispatched,
|
||||
dialogs: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(result).unwrap()["verification"],
|
||||
json!("dispatched")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_result_round_trips() {
|
||||
let r = PressResult {
|
||||
|
||||
@@ -33,8 +33,8 @@ pub struct SnapshotParams {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SnapshotResult {
|
||||
/// Indented aria-snapshot text. Refs are rendered as `@e<N>` so the
|
||||
/// agent can copy them into subsequent `tool.click` / `tool.fill`
|
||||
/// selectors.
|
||||
/// agent can copy them into subsequent `tool.click` / `tool.fill` /
|
||||
/// `tool.type` targets.
|
||||
pub text: String,
|
||||
/// Number of `@e<N>` refs registered for this session by this
|
||||
/// snapshot. Equivalent to the size of the new ref-store map.
|
||||
@@ -208,10 +208,20 @@ pub struct ScreenshotResult {
|
||||
/// Always `"png"` in v0.1; reserved for future JPEG / WebP support.
|
||||
pub format: String,
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub capture: Option<SurfaceCaptureInfo>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SurfaceCaptureInfo {
|
||||
pub id: String,
|
||||
pub surface_ref: String,
|
||||
pub coordinate_space: String,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -293,6 +303,12 @@ mod tests {
|
||||
height: 600,
|
||||
format: "png".into(),
|
||||
tab_id: 7,
|
||||
capture: Some(SurfaceCaptureInfo {
|
||||
id: "sc_test".into(),
|
||||
surface_ref: "@e3".into(),
|
||||
coordinate_space: "capture-image-pixel".into(),
|
||||
expires_at: 1_700_000_030_000,
|
||||
}),
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
|
||||
@@ -5,41 +5,20 @@ npm: [`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wx
|
||||
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) tool plugin that exposes
|
||||
[BrowserSkill](https://github.com/Tencent/BrowserSkill) (`bsk`) browser automation to the model.
|
||||
|
||||
Each tool maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
|
||||
Each action maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
|
||||
structured JSON output, and returns a canonical typed value. The bsk daemon, browser, and browser
|
||||
extension keep owning the actual browser control — this package is a thin, well-typed bridge.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | bsk command | Purpose |
|
||||
| Tool | Actions | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `browser_session_start` | `bsk session start` | Open an Agent Window session; optional initial URL, window size, and device emulation preset. Returns the session id and makes it the current session. |
|
||||
| `browser_session_stop` | `bsk session stop` | Stop a session (the current one by default) and close its Agent Window. Only plugin-created sessions can be stopped. |
|
||||
| `browser_session_list` | — (registry only) | List the sessions this plugin created, marking the current one. Foreign daemon sessions are never shown. |
|
||||
| `browser_navigate` | `bsk navigate` | Navigate the active tab, with `waitUntil` / timeout control. |
|
||||
| `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. |
|
||||
| `browser_observe` | `bsk observe` | Semantic VOM observation (read-only) with `@eN` refs. |
|
||||
| `browser_click` | `bsk click` | Click a snapshot ref or CSS selector (button / click-count options). |
|
||||
| `browser_hover` | `bsk hover` | Hover a ref or selector and allow hover-triggered UI to settle. |
|
||||
| `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). |
|
||||
| `browser_select` | `bsk select` | Select one or more option values on a `<select>`. |
|
||||
| `browser_press` | `bsk press` | Dispatch a key or combo, optionally focusing a target first. |
|
||||
| `browser_screenshot` | `bsk screenshot` | PNG capture of the tab or a ref-cropped element; inlines the image when the deployment supports image input, otherwise returns a file path. |
|
||||
| `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. |
|
||||
| `browser_tab_list` | `bsk tab list` | List user and/or Agent Window tabs visible to an owned session. |
|
||||
| `browser_tab_create` | `bsk tab create` | Create a foreground or background tab in the Agent Window. |
|
||||
| `browser_tab_select` | `bsk tab select` | Focus an Agent Window tab. |
|
||||
| `browser_tab_close` | `bsk tab close` | Close an Agent Window tab. |
|
||||
| `browser_tab_borrow` | `bsk tab borrow` | Move a user tab into the Agent Window for controlled interaction. |
|
||||
| `browser_tab_return` | `bsk tab return` | Return a borrowed tab to its original user window. |
|
||||
| `browser_navigate_back` / `browser_navigate_forward` | `bsk navigate-back` / `bsk navigate-forward` | Traverse tab history and wait for a lifecycle phase. |
|
||||
| `browser_reload` | `bsk reload` | Reload the active tab, optionally bypassing cache. |
|
||||
| `browser_wait_for_navigation` | `bsk wait-for-navigation` | Wait explicitly for a page lifecycle event. |
|
||||
| `browser_request_help` | `bsk request-help` | Pause for login, captcha, OTP, confirmation, or another human browser step. |
|
||||
| `browser_get_html` | `bsk get-html` | Read raw document or ref-scoped HTML after semantic observation is insufficient. |
|
||||
| `browser_console` | `bsk console` | Read buffered console/log/exception entries without evaluating JavaScript. |
|
||||
| `browser_network` | `bsk network` | Read buffered network response/failure metadata. |
|
||||
| `browser_window_resize` | `bsk window resize` | Resize the owned session's Agent Window. |
|
||||
| `browser_session` | `start`, `stop`, `list` | Manage plugin-owned Agent Window sessions. |
|
||||
| `browser_page` | `navigate`, `back`, `forward`, `reload`, `wait` | Navigate the active tab and wait for page lifecycle events. |
|
||||
| `browser_inspect` | `observe`, `snapshot`, `html`, `screenshot`, `console`, `network` | Read semantic or diagnostic page state and capture screenshots. |
|
||||
| `browser_interact` | `click`, `hover`, `fill`, `select`, `press` | Interact with controls using fresh refs or selectors. |
|
||||
| `browser_tabs` | `list`, `create`, `select`, `close`, `borrow`, `return` | Manage Agent Window tabs and temporarily borrow user tabs. |
|
||||
| `browser_assist` | `resize`, `emulate`, `request-help` | Resize or emulate the browser and pause for human-only steps. |
|
||||
|
||||
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
|
||||
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
|
||||
@@ -63,7 +42,7 @@ seam degrade silently.
|
||||
|
||||
One agent conversation can drive several browser sessions at once:
|
||||
|
||||
- `browser_session_start` returns the session id and makes it the **current session**.
|
||||
- `browser_session` with `action: start` returns the session id and makes it the **current session**.
|
||||
- Every operation tool accepts an optional `session` argument. When omitted, the call acts on the
|
||||
current session (the one most recently started or used); when given, that session becomes current.
|
||||
- Every tool result echoes the session it actually acted on, so the model never has to guess.
|
||||
@@ -72,9 +51,9 @@ One agent conversation can drive several browser sessions at once:
|
||||
|
||||
**Ownership boundary**: the bsk daemon may be shared with other agents, terminals, or dsh
|
||||
instances. The plugin therefore only ever sees and operates on sessions it created itself —
|
||||
an explicit `session` argument naming a foreign or unknown id is rejected, `browser_session_list`
|
||||
shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup can never touch
|
||||
a session owned by another program.
|
||||
an explicit `session` argument naming a foreign or unknown id is rejected, the `list` action on
|
||||
`browser_session` shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup
|
||||
can never touch a session owned by another program.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -109,7 +88,7 @@ All fields are optional and validated through the plugin's Schemastery `Config`:
|
||||
# lazyTools: true # reveal browser_* tools only after the skill is invoked
|
||||
```
|
||||
|
||||
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the 28
|
||||
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the six
|
||||
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
|
||||
`browser-skill` skill is actually invoked — the skill catalog entry is the only
|
||||
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
|
||||
@@ -131,7 +110,7 @@ like the BrowserSkill extension without leaking a single selector into the host
|
||||
shell's theme cannot bleed back in.
|
||||
|
||||
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
|
||||
`browser_session_start`; disappears when all sessions stop (or the plugin unloads).
|
||||
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
|
||||
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
|
||||
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
|
||||
stage while the next one loads, and is kept on errors so the card does not flash),
|
||||
@@ -173,11 +152,11 @@ shell's theme cannot bleed back in.
|
||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
||||
and the active model route declares image input; otherwise the PNG path is returned.
|
||||
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
|
||||
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_screenshot`. The
|
||||
custom view keeps the terminal block (command + output) and, when the settled result carries an
|
||||
image block, resolves the durable attachment through the client session's authorized
|
||||
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
|
||||
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
|
||||
an image block, resolves the durable attachment through the client session's authorized
|
||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||
Every other `browser_*` tool keeps the stock terminal card. The bundle follows the dsh client
|
||||
The other five tools keep the stock terminal card. The bundle follows the dsh client
|
||||
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
|
||||
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
|
||||
lightningcss.
|
||||
@@ -198,8 +177,9 @@ pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/
|
||||
## Publishing
|
||||
|
||||
The GitHub Actions workflow **Release dsh plugin** publishes this package to npm
|
||||
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id stays
|
||||
`dsh-plugin-browserskill`.
|
||||
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id, client
|
||||
bundle registration, and `cordis.patch.yml` name are the same specifier so
|
||||
dsh can import and materialize the plugin without a ModuleLoader id mismatch.
|
||||
|
||||
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"external": [
|
||||
"@deepseek-ai/dsh-client-ui-attachment",
|
||||
"@deepseek-ai/dsh-client-ui-primitives"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,37 +18,15 @@ function stripFrontmatter(markdown) {
|
||||
}
|
||||
|
||||
const SUPPORTED_BROWSER_TOOLS = [
|
||||
"browser_session_start",
|
||||
"browser_session_stop",
|
||||
"browser_session_list",
|
||||
"browser_navigate",
|
||||
"browser_navigate_back",
|
||||
"browser_navigate_forward",
|
||||
"browser_reload",
|
||||
"browser_wait_for_navigation",
|
||||
"browser_snapshot",
|
||||
"browser_observe",
|
||||
"browser_get_html",
|
||||
"browser_screenshot",
|
||||
"browser_console",
|
||||
"browser_network",
|
||||
"browser_click",
|
||||
"browser_hover",
|
||||
"browser_fill",
|
||||
"browser_select",
|
||||
"browser_press",
|
||||
"browser_tab_list",
|
||||
"browser_tab_create",
|
||||
"browser_tab_select",
|
||||
"browser_tab_close",
|
||||
"browser_tab_borrow",
|
||||
"browser_tab_return",
|
||||
"browser_request_help",
|
||||
"browser_emulate",
|
||||
"browser_window_resize",
|
||||
"browser_session",
|
||||
"browser_page",
|
||||
"browser_inspect",
|
||||
"browser_interact",
|
||||
"browser_tabs",
|
||||
"browser_assist",
|
||||
];
|
||||
|
||||
function validateModelFacingSkill(markdown) {
|
||||
function validateModelFacingSkill(markdown, supportedTools) {
|
||||
if (/\bbsk\b/i.test(markdown)) {
|
||||
throw new Error("DSH skill must not expose the internal CLI name");
|
||||
}
|
||||
@@ -57,9 +35,9 @@ function validateModelFacingSkill(markdown) {
|
||||
}
|
||||
|
||||
const mentioned = new Set(markdown.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? []);
|
||||
const supported = new Set(SUPPORTED_BROWSER_TOOLS);
|
||||
const supported = new Set(supportedTools);
|
||||
const unknown = [...mentioned].filter((name) => !supported.has(name));
|
||||
const missing = SUPPORTED_BROWSER_TOOLS.filter((name) => !mentioned.has(name));
|
||||
const missing = supportedTools.filter((name) => !mentioned.has(name));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`DSH skill mentions unsupported browser tools: ${unknown.join(", ")}`);
|
||||
}
|
||||
@@ -75,11 +53,11 @@ const DESCRIPTION =
|
||||
"debug page activity, or smoke-test UI changes in a managed Agent Window.";
|
||||
|
||||
const content = `${stripFrontmatter(source).trim()}\n`;
|
||||
validateModelFacingSkill(content);
|
||||
validateModelFacingSkill(content, SUPPORTED_BROWSER_TOOLS);
|
||||
|
||||
const banner =
|
||||
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
|
||||
"// Source: skill/SKILL.md (DeepSeek Harness browser_* tool instructions).\n";
|
||||
"// Source: skill/SKILL.md (DeepSeek Harness six-tool instructions).\n";
|
||||
|
||||
writeFileSync(
|
||||
join(pkg, "src", "skill-content.generated.ts"),
|
||||
|
||||
@@ -1,271 +1,146 @@
|
||||
---
|
||||
name: browser-skill
|
||||
description: |
|
||||
Use the injected browser_* tools to automate the user's logged-in Chromium
|
||||
browser in a managed Agent Window.
|
||||
description: Browser automation through six injected domain tools.
|
||||
---
|
||||
|
||||
# browser-skill for DeepSeek Harness
|
||||
|
||||
Drive the user's real Chromium browser through the structured `browser_*` tools provided by this
|
||||
Drive the user's real Chromium browser through the six structured domain tools provided by this
|
||||
plugin. The browser keeps the user's existing logins and cookies while isolating automation in an
|
||||
Agent Window. Use the tool schemas exactly as exposed by DeepSeek Harness; arguments are structured
|
||||
fields, not command-line flags.
|
||||
Agent Window. Every call must include the tool's `action` field; other fields depend on that action.
|
||||
|
||||
## Tool availability
|
||||
|
||||
Loading this skill reveals the browser tools for the rest of the conversation. If the tools were
|
||||
hidden before this skill was loaded, start browser work only after the skill invocation succeeds.
|
||||
Loading this skill reveals the browser tools for the rest of the conversation. All browser work
|
||||
must use the injected tools directly. Do not invoke another process to control the browser. Direct
|
||||
tool calls preserve session ownership, the live observation window, cancellation, attachments, and
|
||||
cleanup.
|
||||
|
||||
All browser operations must use the injected tools directly. Do not translate the examples in this
|
||||
document into terminal commands or invoke another process to control the browser. Direct tool calls
|
||||
preserve session ownership, the live observation window, tool cards, cancellation, and cleanup.
|
||||
The six tools are:
|
||||
|
||||
## When to use
|
||||
|
||||
- Visit and read pages that require the user's existing browser session.
|
||||
- Fill forms, select options, click through bounded flows, or smoke-test a web UI.
|
||||
- Inspect tabs, page structure, screenshots, console messages, or network metadata.
|
||||
- Work with a user tab after explicitly borrowing it into the Agent Window.
|
||||
|
||||
Do not use browser automation for tasks that need only local files, a purpose-built API, or an
|
||||
explanation. Never extract credentials, cookies, tokens, or other secrets. Do not keep control of a
|
||||
personal tab longer than the immediate task requires.
|
||||
- `browser_session`: start, stop, or list plugin-owned sessions.
|
||||
- `browser_page`: navigate, back, forward, reload, or wait for navigation.
|
||||
- `browser_inspect`: observe, snapshot, html, screenshot, console, or network.
|
||||
- `browser_interact`: click, hover, fill, select, or press.
|
||||
- `browser_tabs`: list, create, select, close, borrow, or return tabs.
|
||||
- `browser_assist`: resize the Agent Window, emulate a device, or request help from the user.
|
||||
|
||||
## Mandatory workflow
|
||||
|
||||
Every browser task follows this lifecycle:
|
||||
|
||||
1. Call `browser_session_start` and retain its returned `sessionId`.
|
||||
2. Use that session for every browser operation. Pass `session` explicitly when more than one
|
||||
session exists.
|
||||
3. Call `browser_session_stop` when the bounded goal is reached or an error ends the task. Treat
|
||||
cleanup like a `finally` path unless the user explicitly asks to keep the session open.
|
||||
1. Call `browser_session` with `action: "start"` and retain its returned `sessionId`.
|
||||
2. Use that session for every later call. Pass `session` explicitly when more than one exists.
|
||||
3. Call `browser_session` with `action: "stop"` when the goal is reached or an error ends the task.
|
||||
Treat cleanup like a finally path unless the user explicitly asks to keep the session open.
|
||||
|
||||
Conceptual tool-call sequence:
|
||||
Conceptual sequence:
|
||||
|
||||
```text
|
||||
browser_session_start({ url: "https://example.com" })
|
||||
browser_observe({ session: sessionId })
|
||||
browser_click({ session: sessionId, target: "@e4" })
|
||||
browser_observe({ session: sessionId })
|
||||
browser_session_stop({ session: sessionId })
|
||||
browser_session({ action: "start", url: "https://example.com" })
|
||||
browser_inspect({ action: "observe", session: sessionId })
|
||||
browser_interact({ action: "click", session: sessionId, target: "@e4" })
|
||||
browser_inspect({ action: "observe", session: sessionId })
|
||||
browser_session({ action: "stop", session: sessionId })
|
||||
```
|
||||
|
||||
The plugin owns only sessions created by `browser_session_start`. `browser_session_list` reports
|
||||
only those owned sessions. Never guess or reuse a session id from another program. When `session`
|
||||
is omitted, most tools use the most recently started or used owned session; explicit ids are safer
|
||||
for multi-session work.
|
||||
The plugin owns only sessions it starts. Never guess or reuse a session id from another program.
|
||||
When `session` is omitted, most actions use the current owned session, but explicit ids are safer.
|
||||
|
||||
## Stop when the goal is met
|
||||
|
||||
Browser work is a bounded task, not open-ended browsing.
|
||||
Browser work is bounded. Define an observable success condition, take the shortest path, and stop
|
||||
as soon as success is visible. Do not refresh or click after success. If an action fails twice
|
||||
without progress, pause instead of brute-forcing. If state is ambiguous, make at most one fresh
|
||||
observation and then ask the user.
|
||||
|
||||
1. Define one observable success condition from the user's request.
|
||||
2. Take the shortest path: observe, act, then perform at most one relevant check.
|
||||
3. Stop immediately when success is visible. Do not refresh, navigate, click, or perform extra
|
||||
verification after success.
|
||||
4. If a human-only step appears, use `browser_request_help`. If an action fails twice without
|
||||
progress, pause instead of brute-forcing.
|
||||
5. When uncertain, make at most one fresh observation. If the state is still ambiguous, ask the
|
||||
user rather than continuing to click.
|
||||
## Observe and interact
|
||||
|
||||
## Core interaction loop
|
||||
Use `browser_inspect` with `action: "observe"` as the primary semantic page view. It returns roles,
|
||||
states, text, and `@eN` refs. Use `action: "snapshot"` when a static accessibility tree is more useful.
|
||||
Prefer a fresh ref over a raw CSS selector for interactions.
|
||||
|
||||
Use `browser_navigate` to open a destination in the active Agent Window tab, then inspect the page
|
||||
before interacting:
|
||||
Refs invalidate after navigation and may become stale after large DOM changes. Observe again after
|
||||
navigation or a meaningful DOM update before using another ref. If an observation identifies a
|
||||
hover-triggered surface, hover the trigger, observe again, then interact with the revealed control.
|
||||
|
||||
1. `browser_observe` is the primary semantic view. It returns roles, states, text, and `@eN` refs,
|
||||
including hints about conditional hover or focus surfaces.
|
||||
2. `browser_snapshot` is the static accessibility-tree fallback when the semantic view is
|
||||
insufficient or a stricter tree is useful.
|
||||
3. Prefer a fresh `@eN` ref over a raw CSS selector for `browser_click`, `browser_hover`,
|
||||
`browser_fill`, `browser_select`, and targeted `browser_press` calls.
|
||||
4. Observe again after navigation or a meaningful DOM change before using another ref.
|
||||
Use `browser_interact` as follows:
|
||||
|
||||
Refs invalidate after navigation and may also become stale after large DOM changes. Never carry a
|
||||
ref to a new page. If an observation marks a control as hover-triggered, call `browser_hover` on the
|
||||
trigger, observe again, and then act on the newly visible ref.
|
||||
- `action: "click"`: requires `target`; optionally accepts `button` and `clickCount`.
|
||||
- `action: "hover"`: requires `target`; optionally accepts `modifiers`, `settleMs`, and `timeoutMs`.
|
||||
- `action: "fill"`: requires `target` and `value`; `noClear` appends instead of replacing.
|
||||
- `action: "select"`: requires `target` and a non-empty `values` array. Values are option value
|
||||
attributes, not necessarily the visible labels.
|
||||
- `action: "press"`: requires `key`; optionally focus `target` first and set `holdMs`.
|
||||
|
||||
Use interaction tools according to their schemas:
|
||||
## Navigate and wait
|
||||
|
||||
- `browser_click`: click a ref or selector; optionally choose mouse button and click count.
|
||||
- `browser_hover`: move to a ref or selector and allow hover UI to settle; then observe again.
|
||||
- `browser_fill`: clear and fill a text-capable element; set `noClear` only when appending is
|
||||
intentional.
|
||||
- `browser_select`: pass `values` as an array, including for a single option.
|
||||
- `browser_press`: send a key or combo, optionally focusing `target` first.
|
||||
Use `browser_page` with these actions:
|
||||
|
||||
Actions may trigger a navigation after the action itself returns. Use
|
||||
`browser_wait_for_navigation` only when that lifecycle wait is actually needed, then observe the
|
||||
new page.
|
||||
- `navigate` requires `url` and optionally accepts `waitUntil` and `timeoutMs`.
|
||||
- `back` and `forward` move one history entry and optionally accept lifecycle wait fields.
|
||||
- `reload` refreshes the current tab; set `hard: true` only when bypassing cache is necessary.
|
||||
- `wait` waits for an expected navigation after an interaction that did not already wait.
|
||||
|
||||
## Observation and page reading priority
|
||||
Avoid speculative waits when no navigation is expected. After any page change, discard old refs and
|
||||
observe again.
|
||||
|
||||
## Reading priority
|
||||
|
||||
Escalate page reading only as needed:
|
||||
|
||||
1. `browser_observe` for normal semantic understanding and interaction refs.
|
||||
2. `browser_snapshot` for a stricter static accessibility tree.
|
||||
3. `browser_get_html` for hidden DOM, metadata, or exact markup that the semantic views cannot
|
||||
provide. A `ref` must come from a fresh observation; keep `maxBytes` bounded.
|
||||
4. `browser_screenshot` for layout, styling, canvas, image content, or another genuinely visual
|
||||
question. Pass a fresh `ref` to crop to one element, or omit it for the visible tab.
|
||||
1. `browser_inspect` with `action: "observe"` for normal understanding and refs.
|
||||
2. The same tool with `action: "snapshot"` for a stricter static tree.
|
||||
3. `action: "html"` for exact markup that semantic views cannot provide; keep `maxBytes` bounded and
|
||||
use only a fresh ref when scoping a subtree.
|
||||
4. `action: "screenshot"` for layout, styling, canvas, or genuinely visual evidence. A fresh `ref`
|
||||
crops to an element; omit it for the visible tab.
|
||||
|
||||
Do not start with raw HTML or a screenshot merely to discover ordinary controls. Screenshots may
|
||||
return an inline image or a saved path depending on the deployment.
|
||||
Do not start with raw HTML or a screenshot merely to discover ordinary controls.
|
||||
|
||||
## Tabs and the Agent Window boundary
|
||||
|
||||
Write actions normally affect only Agent Window tabs.
|
||||
Use `browser_tabs` with these actions:
|
||||
|
||||
- `browser_tab_list` lists `agent`, `user`, or `all` tabs visible to the owned session. Use
|
||||
`scope: "user"` to locate a user tab before borrowing it.
|
||||
- `browser_tab_create` creates an Agent Window tab. It is active by default; set `active: false`
|
||||
for a background tab.
|
||||
- `browser_tab_select` focuses an Agent Window tab.
|
||||
- `browser_tab_close` closes an Agent Window tab.
|
||||
- `browser_tab_borrow` moves a chosen user tab into the Agent Window before any write action.
|
||||
- `browser_tab_return` returns a borrowed tab to its original window and position. Return it as
|
||||
soon as the immediate task is finished; stopping the session is only the fallback cleanup.
|
||||
- `list` returns visible tabs; `scope: "user"` finds a user tab before borrowing.
|
||||
- `create` makes an Agent Window tab and optionally accepts `url`, `active`, and `index`.
|
||||
- `select` focuses an Agent Window tab and requires a returned `tabId`.
|
||||
- `close` closes an Agent Window tab and requires a returned `tabId`.
|
||||
- `borrow` moves a user tab into the Agent Window and requires a listed user `tabId`.
|
||||
- `return` restores a borrowed tab and requires that borrowed `tabId`.
|
||||
|
||||
Use only a `tabId` returned by `browser_tab_list` or `browser_tab_create`. Do not assume a tab id
|
||||
from page position or title.
|
||||
Return borrowed tabs as soon as the immediate task finishes. Stopping the session is only fallback
|
||||
cleanup. Never invent a tab id.
|
||||
|
||||
## Navigation and lifecycle
|
||||
## Human help and display controls
|
||||
|
||||
- `browser_navigate` opens a URL and can wait for a requested lifecycle phase.
|
||||
- `browser_navigate_back` and `browser_navigate_forward` move one history entry.
|
||||
- `browser_reload` reloads the current tab; use `hard: true` only when bypassing cache is necessary.
|
||||
- `browser_wait_for_navigation` waits for a lifecycle event after an action that did not already
|
||||
wait. Avoid speculative waits when no navigation is expected.
|
||||
Use `browser_assist` with `action: "request-help"` for login, captcha, OTP, payment confirmation,
|
||||
consent, or another human-only step. `prompt` is required. Optional `targets` highlight fresh refs or
|
||||
selectors. `completionCriteria` can detect explicit stable success through URL, selector, or text
|
||||
conditions. After the user continues or criteria complete, observe again before reasoning about the
|
||||
new state.
|
||||
|
||||
After navigation, history traversal, or reload, discard old refs and call `browser_observe` or
|
||||
`browser_snapshot` again.
|
||||
Use `action: "resize"` with `width` and `height` to resize the outer Agent Window. Use
|
||||
`action: "emulate"` with a device preset or explicit width and height; `mobile: true` also requires
|
||||
dimensions. Use `off: true` alone to clear emulation. Emulation is per tab.
|
||||
|
||||
## Ask the user for browser help
|
||||
## Read-only diagnostics
|
||||
|
||||
Call `browser_request_help` for login, captcha, OTP, payment confirmation, consent, or another step
|
||||
that requires the user. Give a clear `prompt`; when the prompt refers to a concrete control, pass
|
||||
its fresh ref or selector in `targets` so the overlay can highlight it. Use `title` only when it
|
||||
adds clarity.
|
||||
Use `browser_inspect` with `action: "console"` for buffered logs and exceptions, and
|
||||
`action: "network"` for response and failure metadata. Both accept `tabId`, `since`, `limit`, and
|
||||
`maxTextChars`; continue with the returned sequence cursor rather than rereading the same entries.
|
||||
Console additionally accepts `includeStack`.
|
||||
|
||||
Optional `completionCriteria` can finish the pause automatically when the page has an explicit,
|
||||
stable success signal. It supports `any` or `all` conditions using `urlContains`, `urlMatches`,
|
||||
`selectorExists`, `selectorMissing`, `textExists`, or `textMissing`, plus `stableForMs`.
|
||||
|
||||
```text
|
||||
browser_request_help({
|
||||
session: sessionId,
|
||||
title: "Sign in required",
|
||||
prompt: "Complete sign-in, then return control after the account page appears.",
|
||||
targets: ["@e7"],
|
||||
timeoutMs: 300000,
|
||||
completionCriteria: {
|
||||
any: [{ urlContains: "/account" }, { selectorExists: "[data-testid='account-menu']" }],
|
||||
stableForMs: 1000
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Interpret outcomes deliberately:
|
||||
|
||||
- `continued` or `completed`: the user returned control or the explicit detector matched. Observe
|
||||
again before reasoning about the new state or using refs.
|
||||
- `cancelled`: stop the requested flow.
|
||||
- `timed_out`: report the timeout; do not repeatedly reopen the prompt.
|
||||
- `disabled`: unattended mode does not allow help; proceed only if safe or stop gracefully.
|
||||
- `navigated`: legacy outcome; do not treat navigation alone as proof of success.
|
||||
|
||||
## Debugging without page scripting
|
||||
|
||||
Use the read-only debugging tools only when relevant to the user's task:
|
||||
|
||||
- `browser_console` reads buffered console messages, browser logs, and JavaScript exceptions.
|
||||
Set `includeStack: true` when stack frames are useful.
|
||||
- `browser_network` reads buffered response and failure metadata. It does not return request or
|
||||
response headers or bodies and does not modify traffic.
|
||||
|
||||
Both tools accept `tabId`, `since`, `limit`, and `maxTextChars`. Their result returns `nextSince`;
|
||||
pass that value as the next `since` cursor instead of repeatedly requesting the same entries.
|
||||
|
||||
Arbitrary page script evaluation and interaction recording are intentionally unsupported in this
|
||||
DeepSeek Harness plugin. Do not invent tool names or route around this limitation. If the task
|
||||
strictly requires either capability, explain that it is not available in this phase.
|
||||
|
||||
## Window and device controls
|
||||
|
||||
- `browser_window_resize` changes the Agent Window's outer `width` and `height`; each dimension
|
||||
must be from 100 through 7680 CSS pixels.
|
||||
- `browser_emulate` applies a built-in device preset or explicit viewport settings to the active
|
||||
tab. Explicit `width` and `height` must be supplied together. `mobile: true` also requires both
|
||||
dimensions. Use `off: true` alone to clear emulation. Emulation is per-tab and new tabs do not
|
||||
inherit it.
|
||||
|
||||
## Tool reference
|
||||
|
||||
### Sessions
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `browser_session_start` | Start an owned Agent Window session, optionally with URL, size, browser instance, focus behavior, or device preset. |
|
||||
| `browser_session_stop` | Stop an owned session and close its Agent Window. |
|
||||
| `browser_session_list` | List owned sessions and identify the current one. |
|
||||
|
||||
### Navigation and reading
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `browser_navigate` | Navigate the active tab to a URL. |
|
||||
| `browser_navigate_back` | Go back one history entry. |
|
||||
| `browser_navigate_forward` | Go forward one history entry. |
|
||||
| `browser_reload` | Reload the active tab, optionally bypassing cache. |
|
||||
| `browser_wait_for_navigation` | Wait for an expected page lifecycle event. |
|
||||
| `browser_observe` | Read the primary semantic page representation with refs. |
|
||||
| `browser_snapshot` | Read the static accessibility-tree fallback with refs. |
|
||||
| `browser_get_html` | Read raw document or ref-scoped HTML as a last resort. |
|
||||
| `browser_screenshot` | Capture the visible tab or a ref-cropped element. |
|
||||
|
||||
### Interaction
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `browser_click` | Click a ref or CSS selector. |
|
||||
| `browser_hover` | Hover a ref or selector to reveal conditional UI. |
|
||||
| `browser_fill` | Fill a text-capable element. |
|
||||
| `browser_select` | Select one or more option values. |
|
||||
| `browser_press` | Send a keyboard key or combo. |
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `browser_tab_list` | List visible user and Agent Window tabs. |
|
||||
| `browser_tab_create` | Create an Agent Window tab. |
|
||||
| `browser_tab_select` | Focus an Agent Window tab. |
|
||||
| `browser_tab_close` | Close an Agent Window tab. |
|
||||
| `browser_tab_borrow` | Move a user tab into the Agent Window. |
|
||||
| `browser_tab_return` | Return a borrowed tab to its original window. |
|
||||
|
||||
### Assistance, diagnostics, and display
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `browser_request_help` | Pause for an explicit user browser step. |
|
||||
| `browser_console` | Read buffered console and exception entries. |
|
||||
| `browser_network` | Read buffered response and failure metadata. |
|
||||
| `browser_window_resize` | Resize the Agent Window. |
|
||||
| `browser_emulate` | Apply or clear per-tab device emulation. |
|
||||
Arbitrary page script evaluation and interaction recording are intentionally unsupported. Do not
|
||||
invent tool names or route around that limitation.
|
||||
|
||||
## Error handling and final checklist
|
||||
|
||||
On a stale ref, observe again and retry once with a fresh ref. On a closed or unknown tab, list
|
||||
tabs instead of guessing. On an unknown session, list owned sessions or start a new one; never try
|
||||
foreign ids. On timeout, decide whether one longer, purposeful wait is justified. Avoid repeated
|
||||
actions with unchanged state.
|
||||
On a stale ref, observe again and retry once. On an unknown tab, list tabs instead of guessing. On
|
||||
an unknown session, list owned sessions or start one; never try foreign ids. On timeout, decide
|
||||
whether one longer purposeful wait is justified, and never immediately repeat a failed history
|
||||
action without first checking page state.
|
||||
|
||||
Before finishing a browser task, verify that:
|
||||
|
||||
- The user's observable goal is met, or the blocker is clearly reported.
|
||||
- Any borrowed tab has been returned when practical.
|
||||
- The owned session has been stopped unless the user explicitly asked to keep it open.
|
||||
- No further page action is taken after success.
|
||||
Before finishing, verify that the observable goal is met or the blocker is clear, borrowed tabs are
|
||||
returned when practical, the owned session is stopped, and no extra page action occurs after
|
||||
success.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* are hidden from every surface, so their Agent Windows would otherwise
|
||||
* linger with no way back to them.
|
||||
*
|
||||
* Ownership lineage: browser_session_start records the calling agent's
|
||||
* Ownership lineage: browser_session action=start records the calling agent's
|
||||
* session id PLUS every ancestor along `header.parentSession` (a subagent's
|
||||
* browsers are reaped when any ancestor is archived, the root conversation
|
||||
* included). Lineage is resolved through the host session store; an
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* The six model-facing BrowserSkill tools. Each action dispatches to a private
|
||||
* operation handler, so the model receives only six schemas while every action
|
||||
* retains the existing ownership, cancellation, queuing, observation,
|
||||
* screenshot, and presentation behavior.
|
||||
*/
|
||||
|
||||
import { defineTool, type ParameterSchemaSpec, type ToolDefinition } from "@deepseek-ai/dsh-tools";
|
||||
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM, WAIT_UNTIL_PARAM } from "./tool-params";
|
||||
import { createBrowserOperationDefinitions, type ToolDeps } from "./tools";
|
||||
|
||||
const DEVICE_PRESETS = [
|
||||
"iphone-14",
|
||||
"iphone-14-pro-max",
|
||||
"iphone-se",
|
||||
"pixel-7",
|
||||
"galaxy-s23",
|
||||
"ipad-mini",
|
||||
"galaxy-tab-s8",
|
||||
] as const;
|
||||
|
||||
const TARGET_PARAM = {
|
||||
type: "string",
|
||||
description: "Snapshot ref such as @e3, or a CSS selector.",
|
||||
} as const;
|
||||
|
||||
const DOMAIN_RESULT = { type: "json" } as const;
|
||||
|
||||
interface BrowserToolSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
actions: Record<string, string>;
|
||||
parameters: ParameterSchemaSpec;
|
||||
}
|
||||
|
||||
function operationArgs(args: unknown): Record<string, unknown> {
|
||||
const { action: _action, ...rest } = args as Record<string, unknown>;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function definitionFor(
|
||||
definitions: ReadonlyMap<string, ToolDefinition>,
|
||||
actions: Record<string, string>,
|
||||
action: unknown,
|
||||
): ToolDefinition {
|
||||
const operationName = typeof action === "string" ? actions[action] : undefined;
|
||||
const definition = operationName === undefined ? undefined : definitions.get(operationName);
|
||||
if (definition === undefined) throw new Error(`unsupported browser action: ${String(action)}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function defineBrowserTool(
|
||||
spec: BrowserToolSpec,
|
||||
definitions: ReadonlyMap<string, ToolDefinition>,
|
||||
): ToolDefinition {
|
||||
const actionNames = Object.keys(spec.actions);
|
||||
return defineTool({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
parameters: {
|
||||
action: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: actionNames,
|
||||
description:
|
||||
"Operation to perform. Other arguments are action-dependent and are validated by that operation.",
|
||||
},
|
||||
...spec.parameters,
|
||||
},
|
||||
output: {
|
||||
schema: DOMAIN_RESULT,
|
||||
render(args, value) {
|
||||
const definition = definitionFor(definitions, spec.actions, args.action);
|
||||
return definition.output.render(operationArgs(args), value);
|
||||
},
|
||||
},
|
||||
isConcurrencySafe(args) {
|
||||
const definition = definitionFor(definitions, spec.actions, args.action);
|
||||
return definition.isConcurrencySafe?.(operationArgs(args)) === true;
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const definition = definitionFor(definitions, spec.actions, args.action);
|
||||
return (await definition.execute(operationArgs(args), exec)) as never;
|
||||
},
|
||||
presentCall(args) {
|
||||
const definition = definitionFor(definitions, spec.actions, args.action);
|
||||
return definition.presentCall?.(operationArgs(args));
|
||||
},
|
||||
presentResult(args, result) {
|
||||
const definition = definitionFor(definitions, spec.actions, args.action);
|
||||
return definition.presentResult?.(operationArgs(args), result);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const BROWSER_TOOL_SPECS: BrowserToolSpec[] = [
|
||||
{
|
||||
name: "browser_session",
|
||||
description:
|
||||
"Manage plugin-owned browser sessions. Actions: start opens an Agent Window; stop closes an " +
|
||||
"owned session; list returns owned sessions. For start, url/device/width/height/noFocus/browser " +
|
||||
"are optional. For stop, session is optional and defaults to the current owned session.",
|
||||
actions: {
|
||||
start: "session.start",
|
||||
stop: "session.stop",
|
||||
list: "session.list",
|
||||
},
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
url: { type: "string", description: "Initial URL for start." },
|
||||
width: { type: "integer", description: "Agent Window width; start requires height too." },
|
||||
height: { type: "integer", description: "Agent Window height; start requires width too." },
|
||||
noFocus: { type: "boolean", description: "Start the Agent Window in the background." },
|
||||
browser: { type: "string", description: "Browser instance id for start." },
|
||||
device: { type: "string", enum: DEVICE_PRESETS, description: "Device preset for start." },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_page",
|
||||
description:
|
||||
"Navigate and wait on the active Agent Window tab. Actions: navigate, back, forward, reload, " +
|
||||
"wait. navigate requires url; reload optionally accepts hard; navigation actions accept " +
|
||||
"waitUntil/timeoutMs. Observe again after a meaningful page change before reusing refs.",
|
||||
actions: {
|
||||
navigate: "page.navigate",
|
||||
back: "page.back",
|
||||
forward: "page.forward",
|
||||
reload: "page.reload",
|
||||
wait: "page.wait",
|
||||
},
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
tabId: TAB_ID_PARAM,
|
||||
url: { type: "string", description: "Destination URL; required for navigate." },
|
||||
waitUntil: WAIT_UNTIL_PARAM,
|
||||
timeoutMs: TIMEOUT_MS_PARAM,
|
||||
hard: { type: "boolean", description: "Bypass cache for reload." },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_inspect",
|
||||
description:
|
||||
"Read page state without arbitrary script execution. Actions: observe, snapshot, html, " +
|
||||
"screenshot, console, network. Prefer observe, then snapshot, then bounded html; use screenshot " +
|
||||
"for visual evidence. console/network support cursor fields since/limit/maxTextChars.",
|
||||
actions: {
|
||||
observe: "inspect.observe",
|
||||
snapshot: "inspect.snapshot",
|
||||
html: "inspect.html",
|
||||
screenshot: "inspect.screenshot",
|
||||
console: "inspect.console",
|
||||
network: "inspect.network",
|
||||
},
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
tabId: TAB_ID_PARAM,
|
||||
maxDepth: { type: "integer", description: "Tree depth cap for observe/snapshot." },
|
||||
maxTokens: { type: "integer", description: "Token cap for observe/snapshot." },
|
||||
ref: { type: "string", description: "Fresh ref for scoped html or cropped screenshot." },
|
||||
maxBytes: { type: "integer", description: "HTML byte cap." },
|
||||
since: { type: "integer", description: "Console/network sequence cursor." },
|
||||
limit: { type: "integer", description: "Console/network entry cap." },
|
||||
maxTextChars: { type: "integer", description: "Console/network per-entry text cap." },
|
||||
includeStack: { type: "boolean", description: "Include console stack frames." },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_interact",
|
||||
description:
|
||||
"Interact with an element in the active Agent Window tab. Actions: click, hover, fill, select, " +
|
||||
"press. click/hover/fill/select require target; fill also requires value; select requires " +
|
||||
"values; press requires key and may optionally focus target first.",
|
||||
actions: {
|
||||
click: "interact.click",
|
||||
hover: "interact.hover",
|
||||
fill: "interact.fill",
|
||||
select: "interact.select",
|
||||
press: "interact.press",
|
||||
},
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
tabId: TAB_ID_PARAM,
|
||||
target: TARGET_PARAM,
|
||||
button: { type: "string", enum: ["left", "middle", "right"], description: "Click button." },
|
||||
clickCount: { type: "integer", description: "Click count." },
|
||||
value: { type: "string", description: "Text for fill." },
|
||||
noClear: { type: "boolean", description: "Append instead of clearing for fill." },
|
||||
modifiers: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["alt", "ctrl", "meta", "shift"] },
|
||||
description: "Modifiers held during hover.",
|
||||
},
|
||||
settleMs: { type: "integer", description: "Hover settle delay." },
|
||||
timeoutMs: TIMEOUT_MS_PARAM,
|
||||
values: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Option values for select; at least one is required.",
|
||||
},
|
||||
key: { type: "string", description: "Key or combo for press." },
|
||||
holdMs: { type: "integer", description: "Key hold duration for press." },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_tabs",
|
||||
description:
|
||||
"Manage tabs visible to an owned session. Actions: list, create, select, close, borrow, return. " +
|
||||
"select/close/borrow/return require tabId from list/create. Borrow moves a user tab into the " +
|
||||
"Agent Window; return it as soon as the task finishes.",
|
||||
actions: {
|
||||
list: "tabs.list",
|
||||
create: "tabs.create",
|
||||
select: "tabs.select",
|
||||
close: "tabs.close",
|
||||
borrow: "tabs.borrow",
|
||||
return: "tabs.return",
|
||||
},
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
tabId: TAB_ID_PARAM,
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["user", "agent", "all"],
|
||||
description: "Tab scope for list.",
|
||||
},
|
||||
url: { type: "string", description: "Initial URL for create." },
|
||||
active: { type: "boolean", description: "Focus the created tab (default true)." },
|
||||
index: { type: "integer", description: "Insertion index for create." },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_assist",
|
||||
description:
|
||||
"Display and human-assistance operations. Actions: resize, emulate, request-help. resize " +
|
||||
"requires width/height; emulate accepts device or width/height/mobile, or off alone; " +
|
||||
"request-help requires prompt and can wait for explicit completion criteria.",
|
||||
actions: {
|
||||
resize: "assist.resize",
|
||||
emulate: "assist.emulate",
|
||||
"request-help": "assist.request-help",
|
||||
},
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
tabId: TAB_ID_PARAM,
|
||||
width: { type: "integer", description: "Window/viewport width." },
|
||||
height: { type: "integer", description: "Window/viewport height." },
|
||||
device: { type: "string", enum: DEVICE_PRESETS, description: "Emulation device preset." },
|
||||
mobile: { type: "boolean", description: "Enable a mobile viewport with width/height." },
|
||||
off: { type: "boolean", description: "Clear emulation; use alone." },
|
||||
prompt: { type: "string", description: "Instructions shown to the user for request-help." },
|
||||
title: { type: "string", description: "Optional request-help title." },
|
||||
targets: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Refs/selectors highlighted for request-help.",
|
||||
},
|
||||
timeoutMs: TIMEOUT_MS_PARAM,
|
||||
completionCriteria: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
description: "Automatic completion detector for request-help.",
|
||||
properties: {
|
||||
any: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
urlContains: { type: "string" },
|
||||
urlMatches: { type: "string" },
|
||||
selectorExists: { type: "string" },
|
||||
selectorMissing: { type: "string" },
|
||||
textExists: { type: "string" },
|
||||
textMissing: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
all: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
urlContains: { type: "string" },
|
||||
urlMatches: { type: "string" },
|
||||
selectorExists: { type: "string" },
|
||||
selectorMissing: { type: "string" },
|
||||
textExists: { type: "string" },
|
||||
textMissing: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stableForMs: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function indexOperations(definitions: ToolDefinition[]): ReadonlyMap<string, ToolDefinition> {
|
||||
const indexed = new Map<string, ToolDefinition>();
|
||||
for (const definition of definitions) {
|
||||
if (indexed.has(definition.name)) {
|
||||
throw new Error(`duplicate browser operation definition: ${definition.name}`);
|
||||
}
|
||||
indexed.set(definition.name, definition);
|
||||
}
|
||||
|
||||
const routed = new Set(BROWSER_TOOL_SPECS.flatMap((spec) => Object.values(spec.actions)));
|
||||
const missing = [...routed].filter((name) => !indexed.has(name));
|
||||
const unreachable = [...indexed.keys()].filter((name) => !routed.has(name));
|
||||
if (missing.length > 0 || unreachable.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
missing.length > 0 ? `missing operations: ${missing.join(", ")}` : undefined,
|
||||
unreachable.length > 0 ? `unreachable operations: ${unreachable.join(", ")}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
);
|
||||
}
|
||||
return indexed;
|
||||
}
|
||||
|
||||
/** Register the complete six-tool browser suite; returns its combined disposer. */
|
||||
export function registerBrowserTools(deps: ToolDeps): () => void {
|
||||
const definitions = indexOperations(createBrowserOperationDefinitions(deps));
|
||||
const disposers = BROWSER_TOOL_SPECS.map((spec) =>
|
||||
deps.ctx.tools.register(defineBrowserTool(spec, definitions)),
|
||||
).filter((dispose): dispose is () => void => typeof dispose === "function");
|
||||
return () => {
|
||||
for (const dispose of disposers.splice(0)) dispose();
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* browser_screenshot toolview: disclosure row over a terminal block plus the
|
||||
/* browser_inspect screenshot toolview: disclosure row over a terminal block plus the
|
||||
screenshot image. Semantic tokens only (--dsw-alias-*), no literal colors. */
|
||||
|
||||
.card {
|
||||
+80
-23
@@ -1,8 +1,6 @@
|
||||
// browser_screenshot keyed toolview: the terminal card's command line and
|
||||
// output stay exactly as the host renders them, and a result carrying an
|
||||
// image block additionally renders the screenshot itself (thumbnail with the
|
||||
// shared click-to-open lightbox). Pure function of the frozen call/result
|
||||
// slice — live and replay paths render identically.
|
||||
// browser_inspect keyed toolview: all inspect actions retain a terminal card;
|
||||
// screenshot results additionally render the image attachment. Pure function
|
||||
// of the frozen call/result slice, so live and replay paths stay identical.
|
||||
|
||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||
import {
|
||||
@@ -19,13 +17,13 @@ import {
|
||||
} from "@deepseek-ai/dsh-client-ui-primitives";
|
||||
import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client";
|
||||
import { useState } from "react";
|
||||
import css from "./ScreenshotToolView.module.css";
|
||||
import css from "./BrowserInspectToolView.module.css";
|
||||
|
||||
/** Resolved loader bound to the owning session at registration time. */
|
||||
export type ScreenshotImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
|
||||
export type BrowserInspectImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
|
||||
|
||||
export type ScreenshotToolViewProps = ToolCallViewProps & {
|
||||
loadImage: ScreenshotImageLoader;
|
||||
export type BrowserInspectToolViewProps = ToolCallViewProps & {
|
||||
loadImage: BrowserInspectImageLoader;
|
||||
};
|
||||
|
||||
type ViewState = "running" | "ok" | "error";
|
||||
@@ -36,6 +34,7 @@ interface ViewModel {
|
||||
readonly output: string | null;
|
||||
readonly image: ImageAttachmentRef | null;
|
||||
readonly summary: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
const TERMINAL_LABELS: Partial<TerminalBlockLabels> = {
|
||||
@@ -66,18 +65,71 @@ function firstLine(text: string): string {
|
||||
}
|
||||
|
||||
/** Rebuild the command line from the logged arguments (mirrors the host presenter). */
|
||||
function commandOf(argsRaw: string, callId: string): string {
|
||||
const INSPECT_COMMANDS: Record<string, string> = {
|
||||
observe: "observe",
|
||||
snapshot: "snapshot",
|
||||
html: "get-html",
|
||||
screenshot: "screenshot",
|
||||
console: "console",
|
||||
network: "network",
|
||||
};
|
||||
|
||||
function titleOf(action: string): string {
|
||||
return action === "html" ? "HTML" : `${action.slice(0, 1).toUpperCase()}${action.slice(1)}`;
|
||||
}
|
||||
|
||||
interface InspectArgs {
|
||||
action?: unknown;
|
||||
session?: unknown;
|
||||
tabId?: unknown;
|
||||
maxDepth?: unknown;
|
||||
maxTokens?: unknown;
|
||||
ref?: unknown;
|
||||
maxBytes?: unknown;
|
||||
since?: unknown;
|
||||
limit?: unknown;
|
||||
maxTextChars?: unknown;
|
||||
includeStack?: unknown;
|
||||
}
|
||||
|
||||
function appendNumber(parts: string[], flag: string, value: unknown): void {
|
||||
if (typeof value === "number" && Number.isFinite(value)) parts.push(flag, String(value));
|
||||
}
|
||||
|
||||
function commandOf(argsRaw: string, callId: string): { command: string; title: string } {
|
||||
try {
|
||||
const args = JSON.parse(argsRaw) as { session?: unknown; ref?: unknown };
|
||||
const parts = ["bsk", "screenshot", "--session"];
|
||||
const args = JSON.parse(argsRaw) as InspectArgs;
|
||||
const action = typeof args.action === "string" ? args.action : "inspect";
|
||||
const command = INSPECT_COMMANDS[action] ?? action;
|
||||
const parts = ["bsk", command, "--session"];
|
||||
parts.push(
|
||||
typeof args.session === "string" && args.session !== "" ? args.session : "(current)",
|
||||
);
|
||||
if (typeof args.ref === "string" && args.ref !== "") parts.push("--ref", args.ref);
|
||||
return parts.join(" ");
|
||||
if (action === "observe" || action === "snapshot") {
|
||||
appendNumber(parts, "--max-depth", args.maxDepth);
|
||||
appendNumber(parts, "--max-tokens", args.maxTokens);
|
||||
}
|
||||
if (action === "html" || action === "console" || action === "network") {
|
||||
appendNumber(parts, "--tab-id", args.tabId);
|
||||
}
|
||||
if ((action === "html" || action === "screenshot") && typeof args.ref === "string") {
|
||||
if (args.ref !== "") parts.push("--ref", args.ref);
|
||||
}
|
||||
if (action === "html") appendNumber(parts, "--max-bytes", args.maxBytes);
|
||||
if (action === "console" || action === "network") {
|
||||
appendNumber(parts, "--since", args.since);
|
||||
appendNumber(parts, "--limit", args.limit);
|
||||
appendNumber(parts, "--max-text-chars", args.maxTextChars);
|
||||
}
|
||||
if (action === "console" && args.includeStack === true) parts.push("--include-stack");
|
||||
return { command: parts.join(" "), title: titleOf(action) };
|
||||
} catch {
|
||||
// Streaming can expose a truncated JSON prefix; it is still the best label.
|
||||
return argsRaw === "" ? `bsk screenshot (${callId})` : `bsk screenshot ${firstLine(argsRaw)}`;
|
||||
return {
|
||||
command:
|
||||
argsRaw === "" ? `browser_inspect (${callId})` : `browser_inspect ${firstLine(argsRaw)}`,
|
||||
title: "Inspect",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +137,9 @@ function commandOf(argsRaw: string, callId: string): string {
|
||||
export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
|
||||
const settled = "kind" in block;
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? "";
|
||||
const command = commandOf(argsRaw, block.callId);
|
||||
const { command, title } = commandOf(argsRaw, block.callId);
|
||||
if (!settled) {
|
||||
return { state: "running", command, output: null, image: null, summary: command };
|
||||
return { state: "running", command, output: null, image: null, summary: command, title };
|
||||
}
|
||||
const textBlock = block.content.find((item) => item.type === "text");
|
||||
const imageBlock = block.content.find((item) => item.type === "image");
|
||||
@@ -96,7 +148,7 @@ export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
|
||||
imageBlock !== undefined && imageBlock.type === "image" ? imageBlock.attachment : null;
|
||||
const state: ViewState = block.isError ? "error" : "ok";
|
||||
const summary = output !== null ? firstLine(output) : command;
|
||||
return { state, command, output, image, summary };
|
||||
return { state, command, output, image, summary, title };
|
||||
}
|
||||
|
||||
function dotState(state: ViewState): StateDotState {
|
||||
@@ -111,19 +163,24 @@ function dotState(state: ViewState): StateDotState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one browser_screenshot call: a disclosure row over a terminal block
|
||||
* (command + output) plus the screenshot image when the result carries one.
|
||||
* Render one browser_inspect call: a disclosure row over a terminal block,
|
||||
* plus the screenshot image when that action returns an image attachment.
|
||||
*/
|
||||
export function ScreenshotToolView({ block, cwd, inspect, loadImage }: ScreenshotToolViewProps) {
|
||||
export function BrowserInspectToolView({
|
||||
block,
|
||||
cwd,
|
||||
inspect,
|
||||
loadImage,
|
||||
}: BrowserInspectToolViewProps) {
|
||||
const model = viewModelOf(block);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const expandable = model.state === "running" || model.output !== null || model.image !== null;
|
||||
const open = expanded && expandable;
|
||||
return (
|
||||
<div className={css.card} data-tool="browser_screenshot" data-state={model.state}>
|
||||
<div className={css.card} data-tool="browser_inspect" data-state={model.state}>
|
||||
<DisclosureRow
|
||||
icon={<StateDot state={dotState(model.state)} />}
|
||||
title="Screenshot"
|
||||
title={model.title}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
onToggle={() => setExpanded((value) => !value)}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
|
||||
* toolview plus the observation overlay (live thumbnails + interrupt). The
|
||||
* dsh-plugin-browserskill browser half: the `browser_inspect` keyed toolview
|
||||
* plus the observation overlay (live thumbnails + interrupt). The
|
||||
* default carrier is a floating card on the `shell.overlay` seat; when the
|
||||
* dsh-better-sidebar plugin provides its `betterSidebar` service, the view
|
||||
* moves into a sidebar tab instead (see observation-sidebar.tsx).
|
||||
@@ -16,10 +16,10 @@ import { createElement } from "react";
|
||||
// <style> tags; selectors stay unhashed so `cn(..., "bsk-obs")` roots match).
|
||||
import "./bsk-tokens.nomodule.css";
|
||||
import "./bsk-ui.nomodule.css";
|
||||
import { BrowserInspectToolView } from "./BrowserInspectToolView";
|
||||
import { ObservationOverlay } from "./ObservationOverlay";
|
||||
import { type BetterSidebarLike, registerObservationSidebar } from "./observation-sidebar";
|
||||
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
|
||||
import { ScreenshotToolView } from "./ScreenshotToolView";
|
||||
|
||||
/** Required services: slots, session-scoped attachment reads, and the overlay seat. */
|
||||
export const inject = ["slots", "sessions"];
|
||||
@@ -65,9 +65,9 @@ export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.get("sessions") as unknown as ISessions;
|
||||
ctx.slots.inject("tool.call.toolview", () =>
|
||||
ctx.slots.register(
|
||||
{ name: "tool.call.toolview", key: "browser_screenshot" },
|
||||
{ name: "tool.call.toolview", key: "browser_inspect" },
|
||||
(props: ToolCallViewProps) =>
|
||||
createElement(ScreenshotToolView, {
|
||||
createElement(BrowserInspectToolView, {
|
||||
...props,
|
||||
loadImage: (attachment: ImageAttachmentRef) =>
|
||||
loadSessionImage(sessions, props.sessionId, attachment),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Screenshot delivery. The canonical tool value stays plain JSON (a PNG file
|
||||
* path plus pixel metadata); when the host mounts a durable attachment store
|
||||
* AND the calling route declares image input, the PNG bytes are additionally
|
||||
* committed through `ctx.attachments` so the render step can attach the image
|
||||
* itself to the tool result. Any uncertainty (no store, unknown route,
|
||||
* text-only model) falls back to the path-only form.
|
||||
* Screenshot delivery. The canonical tool value stays plain JSON (a screenshot
|
||||
* file path plus pixel metadata); when the host mounts a durable attachment
|
||||
* store AND the calling route declares image input, the capture bytes are
|
||||
* additionally committed through `ctx.attachments` so the render step can
|
||||
* attach the image itself to the tool result. Any uncertainty (no store,
|
||||
* unknown route, text-only model, unrecognized bytes) falls back to the
|
||||
* path-only form.
|
||||
*/
|
||||
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
@@ -33,6 +34,54 @@ interface LlmLike {
|
||||
): Promise<{ inputModalities?: readonly string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniff the image media type from magic bytes. The capture path can hand back
|
||||
* JPEG (e.g. `chrome.tabs.captureVisibleTab` on some Chromium/Edge builds
|
||||
* returning JPEG regardless of the requested format), so the declared type can
|
||||
* never be trusted from the file extension alone.
|
||||
* @param data - first bytes of the capture.
|
||||
* @returns the detected media type, or undefined when unrecognized.
|
||||
*/
|
||||
function sniffImageMediaType(data: Uint8Array): string | undefined {
|
||||
if (data.length >= 4) {
|
||||
// PNG signature: 89 50 4E 47 (0D 0A 1A 0A)
|
||||
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
|
||||
return "image/png";
|
||||
}
|
||||
// JPEG SOI: FF D8 FF
|
||||
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
// WebP: "RIFF" (52 49 46 46) + "WEBP" at offset 8
|
||||
if (
|
||||
data[0] === 0x52 &&
|
||||
data[1] === 0x49 &&
|
||||
data[2] === 0x46 &&
|
||||
data[3] === 0x46 &&
|
||||
data.length >= 12 &&
|
||||
data[8] === 0x57 &&
|
||||
data[9] === 0x45 &&
|
||||
data[10] === 0x42 &&
|
||||
data[11] === 0x50
|
||||
) {
|
||||
return "image/webp";
|
||||
}
|
||||
// GIF: "GIF87a" or "GIF89a"
|
||||
if (
|
||||
data[0] === 0x47 &&
|
||||
data[1] === 0x49 &&
|
||||
data[2] === 0x46 &&
|
||||
data[3] === 0x38 &&
|
||||
data.length >= 6 &&
|
||||
(data[4] === 0x37 || data[4] === 0x39) &&
|
||||
data[5] === 0x61
|
||||
) {
|
||||
return "image/gif";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit screenshot bytes to the host attachment store when the composition
|
||||
* supports durable images on the current model route.
|
||||
@@ -46,7 +95,14 @@ export async function trySaveScreenshot(
|
||||
): Promise<ImageAttachmentRef | undefined> {
|
||||
const attachments = ctx.get("attachments") as AttachmentLike | undefined;
|
||||
if (attachments === undefined) return undefined;
|
||||
if (!attachments.imageLimits.mediaTypes.includes("image/png")) return undefined;
|
||||
// Declare the real media type from the bytes, never a hard-coded "image/png".
|
||||
// Declaring the wrong type trips the store's IMAGE_TYPE_MISMATCH check and
|
||||
// silently degrades to path-only; sniffing first lets JPEG captures (returned
|
||||
// by some Chromium builds for `captureVisibleTab({ format: "png" })`) still
|
||||
// inline correctly.
|
||||
const mediaType = sniffImageMediaType(data);
|
||||
if (mediaType === undefined) return undefined;
|
||||
if (!attachments.imageLimits.mediaTypes.includes(mediaType)) return undefined;
|
||||
if (
|
||||
data.byteLength >
|
||||
Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes)
|
||||
@@ -55,9 +111,9 @@ export async function trySaveScreenshot(
|
||||
}
|
||||
if (!(await isImageCapableRoute(ctx, exec))) return undefined;
|
||||
try {
|
||||
return await attachments.saveImage({ data, mediaType: "image/png", name });
|
||||
return await attachments.saveImage({ data, mediaType, name });
|
||||
} catch {
|
||||
// A store hiccup must not fail the tool call; the PNG path still works.
|
||||
// A store hiccup must not fail the tool call; the path-only form still works.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import Schema from "@deepseek-ai/schemastery";
|
||||
import { armArchiveCleanup } from "./archive-cleanup";
|
||||
import { registerBrowserTools } from "./browser-tools";
|
||||
import { armLazyTools } from "./lazy-tools";
|
||||
import { ObservationService } from "./observation";
|
||||
import { registerObservationRoutes } from "./observation-http";
|
||||
@@ -20,9 +21,9 @@ import { KeyedExecutor } from "./queue";
|
||||
import { type BskRunner, createBskRunner } from "./runner";
|
||||
import { SessionRegistry } from "./sessions";
|
||||
import { armAgentScopedBskSkill, registerBskSkill } from "./skill";
|
||||
import { type PluginConfig, registerTools } from "./tools";
|
||||
import type { PluginConfig } from "./tools";
|
||||
|
||||
export const name = "dsh-plugin-browserskill";
|
||||
export const name = "@wxg-prc-cpg/browser-skill-dsh-plugin";
|
||||
export const inject = ["tools"];
|
||||
|
||||
/** Runtime configuration schema (validated and defaulted by Cordis). */
|
||||
@@ -100,11 +101,9 @@ export function apply(
|
||||
// exact agent context at startup so DSH always loads the browser_* protocol
|
||||
// instructions; the shared CLI skill remains untouched for other agents.
|
||||
const disarmAgentSkill = armAgentScopedBskSkill(ctx);
|
||||
const removeSuite = resolved.lazyTools
|
||||
? armLazyTools(ctx, () =>
|
||||
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
|
||||
)
|
||||
: registerTools({ ctx, runner, registry, config: resolved, observation, queue });
|
||||
const registerSuite = () =>
|
||||
registerBrowserTools({ ctx, runner, registry, config: resolved, observation, queue });
|
||||
const removeSuite = resolved.lazyTools ? armLazyTools(ctx, registerSuite) : registerSuite();
|
||||
// Route registration rides ctx.inject: the webServer service may be provided
|
||||
// AFTER this plugin loads, and in headless compositions it never appears (the
|
||||
// callback simply never runs, leaving the rest of the plugin unaffected).
|
||||
@@ -133,7 +132,7 @@ export function apply(
|
||||
);
|
||||
|
||||
// Unload cleanup: kill in-flight children, then stop every session this
|
||||
// plugin OWNS (created via browser_session_start). Referenced or unknown
|
||||
// plugin OWNS (created via browser_session action=start). Referenced or unknown
|
||||
// sessions belonging to other programs on the shared daemon are never
|
||||
// touched; per-stop failures (already stopped externally, daemon restart)
|
||||
// are swallowed so one stale handle cannot abort the rest.
|
||||
@@ -154,6 +153,7 @@ export function apply(
|
||||
}
|
||||
|
||||
export { armArchiveCleanup, ownerSessionIds } from "./archive-cleanup";
|
||||
export { registerBrowserTools } from "./browser-tools";
|
||||
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
|
||||
export { ObservationService } from "./observation";
|
||||
export { registerObservationRoutes } from "./observation-http";
|
||||
@@ -162,4 +162,3 @@ export type { BskRunner, BskRunResult, SpawnImpl } from "./runner";
|
||||
export { BskError, createBskRunner } from "./runner";
|
||||
export { SessionRegistry } from "./sessions";
|
||||
export type { PluginConfig, ToolDeps } from "./tools";
|
||||
export { registerTools } from "./tools";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Lazy tool-schema injection ("progressive disclosure", final stage): with
|
||||
* `lazyTools` on (the default), the 28 browser_* tool schemas stay OUT of the
|
||||
* `lazyTools` on (the default), the six browser tool schemas stay OUT of the
|
||||
* system prompt until the `browser-skill` skill has actually been invoked —
|
||||
* the skill catalog entry is the only advertisement. One successful
|
||||
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
|
||||
@@ -96,7 +96,7 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]
|
||||
/**
|
||||
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
|
||||
* the reveal already happened — the tool suite itself.
|
||||
* @param registerSuite - registers the 28 browser_* tools, returns their disposer.
|
||||
* @param registerSuite - registers the six browser tools and returns their disposer.
|
||||
*/
|
||||
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
|
||||
let suiteDisposer: (() => void) | undefined;
|
||||
|
||||
@@ -161,7 +161,7 @@ export class ObservationService {
|
||||
this.emit({ type: "upsert", session: { ...entry } });
|
||||
}
|
||||
|
||||
/** Register a fresh owned session (called from browser_session_start). */
|
||||
/** Register a fresh owned session (called from browser_session action=start). */
|
||||
addSession(sessionId: string, url?: string): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
const dshSessionIds = this.deps.registry.dshOwnersOf(sessionId);
|
||||
@@ -176,7 +176,7 @@ export class ObservationService {
|
||||
this.scheduleCapture(sessionId, 0);
|
||||
}
|
||||
|
||||
/** Drop a session (called from browser_session_stop). */
|
||||
/** Drop a session (called from browser_session action=stop). */
|
||||
removeSession(sessionId: string): void {
|
||||
this.cancelCapture(sessionId);
|
||||
this.foregroundDepth.delete(sessionId);
|
||||
@@ -276,7 +276,7 @@ export class ObservationService {
|
||||
|
||||
/**
|
||||
* Stop one owned session and close its Agent Window (the overlay's stop
|
||||
* button — same end state as `browser_session_stop`). Never waits behind a
|
||||
* button — same end state as `browser_session` action=stop). Never waits behind a
|
||||
* hung in-flight command: tool children are killed first so the session's
|
||||
* keyed queue drains immediately, and no further captures queue up. A
|
||||
* session the daemon already forgot stops idempotently — the goal state
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolDefinition, ToolResult, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
||||
import type { ToolDeps } from "./tools";
|
||||
|
||||
export type ToolRegistrar = (definition: ToolDefinition) => () => void;
|
||||
export type ToolRegistrar = (definition: ToolDefinition) => void;
|
||||
|
||||
type TerminalPresentation = { card: "terminal"; output: string; exitCode: number } | undefined;
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ export function registerPhaseOneInteractionTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_hover",
|
||||
name: "interact.hover",
|
||||
description:
|
||||
"Move the mouse over a snapshot ref or CSS selector to reveal hover-triggered UI. " +
|
||||
"Run browser_observe or browser_snapshot again afterwards to discover newly visible refs.",
|
||||
"Run browser_inspect action=observe or snapshot afterwards to discover newly visible refs.",
|
||||
parameters: {
|
||||
target: {
|
||||
type: "string",
|
||||
@@ -68,7 +68,7 @@ export function registerPhaseOneInteractionTools(
|
||||
requireNonEmpty(args.target, "target");
|
||||
requirePositive(args.timeoutMs, "timeoutMs");
|
||||
requirePositive(args.settleMs, "settleMs");
|
||||
const sessionId = registry.resolve(args.session, "browser_hover");
|
||||
const sessionId = registry.resolve(args.session, "browser_interact(action=hover)");
|
||||
const cmdArgs = ["hover", "--session", sessionId];
|
||||
appendTabId(cmdArgs, args.tabId);
|
||||
if (args.modifiers !== undefined && args.modifiers.length > 0) {
|
||||
@@ -102,7 +102,7 @@ export function registerPhaseOneInteractionTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_select",
|
||||
name: "interact.select",
|
||||
description:
|
||||
"Set a select element's option values. Pass one value for a normal select or multiple " +
|
||||
"values for a multi-select; values replace the current selection.",
|
||||
@@ -155,7 +155,7 @@ export function registerPhaseOneInteractionTools(
|
||||
requireNonEmpty(args.target, "target");
|
||||
requirePositive(args.timeoutMs, "timeoutMs");
|
||||
if (args.values.length === 0) throw new Error("values must contain at least one option");
|
||||
const sessionId = registry.resolve(args.session, "browser_select");
|
||||
const sessionId = registry.resolve(args.session, "browser_interact(action=select)");
|
||||
const cmdArgs = ["select", "--session", sessionId];
|
||||
appendTabId(cmdArgs, args.tabId);
|
||||
for (const value of args.values) cmdArgs.push("--value", value);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function registerPhaseOneNavigationTools(
|
||||
const { registry } = deps;
|
||||
|
||||
const registerHistory = (direction: "back" | "forward") => {
|
||||
const toolName = `browser_navigate_${direction}` as const;
|
||||
const toolName = `page.${direction}` as const;
|
||||
const command = `navigate-${direction}`;
|
||||
register(
|
||||
defineTool({
|
||||
@@ -106,7 +106,7 @@ export function registerPhaseOneNavigationTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_reload",
|
||||
name: "page.reload",
|
||||
description:
|
||||
"Reload the active Agent Window tab and wait for a lifecycle phase. Set hard=true to " +
|
||||
"bypass the HTTP cache.",
|
||||
@@ -132,7 +132,7 @@ export function registerPhaseOneNavigationTools(
|
||||
},
|
||||
async execute(args, exec) {
|
||||
requirePositive(args.timeoutMs, "timeoutMs");
|
||||
const sessionId = registry.resolve(args.session, "browser_reload");
|
||||
const sessionId = registry.resolve(args.session, "browser_page(action=reload)");
|
||||
const cmdArgs = ["reload", "--session", sessionId];
|
||||
appendTabId(cmdArgs, args.tabId);
|
||||
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
|
||||
@@ -163,7 +163,7 @@ export function registerPhaseOneNavigationTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_wait_for_navigation",
|
||||
name: "page.wait",
|
||||
description:
|
||||
"Wait for a page lifecycle event on the active Agent Window tab. Use after an action " +
|
||||
"that may navigate when the action result itself does not wait for navigation.",
|
||||
@@ -195,7 +195,7 @@ export function registerPhaseOneNavigationTools(
|
||||
},
|
||||
async execute(args, exec) {
|
||||
requirePositive(args.timeoutMs, "timeoutMs");
|
||||
const sessionId = registry.resolve(args.session, "browser_wait_for_navigation");
|
||||
const sessionId = registry.resolve(args.session, "browser_page(action=wait)");
|
||||
const timeoutMs = args.timeoutMs ?? 30_000;
|
||||
const cmdArgs = ["wait-for-navigation", "--session", sessionId];
|
||||
appendTabId(cmdArgs, args.tabId);
|
||||
|
||||
@@ -143,7 +143,7 @@ export function registerPhaseOneSupportTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_request_help",
|
||||
name: "assist.request-help",
|
||||
description:
|
||||
"Pause browser automation and ask the user to complete an in-page step such as login, " +
|
||||
"captcha, OTP, or confirmation. After continued/completed, observe again before using refs.",
|
||||
@@ -233,7 +233,7 @@ export function registerPhaseOneSupportTools(
|
||||
if (criteria?.stableForMs !== undefined && criteria.stableForMs < 0) {
|
||||
throw new Error("completionCriteria.stableForMs must be zero or greater");
|
||||
}
|
||||
const sessionId = registry.resolve(args.session, "browser_request_help");
|
||||
const sessionId = registry.resolve(args.session, "browser_assist(action=request-help)");
|
||||
const timeoutMs = args.timeoutMs ?? 300_000;
|
||||
const cmdArgs = [
|
||||
"request-help",
|
||||
@@ -285,10 +285,10 @@ export function registerPhaseOneSupportTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_get_html",
|
||||
name: "inspect.html",
|
||||
description:
|
||||
"Read raw DOM HTML from the active Agent Window tab, optionally scoped to a fresh " +
|
||||
"snapshot ref. Use only when browser_observe/browser_snapshot cannot answer the question.",
|
||||
"snapshot ref. Use only when browser_inspect observe/snapshot cannot answer the question.",
|
||||
parameters: {
|
||||
session: SESSION_PARAM,
|
||||
tabId: TAB_ID_PARAM,
|
||||
@@ -326,7 +326,7 @@ export function registerPhaseOneSupportTools(
|
||||
if (!isSnapshotRef(args.ref)) throw new Error("ref must be a snapshot ref such as @e3");
|
||||
}
|
||||
requirePositive(args.maxBytes, "maxBytes");
|
||||
const sessionId = registry.resolve(args.session, "browser_get_html");
|
||||
const sessionId = registry.resolve(args.session, "browser_inspect(action=html)");
|
||||
const cmdArgs = ["get-html", "--session", sessionId];
|
||||
appendTabId(cmdArgs, args.tabId);
|
||||
if (args.ref !== undefined) cmdArgs.push("--ref", args.ref);
|
||||
@@ -361,7 +361,7 @@ export function registerPhaseOneSupportTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_console",
|
||||
name: "inspect.console",
|
||||
description:
|
||||
"Read buffered console messages, browser log entries, and JavaScript exceptions from a tab. " +
|
||||
"This is read-only and does not evaluate JavaScript.",
|
||||
@@ -448,7 +448,7 @@ export function registerPhaseOneSupportTools(
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
validateDebugArgs(args);
|
||||
const sessionId = registry.resolve(args.session, "browser_console");
|
||||
const sessionId = registry.resolve(args.session, "browser_inspect(action=console)");
|
||||
const cmdArgs = ["console", "--session", sessionId];
|
||||
appendDebugOptions(cmdArgs, args);
|
||||
if (args.includeStack === true) cmdArgs.push("--include-stack");
|
||||
@@ -493,7 +493,7 @@ export function registerPhaseOneSupportTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_network",
|
||||
name: "inspect.network",
|
||||
description:
|
||||
"Read buffered network responses and failures from a tab. Returns metadata only; request " +
|
||||
"and response headers and bodies are not captured.",
|
||||
@@ -552,7 +552,7 @@ export function registerPhaseOneSupportTools(
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
validateDebugArgs(args);
|
||||
const sessionId = registry.resolve(args.session, "browser_network");
|
||||
const sessionId = registry.resolve(args.session, "browser_inspect(action=network)");
|
||||
const cmdArgs = ["network", "--session", sessionId];
|
||||
appendDebugOptions(cmdArgs, args);
|
||||
const reply = (await runtime.run(exec, cmdArgs, "network", sessionId)) as {
|
||||
@@ -592,7 +592,7 @@ export function registerPhaseOneSupportTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_window_resize",
|
||||
name: "assist.resize",
|
||||
description: "Resize the owned session's Agent Window using outer CSS-pixel dimensions.",
|
||||
parameters: {
|
||||
width: {
|
||||
@@ -629,7 +629,7 @@ export function registerPhaseOneSupportTools(
|
||||
if (args.width < 100 || args.width > 7680 || args.height < 100 || args.height > 7680) {
|
||||
throw new Error("width and height must each be in the range 100..=7680");
|
||||
}
|
||||
const sessionId = registry.resolve(args.session, "browser_window_resize");
|
||||
const sessionId = registry.resolve(args.session, "browser_assist(action=resize)");
|
||||
const reply = (await runtime.run(
|
||||
exec,
|
||||
[
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ToolDeps } from "./tools";
|
||||
const TAB_ID_REQUIRED = {
|
||||
type: "integer",
|
||||
required: true,
|
||||
description: "Chrome tab id returned by browser_tab_list or browser_tab_create.",
|
||||
description: "Chrome tab id returned by browser_tabs list or create.",
|
||||
} as const;
|
||||
|
||||
interface RawTabInfo {
|
||||
@@ -29,7 +29,7 @@ export function registerPhaseOneTabTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_tab_list",
|
||||
name: "tabs.list",
|
||||
description:
|
||||
"List tabs visible to an owned browser session. Other sessions' Agent Windows remain " +
|
||||
"hidden; use scope=user to find a user tab before borrowing it.",
|
||||
@@ -83,7 +83,7 @@ export function registerPhaseOneTabTools(
|
||||
},
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_tab_list");
|
||||
const sessionId = registry.resolve(args.session, "browser_tabs(action=list)");
|
||||
const cmdArgs = ["tab", "list", "--session", sessionId];
|
||||
if (args.scope !== undefined) cmdArgs.push("--scope", args.scope);
|
||||
const reply = (await runtime.run(exec, cmdArgs, "tab list", sessionId)) as {
|
||||
@@ -118,7 +118,7 @@ export function registerPhaseOneTabTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_tab_create",
|
||||
name: "tabs.create",
|
||||
description:
|
||||
"Create a tab inside the owned session's Agent Window. The tab is focused by default; " +
|
||||
"set active=false to open it in the background.",
|
||||
@@ -148,7 +148,7 @@ export function registerPhaseOneTabTools(
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (args.url !== undefined) requireNonEmpty(args.url, "url");
|
||||
const sessionId = registry.resolve(args.session, "browser_tab_create");
|
||||
const sessionId = registry.resolve(args.session, "browser_tabs(action=create)");
|
||||
const cmdArgs = ["tab", "create", "--session", sessionId];
|
||||
if (args.url !== undefined) cmdArgs.push("--url", args.url);
|
||||
if (args.active === false) cmdArgs.push("--no-active");
|
||||
@@ -185,7 +185,7 @@ export function registerPhaseOneTabTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_tab_close",
|
||||
name: "tabs.close",
|
||||
description: "Close a tab in the owned session's Agent Window.",
|
||||
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||
output: {
|
||||
@@ -202,7 +202,7 @@ export function registerPhaseOneTabTools(
|
||||
],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_tab_close");
|
||||
const sessionId = registry.resolve(args.session, "browser_tabs(action=close)");
|
||||
const reply = (await runtime.run(
|
||||
exec,
|
||||
["tab", "close", String(args.tabId), "--session", sessionId],
|
||||
@@ -228,7 +228,7 @@ export function registerPhaseOneTabTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_tab_select",
|
||||
name: "tabs.select",
|
||||
description: "Focus a tab in the owned session's Agent Window.",
|
||||
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||
output: {
|
||||
@@ -249,7 +249,7 @@ export function registerPhaseOneTabTools(
|
||||
],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_tab_select");
|
||||
const sessionId = registry.resolve(args.session, "browser_tabs(action=select)");
|
||||
const reply = (await runtime.run(
|
||||
exec,
|
||||
["tab", "select", String(args.tabId), "--session", sessionId],
|
||||
@@ -275,10 +275,10 @@ export function registerPhaseOneTabTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_tab_borrow",
|
||||
name: "tabs.borrow",
|
||||
description:
|
||||
"Move a user-window tab into the owned session's Agent Window for controlled interaction. " +
|
||||
"Return it with browser_tab_return when finished; stopping the session also auto-returns it.",
|
||||
"Return it with browser_tabs action=return; stopping the session also auto-returns it.",
|
||||
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||
output: {
|
||||
schema: {
|
||||
@@ -302,7 +302,7 @@ export function registerPhaseOneTabTools(
|
||||
],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_tab_borrow");
|
||||
const sessionId = registry.resolve(args.session, "browser_tabs(action=borrow)");
|
||||
const reply = (await runtime.run(
|
||||
exec,
|
||||
["tab", "borrow", String(args.tabId), "--session", sessionId],
|
||||
@@ -339,7 +339,7 @@ export function registerPhaseOneTabTools(
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_tab_return",
|
||||
name: "tabs.return",
|
||||
description: "Return a borrowed tab to its original user window and tab-strip position.",
|
||||
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||
output: {
|
||||
@@ -365,7 +365,7 @@ export function registerPhaseOneTabTools(
|
||||
],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_tab_return");
|
||||
const sessionId = registry.resolve(args.session, "browser_tabs(action=return)");
|
||||
const reply = (await runtime.run(
|
||||
exec,
|
||||
["tab", "return", String(args.tabId), "--session", sessionId],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Strict ownership boundary (the bsk daemon may be shared with other agents,
|
||||
* terminals, or dsh instances): the registry ONLY ever holds sessions created
|
||||
* by this plugin's browser_session_start. Tools cannot see or act on any
|
||||
* by this plugin's browser_session start action. Tools cannot see or act on any
|
||||
* other session — an explicit `session` argument naming a foreign id is an
|
||||
* error, the list tool shows owned sessions only, and stop/unload cleanup
|
||||
* can never touch a session this plugin did not create.
|
||||
@@ -48,7 +48,7 @@ export class SessionRegistry {
|
||||
if (this.sessions.size + this.pendingStarts >= this.maxSessions) {
|
||||
throw new Error(
|
||||
`session limit reached (${this.maxSessions} concurrent sessions); ` +
|
||||
"stop one with browser_session_stop before starting another",
|
||||
"stop one with browser_session action=stop before starting another",
|
||||
);
|
||||
}
|
||||
this.pendingStarts += 1;
|
||||
@@ -69,7 +69,7 @@ export class SessionRegistry {
|
||||
if (!this.sessions.has(session.sessionId) && this.sessions.size >= this.maxSessions) {
|
||||
throw new Error(
|
||||
`session limit reached (${this.maxSessions} concurrent sessions); ` +
|
||||
"stop one with browser_session_stop before starting another",
|
||||
"stop one with browser_session action=stop before starting another",
|
||||
);
|
||||
}
|
||||
this.sessions.set(session.sessionId, { ...session, owned: true });
|
||||
@@ -149,7 +149,7 @@ export class SessionRegistry {
|
||||
private foreignError(sessionId: string, toolName: string): Error {
|
||||
return new Error(
|
||||
`${toolName}: session "${sessionId}" does not belong to this plugin — only sessions ` +
|
||||
"created by browser_session_start are visible and operable here",
|
||||
"created by browser_session action=start are visible and operable here",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export class SessionRegistry {
|
||||
const current = this.current();
|
||||
if (current === undefined) {
|
||||
throw new Error(
|
||||
`${toolName} needs a session but none is active — start one with browser_session_start`,
|
||||
`${toolName} needs a session but none is active — use browser_session action=start`,
|
||||
);
|
||||
}
|
||||
this.touch(current);
|
||||
@@ -184,10 +184,11 @@ export class SessionRegistry {
|
||||
explicit !== undefined && explicit.trim().length > 0 ? explicit : this.current();
|
||||
if (candidate === undefined) {
|
||||
throw new Error(
|
||||
"browser_session_stop needs a session but none is active — start one with browser_session_start",
|
||||
"browser_session action=stop needs a session but none is active — use action=start first",
|
||||
);
|
||||
}
|
||||
if (!this.isOwned(candidate)) throw this.foreignError(candidate, "browser_session_stop");
|
||||
if (!this.isOwned(candidate))
|
||||
throw this.foreignError(candidate, "browser_session(action=stop)");
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export const SESSION_PARAM = {
|
||||
type: "string",
|
||||
description:
|
||||
"bsk session id to act on; must be one created by this plugin's browser_session_start. " +
|
||||
"bsk session id to act on; must be one created by browser_session with action=start. " +
|
||||
"Omit to use the current session (the one most recently started or used).",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Model-facing tool definitions. Each tool maps to one `bsk <cmd> --json`
|
||||
* invocation: spawn via the runner, parse the JSON envelope, and return a
|
||||
* canonical value whose `output.render` produces the model-facing text.
|
||||
* UI cards reuse the terminal style: the pending card is the command line,
|
||||
* the completed card carries the output.
|
||||
* Internal browser operation definitions shared by the six model-facing tools.
|
||||
* These definitions are never registered with `ctx.tools`: they provide the
|
||||
* action-level validation, execution, rendering, and UI presentation reused by
|
||||
* the domain tools without exposing one schema per operation to the model.
|
||||
*/
|
||||
|
||||
import { readFile, unlink } from "node:fs/promises";
|
||||
@@ -158,22 +157,15 @@ function abortError(): Error {
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Register the full browser tool suite; returns the combined unregister disposer. */
|
||||
export function registerTools(deps: ToolDeps): () => void {
|
||||
const { ctx, registry } = deps;
|
||||
// Track every registration without touching the cordis context itself
|
||||
// (contexts are fiber-owned; the lazy path registers from a listener fiber).
|
||||
const disposers: (() => void)[] = [];
|
||||
const register = (definition: ToolDefinition): (() => void) => {
|
||||
const dispose = ctx.tools.register(definition);
|
||||
// Test doubles occasionally return the registry map instead of a disposer.
|
||||
if (typeof dispose === "function") disposers.push(dispose);
|
||||
return dispose;
|
||||
};
|
||||
type DefinitionRegistrar = (definition: ToolDefinition) => void;
|
||||
|
||||
/** Define the private action handlers through an injected collector. */
|
||||
function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar): void {
|
||||
const { registry } = deps;
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_session_start",
|
||||
name: "session.start",
|
||||
description:
|
||||
"Start a new browser session: opens an Agent Window in the connected browser and returns " +
|
||||
"its session id. The new session becomes the current session for subsequent browser_* calls. " +
|
||||
@@ -314,10 +306,10 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_session_stop",
|
||||
name: "session.stop",
|
||||
description:
|
||||
"Stop a browser session and close its Agent Window. Stops the given session, or the " +
|
||||
"current session when `session` is omitted. Only sessions created by browser_session_start " +
|
||||
"current session when `session` is omitted. Only plugin-created sessions " +
|
||||
"can be stopped — sessions owned by other programs sharing the bsk daemon are refused.",
|
||||
parameters: { session: SESSION_PARAM },
|
||||
output: {
|
||||
@@ -354,7 +346,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_session_list",
|
||||
name: "session.list",
|
||||
description:
|
||||
"List the browser sessions created by this plugin. Sessions owned by other programs on the " +
|
||||
"shared bsk daemon are not visible here. The session marked `current` is the one browser_* " +
|
||||
@@ -419,7 +411,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_navigate",
|
||||
name: "page.navigate",
|
||||
description:
|
||||
"Navigate the session's active tab to a URL and wait for a page lifecycle phase " +
|
||||
"(default: load). Returns the final URL after redirects.",
|
||||
@@ -461,7 +453,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (args.url.trim().length === 0) throw new Error("url must be a non-empty string");
|
||||
const sessionId = registry.resolve(args.session, "browser_navigate");
|
||||
const sessionId = registry.resolve(args.session, "browser_page(action=navigate)");
|
||||
const cmdArgs = ["navigate", "--session", sessionId];
|
||||
if (args.waitUntil !== undefined) cmdArgs.push("--wait-until", args.waitUntil);
|
||||
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
|
||||
@@ -492,14 +484,14 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
const registerObservationTool = (kind: "snapshot" | "observe") => {
|
||||
const isSnapshot = kind === "snapshot";
|
||||
const name = isSnapshot ? "browser_snapshot" : "browser_observe";
|
||||
const name = isSnapshot ? "inspect.snapshot" : "inspect.observe";
|
||||
const description = isSnapshot
|
||||
? "Capture an indented aria-tree snapshot of the session's active tab. Interactive elements " +
|
||||
"carry @eN refs for browser_click / browser_fill. Prefer browser_observe for a richer semantic view."
|
||||
"carry @eN refs for browser_interact actions. Prefer browser_inspect action=observe for a richer semantic view."
|
||||
: "Produce a semantic VOM observation of the session's active tab (roles, states, perception " +
|
||||
"probes) with @eN refs. Visual-only surfaces require image understanding and their refs are " +
|
||||
"screenshot-only; keep using any DOM/AX semantics rendered alongside them. " +
|
||||
"Read-only: never submits input.";
|
||||
"probes) with @eN refs for browser_interact actions. Visual-only surfaces require image " +
|
||||
"understanding and their refs are screenshot-only; keep using any DOM/AX semantics rendered " +
|
||||
"alongside them. Read-only: never submits input.";
|
||||
register(
|
||||
defineTool({
|
||||
name,
|
||||
@@ -568,10 +560,11 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_click",
|
||||
name: "interact.click",
|
||||
description:
|
||||
"Click an element in the session's active tab. Target is a snapshot ref (@e3) from the " +
|
||||
"last browser_snapshot/browser_observe, or a CSS selector.",
|
||||
"last browser_inspect observation, or a CSS selector. A visual Surface requires the " +
|
||||
"captureId and image coordinates returned by browser_inspect action=screenshot.",
|
||||
parameters: {
|
||||
target: {
|
||||
type: "string",
|
||||
@@ -588,6 +581,24 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
type: "integer",
|
||||
description: "Number of consecutive presses (double-click = 2).",
|
||||
},
|
||||
modifiers: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["alt", "ctrl", "meta", "shift"] },
|
||||
description: "Keyboard modifiers held during the click.",
|
||||
},
|
||||
captureId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Short-lived Surface capture id returned by browser_inspect action=screenshot.",
|
||||
},
|
||||
imageX: {
|
||||
type: "number",
|
||||
description: "X coordinate in capture-image pixels; requires captureId and imageY.",
|
||||
},
|
||||
imageY: {
|
||||
type: "number",
|
||||
description: "Y coordinate in capture-image pixels; requires captureId and imageX.",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
@@ -598,6 +609,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
tabId: { type: "integer", required: true },
|
||||
x: { type: "number", required: true },
|
||||
y: { type: "number", required: true },
|
||||
captureId: { type: "string" },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [
|
||||
@@ -609,17 +621,44 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
|
||||
const sessionId = registry.resolve(args.session, "browser_click");
|
||||
const pointArgCount =
|
||||
Number(args.captureId !== undefined) +
|
||||
Number(args.imageX !== undefined) +
|
||||
Number(args.imageY !== undefined);
|
||||
if (pointArgCount !== 0 && pointArgCount !== 3) {
|
||||
throw new Error("captureId, imageX, and imageY must be given together");
|
||||
}
|
||||
const sessionId = registry.resolve(args.session, "browser_interact(action=click)");
|
||||
const cmdArgs = ["click", "--session", sessionId];
|
||||
if (args.button !== undefined) cmdArgs.push("--button", args.button);
|
||||
if (args.clickCount !== undefined) cmdArgs.push("--click-count", String(args.clickCount));
|
||||
if (args.modifiers !== undefined && args.modifiers.length > 0) {
|
||||
cmdArgs.push("--modifiers", args.modifiers.join(","));
|
||||
}
|
||||
if (args.captureId !== undefined) {
|
||||
cmdArgs.push(
|
||||
"--capture",
|
||||
args.captureId,
|
||||
"--image-x",
|
||||
String(args.imageX),
|
||||
"--image-y",
|
||||
String(args.imageY),
|
||||
);
|
||||
}
|
||||
cmdArgs.push(args.target);
|
||||
const reply = (await runBsk(deps, exec, cmdArgs, "click", sessionId)) as {
|
||||
tab_id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
capture_id?: string;
|
||||
};
|
||||
return {
|
||||
session: sessionId,
|
||||
tabId: reply.tab_id,
|
||||
x: reply.x,
|
||||
y: reply.y,
|
||||
...(reply.capture_id !== undefined ? { captureId: reply.capture_id } : {}),
|
||||
};
|
||||
return { session: sessionId, tabId: reply.tab_id, x: reply.x, y: reply.y };
|
||||
},
|
||||
presentCall: (args) => ({
|
||||
card: "terminal",
|
||||
@@ -632,7 +671,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_fill",
|
||||
name: "interact.fill",
|
||||
description:
|
||||
"Fill an input / textarea / contenteditable element, clearing it first by default. " +
|
||||
"Target is a snapshot ref (@e3) or a CSS selector.",
|
||||
@@ -668,7 +707,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
|
||||
const sessionId = registry.resolve(args.session, "browser_fill");
|
||||
const sessionId = registry.resolve(args.session, "browser_interact(action=fill)");
|
||||
const cmdArgs = ["fill", "--session", sessionId, "--value", args.value];
|
||||
if (args.noClear === true) cmdArgs.push("--no-clear");
|
||||
cmdArgs.push(args.target);
|
||||
@@ -689,7 +728,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_press",
|
||||
name: "interact.press",
|
||||
description:
|
||||
"Dispatch a keyboard key or combo (Enter, Escape, ArrowDown, Ctrl+A, …) to the session's " +
|
||||
"active tab, optionally focusing a target element first.",
|
||||
@@ -728,7 +767,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (args.key.trim().length === 0) throw new Error("key must be a non-empty string");
|
||||
const sessionId = registry.resolve(args.session, "browser_press");
|
||||
const sessionId = registry.resolve(args.session, "browser_interact(action=press)");
|
||||
const cmdArgs = ["press", "--session", sessionId];
|
||||
if (args.target !== undefined) {
|
||||
if (args.target.trim().length === 0) throw new Error("target must be a non-empty string");
|
||||
@@ -759,10 +798,10 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_screenshot",
|
||||
name: "inspect.screenshot",
|
||||
description:
|
||||
"Capture a PNG screenshot of the session's active tab, or crop to a snapshot ref element. " +
|
||||
"This is the supported operation for visual surface refs emitted by browser_observe. Only use " +
|
||||
"This is the supported operation for visual surface refs emitted by inspect.observe. Only use " +
|
||||
"it to interpret a visual-only surface when you can inspect image output. If you lack multimodal " +
|
||||
"or image-reading capability, tell the user to switch to a model with image understanding; do " +
|
||||
"not guess content or coordinates. " +
|
||||
@@ -786,12 +825,18 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
width: { type: "integer", required: true },
|
||||
height: { type: "integer", required: true },
|
||||
byteSize: { type: "integer", required: true },
|
||||
captureId: { type: "string" },
|
||||
captureExpiresAt: { type: "integer" },
|
||||
image: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
attachmentId: { type: "string", required: true },
|
||||
mediaType: { type: "string", required: true, const: "image/png" },
|
||||
mediaType: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
||||
},
|
||||
bytes: { type: "integer", required: true },
|
||||
width: { type: "integer", required: true },
|
||||
height: { type: "integer", required: true },
|
||||
@@ -805,7 +850,11 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
value.image !== undefined
|
||||
? `[session ${value.session}] screenshot of tab ${value.tabId} (${value.width}x${value.height}px)`
|
||||
: `[session ${value.session}] screenshot saved to ${value.path} (${value.width}x${value.height}px, ${value.byteSize} bytes) — this deployment cannot inline images; read the file to view it`;
|
||||
const blocks: ContentBlock[] = [{ type: "text", text }];
|
||||
const captureText =
|
||||
value.captureId !== undefined
|
||||
? ` Surface capture ${value.captureId} uses capture-image-pixel coordinates and expires at ${value.captureExpiresAt}.`
|
||||
: "";
|
||||
const blocks: ContentBlock[] = [{ type: "text", text: text + captureText }];
|
||||
if (value.image !== undefined) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
@@ -825,7 +874,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
},
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_screenshot");
|
||||
const sessionId = registry.resolve(args.session, "browser_inspect(action=screenshot)");
|
||||
const outPath = join(
|
||||
tmpdir(),
|
||||
`bsk-screenshot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
|
||||
@@ -844,6 +893,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
height: number;
|
||||
path: string;
|
||||
byte_size: number;
|
||||
capture?: { id: string; expires_at: number };
|
||||
};
|
||||
writtenPath = reply.path;
|
||||
const data = await readFile(reply.path);
|
||||
@@ -857,11 +907,14 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
width: reply.width,
|
||||
height: reply.height,
|
||||
byteSize: reply.byte_size,
|
||||
...(reply.capture !== undefined
|
||||
? { captureId: reply.capture.id, captureExpiresAt: reply.capture.expires_at }
|
||||
: {}),
|
||||
...(ref !== undefined
|
||||
? {
|
||||
image: {
|
||||
attachmentId: String(ref.attachmentId),
|
||||
mediaType: "image/png" as const,
|
||||
mediaType: ref.mediaType,
|
||||
bytes: ref.bytes,
|
||||
width: ref.width,
|
||||
height: ref.height,
|
||||
@@ -890,7 +943,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
|
||||
register(
|
||||
defineTool({
|
||||
name: "browser_emulate",
|
||||
name: "assist.emulate",
|
||||
description:
|
||||
"Apply (or clear) mobile device emulation on the session's active tab: viewport metrics, " +
|
||||
"user agent, and touch. Overrides are per-tab and not inherited by new tabs.",
|
||||
@@ -931,7 +984,7 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolve(args.session, "browser_emulate");
|
||||
const sessionId = registry.resolve(args.session, "browser_assist(action=emulate)");
|
||||
if (args.off === true) {
|
||||
if (args.device !== undefined || args.width !== undefined || args.height !== undefined) {
|
||||
throw new Error("off is mutually exclusive with device/width/height");
|
||||
@@ -980,7 +1033,17 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
commandLine: (args) => cmdline(deps, args),
|
||||
presentTerminalResult,
|
||||
});
|
||||
return () => {
|
||||
for (const dispose of disposers.splice(0)) dispose();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the private action handlers without publishing them to the model. The
|
||||
* six browser tools dispatch through these handlers, preserving validation,
|
||||
* rendering, cancellation, ownership, UI presentation, and attachments.
|
||||
*/
|
||||
export function createBrowserOperationDefinitions(deps: ToolDeps): ToolDefinition[] {
|
||||
const definitions: ToolDefinition[] = [];
|
||||
defineBrowserOperations(deps, (definition) => {
|
||||
definitions.push(definition);
|
||||
});
|
||||
return definitions;
|
||||
}
|
||||
|
||||
+48
-22
@@ -1,17 +1,17 @@
|
||||
// @vitest-environment happy-dom
|
||||
// browser_screenshot keyed toolview: view-model derivation from frozen blocks,
|
||||
// browser_inspect keyed toolview: view-model derivation from frozen blocks,
|
||||
// image/path-only/running/error rendering, the registration key, and the
|
||||
// session-bound attachment loader.
|
||||
|
||||
import type { RunningToolCall, ToolResultNode } from "@deepseek-ai/dsh-client-runtime/client";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { apply } from "../../src/client/index";
|
||||
import {
|
||||
ScreenshotToolView,
|
||||
type ScreenshotToolViewProps,
|
||||
BrowserInspectToolView,
|
||||
type BrowserInspectToolViewProps,
|
||||
viewModelOf,
|
||||
} from "../../src/client/ScreenshotToolView";
|
||||
} from "../../src/client/BrowserInspectToolView";
|
||||
import { apply } from "../../src/client/index";
|
||||
|
||||
const ATTACHMENT = {
|
||||
attachmentId: "sha256:abc123",
|
||||
@@ -25,7 +25,7 @@ const ATTACHMENT = {
|
||||
function runningBlock(argsRaw: string): RunningToolCall {
|
||||
return {
|
||||
callId: "c1",
|
||||
name: "browser_screenshot",
|
||||
name: "browser_inspect",
|
||||
argsRaw,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -41,7 +41,7 @@ function settledBlock(argsRaw: string, content: unknown[], isError = false): Too
|
||||
seq: 1,
|
||||
time: 0,
|
||||
callId: "c1",
|
||||
call: { name: "browser_screenshot", argsRaw },
|
||||
call: { name: "browser_inspect", argsRaw },
|
||||
callTime: 0,
|
||||
content,
|
||||
isError,
|
||||
@@ -58,14 +58,14 @@ afterEach(cleanup);
|
||||
|
||||
describe("viewModelOf", () => {
|
||||
it("derives the running model from the call frame", () => {
|
||||
const model = viewModelOf(runningBlock('{"session":"s1"}'));
|
||||
const model = viewModelOf(runningBlock('{"action":"screenshot","session":"s1"}'));
|
||||
expect(model.state).toBe("running");
|
||||
expect(model.command).toBe("bsk screenshot --session s1");
|
||||
expect(model.image).toBeNull();
|
||||
});
|
||||
|
||||
it("marks settled results carrying an image block", () => {
|
||||
const block = settledBlock("{}", [
|
||||
const block = settledBlock('{"action":"screenshot"}', [
|
||||
{ type: "text", text: IMAGE_TEXT },
|
||||
{ type: "image", attachment: ATTACHMENT },
|
||||
]);
|
||||
@@ -77,30 +77,56 @@ describe("viewModelOf", () => {
|
||||
});
|
||||
|
||||
it("keeps the path-only form image-free", () => {
|
||||
const model = viewModelOf(settledBlock("{}", [{ type: "text", text: PATH_TEXT }]));
|
||||
const model = viewModelOf(
|
||||
settledBlock('{"action":"screenshot"}', [{ type: "text", text: PATH_TEXT }]),
|
||||
);
|
||||
expect(model.state).toBe("ok");
|
||||
expect(model.image).toBeNull();
|
||||
expect(model.summary).toBe(PATH_TEXT);
|
||||
});
|
||||
|
||||
it("marks error results", () => {
|
||||
const model = viewModelOf(settledBlock("{}", [{ type: "text", text: "Error: boom" }], true));
|
||||
const model = viewModelOf(
|
||||
settledBlock('{"action":"screenshot"}', [{ type: "text", text: "Error: boom" }], true),
|
||||
);
|
||||
expect(model.state).toBe("error");
|
||||
});
|
||||
|
||||
it("renders non-screenshot inspect actions as ordinary terminal calls", () => {
|
||||
const model = viewModelOf(
|
||||
settledBlock('{"action":"observe","session":"s1"}', [
|
||||
{ type: "text", text: "page observation" },
|
||||
]),
|
||||
);
|
||||
expect(model.title).toBe("Observe");
|
||||
expect(model.command).toBe("bsk observe --session s1");
|
||||
expect(model.image).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves action-specific arguments in diagnostic command cards", () => {
|
||||
const model = viewModelOf(
|
||||
runningBlock(
|
||||
'{"action":"console","session":"s1","tabId":7,"since":4,"limit":20,"maxTextChars":500,"includeStack":true}',
|
||||
),
|
||||
);
|
||||
expect(model.command).toBe(
|
||||
"bsk console --session s1 --tab-id 7 --since 4 --limit 20 --max-text-chars 500 --include-stack",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScreenshotToolView", () => {
|
||||
const baseProps = { callId: "c1", toolName: "browser_screenshot", openFile: () => {} };
|
||||
describe("BrowserInspectToolView", () => {
|
||||
const baseProps = { callId: "c1", toolName: "browser_inspect", openFile: () => {} };
|
||||
const renderView = (
|
||||
block: ScreenshotToolViewProps["block"],
|
||||
loadImage: ScreenshotToolViewProps["loadImage"],
|
||||
block: BrowserInspectToolViewProps["block"],
|
||||
loadImage: BrowserInspectToolViewProps["loadImage"],
|
||||
) => {
|
||||
const props = { ...baseProps, block, loadImage } as unknown as ScreenshotToolViewProps;
|
||||
return render(<ScreenshotToolView {...props} />);
|
||||
const props = { ...baseProps, block, loadImage } as unknown as BrowserInspectToolViewProps;
|
||||
return render(<BrowserInspectToolView {...props} />);
|
||||
};
|
||||
|
||||
it("renders the screenshot image through the loader", async () => {
|
||||
const block = settledBlock("{}", [
|
||||
const block = settledBlock('{"action":"screenshot"}', [
|
||||
{ type: "text", text: IMAGE_TEXT },
|
||||
{ type: "image", attachment: ATTACHMENT },
|
||||
]);
|
||||
@@ -115,7 +141,7 @@ describe("ScreenshotToolView", () => {
|
||||
});
|
||||
|
||||
it("renders the path-only form without touching the loader", async () => {
|
||||
const block = settledBlock("{}", [{ type: "text", text: PATH_TEXT }]);
|
||||
const block = settledBlock('{"action":"screenshot"}', [{ type: "text", text: PATH_TEXT }]);
|
||||
const loadImage = vi.fn(async () => "blob:unused");
|
||||
renderView(block, loadImage);
|
||||
screen.getByRole("button", { name: /screenshot/i }).click();
|
||||
@@ -125,14 +151,14 @@ describe("ScreenshotToolView", () => {
|
||||
|
||||
it("renders the running form without output", () => {
|
||||
const loadImage = vi.fn();
|
||||
renderView(runningBlock("{}"), loadImage);
|
||||
renderView(runningBlock('{"action":"screenshot"}'), loadImage);
|
||||
expect(screen.getByText("bsk screenshot --session (current)")).toBeTruthy();
|
||||
expect(loadImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("client plugin registration", () => {
|
||||
it("registers the browser_screenshot toolview and the shell observation overlay", () => {
|
||||
it("registers the browser_inspect toolview and the shell observation overlay", () => {
|
||||
const registrations: { name: string; key?: string; id?: string }[] = [];
|
||||
const sessions = { binding: () => undefined };
|
||||
const ctx = {
|
||||
@@ -150,7 +176,7 @@ describe("client plugin registration", () => {
|
||||
};
|
||||
apply(ctx as never);
|
||||
expect(registrations).toEqual([
|
||||
{ name: "tool.call.toolview", key: "browser_screenshot" },
|
||||
{ name: "tool.call.toolview", key: "browser_inspect" },
|
||||
{ name: "shell.overlay", id: "bsk-observation" },
|
||||
]);
|
||||
});
|
||||
@@ -73,15 +73,15 @@ describe("dispose cleanup ownership", () => {
|
||||
};
|
||||
apply(ctx as never, { maxSessions: 5, lazyTools: false }, { runnerFactory: () => runner });
|
||||
|
||||
const start = tools.get("browser_session_start");
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
if (start === undefined || snapshot === undefined) throw new Error("tools not registered");
|
||||
await start.execute({}, makeExec());
|
||||
await start.execute({}, makeExec());
|
||||
const session = tools.get("browser_session");
|
||||
const inspect = tools.get("browser_inspect");
|
||||
if (session === undefined || inspect === undefined) throw new Error("tools not registered");
|
||||
await session.execute({ action: "start" }, makeExec());
|
||||
await session.execute({ action: "start" }, makeExec());
|
||||
// A foreign session cannot even be referenced any more.
|
||||
await expect(snapshot.execute({ session: "ext9" }, makeExec())).rejects.toThrow(
|
||||
/does not belong to this plugin/,
|
||||
);
|
||||
await expect(
|
||||
inspect.execute({ action: "snapshot", session: "ext9" }, makeExec()),
|
||||
).rejects.toThrow(/does not belong to this plugin/);
|
||||
|
||||
await Promise.all(disposers.map((dispose) => dispose()));
|
||||
|
||||
|
||||
@@ -211,19 +211,25 @@ describe("lazyTools wiring in apply()", () => {
|
||||
|
||||
it("lazyTools: false registers the suite at apply time", () => {
|
||||
const { tools } = applyHarness({ lazyTools: false });
|
||||
expect(tools.has("browser_session_start")).toBe(true);
|
||||
expect(tools.has("browser_emulate")).toBe(true);
|
||||
expect([...tools.keys()].sort()).toEqual([
|
||||
"browser_assist",
|
||||
"browser_inspect",
|
||||
"browser_interact",
|
||||
"browser_page",
|
||||
"browser_session",
|
||||
"browser_tabs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lazyTools: true hides the suite until the skill fires", () => {
|
||||
const { tools, listeners } = applyHarness({ lazyTools: true });
|
||||
expect(tools.has("browser_session_start")).toBe(false);
|
||||
expect(tools.has("browser_session")).toBe(false);
|
||||
callListeners(
|
||||
listeners,
|
||||
"tools/result",
|
||||
{ name: "skill", arguments: { name: "browser-skill" } },
|
||||
{ isError: false },
|
||||
);
|
||||
expect(tools.has("browser_session_start")).toBe(true);
|
||||
expect(tools.has("browser_session")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// dsh 0.1 keys the client graph and Node import by `export const name`.
|
||||
// That string must stay identical to the published package name (and therefore
|
||||
// the client.cjs __ModuleLoader__ id, which tsdown reads from package.json).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { name } from "../src/index";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
|
||||
) as {
|
||||
name: string;
|
||||
dsh: { client: { external?: string[] } };
|
||||
};
|
||||
|
||||
describe("plugin identity", () => {
|
||||
it("uses the published package name as the Cordis plugin id", () => {
|
||||
expect(name).toBe("@wxg-prc-cpg/browser-skill-dsh-plugin");
|
||||
expect(name).toBe(pkg.name);
|
||||
});
|
||||
|
||||
it("declares client require()s so dsh arrives them before materialize", () => {
|
||||
expect(pkg.dsh.client.external).toEqual(
|
||||
expect.arrayContaining([
|
||||
"@deepseek-ai/dsh-client-ui-attachment",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,9 @@ describe("SessionRegistry", () => {
|
||||
|
||||
it("resolve throws actionable guidance when no session exists", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
expect(() => registry.resolve(undefined, "browser_click")).toThrow(/browser_session_start/);
|
||||
expect(() => registry.resolve(undefined, "browser_interact(action=click)")).toThrow(
|
||||
/browser_session action=start/,
|
||||
);
|
||||
});
|
||||
|
||||
it("recency order survives remove of the current session", () => {
|
||||
|
||||
@@ -32,44 +32,22 @@ describe("registerBskSkill", () => {
|
||||
const content = String(skill.content);
|
||||
const mentionedTools = [...new Set(content.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? [])].sort();
|
||||
expect(mentionedTools).toEqual([
|
||||
"browser_click",
|
||||
"browser_console",
|
||||
"browser_emulate",
|
||||
"browser_fill",
|
||||
"browser_get_html",
|
||||
"browser_hover",
|
||||
"browser_navigate",
|
||||
"browser_navigate_back",
|
||||
"browser_navigate_forward",
|
||||
"browser_network",
|
||||
"browser_observe",
|
||||
"browser_press",
|
||||
"browser_reload",
|
||||
"browser_request_help",
|
||||
"browser_screenshot",
|
||||
"browser_select",
|
||||
"browser_session_list",
|
||||
"browser_session_start",
|
||||
"browser_session_stop",
|
||||
"browser_snapshot",
|
||||
"browser_tab_borrow",
|
||||
"browser_tab_close",
|
||||
"browser_tab_create",
|
||||
"browser_tab_list",
|
||||
"browser_tab_return",
|
||||
"browser_tab_select",
|
||||
"browser_wait_for_navigation",
|
||||
"browser_window_resize",
|
||||
"browser_assist",
|
||||
"browser_inspect",
|
||||
"browser_interact",
|
||||
"browser_page",
|
||||
"browser_session",
|
||||
"browser_tabs",
|
||||
]);
|
||||
expect(String(skill.description)).not.toMatch(/\bbsk\b/i);
|
||||
expect(content).not.toMatch(/\bbsk\b/i);
|
||||
expect(content).not.toMatch(/```(?:bash|sh|shell)\b/i);
|
||||
expect(content).not.toMatch(/--[a-z]/);
|
||||
expect(content).toContain("All browser operations must use the injected tools directly");
|
||||
expect(content).toMatch(/All browser work\s+must use the injected tools directly/);
|
||||
expect(content).toContain("Mandatory workflow");
|
||||
expect(content).toContain("Refs invalidate after navigation");
|
||||
expect(content).toContain("evaluation and interaction recording are intentionally unsupported");
|
||||
expect(content.length).toBeGreaterThan(10_000);
|
||||
expect(content.length).toBeGreaterThan(6_000);
|
||||
expect(skill.source).toBe("bundled");
|
||||
// Source frontmatter is registration metadata and must not leak into the body.
|
||||
expect(content.startsWith("---")).toBe(false);
|
||||
@@ -144,7 +122,7 @@ describe("armAgentScopedBskSkill", () => {
|
||||
});
|
||||
|
||||
const after = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
|
||||
expect(after?.content).toContain("All browser operations must use the injected tools directly");
|
||||
expect(after?.content).toMatch(/All browser work\s+must use the injected tools directly/);
|
||||
expect(after?.content).not.toMatch(/\bbsk\b/i);
|
||||
expect(after?.source).toBe("bundled");
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerBrowserTools } from "../src/browser-tools";
|
||||
import { ObservationService } from "../src/observation";
|
||||
import { KeyedExecutor } from "../src/queue";
|
||||
import type { BskRunner, BskRunOptions, BskRunResult } from "../src/runner";
|
||||
import { SessionRegistry } from "../src/sessions";
|
||||
import { type PluginConfig, registerTools } from "../src/tools";
|
||||
import type { PluginConfig } from "../src/tools";
|
||||
|
||||
const CONFIG: PluginConfig = {
|
||||
bskPath: "bsk",
|
||||
@@ -84,8 +85,66 @@ function fakeRunner(responses: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
const ACTION_ROUTES: Record<string, readonly [string, string]> = {
|
||||
"session.start": ["browser_session", "start"],
|
||||
"session.stop": ["browser_session", "stop"],
|
||||
"session.list": ["browser_session", "list"],
|
||||
"page.navigate": ["browser_page", "navigate"],
|
||||
"page.back": ["browser_page", "back"],
|
||||
"page.forward": ["browser_page", "forward"],
|
||||
"page.reload": ["browser_page", "reload"],
|
||||
"page.wait": ["browser_page", "wait"],
|
||||
"inspect.observe": ["browser_inspect", "observe"],
|
||||
"inspect.snapshot": ["browser_inspect", "snapshot"],
|
||||
"inspect.html": ["browser_inspect", "html"],
|
||||
"inspect.screenshot": ["browser_inspect", "screenshot"],
|
||||
"inspect.console": ["browser_inspect", "console"],
|
||||
"inspect.network": ["browser_inspect", "network"],
|
||||
"interact.click": ["browser_interact", "click"],
|
||||
"interact.hover": ["browser_interact", "hover"],
|
||||
"interact.fill": ["browser_interact", "fill"],
|
||||
"interact.select": ["browser_interact", "select"],
|
||||
"interact.press": ["browser_interact", "press"],
|
||||
"tabs.list": ["browser_tabs", "list"],
|
||||
"tabs.create": ["browser_tabs", "create"],
|
||||
"tabs.select": ["browser_tabs", "select"],
|
||||
"tabs.close": ["browser_tabs", "close"],
|
||||
"tabs.borrow": ["browser_tabs", "borrow"],
|
||||
"tabs.return": ["browser_tabs", "return"],
|
||||
"assist.resize": ["browser_assist", "resize"],
|
||||
"assist.emulate": ["browser_assist", "emulate"],
|
||||
"assist.request-help": ["browser_assist", "request-help"],
|
||||
};
|
||||
|
||||
class TestToolRegistry extends Map<string, ToolDefinition> {
|
||||
override get(name: string): ToolDefinition | undefined {
|
||||
const direct = super.get(name);
|
||||
if (direct !== undefined) return direct;
|
||||
const route = ACTION_ROUTES[name];
|
||||
if (route === undefined) return undefined;
|
||||
const [toolName, action] = route;
|
||||
const tool = super.get(toolName);
|
||||
if (tool === undefined) return undefined;
|
||||
const withAction = (args: unknown) => ({
|
||||
action,
|
||||
...(args as Record<string, unknown>),
|
||||
});
|
||||
return {
|
||||
...tool,
|
||||
output: {
|
||||
...tool.output,
|
||||
render: (args, value) => tool.output.render(withAction(args) as never, value),
|
||||
},
|
||||
execute: (args, exec) => tool.execute(withAction(args) as never, exec),
|
||||
isConcurrencySafe: (args) => tool.isConcurrencySafe?.(withAction(args) as never) === true,
|
||||
presentCall: (args) => tool.presentCall?.(withAction(args) as never),
|
||||
presentResult: (args, result) => tool.presentResult?.(withAction(args) as never, result),
|
||||
} as ToolDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
function makeCtx(services: Record<string, unknown> = {}) {
|
||||
const tools = new Map<string, ToolDefinition>();
|
||||
const tools = new TestToolRegistry();
|
||||
const ctx = {
|
||||
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
|
||||
get: (key: string) => services[key],
|
||||
@@ -118,7 +177,7 @@ function setup(
|
||||
const { runner, calls } = fakeRunner(responses);
|
||||
const registry = new SessionRegistry(maxSessions);
|
||||
const config = { ...CONFIG, maxSessions };
|
||||
registerTools({
|
||||
registerBrowserTools({
|
||||
ctx: ctx as never,
|
||||
runner: runner as BskRunner,
|
||||
registry,
|
||||
@@ -130,56 +189,98 @@ function setup(
|
||||
}
|
||||
|
||||
async function startSession(tools: Map<string, ToolDefinition>, sessionId = "s1") {
|
||||
const tool = tools.get("browser_session_start");
|
||||
if (tool === undefined) throw new Error("browser_session_start not registered");
|
||||
const tool = tools.get("session.start");
|
||||
if (tool === undefined) throw new Error("session.start not registered");
|
||||
return tool.execute({}, makeExec()) as Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
const START_REPLY = (id: string) => ({ session_id: id, browser_instance_id: "chrome-1" });
|
||||
|
||||
const EXPECTED_ACTIONS = {
|
||||
browser_session: ["start", "stop", "list"],
|
||||
browser_page: ["navigate", "back", "forward", "reload", "wait"],
|
||||
browser_inspect: ["observe", "snapshot", "html", "screenshot", "console", "network"],
|
||||
browser_interact: ["click", "hover", "fill", "select", "press"],
|
||||
browser_tabs: ["list", "create", "select", "close", "borrow", "return"],
|
||||
browser_assist: ["resize", "emulate", "request-help"],
|
||||
} as const;
|
||||
|
||||
describe("tool registration", () => {
|
||||
it("registers the full browser tool suite", () => {
|
||||
it("registers exactly six public schemas with the complete action contract", () => {
|
||||
const { tools } = setup({});
|
||||
expect([...tools.keys()].sort()).toEqual([
|
||||
"browser_click",
|
||||
"browser_console",
|
||||
"browser_emulate",
|
||||
"browser_fill",
|
||||
"browser_get_html",
|
||||
"browser_hover",
|
||||
"browser_navigate",
|
||||
"browser_navigate_back",
|
||||
"browser_navigate_forward",
|
||||
"browser_network",
|
||||
"browser_observe",
|
||||
"browser_press",
|
||||
"browser_reload",
|
||||
"browser_request_help",
|
||||
"browser_screenshot",
|
||||
"browser_select",
|
||||
"browser_session_list",
|
||||
"browser_session_start",
|
||||
"browser_session_stop",
|
||||
"browser_snapshot",
|
||||
"browser_tab_borrow",
|
||||
"browser_tab_close",
|
||||
"browser_tab_create",
|
||||
"browser_tab_list",
|
||||
"browser_tab_return",
|
||||
"browser_tab_select",
|
||||
"browser_wait_for_navigation",
|
||||
"browser_window_resize",
|
||||
]);
|
||||
expect([...tools.keys()].sort()).toEqual(Object.keys(EXPECTED_ACTIONS).sort());
|
||||
for (const [name, actions] of Object.entries(EXPECTED_ACTIONS)) {
|
||||
const properties = tools.get(name)?.parameters.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(properties?.action).toMatchObject({ enum: [...actions] });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an action on every public tool", async () => {
|
||||
const { tools } = setup({});
|
||||
for (const name of Object.keys(EXPECTED_ACTIONS)) {
|
||||
await expect(tools.get(name)?.execute({}, makeExec())).rejects.toThrow(/invalid arguments/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates model args through defineTool (missing required param)", async () => {
|
||||
const { tools } = setup({});
|
||||
const navigate = tools.get("browser_navigate");
|
||||
const navigate = tools.get("page.navigate");
|
||||
await expect(navigate?.execute({}, makeExec())).rejects.toThrow(/invalid arguments/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser_session_start", () => {
|
||||
describe("action dispatch", () => {
|
||||
it("rejects actions outside the public contract", async () => {
|
||||
const { tools } = setup({});
|
||||
await expect(
|
||||
tools.get("browser_page")?.execute({ action: "unsupported" }, makeExec()),
|
||||
).rejects.toThrow(/invalid arguments|unsupported browser action/i);
|
||||
});
|
||||
|
||||
it("dispatches through the existing operation definitions", async () => {
|
||||
const { tools, calls, registry } = setup({
|
||||
"session start": START_REPLY("s1"),
|
||||
navigate: { tab_id: 7, url: "https://example.test/", reached: "load" },
|
||||
observe: SNAPSHOT_REPLY,
|
||||
fill: { tab_id: 7, value_length: 5 },
|
||||
"tab list": { tabs: [] },
|
||||
"window resize": { window_id: 9, width: 900, height: 700 },
|
||||
});
|
||||
await tools.get("browser_session")?.execute({ action: "start" }, makeExec());
|
||||
await tools
|
||||
.get("browser_page")
|
||||
?.execute({ action: "navigate", url: "https://example.test/" }, makeExec());
|
||||
await tools.get("browser_inspect")?.execute({ action: "observe" }, makeExec());
|
||||
await tools
|
||||
.get("browser_interact")
|
||||
?.execute({ action: "fill", target: "@e1", value: "hello" }, makeExec());
|
||||
await tools.get("browser_tabs")?.execute({ action: "list" }, makeExec());
|
||||
await tools
|
||||
.get("browser_assist")
|
||||
?.execute({ action: "resize", width: 900, height: 700 }, makeExec());
|
||||
|
||||
expect(registry.current()).toBe("s1");
|
||||
expect(calls.map(({ args }) => args)).toEqual([
|
||||
["session", "start"],
|
||||
["navigate", "--session", "s1", "https://example.test/"],
|
||||
["observe", "--session", "s1"],
|
||||
["fill", "--session", "s1", "--value", "hello", "@e1"],
|
||||
["tab", "list", "--session", "s1"],
|
||||
["window", "resize", "--session", "s1", "--width", "900", "--height", "700"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps action-specific validation in the private operation", async () => {
|
||||
const { tools } = setup({});
|
||||
await expect(
|
||||
tools.get("browser_page")?.execute({ action: "navigate" }, makeExec()),
|
||||
).rejects.toThrow(/url.*required|invalid arguments/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.start", () => {
|
||||
it("maps the start reply and tracks the session as current", async () => {
|
||||
const { tools, registry } = setup({ "session start": START_REPLY("s1") });
|
||||
const value = await startSession(tools);
|
||||
@@ -193,7 +294,7 @@ describe("browser_session_start", () => {
|
||||
emulate: { tab_id: 7, cleared: false },
|
||||
navigate: { tab_id: 7, url: "https://example.com", reached: "load" },
|
||||
});
|
||||
const tool = tools.get("browser_session_start");
|
||||
const tool = tools.get("session.start");
|
||||
const value = (await tool?.execute(
|
||||
{ width: 1280, height: 800, url: "https://example.com", device: "iphone-14", noFocus: true },
|
||||
makeExec(),
|
||||
@@ -218,7 +319,7 @@ describe("browser_session_start", () => {
|
||||
|
||||
it("requires width and height together", async () => {
|
||||
const { tools } = setup({ "session start": START_REPLY("s1") });
|
||||
const tool = tools.get("browser_session_start");
|
||||
const tool = tools.get("session.start");
|
||||
await expect(tool?.execute({ width: 1280 }, makeExec())).rejects.toThrow(/together/);
|
||||
});
|
||||
|
||||
@@ -228,7 +329,7 @@ describe("browser_session_start", () => {
|
||||
"session stop": {},
|
||||
});
|
||||
// navigate has no canned response -> non-zero exit -> error.
|
||||
const tool = tools.get("browser_session_start");
|
||||
const tool = tools.get("session.start");
|
||||
await expect(tool?.execute({ url: "https://example.com" }, makeExec())).rejects.toThrow(
|
||||
/navigate/,
|
||||
);
|
||||
@@ -246,7 +347,7 @@ describe("multi-session behavior", () => {
|
||||
it("operation tools default to the current session and echo it back", async () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
const value = (await snapshot?.execute({}, makeExec())) as {
|
||||
session: string;
|
||||
refCount: number;
|
||||
@@ -262,7 +363,7 @@ describe("multi-session behavior", () => {
|
||||
});
|
||||
await startSession(tools, "s1");
|
||||
await startSession(tools, "s2");
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
const value = (await snapshot?.execute({ session: "s1" }, makeExec())) as {
|
||||
session: string;
|
||||
};
|
||||
@@ -275,7 +376,7 @@ describe("multi-session behavior", () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const callsBefore = calls.length;
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
await expect(snapshot?.execute({ session: "other" }, makeExec())).rejects.toThrow(
|
||||
/does not belong to this plugin/,
|
||||
);
|
||||
@@ -284,8 +385,8 @@ describe("multi-session behavior", () => {
|
||||
|
||||
it("fails with guidance when no session exists", async () => {
|
||||
const { tools } = setup(responses);
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/browser_session_start/);
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/browser_session action=start/);
|
||||
});
|
||||
|
||||
it("enforces the configured session cap before spawning", async () => {
|
||||
@@ -295,7 +396,7 @@ describe("multi-session behavior", () => {
|
||||
1,
|
||||
);
|
||||
await startSession(tools);
|
||||
const start = tools.get("browser_session_start");
|
||||
const start = tools.get("session.start");
|
||||
await expect(start?.execute({}, makeExec())).rejects.toThrow(/session limit/);
|
||||
// A rejected start must not spawn another bsk session (no leaked session).
|
||||
expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1);
|
||||
@@ -307,7 +408,7 @@ describe("multi-session behavior", () => {
|
||||
{},
|
||||
1,
|
||||
);
|
||||
const start = tools.get("browser_session_start");
|
||||
const start = tools.get("session.start");
|
||||
if (start === undefined) throw new Error("not registered");
|
||||
// Two truly concurrent starts against a cap of 1: exactly one may spawn.
|
||||
const outcomes = await Promise.allSettled([
|
||||
@@ -325,7 +426,7 @@ describe("multi-session behavior", () => {
|
||||
it("refuses to stop a session the plugin did not create", async () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const stop = tools.get("browser_session_stop");
|
||||
const stop = tools.get("session.stop");
|
||||
await expect(stop?.execute({ session: "ext7" }, makeExec())).rejects.toThrow(
|
||||
/does not belong to this plugin/,
|
||||
);
|
||||
@@ -334,14 +435,14 @@ describe("multi-session behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser_session_stop / list", () => {
|
||||
describe("session.stop / list", () => {
|
||||
it("stops the current session and clears the registry", async () => {
|
||||
const { tools, calls, registry } = setup({
|
||||
"session start": START_REPLY("s1"),
|
||||
"session stop": {},
|
||||
});
|
||||
await startSession(tools);
|
||||
const stop = tools.get("browser_session_stop");
|
||||
const stop = tools.get("session.stop");
|
||||
const value = (await stop?.execute({}, makeExec())) as { stopped: string };
|
||||
expect(value.stopped).toBe("s1");
|
||||
expect(calls[1].args).toEqual(["session", "stop", "s1"]);
|
||||
@@ -354,7 +455,7 @@ describe("browser_session_stop / list", () => {
|
||||
});
|
||||
await startSession(tools);
|
||||
await startSession(tools);
|
||||
const list = tools.get("browser_session_list");
|
||||
const list = tools.get("session.list");
|
||||
const value = (await list?.execute({}, makeExec())) as {
|
||||
sessions: { sessionId: string; current: boolean }[];
|
||||
};
|
||||
@@ -377,12 +478,12 @@ describe("interaction tools", () => {
|
||||
emulate: { tab_id: 7, cleared: false },
|
||||
};
|
||||
|
||||
it("browser_click maps target, button, and click count", async () => {
|
||||
it("interact.click maps target, button, click count, and modifiers", async () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const click = tools.get("browser_click");
|
||||
const click = tools.get("interact.click");
|
||||
const value = (await click?.execute(
|
||||
{ target: "@e1", button: "right", clickCount: 2 },
|
||||
{ target: "@e1", button: "right", clickCount: 2, modifiers: ["ctrl", "shift"] },
|
||||
makeExec(),
|
||||
)) as { session: string; x: number; y: number };
|
||||
expect(value).toMatchObject({ session: "s1", x: 10, y: 20 });
|
||||
@@ -394,14 +495,51 @@ describe("interaction tools", () => {
|
||||
"right",
|
||||
"--click-count",
|
||||
"2",
|
||||
"--modifiers",
|
||||
"ctrl,shift",
|
||||
"@e1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("browser_fill passes value and --no-clear", async () => {
|
||||
it("interact.click forwards a complete Surface capture coordinate transaction", async () => {
|
||||
const { tools, calls } = setup({
|
||||
...responses,
|
||||
click: {
|
||||
tab_id: 7,
|
||||
used_ref: "e1",
|
||||
x: 110,
|
||||
y: 70,
|
||||
capture_id: "sc_test",
|
||||
image_x: 200,
|
||||
image_y: 100,
|
||||
},
|
||||
});
|
||||
await startSession(tools);
|
||||
const click = tools.get("interact.click");
|
||||
const value = await click?.execute(
|
||||
{ target: "@e1", captureId: "sc_test", imageX: 200, imageY: 100 },
|
||||
makeExec(),
|
||||
);
|
||||
|
||||
expect(value).toMatchObject({ captureId: "sc_test", x: 110, y: 70 });
|
||||
expect(calls[1].args).toEqual([
|
||||
"click",
|
||||
"--session",
|
||||
"s1",
|
||||
"--capture",
|
||||
"sc_test",
|
||||
"--image-x",
|
||||
"200",
|
||||
"--image-y",
|
||||
"100",
|
||||
"@e1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("interact.fill passes value and --no-clear", async () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const fill = tools.get("browser_fill");
|
||||
const fill = tools.get("interact.fill");
|
||||
await fill?.execute({ target: "e2", value: "hello", noClear: true }, makeExec());
|
||||
expect(calls[1].args).toEqual([
|
||||
"fill",
|
||||
@@ -414,20 +552,20 @@ describe("interaction tools", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("browser_press picks --ref for snapshot refs and --selector otherwise", async () => {
|
||||
it("interact.press picks --ref for snapshot refs and --selector otherwise", async () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const press = tools.get("browser_press");
|
||||
const press = tools.get("interact.press");
|
||||
await press?.execute({ key: "Enter", target: "@e3" }, makeExec());
|
||||
expect(calls[1].args).toEqual(["press", "--session", "s1", "--ref", "@e3", "Enter"]);
|
||||
await press?.execute({ key: "Ctrl+A", target: "#field" }, makeExec());
|
||||
expect(calls[2].args).toEqual(["press", "--session", "s1", "--selector", "#field", "Ctrl+A"]);
|
||||
});
|
||||
|
||||
it("browser_navigate converts waitUntil and timeoutMs", async () => {
|
||||
it("page.navigate converts waitUntil and timeoutMs", async () => {
|
||||
const { tools, calls } = setup(responses);
|
||||
await startSession(tools);
|
||||
const navigate = tools.get("browser_navigate");
|
||||
const navigate = tools.get("page.navigate");
|
||||
const value = (await navigate?.execute(
|
||||
{ url: "https://a.test", waitUntil: "networkidle", timeoutMs: 1500 },
|
||||
makeExec(),
|
||||
@@ -445,16 +583,16 @@ describe("interaction tools", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("browser_emulate rejects off combined with overrides", async () => {
|
||||
it("assist.emulate rejects off combined with overrides", async () => {
|
||||
const { tools } = setup(responses);
|
||||
await startSession(tools);
|
||||
const emulate = tools.get("browser_emulate");
|
||||
const emulate = tools.get("assist.emulate");
|
||||
await expect(emulate?.execute({ off: true, device: "iphone-14" }, makeExec())).rejects.toThrow(
|
||||
/mutually exclusive/,
|
||||
);
|
||||
});
|
||||
|
||||
it("browser_hover and browser_select map their structured arguments", async () => {
|
||||
it("interact.hover and interact.select map their structured arguments", async () => {
|
||||
const { tools, calls } = setup({
|
||||
"session start": START_REPLY("s1"),
|
||||
hover: { tab_id: 7, used_ref: "@e3", x: 12, y: 24 },
|
||||
@@ -468,7 +606,7 @@ describe("interaction tools", () => {
|
||||
});
|
||||
await startSession(tools);
|
||||
const hovered = (await tools
|
||||
.get("browser_hover")
|
||||
.get("interact.hover")
|
||||
?.execute(
|
||||
{ target: "@e3", modifiers: ["shift"], settleMs: 250, timeoutMs: 2000 },
|
||||
makeExec(),
|
||||
@@ -488,7 +626,7 @@ describe("interaction tools", () => {
|
||||
]);
|
||||
|
||||
const selected = (await tools
|
||||
.get("browser_select")
|
||||
.get("interact.select")
|
||||
?.execute({ target: "@e4", values: ["a", "b"], tabId: 7 }, makeExec())) as {
|
||||
selectedValues: string[];
|
||||
selectedLabels: string[];
|
||||
@@ -546,25 +684,25 @@ describe("phase-one tab tools", () => {
|
||||
});
|
||||
await startSession(tools);
|
||||
|
||||
const listed = (await tools
|
||||
.get("browser_tab_list")
|
||||
?.execute({ scope: "agent" }, makeExec())) as { tabs: { tabId: number }[] };
|
||||
const listed = (await tools.get("tabs.list")?.execute({ scope: "agent" }, makeExec())) as {
|
||||
tabs: { tabId: number }[];
|
||||
};
|
||||
expect(listed.tabs[0].tabId).toBe(7);
|
||||
const created = (await tools
|
||||
.get("browser_tab_create")
|
||||
.get("tabs.create")
|
||||
?.execute({ url: "https://new.test", active: false, index: 1 }, makeExec())) as {
|
||||
tabId: number;
|
||||
};
|
||||
expect(created.tabId).toBe(8);
|
||||
await tools.get("browser_tab_close")?.execute({ tabId: 8 }, makeExec());
|
||||
await tools.get("browser_tab_select")?.execute({ tabId: 7 }, makeExec());
|
||||
const borrowed = (await tools
|
||||
.get("browser_tab_borrow")
|
||||
?.execute({ tabId: 20 }, makeExec())) as { originalWindowId: number };
|
||||
await tools.get("tabs.close")?.execute({ tabId: 8 }, makeExec());
|
||||
await tools.get("tabs.select")?.execute({ tabId: 7 }, makeExec());
|
||||
const borrowed = (await tools.get("tabs.borrow")?.execute({ tabId: 20 }, makeExec())) as {
|
||||
originalWindowId: number;
|
||||
};
|
||||
expect(borrowed.originalWindowId).toBe(9);
|
||||
const returned = (await tools
|
||||
.get("browser_tab_return")
|
||||
?.execute({ tabId: 20 }, makeExec())) as { fallback: boolean };
|
||||
const returned = (await tools.get("tabs.return")?.execute({ tabId: 20 }, makeExec())) as {
|
||||
fallback: boolean;
|
||||
};
|
||||
expect(returned.fallback).toBe(false);
|
||||
|
||||
expect(calls.slice(1).map((call) => call.args)).toEqual([
|
||||
@@ -614,15 +752,14 @@ describe("phase-one navigation tools", () => {
|
||||
});
|
||||
await startSession(tools);
|
||||
await tools
|
||||
.get("browser_navigate_back")
|
||||
.get("page.back")
|
||||
?.execute({ tabId: 7, waitUntil: "commit", timeoutMs: 1000 }, makeExec());
|
||||
await tools.get("browser_navigate_forward")?.execute({}, makeExec());
|
||||
await tools
|
||||
.get("browser_reload")
|
||||
?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
|
||||
const waited = (await tools
|
||||
.get("browser_wait_for_navigation")
|
||||
?.execute({ timeoutMs: 2500 }, makeExec())) as { reached: string; errorText: string };
|
||||
await tools.get("page.forward")?.execute({}, makeExec());
|
||||
await tools.get("page.reload")?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
|
||||
const waited = (await tools.get("page.wait")?.execute({ timeoutMs: 2500 }, makeExec())) as {
|
||||
reached: string;
|
||||
errorText: string;
|
||||
};
|
||||
expect(waited).toMatchObject({ reached: "timeout", errorText: "no navigation" });
|
||||
expect(calls[1].args).toEqual([
|
||||
"navigate-back",
|
||||
@@ -668,7 +805,7 @@ describe("phase-one support tools", () => {
|
||||
},
|
||||
});
|
||||
await startSession(tools);
|
||||
const value = (await tools.get("browser_request_help")?.execute(
|
||||
const value = (await tools.get("assist.request-help")?.execute(
|
||||
{
|
||||
prompt: "Please sign in",
|
||||
title: "Login required",
|
||||
@@ -736,17 +873,17 @@ describe("phase-one support tools", () => {
|
||||
});
|
||||
await startSession(tools);
|
||||
const html = (await tools
|
||||
.get("browser_get_html")
|
||||
.get("inspect.html")
|
||||
?.execute({ ref: "@e2", maxBytes: 2048 }, makeExec())) as { html: string };
|
||||
expect(html.html).toBe("<main>Hi</main>");
|
||||
const consoleValue = (await tools
|
||||
.get("browser_console")
|
||||
.get("inspect.console")
|
||||
?.execute({ since: 1, limit: 10, includeStack: true }, makeExec())) as {
|
||||
entries: { stackTrace: { functionName: string }[] }[];
|
||||
};
|
||||
expect(consoleValue.entries[0].stackTrace[0].functionName).toBe("run");
|
||||
const networkValue = (await tools
|
||||
.get("browser_network")
|
||||
.get("inspect.network")
|
||||
?.execute({ tabId: 7, maxTextChars: 512 }, makeExec())) as {
|
||||
entries: { mimeType: string }[];
|
||||
};
|
||||
@@ -764,7 +901,7 @@ describe("phase-one support tools", () => {
|
||||
"window resize": { window_id: 2, width: 1280, height: 800 },
|
||||
});
|
||||
await startSession(tools);
|
||||
const resize = tools.get("browser_window_resize");
|
||||
const resize = tools.get("assist.resize");
|
||||
const value = (await resize?.execute({ width: 1280, height: 800 }, makeExec())) as {
|
||||
windowId: number;
|
||||
};
|
||||
@@ -785,7 +922,7 @@ describe("phase-one support tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser_screenshot", () => {
|
||||
describe("inspect.screenshot", () => {
|
||||
function pngFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bsk-test-"));
|
||||
const path = join(dir, "shot.png");
|
||||
@@ -794,6 +931,15 @@ describe("browser_screenshot", () => {
|
||||
return path;
|
||||
}
|
||||
|
||||
function jpegFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bsk-test-"));
|
||||
const path = join(dir, "shot.png");
|
||||
// A JPEG SOI marker (FF D8 FF) even though the file is named ".png" —
|
||||
// mirrors captureVisibleTab returning JPEG on some Chromium builds.
|
||||
writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0, 1, 2, 3, 4]));
|
||||
return path;
|
||||
}
|
||||
|
||||
it("returns the PNG path when no attachment store is mounted", async () => {
|
||||
const path = pngFile();
|
||||
const { tools, calls } = setup({
|
||||
@@ -801,7 +947,7 @@ describe("browser_screenshot", () => {
|
||||
screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 8 },
|
||||
});
|
||||
await startSession(tools);
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = (await screenshot?.execute({ ref: "@e1" }, makeExec())) as {
|
||||
path: string;
|
||||
image?: unknown;
|
||||
@@ -814,6 +960,32 @@ describe("browser_screenshot", () => {
|
||||
expect(rendered?.every((block) => block.type === "text")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns Surface capture metadata alongside the screenshot", async () => {
|
||||
const path = pngFile();
|
||||
const { tools } = setup({
|
||||
"session start": START_REPLY("s1"),
|
||||
screenshot: {
|
||||
tab_id: 7,
|
||||
width: 800,
|
||||
height: 600,
|
||||
format: "png",
|
||||
path,
|
||||
byte_size: 8,
|
||||
capture: { id: "sc_test", expires_at: 1700000030000 },
|
||||
},
|
||||
});
|
||||
await startSession(tools);
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = await screenshot?.execute({ ref: "@e1" }, makeExec());
|
||||
|
||||
expect(value).toMatchObject({
|
||||
captureId: "sc_test",
|
||||
captureExpiresAt: 1700000030000,
|
||||
});
|
||||
const rendered = screenshot?.output.render({ session: "s1" }, value as never);
|
||||
expect(rendered?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("sc_test") });
|
||||
});
|
||||
|
||||
it("commits the image through the attachment store on an image-capable route", async () => {
|
||||
const path = pngFile();
|
||||
const services = {
|
||||
@@ -850,7 +1022,7 @@ describe("browser_screenshot", () => {
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = (await screenshot?.execute({}, exec)) as {
|
||||
image?: { attachmentId: string; mediaType: string };
|
||||
};
|
||||
@@ -859,6 +1031,50 @@ describe("browser_screenshot", () => {
|
||||
expect(rendered.map((block) => block.type)).toEqual(["text", "image"]);
|
||||
});
|
||||
|
||||
it("declares the real media type when capture bytes are JPEG, not a hard-coded PNG", async () => {
|
||||
const path = jpegFile();
|
||||
const services = {
|
||||
attachments: {
|
||||
imageLimits: {
|
||||
mediaTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
||||
maxImageBytes: 10_000_000,
|
||||
maxMessageImageBytes: 10_000_000,
|
||||
},
|
||||
saveImage: async (input: { data: Uint8Array; mediaType: string; name?: string }) => ({
|
||||
attachmentId: "att-jpeg",
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 800,
|
||||
height: 600,
|
||||
name: input.name,
|
||||
}),
|
||||
},
|
||||
llm: {
|
||||
resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }),
|
||||
},
|
||||
};
|
||||
const { tools } = setup(
|
||||
{
|
||||
"session start": START_REPLY("s1"),
|
||||
screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 9 },
|
||||
},
|
||||
services,
|
||||
);
|
||||
await startSession(tools);
|
||||
const exec = makeExec({
|
||||
agent: {
|
||||
session: { requestHeader: () => ({ config: { provider: "deepseek", model: "vl" } }) },
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = (await screenshot?.execute({}, exec)) as {
|
||||
image?: { attachmentId: string; mediaType: string };
|
||||
};
|
||||
// The reference media type must reflect the sniffed JPEG bytes, not "image/png".
|
||||
expect(value.image).toMatchObject({ attachmentId: "att-jpeg", mediaType: "image/jpeg" });
|
||||
});
|
||||
|
||||
it("stays path-only when the model route is text-only", async () => {
|
||||
const path = pngFile();
|
||||
const services = {
|
||||
@@ -884,7 +1100,7 @@ describe("browser_screenshot", () => {
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string };
|
||||
expect(value.image).toBeUndefined();
|
||||
expect(value.path).toBe(path);
|
||||
@@ -897,14 +1113,14 @@ describe("error and cancellation semantics", () => {
|
||||
"session start": START_REPLY("s1"),
|
||||
});
|
||||
await startSession(tools);
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/no canned response/);
|
||||
});
|
||||
|
||||
it("turns an aborted child into an AbortError", async () => {
|
||||
const { tools } = setup({ "session start": START_REPLY("s1") });
|
||||
await startSession(tools);
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const exec = makeExec({ signal: controller.signal });
|
||||
@@ -921,7 +1137,7 @@ describe("error and cancellation semantics", () => {
|
||||
killAll() {},
|
||||
killFor: () => 0,
|
||||
};
|
||||
registerTools({
|
||||
registerBrowserTools({
|
||||
ctx: ctx as never,
|
||||
runner,
|
||||
registry,
|
||||
@@ -929,7 +1145,7 @@ describe("error and cancellation semantics", () => {
|
||||
observation: disabledObservation({ ctx, runner, registry }),
|
||||
queue: new KeyedExecutor(),
|
||||
});
|
||||
const start = tools.get("browser_session_start");
|
||||
const start = tools.get("session.start");
|
||||
await expect(start?.execute({}, makeExec())).rejects.toThrow(/BrowserSkill must be installed/);
|
||||
});
|
||||
});
|
||||
@@ -937,7 +1153,7 @@ describe("error and cancellation semantics", () => {
|
||||
describe("presentation", () => {
|
||||
it("presents calls as terminal cards with the bsk command line", () => {
|
||||
const { tools } = setup({});
|
||||
const click = tools.get("browser_click");
|
||||
const click = tools.get("interact.click");
|
||||
const view = click?.presentCall?.({ target: "@e1" }) as {
|
||||
card: string;
|
||||
title: string;
|
||||
@@ -948,7 +1164,7 @@ describe("presentation", () => {
|
||||
|
||||
it("presents results as terminal cards carrying the rendered text", () => {
|
||||
const { tools } = setup({});
|
||||
const click = tools.get("browser_click");
|
||||
const click = tools.get("interact.click");
|
||||
const view = click?.presentResult?.({ target: "@e1" }, {
|
||||
content: [{ type: "text", text: "clicked" }],
|
||||
isError: false,
|
||||
@@ -958,7 +1174,7 @@ describe("presentation", () => {
|
||||
|
||||
it("projects the text block of multi-block results (screenshot text + image)", () => {
|
||||
const { tools } = setup({});
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const view = screenshot?.presentResult?.({}, {
|
||||
content: [
|
||||
{ type: "text", text: "[session s1] screenshot of tab 7" },
|
||||
@@ -1017,7 +1233,7 @@ describe("screenshot scratch file lifecycle", () => {
|
||||
function setupWithRunner(runner: BskRunner, services: Record<string, unknown> = {}) {
|
||||
const { ctx, tools } = makeCtx(services);
|
||||
const registry = new SessionRegistry(5);
|
||||
registerTools({
|
||||
registerBrowserTools({
|
||||
ctx: ctx as never,
|
||||
runner,
|
||||
registry,
|
||||
@@ -1052,7 +1268,7 @@ describe("screenshot scratch file lifecycle", () => {
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string };
|
||||
expect(value.image).toBeDefined();
|
||||
const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1);
|
||||
@@ -1064,7 +1280,7 @@ describe("screenshot scratch file lifecycle", () => {
|
||||
const { runner, calls } = outWritingRunner();
|
||||
const { tools } = setupWithRunner(runner);
|
||||
await startSession(tools);
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const screenshot = tools.get("inspect.screenshot");
|
||||
const value = (await screenshot?.execute({}, makeExec())) as { image?: unknown; path: string };
|
||||
expect(value.image).toBeUndefined();
|
||||
const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1);
|
||||
@@ -1139,11 +1355,18 @@ describe("observation action instrumentation timing", () => {
|
||||
queue,
|
||||
options: { enabled: true, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 },
|
||||
});
|
||||
registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation, queue });
|
||||
registerBrowserTools({
|
||||
ctx: ctx as never,
|
||||
runner,
|
||||
registry,
|
||||
config: CONFIG,
|
||||
observation,
|
||||
queue,
|
||||
});
|
||||
|
||||
await startSession(tools);
|
||||
const navigate = tools.get("browser_navigate");
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const navigate = tools.get("page.navigate");
|
||||
const snapshot = tools.get("inspect.snapshot");
|
||||
const a = navigate?.execute({ url: "http://x" }, makeExec()) as Promise<unknown>;
|
||||
// A starts immediately (empty queue) and blocks on the gate.
|
||||
await vi.waitFor(() =>
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"version_skew": "Protocol mismatch",
|
||||
"version_skew": "Connected",
|
||||
"disabled": "Connection off"
|
||||
},
|
||||
"stateDetail": {
|
||||
"disconnected": "Open BrowserSkill to connect.",
|
||||
"connecting": "Connecting to BrowserSkill…",
|
||||
"connected": "Ready for automation tasks.",
|
||||
"version_skew": "Some features may be unavailable."
|
||||
"version_skew": "Protocol versions differ. Please upgrade."
|
||||
},
|
||||
"stateBadge": {
|
||||
"disconnected": "Standby",
|
||||
"connecting": "Igniting",
|
||||
"connected": "Ready",
|
||||
"version_skew": "Action needed",
|
||||
"version_skew": "Upgradable",
|
||||
"disabled": "Off"
|
||||
},
|
||||
"connectionToggleTitle": "BrowserSkill connection",
|
||||
"controlHintsToggleTitle": "Control hints",
|
||||
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
|
||||
"controlHintsInfoLabel": "About control hints",
|
||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, daemon v{{daemonProtocol}}.",
|
||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, CLI protocol v{{cliProtocol}}. Protocol versions differ — please upgrade.",
|
||||
"upgradeAvailable": "Upgradable",
|
||||
"launcher": {
|
||||
"title": "Quick actions"
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
"disconnected": "未连接",
|
||||
"connecting": "连接中…",
|
||||
"connected": "已连接",
|
||||
"version_skew": "协议不一致",
|
||||
"version_skew": "已连接",
|
||||
"disabled": "连接已关闭"
|
||||
},
|
||||
"stateDetail": {
|
||||
"disconnected": "请先打开 BrowserSkill。",
|
||||
"connecting": "正在连接 BrowserSkill…",
|
||||
"connected": "可接收自动化任务。",
|
||||
"version_skew": "部分能力可能不可用。"
|
||||
"version_skew": "协议版本不同,请及时升级。"
|
||||
},
|
||||
"stateBadge": {
|
||||
"disconnected": "Standby",
|
||||
"connecting": "Igniting",
|
||||
"connected": "Ready",
|
||||
"version_skew": "Action needed",
|
||||
"version_skew": "可升级",
|
||||
"disabled": "Off"
|
||||
},
|
||||
"connectionToggleTitle": "BrowserSkill 连接",
|
||||
"controlHintsToggleTitle": "控制提示",
|
||||
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
|
||||
"controlHintsInfoLabel": "控制提示说明",
|
||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。",
|
||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},CLI 协议 v{{cliProtocol}}。协议版本不同,请及时升级。",
|
||||
"upgradeAvailable": "可升级",
|
||||
"launcher": {
|
||||
"title": "快捷功能"
|
||||
|
||||
+15
-7
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
||||
## Prerequisites
|
||||
|
||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||
|
||||
## Mandatory workflow
|
||||
@@ -80,7 +80,15 @@ Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` /
|
||||
|
||||
When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover <that-ref> --session <id>`, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action.
|
||||
|
||||
When `bsk observe` renders `@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered canvas content that is not represented by the text observation, not an interactable DOM control. Keep using any reliable DOM/AX text and controls that appear alongside it. If you can actually inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop. If you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its contents, and never guess coordinates; tell the user that they need to switch to a model with image-understanding capability. Do not pass a visual surface ref to click, fill, hover, or select; those commands intentionally reject screenshot-only refs.
|
||||
When `bsk observe` renders `@eN surface ... [visual-only; requires=image-understanding; ...]`, the ref identifies rendered canvas content that is not represented by the text observation, not an interactable DOM control. Keep using any reliable DOM/AX text and controls that appear alongside it. If you lack multimodal or image-reading capability, do not take a screenshot and pretend to know its contents, and never guess coordinates; tell the user that they need to switch to a model with image-understanding capability.
|
||||
|
||||
If you can actually inspect image output, use `bsk screenshot --ref @eN --session <id>` to obtain the visible crop and its short-lived, single-use Surface capture id. Only when the task requires a point action and the image provides a clear target, bind that exact screenshot to the same general click command:
|
||||
|
||||
```bash
|
||||
bsk click @eN --capture <capture-id> --image-x <px> --image-y <px> --session <id>
|
||||
```
|
||||
|
||||
Coordinates are pixels in the returned capture image, not viewport CSS coordinates. Re-observing, navigating, scrolling, resizing, zooming, changing Frame projection, expiry, or reusing the capture makes the action fail; take a new Surface screenshot instead of adjusting or retrying coordinates. A point click does not reveal Canvas semantics and must not be followed by an assumed fill/press workflow. Without all three capture arguments, visual Surface refs remain screenshot-only and click/fill/hover/select reject them.
|
||||
|
||||
## Observation priority
|
||||
|
||||
@@ -176,7 +184,7 @@ bsk emulate --session <id> --off
|
||||
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
|
||||
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
|
||||
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
|
||||
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
|
||||
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional); Surface crops return a short-lived capture id |
|
||||
|
||||
### Console & network debugging (read-only; require `--session`)
|
||||
|
||||
@@ -202,7 +210,7 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk click <ref-or-selector>` | Click element (`--button`, `--click-count`, `--modifiers`) |
|
||||
| `bsk click <ref-or-selector>` | Click a DOM element (`--button`, `--click-count`, `--modifiers`), or perform one screenshot-bound Surface point click with `--capture`, `--image-x`, and `--image-y` |
|
||||
| `bsk hover <ref-or-selector>` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) |
|
||||
| `bsk fill <ref-or-selector> --value <text>` | Clear and type into input |
|
||||
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
|
||||
@@ -212,7 +220,7 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines); JS throw → stderr, **exit 0** |
|
||||
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
|
||||
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
|
||||
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
|
||||
|
||||
@@ -296,12 +304,12 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|------|---------|------------|
|
||||
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue |
|
||||
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue; for `evaluate`, check `--json` `.ok` to distinguish JS success from RPC success |
|
||||
| `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot |
|
||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
|
||||
|
||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user