mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
//! Shared JavaScript dialog observability types.
|
||||
//!
|
||||
//! Mirrors CDP `Page.javascriptDialogOpening` / `Page.handleJavaScriptDialog`.
|
||||
//! Dialogs are surfaced as in-band data on tool results — not RPC errors.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Native JS dialog kind reported by CDP.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JavaScriptDialogType {
|
||||
Alert,
|
||||
Confirm,
|
||||
Prompt,
|
||||
#[serde(rename = "beforeunload")]
|
||||
BeforeUnload,
|
||||
}
|
||||
|
||||
/// How the extension resolved the dialog so CDP could continue.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JavaScriptDialogHandledAction {
|
||||
Accepted,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
impl JavaScriptDialogType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Alert => "alert",
|
||||
Self::Confirm => "confirm",
|
||||
Self::Prompt => "prompt",
|
||||
Self::BeforeUnload => "beforeunload",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaScriptDialogHandledAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accepted => "accepted",
|
||||
Self::Dismissed => "dismissed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One observed + handled JavaScript dialog during a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct JavaScriptDialogInfo {
|
||||
pub tab_id: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub dialog_type: JavaScriptDialogType,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub has_browser_handler: Option<bool>,
|
||||
pub handled: JavaScriptDialogHandledAction,
|
||||
/// Monotonic per-tab sequence for ordering within a session.
|
||||
pub sequence: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dialog_info_round_trips() {
|
||||
let info = JavaScriptDialogInfo {
|
||||
tab_id: 4,
|
||||
dialog_type: JavaScriptDialogType::Alert,
|
||||
message: "hello".into(),
|
||||
url: Some("https://example.com/".into()),
|
||||
default_prompt: None,
|
||||
has_browser_handler: Some(false),
|
||||
handled: JavaScriptDialogHandledAction::Accepted,
|
||||
sequence: 1,
|
||||
};
|
||||
let v = serde_json::to_value(&info).unwrap();
|
||||
assert_eq!(v["type"], json!("alert"));
|
||||
assert_eq!(v["handled"], json!("accepted"));
|
||||
let round: JavaScriptDialogInfo = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beforeunload_serialises_as_beforeunload() {
|
||||
let v = serde_json::to_value(JavaScriptDialogType::BeforeUnload).unwrap();
|
||||
assert_eq!(v, json!("beforeunload"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Human-in-loop tool (`tool.request_help`).
|
||||
//!
|
||||
//! Lets an agent pause and ask the human to complete an in-page step
|
||||
//! (captcha, login, confirmation). The extension brings the target tab
|
||||
//! to the foreground, highlights the requested components, and blocks
|
||||
//! until the user clicks Continue / Cancel (or the call times out).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One component the user is asked to look at / interact with. Exactly
|
||||
/// one of `ref` / `selector` should be supplied (mirrors the
|
||||
/// click/fill convention). When neither matches a live element the
|
||||
/// overlay still shows the prompt and reports the miss via
|
||||
/// [`ResolvedTarget`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct HelpTarget {
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct RequestHelpParams {
|
||||
pub session_id: String,
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Message shown to the user explaining what to do.
|
||||
pub prompt: String,
|
||||
/// Optional custom title for the help overlay. Falls back to the
|
||||
/// extension's default localized title when omitted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
/// Optional components to scroll to + flash-highlight.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub targets: Option<Vec<HelpTarget>>,
|
||||
/// Hard upper bound on the wait. Defaults to 300000 (5 min),
|
||||
/// enforced daemon-side via the generic tool timeout.
|
||||
#[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 HelpOutcome {
|
||||
/// User completed the step and clicked Continue.
|
||||
Continued,
|
||||
/// User declined / aborted via the overlay's Cancel button.
|
||||
Cancelled,
|
||||
/// The wait expired before the user acted.
|
||||
TimedOut,
|
||||
/// The page navigated while waiting (full reload or SPA URL change).
|
||||
Navigated,
|
||||
}
|
||||
|
||||
impl HelpOutcome {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Continued => "continued",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::TimedOut => "timed_out",
|
||||
Self::Navigated => "navigated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-target resolution status echoed back so the agent can tell which
|
||||
/// `ref` / `selector` actually matched a live element.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ResolvedTarget {
|
||||
pub matched: bool,
|
||||
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
|
||||
pub ref_: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct RequestHelpResult {
|
||||
pub outcome: HelpOutcome,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_targets: Option<Vec<ResolvedTarget>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn target_serialises_ref_field_name() {
|
||||
let t = HelpTarget {
|
||||
ref_: Some("@e3".into()),
|
||||
selector: None,
|
||||
};
|
||||
let v = serde_json::to_value(&t).unwrap();
|
||||
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
|
||||
assert!(v.get("selector").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_accepts_legacy_ref_alias() {
|
||||
let t: HelpTarget = serde_json::from_value(json!({ "ref_": "e1" })).unwrap();
|
||||
assert_eq!(t.ref_.as_deref(), Some("e1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_omit_optional_fields() {
|
||||
let p = RequestHelpParams {
|
||||
session_id: "abcd".into(),
|
||||
tab_id: None,
|
||||
prompt: "log in".into(),
|
||||
title: None,
|
||||
targets: None,
|
||||
timeout_ms: None,
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert!(v.get("tab_id").is_none());
|
||||
assert!(v.get("title").is_none());
|
||||
assert!(v.get("targets").is_none());
|
||||
assert!(v.get("timeout_ms").is_none());
|
||||
let round: RequestHelpParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_serialises_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(HelpOutcome::TimedOut).unwrap(),
|
||||
json!("timed_out")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(HelpOutcome::Continued).unwrap(),
|
||||
json!("continued")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(HelpOutcome::Cancelled).unwrap(),
|
||||
json!("cancelled")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(HelpOutcome::Navigated).unwrap(),
|
||||
json!("navigated")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_round_trips() {
|
||||
let r = RequestHelpResult {
|
||||
outcome: HelpOutcome::Continued,
|
||||
note: Some("done".into()),
|
||||
tab_id: 7,
|
||||
resolved_targets: Some(vec![ResolvedTarget {
|
||||
matched: true,
|
||||
ref_: Some("@e1".into()),
|
||||
selector: None,
|
||||
}]),
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let round: RequestHelpResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! DOM interaction tools (`tool.click`, `tool.fill`, `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
|
||||
//! resolved at call time. Modifiers / mouse buttons are encoded as
|
||||
//! lowercase JSON strings so the same wire shape works for CLI flags and
|
||||
//! the extension's CDP bridge.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::JavaScriptDialogInfo;
|
||||
|
||||
/// Keyboard modifier flags. Multiple flags may be combined; the
|
||||
/// extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4,
|
||||
/// shift=8`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum KeyModifier {
|
||||
Alt,
|
||||
Ctrl,
|
||||
Meta,
|
||||
Shift,
|
||||
}
|
||||
|
||||
/// Mouse button selector for `tool.click`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MouseButton {
|
||||
#[default]
|
||||
Left,
|
||||
Middle,
|
||||
Right,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ClickParams {
|
||||
pub session_id: String,
|
||||
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
|
||||
/// Mutually exclusive with `selector` (caller must supply exactly
|
||||
/// one). Accepts both `"e3"` and `"@e3"`.
|
||||
#[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>,
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub button: Option<MouseButton>,
|
||||
/// Number of consecutive mouse presses (double-click = 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub click_count: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<Vec<KeyModifier>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ClickResult {
|
||||
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>,
|
||||
/// Viewport-relative click coordinates (CSS pixels). Reported so
|
||||
/// agents can correlate with a follow-up `tool.screenshot`.
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
/// Native JS dialogs observed and auto-handled during this call.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct FillParams {
|
||||
pub session_id: String,
|
||||
pub value: String,
|
||||
#[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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Clear the field before typing. Defaults to `true`; pass `false`
|
||||
/// to append instead of replacing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clear_before: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct FillResult {
|
||||
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 the value that was finally typed
|
||||
/// (matches what `input.value.length` would report in the page).
|
||||
pub value_length: u32,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// press
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PressParams {
|
||||
pub session_id: String,
|
||||
/// Logical key name. Accepts CDP `key` strings (`Enter`, `Escape`,
|
||||
/// `ArrowDown`, single characters like `a`), or a compound
|
||||
/// expression such as `Ctrl+A` / `Meta+Shift+P`. Modifiers in the
|
||||
/// compound form combine with anything supplied via `modifiers`.
|
||||
pub key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<Vec<KeyModifier>>,
|
||||
/// Optional target to focus before dispatching the key.
|
||||
#[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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Hold the key down for this many milliseconds between `keyDown`
|
||||
/// and `keyUp`. Useful for testing long-press handlers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hold_ms: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PressResult {
|
||||
pub tab_id: i64,
|
||||
pub key: String,
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub modifiers: Vec<KeyModifier>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// select
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SelectParams {
|
||||
pub session_id: String,
|
||||
/// Option `value` strings to set as the final selection. For a
|
||||
/// single-select `<select>` exactly one value is required; for
|
||||
/// `<select multiple>` the list replaces the current selection
|
||||
/// (an empty list clears all selections).
|
||||
pub values: Vec<String>,
|
||||
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
|
||||
/// Mutually exclusive with `selector`. Accepts both `"e3"` and
|
||||
/// `"@e3"`.
|
||||
#[serde(
|
||||
rename = "ref",
|
||||
alias = "ref_",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub ref_: Option<String>,
|
||||
/// CSS selector resolved against the live DOM. Mutually exclusive
|
||||
/// with `ref_`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Maximum time the daemon waits for the tool call before timing out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SelectResult {
|
||||
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>,
|
||||
/// Whether the target `<select>` had the `multiple` attribute.
|
||||
pub multiple: bool,
|
||||
/// Final selected option `value` attributes after the call.
|
||||
pub selected_values: Vec<String>,
|
||||
/// Visible labels of the selected options (same order as
|
||||
/// `selected_values`).
|
||||
pub selected_labels: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn click_params_serialise_ref_field_name() {
|
||||
let p = ClickParams {
|
||||
session_id: "abcd".into(),
|
||||
ref_: Some("@e3".into()),
|
||||
selector: None,
|
||||
tab_id: Some(42),
|
||||
button: Some(MouseButton::Left),
|
||||
click_count: Some(1),
|
||||
modifiers: Some(vec![KeyModifier::Ctrl]),
|
||||
timeout_ms: Some(5_000),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
|
||||
assert!(v.get("ref_").is_none());
|
||||
let round: ClickParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_params_accept_legacy_ref_alias() {
|
||||
let p: ClickParams = serde_json::from_value(json!({
|
||||
"session_id": "a",
|
||||
"ref_": "e1",
|
||||
"selector": null,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.ref_.as_deref(), Some("e1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_render_as_lowercase_strings() {
|
||||
let v = serde_json::to_value(KeyModifier::Ctrl).unwrap();
|
||||
assert_eq!(v, json!("ctrl"));
|
||||
let v = serde_json::to_value(KeyModifier::Meta).unwrap();
|
||||
assert_eq!(v, json!("meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_result_round_trips() {
|
||||
let r = PressResult {
|
||||
tab_id: 5,
|
||||
key: "a".into(),
|
||||
code: "KeyA".into(),
|
||||
modifiers: vec![KeyModifier::Ctrl, KeyModifier::Shift],
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let round: PressResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_params_default_clear_before_is_omitted() {
|
||||
let p = FillParams {
|
||||
session_id: "abcd".into(),
|
||||
value: "hello".into(),
|
||||
ref_: Some("@e1".into()),
|
||||
selector: None,
|
||||
tab_id: None,
|
||||
clear_before: None,
|
||||
timeout_ms: None,
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert!(v.get("clear_before").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_params_round_trips_values() {
|
||||
let p = SelectParams {
|
||||
session_id: "abcd".into(),
|
||||
values: vec!["us".into(), "ca".into()],
|
||||
ref_: Some("@e3".into()),
|
||||
selector: None,
|
||||
tab_id: Some(12),
|
||||
timeout_ms: Some(5_000),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v.get("values").cloned(), Some(json!(["us", "ca"])));
|
||||
let round: SelectParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Typed params/results for the 21 `tool.*` RPC methods (§7).
|
||||
|
||||
pub mod dialog;
|
||||
pub mod human_loop;
|
||||
pub mod interaction;
|
||||
pub mod navigation;
|
||||
pub mod observation;
|
||||
pub mod script;
|
||||
pub mod session;
|
||||
pub mod tabs;
|
||||
pub mod waits;
|
||||
|
||||
pub use dialog::*;
|
||||
pub use human_loop::*;
|
||||
pub use interaction::*;
|
||||
pub use navigation::*;
|
||||
pub use observation::*;
|
||||
pub use script::*;
|
||||
pub use session::*;
|
||||
pub use tabs::*;
|
||||
pub use waits::*;
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Navigation tools (`tool.navigate*`, `tool.reload`).
|
||||
//!
|
||||
//! M7 expands each method with `wait_until`, `timeout_ms`, and a
|
||||
//! richer result shape that lets the daemon / CLI know which lifecycle
|
||||
//! phase actually fired (so a `--wait-until=load` that times out still
|
||||
//! reports `reached: "commit"` instead of an empty success). All
|
||||
//! `tab_id` fields are optional and resolve to the requesting
|
||||
//! session's Agent Window active tab when omitted (design §7).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::JavaScriptDialogInfo;
|
||||
|
||||
/// Page-lifecycle checkpoint a navigation should wait on.
|
||||
///
|
||||
/// Mapping to CDP `Page.lifecycleEvent` names:
|
||||
/// * `Load` → `load`
|
||||
/// * `DomContentLoaded` → `DOMContentLoaded`
|
||||
/// * `NetworkIdle` → `networkIdle`
|
||||
/// * `Commit` → `commit` (earliest event after the navigation actually
|
||||
/// commits to a new document)
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WaitUntil {
|
||||
#[default]
|
||||
Load,
|
||||
DomContentLoaded,
|
||||
NetworkIdle,
|
||||
Commit,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// navigate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NavigateParams {
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
/// Target tab. Defaults to the Agent Window's currently active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Lifecycle phase to wait on. Defaults to [`WaitUntil::Load`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wait_until: Option<WaitUntil>,
|
||||
/// Hard upper bound on the wait; the extension also enforces its
|
||||
/// own AbortController hookup (M7 stops listening for further
|
||||
/// lifecycle events once exceeded). Defaults to 30s if omitted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Outcome of a navigation. `reached` is the lifecycle name the
|
||||
/// extension actually observed before returning — either the requested
|
||||
/// `wait_until` value, or `"timeout"` when the wait expired before
|
||||
/// that event fired.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NavigateResult {
|
||||
pub tab_id: i64,
|
||||
pub url: String,
|
||||
/// Tab URL after the navigation settles. May differ from `url` if
|
||||
/// the server redirected (e.g. http → https) or `wait_until=commit`
|
||||
/// raced.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub final_url: Option<String>,
|
||||
pub reached: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// navigate_back / navigate_forward
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NavigateBackParams {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wait_until: Option<WaitUntil>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NavigateBackResult {
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub previous_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub final_url: Option<String>,
|
||||
pub reached: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NavigateForwardParams {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wait_until: Option<WaitUntil>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NavigateForwardResult {
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub previous_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub final_url: Option<String>,
|
||||
pub reached: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ReloadParams {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wait_until: Option<WaitUntil>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
/// When `true`, bypass the HTTP cache (CDP `Page.reload(ignoreCache=true)`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hard: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ReloadResult {
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub previous_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub final_url: Option<String>,
|
||||
pub reached: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn wait_until_round_trips_through_lowercase() {
|
||||
for v in [
|
||||
WaitUntil::Load,
|
||||
WaitUntil::DomContentLoaded,
|
||||
WaitUntil::NetworkIdle,
|
||||
WaitUntil::Commit,
|
||||
] {
|
||||
let s = serde_json::to_string(&v).unwrap();
|
||||
let parsed: WaitUntil = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(v, parsed);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WaitUntil::Commit).unwrap(),
|
||||
"\"commit\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WaitUntil::DomContentLoaded).unwrap(),
|
||||
"\"domcontentloaded\"",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigate_params_omit_tab_id_when_none() {
|
||||
let p = NavigateParams {
|
||||
session_id: "abcd".into(),
|
||||
url: "https://example.com/".into(),
|
||||
tab_id: None,
|
||||
wait_until: Some(WaitUntil::Load),
|
||||
timeout_ms: Some(30_000),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert!(v.get("tab_id").is_none());
|
||||
let round: NavigateParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigate_result_round_trips_with_optional_fields() {
|
||||
let r = NavigateResult {
|
||||
tab_id: 7,
|
||||
url: "https://example.com/".into(),
|
||||
final_url: Some("https://www.example.com/".into()),
|
||||
reached: "load".into(),
|
||||
error_text: None,
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let round: NavigateResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_params_accept_hard_flag() {
|
||||
let raw = json!({
|
||||
"session_id": "abcd",
|
||||
"hard": true,
|
||||
});
|
||||
let parsed: ReloadParams = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(parsed.hard, Some(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Read-only observation tools (`tool.snapshot`, `tool.get_html`, `tool.screenshot`).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::JavaScriptDialogInfo;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `tool.snapshot` — produce an indented accessibility
|
||||
/// tree for one tab plus a fresh `@e<N>` ref-store on the extension
|
||||
/// side (design §4 / §7).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SnapshotParams {
|
||||
pub session_id: String,
|
||||
/// Optional target tab. Defaults to the Agent Window's currently
|
||||
/// active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Cap the depth of the rendered tree (defense against very deep
|
||||
/// pages). Implementation-defined default; agents typically use
|
||||
/// `Some(16)` for "browse and read" prompts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_depth: Option<u32>,
|
||||
/// Soft cap on rendered tokens (approximate; extension uses a
|
||||
/// best-effort heuristic based on character count).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[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.
|
||||
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.
|
||||
pub ref_count: u32,
|
||||
/// Tab the snapshot was actually computed for (after resolving the
|
||||
/// optional `tab_id` default).
|
||||
pub tab_id: i64,
|
||||
/// Whether the rendered tree was truncated because of `max_depth` /
|
||||
/// `max_tokens`. Agents may re-run with looser caps.
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_html
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `tool.get_html`. Either give a tab and (optionally) a
|
||||
/// snapshot ref to scope the dump to one subtree, or leave both empty
|
||||
/// to fall back to the Agent Window's active tab and the document
|
||||
/// root.
|
||||
///
|
||||
/// The wire field is `"ref"` (a Rust keyword); the Rust binding lives
|
||||
/// in [`Self::ref_`] and uses serde rename / alias so JSON stays clean.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct GetHtmlParams {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
|
||||
/// Accepts both `"e3"` and `"@e3"`. When given the extension calls
|
||||
/// `DOM.getOuterHTML({ backendNodeId })` for the matching node;
|
||||
/// otherwise it fetches the document element.
|
||||
#[serde(
|
||||
rename = "ref",
|
||||
alias = "ref_",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub ref_: Option<String>,
|
||||
/// Truncate the HTML to at most this many bytes. Optional;
|
||||
/// extension applies a sensible default (`524288` = 512 KiB) when
|
||||
/// omitted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_bytes: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct GetHtmlResult {
|
||||
pub html: String,
|
||||
/// True when `html` was truncated to honour `max_bytes`. Agents
|
||||
/// should treat the missing tail as elided.
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
/// Total byte length of the original (un-truncated) HTML payload.
|
||||
pub byte_size: u64,
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// screenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `tool.screenshot`. Omit `ref` to capture the visible
|
||||
/// tab; pass an `@e<N>` ref from the last `tool.snapshot` to crop to
|
||||
/// that element.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ScreenshotParams {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
|
||||
/// Accepts both `"e3"` and `"@e3"`.
|
||||
#[serde(
|
||||
rename = "ref",
|
||||
alias = "ref_",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub ref_: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ScreenshotResult {
|
||||
/// Base64-encoded PNG payload (no `data:` prefix).
|
||||
pub image_base64: String,
|
||||
/// Pixel width parsed from the PNG IHDR. May be `0` when parsing
|
||||
/// fails (extension only logs a warning in that case so the agent
|
||||
/// still gets the image).
|
||||
#[serde(default)]
|
||||
pub width: u32,
|
||||
/// Pixel height (see [`Self::width`]).
|
||||
#[serde(default)]
|
||||
pub height: u32,
|
||||
/// Always `"png"` in v0.1; reserved for future JPEG / WebP support.
|
||||
pub format: String,
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn get_html_params_serialises_with_ref_field_name() {
|
||||
let p = GetHtmlParams {
|
||||
session_id: "abcd".into(),
|
||||
tab_id: Some(7),
|
||||
ref_: Some("@e3".into()),
|
||||
max_bytes: Some(1024),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
// Wire field name is `ref`, never `ref_`.
|
||||
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
|
||||
assert!(v.get("ref_").is_none());
|
||||
let round: GetHtmlParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_html_params_accepts_both_ref_aliases() {
|
||||
let from_wire: GetHtmlParams =
|
||||
serde_json::from_value(json!({ "session_id": "a", "ref": "e1" })).unwrap();
|
||||
assert_eq!(from_wire.ref_.as_deref(), Some("e1"));
|
||||
|
||||
let from_alias: GetHtmlParams =
|
||||
serde_json::from_value(json!({ "session_id": "a", "ref_": "e2" })).unwrap();
|
||||
assert_eq!(from_alias.ref_.as_deref(), Some("e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_result_round_trips_with_ref_count() {
|
||||
let r = SnapshotResult {
|
||||
text: "root\n @e1 button \"submit\"\n".into(),
|
||||
ref_count: 1,
|
||||
tab_id: 42,
|
||||
truncated: false,
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v["ref_count"], 1);
|
||||
assert_eq!(v["text"], "root\n @e1 button \"submit\"\n");
|
||||
let round: SnapshotResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_params_serialises_with_ref_field_name() {
|
||||
let p = ScreenshotParams {
|
||||
session_id: "abcd".into(),
|
||||
tab_id: Some(7),
|
||||
ref_: Some("@e3".into()),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
|
||||
assert!(v.get("ref_").is_none());
|
||||
let round: ScreenshotParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_params_accepts_both_ref_aliases() {
|
||||
let from_wire: ScreenshotParams =
|
||||
serde_json::from_value(json!({ "session_id": "a", "ref": "e1" })).unwrap();
|
||||
assert_eq!(from_wire.ref_.as_deref(), Some("e1"));
|
||||
|
||||
let from_alias: ScreenshotParams =
|
||||
serde_json::from_value(json!({ "session_id": "a", "ref_": "e2" })).unwrap();
|
||||
assert_eq!(from_alias.ref_.as_deref(), Some("e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_result_round_trips_with_image_fields() {
|
||||
let r = ScreenshotResult {
|
||||
image_base64: "iVBORw0KGgo=".into(),
|
||||
width: 800,
|
||||
height: 600,
|
||||
format: "png".into(),
|
||||
tab_id: 7,
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let round: ScreenshotResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `tool.evaluate` (design §4 / §7).
|
||||
//!
|
||||
//! M9.1 expands the M1 placeholder into the wire shape the extension
|
||||
//! reports back from `Runtime.evaluate`: the call is sandboxed to the
|
||||
//! session's Agent Window (borrowed tabs included), `tab_id` is
|
||||
//! optional and defaults to the Agent Window's active tab, and JS
|
||||
//! exceptions thrown by the evaluated expression are surfaced as
|
||||
//! `ok: false` payloads (NOT structured RPC errors) so an agent can
|
||||
//! inspect the `throw` text in-band.
|
||||
//!
|
||||
//! `value` is always a `serde_json::Value` — when the underlying CDP
|
||||
//! result is unserialisable (`Infinity`, `NaN`, `BigInt`, …) the
|
||||
//! extension stringifies it before returning so the wire payload stays
|
||||
//! self-describing.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::JavaScriptDialogInfo;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EvaluateParams {
|
||||
pub session_id: String,
|
||||
pub expression: String,
|
||||
/// Target tab. Defaults to the Agent Window's currently active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Await the expression when it resolves to a Promise (CDP
|
||||
/// `Runtime.evaluate(awaitPromise=...)`). Defaults to `true`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
/// Ask CDP to serialise the result by value (vs. returning a
|
||||
/// `RemoteObject` id we cannot use). Defaults to `true`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
/// Hard upper bound on the evaluation. Defaults to 30s.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Outcome of an `evaluate` call.
|
||||
///
|
||||
/// * `ok: true` → expression evaluated successfully; `value` holds the
|
||||
/// JSON-serialised return value (`null` is meaningful and distinct
|
||||
/// from "missing").
|
||||
/// * `ok: false` → the expression threw; `error.text` holds the
|
||||
/// stringified `exceptionDetails.text` / `exception.description`.
|
||||
/// The RPC itself still succeeds — the daemon does NOT map JS throws
|
||||
/// to top-level RPC errors so agents can act on the throw text.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EvaluateResult {
|
||||
pub ok: bool,
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<EvaluateError>,
|
||||
/// Native JS dialogs observed and auto-handled during this call.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EvaluateError {
|
||||
pub text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub line: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub column: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn params_omit_optional_fields() {
|
||||
let p = EvaluateParams {
|
||||
session_id: "abcd".into(),
|
||||
expression: "1+1".into(),
|
||||
tab_id: None,
|
||||
await_promise: None,
|
||||
return_by_value: None,
|
||||
timeout_ms: None,
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert!(v.get("tab_id").is_none());
|
||||
assert!(v.get("await_promise").is_none());
|
||||
assert!(v.get("return_by_value").is_none());
|
||||
assert!(v.get("timeout_ms").is_none());
|
||||
let round: EvaluateParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_ok_with_value_round_trips() {
|
||||
let r = EvaluateResult {
|
||||
ok: true,
|
||||
tab_id: 7,
|
||||
value: Some(json!(2)),
|
||||
error: None,
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v.get("value"), Some(&json!(2)));
|
||||
assert!(v.get("error").is_none());
|
||||
let round: EvaluateResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_throw_carries_error_text_and_position() {
|
||||
let r = EvaluateResult {
|
||||
ok: false,
|
||||
tab_id: 7,
|
||||
value: None,
|
||||
error: Some(EvaluateError {
|
||||
text: "Uncaught Error: boom".into(),
|
||||
line: Some(1),
|
||||
column: Some(6),
|
||||
}),
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert!(v.get("value").is_none());
|
||||
let round: EvaluateResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Session-scoped tools (`tool.session_*`).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ErrorCode;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SessionStartParams {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub browser_instance_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SessionStartResult {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_window_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SessionStopParams {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ReturnFailure {
|
||||
pub tab_id: i64,
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SessionStopResult {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub returned_tab_ids: Vec<i64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub return_failures: Vec<ReturnFailure>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn session_stop_result_round_trips_auto_return_payload() {
|
||||
let result: SessionStopResult = serde_json::from_value(json!({
|
||||
"returned_tab_ids": [7, 8],
|
||||
"return_failures": [
|
||||
{ "tab_id": 9, "code": "cdp_failed", "message": "move failed" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.returned_tab_ids, vec![7, 8]);
|
||||
assert_eq!(result.return_failures[0].tab_id, 9);
|
||||
assert_eq!(result.return_failures[0].code, ErrorCode::CdpFailed);
|
||||
let encoded = serde_json::to_value(result).unwrap();
|
||||
assert_eq!(encoded["returned_tab_ids"], json!([7, 8]));
|
||||
assert_eq!(encoded["return_failures"][0]["code"], "cdp_failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Tab tools (`tool.tab_*`).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// View scope for [`TabListParams`] (§6 sandbox table).
|
||||
///
|
||||
/// * `User` — tabs that live in any window other than an Agent Window.
|
||||
/// * `Agent` — tabs in the requesting session's Agent Window only.
|
||||
/// * `All` — both of the above; never reveals other sessions' Agent
|
||||
/// Windows (cross-session isolation per §6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TabScope {
|
||||
User,
|
||||
Agent,
|
||||
#[default]
|
||||
All,
|
||||
}
|
||||
|
||||
/// A single tab entry returned by [`TabListResult`].
|
||||
///
|
||||
/// `scope` reports how the requesting session sees this tab: tabs owned
|
||||
/// by the session's own Agent Window are tagged `agent`; everything
|
||||
/// else falls into `user`. (Other sessions' Agent Windows are filtered
|
||||
/// out before they reach this struct.)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabInfo {
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub window_id: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active: Option<bool>,
|
||||
/// Where the tab sits relative to the requesting session: `user` or
|
||||
/// `agent`. `all` is a request-side filter only and never appears
|
||||
/// on an individual tab entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<TabScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabListParams {
|
||||
#[serde(default)]
|
||||
pub scope: TabScope,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabListResult {
|
||||
pub tabs: Vec<TabInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tab_create (M8.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Params for `tool.tab_create`. The new tab is always created inside
|
||||
/// the requesting session's Agent Window (design §6 sandbox rule —
|
||||
/// agents never spawn tabs in user windows).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabCreateParams {
|
||||
pub session_id: String,
|
||||
/// Destination URL. Defaults to `chrome://newtab` when omitted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// Focus the new tab? Defaults to `true` (matches `chrome.tabs.create`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active: Option<bool>,
|
||||
/// Insertion index within the Agent Window's tab strip. Omit to
|
||||
/// append at the end.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub index: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabCreateResult {
|
||||
pub tab_id: i64,
|
||||
pub window_id: i64,
|
||||
/// Best-effort URL of the newly created tab. May be empty if Chrome
|
||||
/// has not yet committed the navigation (e.g. pending load).
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tab_close (M8.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabCloseParams {
|
||||
pub tab_id: i64,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabCloseResult {
|
||||
pub tab_id: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tab_select (M8.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabSelectParams {
|
||||
pub tab_id: i64,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabSelectResult {
|
||||
pub tab_id: i64,
|
||||
pub window_id: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tab_borrow (M8.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Params for `tool.tab_borrow`. Moves a *user* tab into the
|
||||
/// requesting session's Agent Window, recording its original window /
|
||||
/// index so `tab_return` (or session_stop) can put it back.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabBorrowParams {
|
||||
pub tab_id: i64,
|
||||
pub session_id: String,
|
||||
/// Whether to wait for an inline user confirmation overlay (M10
|
||||
/// will introduce the UI). Currently ignored: the M8 stub always
|
||||
/// proceeds when `false` and forwards `true` to a no-op approver.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub confirm: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabBorrowResult {
|
||||
pub tab_id: i64,
|
||||
pub original_window_id: i64,
|
||||
pub original_index: i32,
|
||||
pub agent_window_id: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tab_return (M8.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabReturnParams {
|
||||
pub tab_id: i64,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TabReturnResult {
|
||||
pub tab_id: i64,
|
||||
pub returned_to_window_id: i64,
|
||||
pub returned_to_index: i32,
|
||||
/// True when the original window had been closed and we fell back
|
||||
/// to either the last focused normal window or a newly-created
|
||||
/// window. Lets the CLI surface "tab returned to a different
|
||||
/// window" hints.
|
||||
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
|
||||
pub fallback: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tab_info_scope_is_omitted_when_none() {
|
||||
let info = TabInfo {
|
||||
tab_id: 1,
|
||||
title: Some("hi".into()),
|
||||
url: None,
|
||||
window_id: Some(2),
|
||||
active: Some(true),
|
||||
scope: None,
|
||||
};
|
||||
let v = serde_json::to_value(&info).unwrap();
|
||||
assert!(v.get("scope").is_none());
|
||||
let round: TabInfo = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_info_scope_renders_as_lowercase_enum() {
|
||||
let info = TabInfo {
|
||||
tab_id: 1,
|
||||
title: None,
|
||||
url: None,
|
||||
window_id: None,
|
||||
active: None,
|
||||
scope: Some(TabScope::Agent),
|
||||
};
|
||||
let v = serde_json::to_value(&info).unwrap();
|
||||
assert_eq!(v["scope"], "agent");
|
||||
// Round-trip from upstream JSON (no `title` / `url` keys).
|
||||
let from_json: TabInfo = serde_json::from_value(json!({
|
||||
"tab_id": 1,
|
||||
"scope": "user"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(from_json.scope, Some(TabScope::User));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_params_default_optional_fields() {
|
||||
let v = json!({ "session_id": "aa11" });
|
||||
let p: TabCreateParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(p.session_id, "aa11");
|
||||
assert!(p.url.is_none());
|
||||
assert!(p.active.is_none());
|
||||
assert!(p.index.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_params_round_trip_full() {
|
||||
let p = TabCreateParams {
|
||||
session_id: "aa11".into(),
|
||||
url: Some("https://example.com/".into()),
|
||||
active: Some(false),
|
||||
index: Some(3),
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v["url"], "https://example.com/");
|
||||
assert_eq!(v["active"], false);
|
||||
assert_eq!(v["index"], 3);
|
||||
let round: TabCreateParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_result_serialises_url_even_when_empty() {
|
||||
let r = TabCreateResult {
|
||||
tab_id: 7,
|
||||
window_id: 100,
|
||||
url: String::new(),
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v["tab_id"], 7);
|
||||
assert_eq!(v["url"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_close_select_result_round_trips() {
|
||||
let c: TabCloseResult = serde_json::from_value(json!({ "tab_id": 9 })).unwrap();
|
||||
assert_eq!(c.tab_id, 9);
|
||||
let s: TabSelectResult =
|
||||
serde_json::from_value(json!({ "tab_id": 9, "window_id": 100 })).unwrap();
|
||||
assert_eq!(s.window_id, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_borrow_params_confirm_defaults_to_none() {
|
||||
let p: TabBorrowParams =
|
||||
serde_json::from_value(json!({ "tab_id": 1, "session_id": "aa11" })).unwrap();
|
||||
assert!(p.confirm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_borrow_result_round_trip() {
|
||||
let r = TabBorrowResult {
|
||||
tab_id: 9,
|
||||
original_window_id: 200,
|
||||
original_index: 4,
|
||||
agent_window_id: 100,
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v["original_window_id"], 200);
|
||||
assert_eq!(v["original_index"], 4);
|
||||
let round: TabBorrowResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_return_result_omits_fallback_when_false() {
|
||||
let r = TabReturnResult {
|
||||
tab_id: 9,
|
||||
returned_to_window_id: 200,
|
||||
returned_to_index: 4,
|
||||
fallback: false,
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert!(v.get("fallback").is_none());
|
||||
let r2 = TabReturnResult {
|
||||
fallback: true,
|
||||
..r
|
||||
};
|
||||
let v2 = serde_json::to_value(&r2).unwrap();
|
||||
assert_eq!(v2["fallback"], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Timing helpers (`tool.wait_for_navigation`, `tool.wait_ms`).
|
||||
//!
|
||||
//! M9.2: `wait_for_navigation` mirrors the `navigate` wire shape — it
|
||||
//! waits on a CDP `Page.lifecycleEvent` and reports back via `reached`
|
||||
//! / `error_text` so a timeout still tells the caller which lifecycle
|
||||
//! phase the page actually reached. `wait_until` defaults to `load`.
|
||||
//!
|
||||
//! M9.3: `wait_ms` is a pure daemon-side sleep (no extension hop, no
|
||||
//! session needed). The result echoes the requested duration so the
|
||||
//! caller can confirm a 0ms wait still went through the IPC layer.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::JavaScriptDialogInfo;
|
||||
use super::navigation::WaitUntil;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wait_for_navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WaitForNavigationParams {
|
||||
pub session_id: String,
|
||||
/// Target tab. Defaults to the Agent Window's currently active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Lifecycle phase to wait on. Defaults to [`WaitUntil::Load`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wait_until: Option<WaitUntil>,
|
||||
/// Hard upper bound on the wait. Defaults to 30s.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Outcome of a wait_for_navigation. `reached` is the wire name of the
|
||||
/// lifecycle phase the extension actually observed before returning —
|
||||
/// either the requested `wait_until`, or `"timeout"` when the wait
|
||||
/// expired before that event fired.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WaitForNavigationResult {
|
||||
pub tab_id: i64,
|
||||
pub reached: WaitForNavigationReached,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum WaitForNavigationReached {
|
||||
#[serde(rename = "load")]
|
||||
Load,
|
||||
#[serde(rename = "domcontentloaded")]
|
||||
DomContentLoaded,
|
||||
#[serde(rename = "networkidle")]
|
||||
NetworkIdle,
|
||||
#[serde(rename = "commit")]
|
||||
Commit,
|
||||
#[serde(rename = "timeout")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl WaitForNavigationReached {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Load => "load",
|
||||
Self::DomContentLoaded => "domcontentloaded",
|
||||
Self::NetworkIdle => "networkidle",
|
||||
Self::Commit => "commit",
|
||||
Self::Timeout => "timeout",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wait_ms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WaitMsParams {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WaitMsResult {
|
||||
pub waited_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn wait_for_navigation_params_omit_optional_fields() {
|
||||
let p = WaitForNavigationParams {
|
||||
session_id: "abcd".into(),
|
||||
tab_id: None,
|
||||
wait_until: None,
|
||||
timeout_ms: None,
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert!(v.get("tab_id").is_none());
|
||||
assert!(v.get("wait_until").is_none());
|
||||
assert!(v.get("timeout_ms").is_none());
|
||||
let round: WaitForNavigationParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_navigation_result_round_trips_timeout() {
|
||||
let r = WaitForNavigationResult {
|
||||
tab_id: 9,
|
||||
reached: WaitForNavigationReached::Timeout,
|
||||
error_text: Some("timed out waiting for lifecycle \"load\"".into()),
|
||||
dialogs: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let round: WaitForNavigationResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_navigation_result_rejects_unknown_reached_value() {
|
||||
let res = serde_json::from_value::<WaitForNavigationResult>(json!({
|
||||
"tab_id": 9,
|
||||
"reached": "painted"
|
||||
}));
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_ms_round_trips() {
|
||||
let params: WaitMsParams = serde_json::from_value(json!({ "duration_ms": 250 })).unwrap();
|
||||
assert_eq!(params.duration_ms, 250);
|
||||
let result = WaitMsResult { waited_ms: 250 };
|
||||
let v = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(v, json!({ "waited_ms": 250 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user