Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion crates/originweave-core/src/semantic_action_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,41 @@ impl SemanticNodeActionTarget {
current_epoch,
)
}

/// Revalidate this target against one freshly observed exact semantic node.
///
/// The caller is responsible for obtaining the current observation from a trusted adapter
/// immediately before use. This check prevents an older target from ignoring changed node
/// identity, supported-action, or enabled-state evidence.
pub fn validate_current_observation(
&self,
current_observation: &SemanticNodeObservation,
) -> Result<(), SemanticNodeActionTargetError> {
if current_observation.handle() != &self.handle {
return Err(SemanticNodeActionTargetError::ObservationAuthorityMismatch);
}
if !current_observation
.supported_actions()
.contains(&self.action)
{
return Err(SemanticNodeActionTargetError::UnsupportedAction);
}
if self.action != NodeActionKind::ScrollIntoView && !current_observation.is_enabled() {
return Err(SemanticNodeActionTargetError::NodeNotEnabled);
}
Ok(())
}
}

/// A bounded validation failure when deriving one semantic node action target.
/// A bounded validation failure when deriving or revalidating one semantic node action target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticNodeActionTargetError {
/// The requested action was not advertised by the semantic observation.
UnsupportedAction,
/// The observation reported the target disabled for an interactive action.
NodeNotEnabled,
/// The current observation describes a different OriginWeave-owned node authority.
ObservationAuthorityMismatch,
}

impl fmt::Display for SemanticNodeActionTargetError {
Expand All @@ -80,6 +106,9 @@ impl fmt::Display for SemanticNodeActionTargetError {
Self::NodeNotEnabled => {
formatter.write_str("semantic node is not enabled for the requested action")
}
Self::ObservationAuthorityMismatch => {
formatter.write_str("current semantic observation does not match the action target")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use std::collections::BTreeSet;

use originweave_core::{
BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel,
ObservedNodeHandle, Origin, SemanticNodeActionTarget, SemanticNodeActionTargetError,
SemanticNodeObservation, SemanticNodeObservationInput,
};

fn origin(value: &str) -> Result<Origin, String> {
Origin::parse(value).map_err(|error| format!("{error:?}"))
}

fn observation(
node_id: u64,
enabled: bool,
supported_actions: BTreeSet<NodeActionKind>,
) -> Result<SemanticNodeObservation, String> {
SemanticNodeObservation::new(SemanticNodeObservationInput {
handle: ObservedNodeHandle::new(
BrowserSessionId::new(7).map_err(|error| error.to_string())?,
BrowsingContextId::new(11).map_err(|error| error.to_string())?,
origin("https://app.example")?,
DocumentEpoch::new(3).map_err(|error| error.to_string())?,
node_id,
)
.map_err(|error| error.to_string())?,
parent: None,
children: Vec::new(),
role: "button".to_owned(),
accessible_name: "Continue".to_owned(),
visible_text: Some("Continue".to_owned()),
enabled,
visible: true,
selected: None,
supported_actions,
evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]),
})
.map_err(|error| error.to_string())
}

#[test]
fn current_semantic_observation_revalidates_exact_target_action_state() -> Result<(), String> {
let initial = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?;
let target = SemanticNodeActionTarget::from_observation(&initial, NodeActionKind::Click)
.map_err(|error| error.to_string())?;

let current = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?;
target
.validate_current_observation(&current)
.map_err(|error| error.to_string())?;

let disabled = observation(17, false, BTreeSet::from([NodeActionKind::Click]))?;
assert_eq!(
target.validate_current_observation(&disabled),
Err(SemanticNodeActionTargetError::NodeNotEnabled)
);

let action_removed = observation(17, true, BTreeSet::new())?;
assert_eq!(
target.validate_current_observation(&action_removed),
Err(SemanticNodeActionTargetError::UnsupportedAction)
);

let other_node = observation(18, true, BTreeSet::from([NodeActionKind::Click]))?;
assert_eq!(
target.validate_current_observation(&other_node),
Err(SemanticNodeActionTargetError::ObservationAuthorityMismatch)
);
Ok(())
}

#[test]
fn scroll_revalidation_preserves_non_enabled_scroll_boundary() -> Result<(), String> {
let initial = observation(17, false, BTreeSet::from([NodeActionKind::ScrollIntoView]))?;
let target =
SemanticNodeActionTarget::from_observation(&initial, NodeActionKind::ScrollIntoView)
.map_err(|error| error.to_string())?;
let current = observation(17, false, BTreeSet::from([NodeActionKind::ScrollIntoView]))?;

target
.validate_current_observation(&current)
.map_err(|error| error.to_string())?;
Ok(())
}
4 changes: 4 additions & 0 deletions crates/originweave-core/tests/semantic_node_action_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,8 @@ fn node_action_target_error_is_stable_and_credential_free() {
SemanticNodeActionTargetError::NodeNotEnabled.to_string(),
"semantic node is not enabled for the requested action"
);
assert_eq!(
SemanticNodeActionTargetError::ObservationAuthorityMismatch.to_string(),
"current semantic observation does not match the action target"
);
}
Loading