diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index dee452f..28b140e 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -834,7 +834,7 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -874,8 +874,7 @@ "$ref": "#/definitions/StepV2" } } - }, - "additionalProperties": false + } }, "TraceV3": { "description": "Wire trace returned by `tool.record_stop` / `await`.", diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index 5d1e04a..c1477d4 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -834,7 +834,7 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -874,8 +874,7 @@ "$ref": "#/definitions/StepV2" } } - }, - "additionalProperties": false + } }, "TraceV3": { "description": "Wire trace returned by `tool.record_stop` / `await`.", diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 750ce6a..4737d0a 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,60 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "TraceV3", - "description": "Wire trace returned by `tool.record_stop` / `await`.", - "type": "object", - "required": [ - "entry", - "recorded_at", - "recorder", - "states", - "steps", - "stopped_by", - "version" - ], - "properties": { - "entry": { - "$ref": "#/definitions/TraceEntry" + "title": "RecordedTrace", + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" }, - "purpose": { - "type": [ - "string", - "null" - ] - }, - "recorded_at": { - "type": "string" - }, - "recorder": { - "$ref": "#/definitions/RecorderInfo" - }, - "started_at": { - "type": [ - "string", - "null" - ] - }, - "states": { - "type": "array", - "items": { - "$ref": "#/definitions/TraceStateV3" - } - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/definitions/StepV3" - } - }, - "stopped_by": { - "$ref": "#/definitions/StopReason" - }, - "version": { - "type": "integer", - "const": 3 + { + "$ref": "#/definitions/TraceV3" } - }, - "additionalProperties": false, + ], "definitions": { "FillCommit": { "type": "string", @@ -86,6 +40,28 @@ "browser" ] }, + "PageRefV2": { + "description": "Page context dictionary entry — referenced by steps via `page` id.", + "type": "object", + "required": [ + "id", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + } + }, "RecorderInfo": { "type": "object", "required": [ @@ -103,6 +79,24 @@ } } }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", @@ -121,6 +115,19 @@ } } }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, "StepResultV3": { "type": "object", "required": [ @@ -132,6 +139,287 @@ } } }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, "StepV3": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ @@ -418,6 +706,48 @@ "cli_stop" ] }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + }, "TargetDescriptorV3": { "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", @@ -491,6 +821,105 @@ "type": "string" } } + }, + "TraceV2": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRefV2" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + } + }, + "TraceV3": { + "description": "Wire trace returned by `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "type": "string" + }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV3" + } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "const": 3 + } + }, + "additionalProperties": false } } } diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json index 7db328c..a914250 100644 --- a/crates/bsk-protocol/schema/trace_v2.json +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "TraceV2", - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -42,7 +42,6 @@ } } }, - "additionalProperties": false, "definitions": { "KeyModifier": { "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", diff --git a/crates/bsk-protocol/schema/trace_v3.json b/crates/bsk-protocol/schema/trace_v3.json new file mode 100644 index 0000000..750ce6a --- /dev/null +++ b/crates/bsk-protocol/schema/trace_v3.json @@ -0,0 +1,496 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TraceV3", + "description": "Wire trace returned by `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "type": "string" + }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV3" + } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "const": 3 + } + }, + "additionalProperties": false, + "definitions": { + "FillCommit": { + "type": "string", + "enum": [ + "enter", + "suggestion", + "blur" + ] + }, + "KeyModifier": { + "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", + "type": "string", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "SelectedOptionV3": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV3": { + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "cause", + "id", + "op", + "result", + "state", + "to" + ], + "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV3" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV3" + }, + { + "type": "null" + } + ] + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "scroll" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + } + } + } + ] + }, + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", + "type": "object", + "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "unmatched": { + "type": "boolean" + } + } + }, + "TraceEntry": { + "type": "object", + "required": [ + "start_url" + ], + "properties": { + "start_url": { + "type": "string" + } + } + }, + "TraceStateV3": { + "description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.", + "type": "object", + "required": [ + "body", + "id", + "url" + ], + "properties": { + "body": { + "description": "Full page observation (front matter + VOM body + annotations).", + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "truncated": { + "type": "boolean" + }, + "url": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 6e565fe..9ec21e8 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -114,7 +114,8 @@ fn main() { dump!(RequestHelpResult, "tool_request_help_result"); dump!(TraceV2, "trace_v2"); - dump!(TraceV3, "trace"); + dump!(TraceV3, "trace_v3"); + dump!(RecordedTrace, "trace"); dump!(StepV3, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); dump!(RecordStartResult, "tool_record_start_result"); diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 11ab651..88347b3 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -11,6 +11,7 @@ pub mod observation; pub mod record; mod record_common; mod record_v2; +mod record_v3; pub mod script; pub mod session; pub mod tabs; diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index fe7c732..cfb1b1a 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -1,212 +1,22 @@ //! 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. +//! Wire traces are either Trace v2 (`pages[]`) or Trace v3 (`version: 3`, +//! `states[]`). Version-specific models live in `record_v2` / `record_v3`. use schemars::JsonSchema; -use serde::{Deserialize, Deserializer, Serialize}; - -use super::interaction::KeyModifier; +use serde::{Deserialize, Serialize}; pub use super::record_common::TraceEntry; pub use super::record_v2::{ PageRefV2, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, }; +pub use super::record_v3::{ + FillCommit, NavigationCause, RecorderInfo, SelectedOptionV3, StepCommonV3, StepResultV3, + StepV3, StopReason, TRACE_VERSION_V3, TargetDescriptorV3, TraceStateV3, TraceV3, + VOM_FORMAT_VERSION, +}; -pub const TRACE_VERSION_V3: u32 = 3; pub const TRACE_VERSION_V2: u32 = 2; -pub const VOM_FORMAT_VERSION: u32 = 1; - -fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let version = u32::deserialize(deserializer)?; - if version != TRACE_VERSION_V3 { - return Err(serde::de::Error::custom(format!( - "unsupported trace version {version} (expected {TRACE_VERSION_V3})" - ))); - } - 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_V3)), - ..Default::default() - } - .into() -} - -// --------------------------------------------------------------------------- -// Target -// --------------------------------------------------------------------------- - -/// Stable semantic handle for an interacted element within a page observation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TargetDescriptorV3 { - #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")] - pub element_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub role: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ctx: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub unmatched: bool, -} - -// --------------------------------------------------------------------------- -// Trace v3 envelope -// --------------------------------------------------------------------------- - -#[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 TraceStateV3 { - pub id: String, - pub url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - /// Full page observation (front matter + VOM body + annotations). - pub body: String, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepResultV3 { - pub state: String, -} - -/// Fields shared by every step variant (flattened in JSON). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepCommonV3 { - pub id: u32, - /// Observation id immediately before this action. - pub state: String, - pub result: StepResultV3, -} - -// --------------------------------------------------------------------------- -// Trace v3 step payloads -// --------------------------------------------------------------------------- - -/// One selected option (`select` op). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct SelectedOptionV3 { - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, -} - -#[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 StepV3 { - Navigate { - #[serde(flatten)] - common: StepCommonV3, - to: String, - cause: NavigationCause, - }, - Click { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - }, - Hover { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - }, - Fill { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - value: String, - commit: FillCommit, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - redacted: bool, - }, - Select { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - selection: Vec, - }, - Press { - #[serde(flatten)] - common: StepCommonV3, - key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - modifiers: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - }, - Scroll { - #[serde(flatten)] - common: StepCommonV3, - }, -} - -// --------------------------------------------------------------------------- -// Trace v3 root -// --------------------------------------------------------------------------- - -/// Wire trace returned by `tool.record_stop` / `await`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TraceV3 { - #[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub started_at: Option, - pub recorded_at: String, - pub stopped_by: StopReason, - pub entry: TraceEntry, - pub recorder: RecorderInfo, - pub states: Vec, - pub steps: Vec, -} // --------------------------------------------------------------------------- // RPC params / results @@ -325,211 +135,11 @@ 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) -> StepCommonV3 { - StepCommonV3 { - id, - state: state.into(), - result: StepResultV3 { - state: result_state.into(), - }, - } - } - - fn sample_target() -> TargetDescriptorV3 { - TargetDescriptorV3 { - element_ref: Some("e21".into()), - role: Some("button".into()), - name: Some("发布".into()), - ctx: Some("金桔柠檬 6 号".into()), - unmatched: false, - } - } - - fn sample_trace() -> TraceV3 { - TraceV3 { - version: TRACE_VERSION_V3, - 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![ - TraceStateV3 { - id: "s1".into(), - url: "https://example.com/".into(), - title: Some("Example Domain".into()), - body: "@vom 1\nRootWebArea \"Example Domain\"".into(), - truncated: false, - }, - TraceStateV3 { - id: "s2".into(), - url: "https://shop.example.com/products?status=draft".into(), - title: Some("商品管理".into()), - body: "@vom 1\nRootWebArea \"商品管理\"".into(), - truncated: false, - }, - ], - steps: vec![ - StepV3::Navigate { - common: sample_common(1, "s1", "s2"), - to: "https://shop.example.com/products?status=draft".into(), - cause: NavigationCause::UserTyped, - }, - StepV3::Fill { - common: sample_common(2, "s2", "s2"), - target: TargetDescriptorV3 { - element_ref: Some("e12".into()), - role: Some("textbox".into()), - name: Some("搜索商品".into()), - ctx: None, - unmatched: false, - }, - value: "金桔柠檬".into(), - commit: FillCommit::Enter, - redacted: false, - }, - StepV3::Click { - common: sample_common(3, "s2", "s2"), - target: sample_target(), - }, - ], - } - } - - #[test] - fn step_click_round_trips() { - let step = StepV3::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: StepV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); - } - - #[test] - fn step_fill_with_commit_round_trips() { - let step = StepV3::Fill { - common: sample_common(2, "s2", "s3"), - target: TargetDescriptorV3 { - 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: StepV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); - } - - #[test] - fn step_fill_password_is_redacted() { - let step = StepV3::Fill { - common: sample_common(1, "s1", "s1"), - target: TargetDescriptorV3 { - 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 = StepV3::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: StepV3 = 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: TraceV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, trace); - } - - #[test] - fn default_fields_are_omitted() { - let step = StepV3::Click { - common: sample_common(1, "s1", "s1"), - target: TargetDescriptorV3 { - 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 = StepV3::Click { - common: sample_common(1, "s1", "s2"), - target: TargetDescriptorV3 { - 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!({ @@ -607,41 +217,29 @@ mod tests { } #[test] - fn extension_trace_deserializes() { - let v = json!({ - "version": 3, + fn standalone_trace_schema_is_recorded_trace_union() { + let schema = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); + assert_eq!(schema["title"], "RecordedTrace"); + let variants = schema["oneOf"] + .as_array() + .expect("standalone trace schema should be a v2|v3 oneOf"); + assert_eq!(variants.len(), 2); + assert!(schema["definitions"].get("TraceV2").is_some()); + assert!(schema["definitions"].get("TraceV3").is_some()); + } + + #[test] + fn recorded_trace_accepts_v2_with_unknown_extension_fields() { + let v2 = json!({ "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", "body": "@vom 1" }, - { "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" } - ], - "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": "发布" } - } - ] + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [], + "meta": { "tool": "legacy-exporter" } }); - let trace: TraceV3 = serde_json::from_value(v).unwrap(); - assert_eq!(trace.version, 3); - assert_eq!(trace.states.len(), 2); - assert_eq!(trace.steps.len(), 2); + match RecordedTrace::classify_value(&v2).unwrap() { + RecordedTrace::V2(trace) => assert_eq!(trace.pages.len(), 1), + other => panic!("expected v2, got {other:?}"), + } } } diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs index 43b36e7..b75b55d 100644 --- a/crates/bsk-protocol/src/tools/record_v2.rs +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -105,8 +105,10 @@ pub enum StepV2 { } /// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. +/// +/// Unknown extension fields are ignored so older traces remain readable. +/// Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct TraceV2 { /// RFC 3339 timestamp when recording stopped. pub recorded_at: String, @@ -222,4 +224,28 @@ mod tests { "hover" ); } + + #[test] + fn trace_v2_ignores_unknown_extension_fields() { + let value = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [], + "meta": { "tool": "legacy-exporter" } + }); + let trace: TraceV2 = serde_json::from_value(value).unwrap(); + assert_eq!(trace.pages.len(), 1); + assert!(trace.steps.is_empty()); + } + + #[test] + fn trace_v2_schema_allows_additional_properties() { + let schema = serde_json::to_value(schemars::schema_for!(TraceV2)).unwrap(); + assert_ne!( + schema.get("additionalProperties"), + Some(&serde_json::Value::Bool(false)), + "Trace v2 must keep accepting traces with unknown extension fields" + ); + } } diff --git a/crates/bsk-protocol/src/tools/record_v3.rs b/crates/bsk-protocol/src/tools/record_v3.rs new file mode 100644 index 0000000..bbdd97f --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_v3.rs @@ -0,0 +1,428 @@ +//! Trace v3 — state-action-state log with `states[]` and step `state` refs. +//! +//! Wire format with top-level `version: 3`. + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::interaction::KeyModifier; +use super::record_common::TraceEntry; + +pub const TRACE_VERSION_V3: u32 = 3; +pub const VOM_FORMAT_VERSION: u32 = 1; + +fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let version = u32::deserialize(deserializer)?; + if version != TRACE_VERSION_V3 { + return Err(serde::de::Error::custom(format!( + "unsupported trace version {version} (expected {TRACE_VERSION_V3})" + ))); + } + 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_V3)), + ..Default::default() + } + .into() +} + +/// Stable semantic handle for an interacted element within a page observation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetDescriptorV3 { + #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")] + pub element_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ctx: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unmatched: bool, +} + +#[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 TraceStateV3 { + pub id: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Full page observation (front matter + VOM body + annotations). + pub body: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepResultV3 { + pub state: String, +} + +/// Fields shared by every step variant (flattened in JSON). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepCommonV3 { + pub id: u32, + /// Observation id immediately before this action. + pub state: String, + pub result: StepResultV3, +} + +/// One selected option (`select` op). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SelectedOptionV3 { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[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 StepV3 { + Navigate { + #[serde(flatten)] + common: StepCommonV3, + to: String, + cause: NavigationCause, + }, + Click { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + }, + Hover { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + }, + Fill { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + value: String, + commit: FillCommit, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + redacted: bool, + }, + Select { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + selection: Vec, + }, + Press { + #[serde(flatten)] + common: StepCommonV3, + key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + modifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, + Scroll { + #[serde(flatten)] + common: StepCommonV3, + }, +} + +/// Wire trace returned by `tool.record_stop` / `await`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TraceV3 { + #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + pub recorded_at: String, + pub stopped_by: StopReason, + pub entry: TraceEntry, + pub recorder: RecorderInfo, + pub states: Vec, + pub steps: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommonV3 { + StepCommonV3 { + id, + state: state.into(), + result: StepResultV3 { + state: result_state.into(), + }, + } + } + + fn sample_target() -> TargetDescriptorV3 { + TargetDescriptorV3 { + element_ref: Some("e21".into()), + role: Some("button".into()), + name: Some("发布".into()), + ctx: Some("金桔柠檬 6 号".into()), + unmatched: false, + } + } + + fn sample_trace() -> TraceV3 { + TraceV3 { + version: TRACE_VERSION_V3, + 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![ + TraceStateV3 { + id: "s1".into(), + url: "https://example.com/".into(), + title: Some("Example Domain".into()), + body: "@vom 1\nRootWebArea \"Example Domain\"".into(), + truncated: false, + }, + TraceStateV3 { + id: "s2".into(), + url: "https://shop.example.com/products?status=draft".into(), + title: Some("商品管理".into()), + body: "@vom 1\nRootWebArea \"商品管理\"".into(), + truncated: false, + }, + ], + steps: vec![ + StepV3::Navigate { + common: sample_common(1, "s1", "s2"), + to: "https://shop.example.com/products?status=draft".into(), + cause: NavigationCause::UserTyped, + }, + StepV3::Fill { + common: sample_common(2, "s2", "s2"), + target: TargetDescriptorV3 { + element_ref: Some("e12".into()), + role: Some("textbox".into()), + name: Some("搜索商品".into()), + ctx: None, + unmatched: false, + }, + value: "金桔柠檬".into(), + commit: FillCommit::Enter, + redacted: false, + }, + StepV3::Click { + common: sample_common(3, "s2", "s2"), + target: sample_target(), + }, + ], + } + } + + #[test] + fn step_click_round_trips() { + let step = StepV3::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: StepV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, step); + } + + #[test] + fn step_fill_with_commit_round_trips() { + let step = StepV3::Fill { + common: sample_common(2, "s2", "s3"), + target: TargetDescriptorV3 { + 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: StepV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, step); + } + + #[test] + fn step_fill_password_is_redacted() { + let step = StepV3::Fill { + common: sample_common(1, "s1", "s1"), + target: TargetDescriptorV3 { + 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 = StepV3::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: StepV3 = 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: TraceV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, trace); + } + + #[test] + fn default_fields_are_omitted() { + let step = StepV3::Click { + common: sample_common(1, "s1", "s1"), + target: TargetDescriptorV3 { + 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 = StepV3::Click { + common: sample_common(1, "s1", "s2"), + target: TargetDescriptorV3 { + 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 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", "body": "@vom 1" }, + { "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" } + ], + "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: TraceV3 = serde_json::from_value(v).unwrap(); + assert_eq!(trace.version, 3); + assert_eq!(trace.states.len(), 2); + assert_eq!(trace.steps.len(), 2); + } +}