mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Require Trace v3 version const 3, reject mixed pages/states payloads, and align TypeScript selection optionality with the generated schema. Trace v2 on main already records hover steps, so the frozen v2 schema must accept them instead of dropping them during reduction. Co-authored-by: Cursor <cursoragent@cursor.com>
685 lines
23 KiB
Rust
685 lines
23 KiB
Rust
//! Semantic user-action recording (`tool.record_start` / `stop` / `await`).
|
|
//!
|
|
//! Trace v3 is a **state-action-state** chain: each step binds to page
|
|
//! observations (VOM) captured before and after the action.
|
|
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Deserializer, Serialize};
|
|
|
|
use super::interaction::KeyModifier;
|
|
|
|
pub use crate::record_v2::{
|
|
PageRef, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2,
|
|
};
|
|
|
|
pub const TRACE_VERSION: u32 = 3;
|
|
pub const TRACE_VERSION_V2: u32 = 2;
|
|
pub const DEFAULT_TRACE_VERSION: u32 = 2;
|
|
pub const VOM_FORMAT_VERSION: u32 = 1;
|
|
|
|
fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let version = u32::deserialize(deserializer)?;
|
|
if version != TRACE_VERSION {
|
|
return Err(serde::de::Error::custom(format!(
|
|
"unsupported trace version {version} (expected {TRACE_VERSION})"
|
|
)));
|
|
}
|
|
Ok(version)
|
|
}
|
|
|
|
fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
|
|
schemars::schema::SchemaObject {
|
|
instance_type: Some(schemars::schema::InstanceType::Integer.into()),
|
|
const_value: Some(serde_json::json!(TRACE_VERSION)),
|
|
..Default::default()
|
|
}
|
|
.into()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Target
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stable semantic handle for an interacted element within a page observation.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct TargetDescriptor {
|
|
#[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
|
|
pub element_ref: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub role: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ctx: Option<String>,
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub unmatched: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace envelope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Recording entry point — first URL the flow starts from.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct TraceEntry {
|
|
pub start_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecorderInfo {
|
|
pub bsk: String,
|
|
pub vom: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum StopReason {
|
|
UserFinish,
|
|
CliStop,
|
|
}
|
|
|
|
/// Page observation dictionary entry — referenced by steps via `state` / `result.state`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct TraceState {
|
|
pub id: String,
|
|
pub url: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub title: Option<String>,
|
|
/// Wire-only: full page observation (front matter + VOM body + annotations).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub body: Option<String>,
|
|
/// Disk-only: filename under the bundle `pages/` directory.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub page: Option<String>,
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub truncated: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct StepResult {
|
|
pub state: String,
|
|
}
|
|
|
|
/// Fields shared by every step variant (flattened in JSON).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct StepCommon {
|
|
pub id: u32,
|
|
/// Observation id immediately before this action.
|
|
pub state: String,
|
|
pub result: StepResult,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step op-specific payloads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One selected option (`select` op).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct SelectedOption {
|
|
pub value: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub label: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum NavigationCause {
|
|
UserTyped,
|
|
Link,
|
|
FormSubmit,
|
|
Reload,
|
|
History,
|
|
Script,
|
|
Browser,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FillCommit {
|
|
Enter,
|
|
Suggestion,
|
|
Blur,
|
|
}
|
|
|
|
/// One recorded user action — discriminated union by `op`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(tag = "op", rename_all = "snake_case")]
|
|
pub enum Step {
|
|
Navigate {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
to: String,
|
|
cause: NavigationCause,
|
|
},
|
|
Click {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
target: TargetDescriptor,
|
|
},
|
|
Hover {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
target: TargetDescriptor,
|
|
},
|
|
Fill {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
target: TargetDescriptor,
|
|
value: String,
|
|
commit: FillCommit,
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
redacted: bool,
|
|
},
|
|
Select {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
target: TargetDescriptor,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
selection: Vec<SelectedOption>,
|
|
},
|
|
Press {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
key: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
modifiers: Option<Vec<KeyModifier>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
target: Option<TargetDescriptor>,
|
|
},
|
|
Scroll {
|
|
#[serde(flatten)]
|
|
common: StepCommon,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace root
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Persisted user-action trace exported by `tool.record_stop` / `await`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Trace {
|
|
#[serde(deserialize_with = "deserialize_trace_v3_version")]
|
|
#[schemars(schema_with = "trace_v3_version_schema")]
|
|
pub version: u32,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub purpose: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub started_at: Option<String>,
|
|
pub recorded_at: String,
|
|
pub stopped_by: StopReason,
|
|
pub entry: TraceEntry,
|
|
pub recorder: RecorderInfo,
|
|
pub states: Vec<TraceState>,
|
|
pub steps: Vec<Step>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RPC params / results
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecordStartParams {
|
|
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 url: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub purpose: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_page_tokens: Option<u32>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub redact_values: Option<bool>,
|
|
/// Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub trace_version: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecordStartResult {
|
|
pub tab_id: i64,
|
|
pub recording: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecordStopParams {
|
|
pub session_id: String,
|
|
}
|
|
|
|
/// Wire trace payload — v2 (legacy `pages[]`) or v3 (`version: 3`, `states[]`).
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum RecordedTrace {
|
|
V2(TraceV2),
|
|
V3(Trace),
|
|
}
|
|
|
|
impl RecordedTrace {
|
|
pub fn classify_value(v: &serde_json::Value) -> Result<Self, String> {
|
|
if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) {
|
|
if ver != u64::from(TRACE_VERSION) {
|
|
return Err(format!("unsupported trace version {ver}"));
|
|
}
|
|
if v.get("pages").is_some() {
|
|
return Err("trace v3 must not include legacy pages[]".into());
|
|
}
|
|
return serde_json::from_value(v.clone())
|
|
.map(RecordedTrace::V3)
|
|
.map_err(|e| e.to_string());
|
|
}
|
|
if v.get("states").is_some() {
|
|
return Err("trace v2 must not include states[]; set version: 3 for Trace v3".into());
|
|
}
|
|
if v.get("pages").is_some() {
|
|
return serde_json::from_value(v.clone())
|
|
.map(RecordedTrace::V2)
|
|
.map_err(|e| e.to_string());
|
|
}
|
|
Err("ambiguous or unparseable trace".into())
|
|
}
|
|
|
|
pub fn is_v3(&self) -> bool {
|
|
matches!(self, RecordedTrace::V3(_))
|
|
}
|
|
|
|
pub fn as_v3(&self) -> Option<&Trace> {
|
|
match self {
|
|
RecordedTrace::V3(t) => Some(t),
|
|
RecordedTrace::V2(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_v2(&self) -> Option<&TraceV2> {
|
|
match self {
|
|
RecordedTrace::V2(t) => Some(t),
|
|
RecordedTrace::V3(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Serialize for RecordedTrace {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
match self {
|
|
RecordedTrace::V2(t) => t.serialize(serializer),
|
|
RecordedTrace::V3(t) => t.serialize(serializer),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for RecordedTrace {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let value = serde_json::Value::deserialize(deserializer)?;
|
|
Self::classify_value(&value).map_err(serde::de::Error::custom)
|
|
}
|
|
}
|
|
|
|
impl JsonSchema for RecordedTrace {
|
|
fn schema_name() -> String {
|
|
"RecordedTrace".into()
|
|
}
|
|
|
|
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema {
|
|
schemars::schema::SchemaObject {
|
|
subschemas: Some(Box::new(schemars::schema::SubschemaValidation {
|
|
one_of: Some(vec![
|
|
generator.subschema_for::<TraceV2>(),
|
|
generator.subschema_for::<Trace>(),
|
|
]),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
}
|
|
.into()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecordStopResult {
|
|
pub trace: RecordedTrace,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecordAwaitParams {
|
|
pub session_id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub timeout_ms: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RecordAwaitResult {
|
|
pub trace: RecordedTrace,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommon {
|
|
StepCommon {
|
|
id,
|
|
state: state.into(),
|
|
result: StepResult {
|
|
state: result_state.into(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn sample_target() -> TargetDescriptor {
|
|
TargetDescriptor {
|
|
element_ref: Some("e21".into()),
|
|
role: Some("button".into()),
|
|
name: Some("发布".into()),
|
|
ctx: Some("金桔柠檬 6 号".into()),
|
|
unmatched: false,
|
|
}
|
|
}
|
|
|
|
fn sample_trace() -> Trace {
|
|
Trace {
|
|
version: TRACE_VERSION,
|
|
purpose: Some("把草稿商品发布上架".into()),
|
|
started_at: Some("2026-08-10T02:10:41.080Z".into()),
|
|
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
|
stopped_by: StopReason::UserFinish,
|
|
entry: TraceEntry {
|
|
start_url: "https://example.com/".into(),
|
|
},
|
|
recorder: RecorderInfo {
|
|
bsk: "0.1.10".into(),
|
|
vom: VOM_FORMAT_VERSION,
|
|
},
|
|
states: vec![
|
|
TraceState {
|
|
id: "s1".into(),
|
|
url: "https://example.com/".into(),
|
|
title: Some("Example Domain".into()),
|
|
body: None,
|
|
page: Some("s1.vom.txt".into()),
|
|
truncated: false,
|
|
},
|
|
TraceState {
|
|
id: "s2".into(),
|
|
url: "https://shop.example.com/products?status=draft".into(),
|
|
title: Some("商品管理".into()),
|
|
body: None,
|
|
page: Some("s2.vom.txt".into()),
|
|
truncated: false,
|
|
},
|
|
],
|
|
steps: vec![
|
|
Step::Navigate {
|
|
common: sample_common(1, "s1", "s2"),
|
|
to: "https://shop.example.com/products?status=draft".into(),
|
|
cause: NavigationCause::UserTyped,
|
|
},
|
|
Step::Fill {
|
|
common: sample_common(2, "s2", "s2"),
|
|
target: TargetDescriptor {
|
|
element_ref: Some("e12".into()),
|
|
role: Some("textbox".into()),
|
|
name: Some("搜索商品".into()),
|
|
ctx: None,
|
|
unmatched: false,
|
|
},
|
|
value: "金桔柠檬".into(),
|
|
commit: FillCommit::Enter,
|
|
redacted: false,
|
|
},
|
|
Step::Click {
|
|
common: sample_common(3, "s2", "s2"),
|
|
target: sample_target(),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn step_click_round_trips() {
|
|
let step = Step::Click {
|
|
common: sample_common(1, "s1", "s2"),
|
|
target: sample_target(),
|
|
};
|
|
let v = serde_json::to_value(&step).unwrap();
|
|
assert_eq!(v.get("op").and_then(|v| v.as_str()), Some("click"));
|
|
assert_eq!(v.get("state").and_then(|v| v.as_str()), Some("s1"));
|
|
assert_eq!(v["result"]["state"], "s2");
|
|
assert_eq!(v["target"]["ref"], "e21");
|
|
let round: Step = serde_json::from_value(v).unwrap();
|
|
assert_eq!(round, step);
|
|
}
|
|
|
|
#[test]
|
|
fn step_fill_with_commit_round_trips() {
|
|
let step = Step::Fill {
|
|
common: sample_common(2, "s2", "s3"),
|
|
target: TargetDescriptor {
|
|
element_ref: Some("e12".into()),
|
|
role: Some("textbox".into()),
|
|
name: Some("搜索商品".into()),
|
|
ctx: None,
|
|
unmatched: false,
|
|
},
|
|
value: "browser skill".into(),
|
|
commit: FillCommit::Enter,
|
|
redacted: false,
|
|
};
|
|
let v = serde_json::to_value(&step).unwrap();
|
|
assert_eq!(v["op"], "fill");
|
|
assert_eq!(v["commit"], "enter");
|
|
assert!(v.get("redacted").is_none());
|
|
let round: Step = serde_json::from_value(v).unwrap();
|
|
assert_eq!(round, step);
|
|
}
|
|
|
|
#[test]
|
|
fn step_fill_password_is_redacted() {
|
|
let step = Step::Fill {
|
|
common: sample_common(1, "s1", "s1"),
|
|
target: TargetDescriptor {
|
|
element_ref: Some("e3".into()),
|
|
role: Some("textbox".into()),
|
|
name: Some("密码".into()),
|
|
ctx: None,
|
|
unmatched: false,
|
|
},
|
|
value: "***".into(),
|
|
commit: FillCommit::Blur,
|
|
redacted: true,
|
|
};
|
|
let v = serde_json::to_value(&step).unwrap();
|
|
assert_eq!(v["value"], "***");
|
|
assert_eq!(v["redacted"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn step_navigate_with_cause_round_trips() {
|
|
let step = Step::Navigate {
|
|
common: sample_common(1, "s1", "s2"),
|
|
to: "https://example.com".into(),
|
|
cause: NavigationCause::UserTyped,
|
|
};
|
|
let v = serde_json::to_value(&step).unwrap();
|
|
assert_eq!(v["op"], "navigate");
|
|
assert_eq!(v["cause"], "user_typed");
|
|
let round: Step = serde_json::from_value(v).unwrap();
|
|
assert_eq!(round, step);
|
|
}
|
|
|
|
#[test]
|
|
fn trace_v3_round_trips() {
|
|
let trace = sample_trace();
|
|
let v = serde_json::to_value(&trace).unwrap();
|
|
assert_eq!(v.get("version").and_then(|v| v.as_u64()), Some(3));
|
|
assert!(v.get("pages").is_none());
|
|
assert_eq!(v["recorder"]["vom"], 1);
|
|
assert_eq!(v["states"].as_array().unwrap().len(), 2);
|
|
let round: Trace = serde_json::from_value(v).unwrap();
|
|
assert_eq!(round, trace);
|
|
}
|
|
|
|
#[test]
|
|
fn default_fields_are_omitted() {
|
|
let step = Step::Click {
|
|
common: sample_common(1, "s1", "s1"),
|
|
target: TargetDescriptor {
|
|
element_ref: Some("e1".into()),
|
|
role: Some("button".into()),
|
|
name: Some("OK".into()),
|
|
ctx: None,
|
|
unmatched: false,
|
|
},
|
|
};
|
|
let v = serde_json::to_value(&step).unwrap();
|
|
assert!(v.get("unmatched").is_none());
|
|
assert!(v["target"].get("ctx").is_none());
|
|
assert!(v["target"].get("unmatched").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unmatched_target_serializes_flag() {
|
|
let step = Step::Click {
|
|
common: sample_common(1, "s1", "s2"),
|
|
target: TargetDescriptor {
|
|
element_ref: None,
|
|
role: Some("button".into()),
|
|
name: Some("发布".into()),
|
|
ctx: None,
|
|
unmatched: true,
|
|
},
|
|
};
|
|
let v = serde_json::to_value(&step).unwrap();
|
|
assert_eq!(v["target"]["unmatched"], true);
|
|
assert!(v["target"].get("ref").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn recorded_trace_classifies_v2_and_v3() {
|
|
let v2 = json!({
|
|
"recorded_at": "2026-07-21T08:00:00Z",
|
|
"entry": { "start_url": "https://example.com/" },
|
|
"pages": [{ "id": "p1", "url": "https://example.com/" }],
|
|
"steps": []
|
|
});
|
|
match RecordedTrace::classify_value(&v2).unwrap() {
|
|
RecordedTrace::V2(_) => {}
|
|
other => panic!("expected v2, got {other:?}"),
|
|
}
|
|
|
|
let v3 = json!({
|
|
"version": 3,
|
|
"recorded_at": "2026-07-21T08:00:00Z",
|
|
"stopped_by": "user_finish",
|
|
"entry": { "start_url": "https://example.com/" },
|
|
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
|
"states": [],
|
|
"steps": []
|
|
});
|
|
match RecordedTrace::classify_value(&v3).unwrap() {
|
|
RecordedTrace::V3(t) => assert_eq!(t.version, 3),
|
|
other => panic!("expected v3, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recorded_trace_rejects_mixed_and_unsupported_versions() {
|
|
let v2_with_states = json!({
|
|
"recorded_at": "2026-07-21T08:00:00Z",
|
|
"entry": { "start_url": "https://example.com/" },
|
|
"pages": [{ "id": "p1", "url": "https://example.com/" }],
|
|
"states": [],
|
|
"steps": []
|
|
});
|
|
assert!(RecordedTrace::classify_value(&v2_with_states).is_err());
|
|
|
|
let v3_with_pages = json!({
|
|
"version": 3,
|
|
"recorded_at": "2026-07-21T08:00:00Z",
|
|
"stopped_by": "user_finish",
|
|
"entry": { "start_url": "https://example.com/" },
|
|
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
|
"pages": [{ "id": "p1", "url": "https://example.com/" }],
|
|
"states": [],
|
|
"steps": []
|
|
});
|
|
assert!(RecordedTrace::classify_value(&v3_with_pages).is_err());
|
|
|
|
let unsupported = json!({
|
|
"version": 2,
|
|
"recorded_at": "2026-07-21T08:00:00Z",
|
|
"stopped_by": "user_finish",
|
|
"entry": { "start_url": "https://example.com/" },
|
|
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
|
"states": [],
|
|
"steps": []
|
|
});
|
|
assert!(RecordedTrace::classify_value(&unsupported).is_err());
|
|
assert!(serde_json::from_value::<Trace>(unsupported).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn recorded_trace_schema_includes_v2_and_v3() {
|
|
let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap();
|
|
let variants = schema["definitions"]["RecordedTrace"]["oneOf"]
|
|
.as_array()
|
|
.expect("RecordedTrace schema should use oneOf");
|
|
|
|
assert_eq!(variants.len(), 2);
|
|
let trace_schema = schema["definitions"]["Trace"].clone();
|
|
assert_eq!(trace_schema["properties"]["version"]["const"], 3);
|
|
}
|
|
|
|
#[test]
|
|
fn extension_trace_deserializes() {
|
|
let v = json!({
|
|
"version": 3,
|
|
"recorded_at": "2026-07-21T08:00:00Z",
|
|
"started_at": "2026-07-21T07:59:00Z",
|
|
"purpose": "demo",
|
|
"stopped_by": "user_finish",
|
|
"entry": { "start_url": "https://example.com/editor" },
|
|
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
|
"states": [
|
|
{ "id": "s1", "url": "https://example.com/editor", "page": "s1.vom.txt" },
|
|
{ "id": "s2", "url": "https://example.com/p/99", "page": "s2.vom.txt" }
|
|
],
|
|
"steps": [
|
|
{
|
|
"op": "fill",
|
|
"id": 1,
|
|
"state": "s1",
|
|
"result": { "state": "s1" },
|
|
"target": { "ref": "e1", "role": "textbox", "name": "标题" },
|
|
"value": "hello",
|
|
"commit": "blur"
|
|
},
|
|
{
|
|
"op": "click",
|
|
"id": 2,
|
|
"state": "s1",
|
|
"result": { "state": "s2" },
|
|
"target": { "ref": "e2", "role": "button", "name": "发布" }
|
|
}
|
|
]
|
|
});
|
|
let trace: Trace = serde_json::from_value(v).unwrap();
|
|
assert_eq!(trace.version, 3);
|
|
assert_eq!(trace.states.len(), 2);
|
|
assert_eq!(trace.steps.len(), 2);
|
|
}
|
|
}
|