use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use wvq_domain::{ObligationId, ProgramId};
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ProgramError {
#[error("unknown test_program schema_v {0}")]
UnknownSchema(u32),
#[error("{0}")]
Invalid(String),
#[error("malformed TestProgram: {0}")]
Malformed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptureWhen {
Never,
OnFailure,
Always,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvidencePolicy {
#[serde(default = "never")]
pub screenshot: CaptureWhen,
#[serde(default = "never")]
pub trace: CaptureWhen,
#[serde(default = "always")]
pub network: CaptureWhen,
#[serde(default = "always")]
pub console: CaptureWhen,
#[serde(default = "on_failure")]
pub storage: CaptureWhen,
}
fn never() -> CaptureWhen {
CaptureWhen::Never
}
fn always() -> CaptureWhen {
CaptureWhen::Always
}
fn on_failure() -> CaptureWhen {
CaptureWhen::OnFailure
}
impl Default for EvidencePolicy {
fn default() -> Self {
Self {
screenshot: CaptureWhen::Never,
trace: CaptureWhen::Never,
network: CaptureWhen::Always,
console: CaptureWhen::Always,
storage: CaptureWhen::OnFailure,
}
}
}
impl EvidencePolicy {
#[must_use]
pub fn allow_screenshot(&self, failed: bool) -> bool {
match self.screenshot {
CaptureWhen::Always => true,
CaptureWhen::OnFailure => failed,
CaptureWhen::Never => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProgramSource {
Authored,
Generated,
Recorded,
Recovered,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Target {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accessible_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component_hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Box<Target>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_css: Option<String>,
}
impl Target {
fn validate(&self) -> Result<(), ProgramError> {
let identities = [
self.role.as_deref(),
self.accessible_name.as_deref(),
self.label.as_deref(),
self.test_id.as_deref(),
self.component_hint.as_deref(),
self.fallback_css.as_deref(),
];
let empty = identities
.iter()
.flatten()
.all(|value| value.trim().is_empty());
if empty {
return Err(ProgramError::Invalid(
"target needs a semantic identity (test_id, role, name, label, or CSS fallback)"
.into(),
));
}
if identities.iter().flatten().any(|value| {
let value = value.trim().to_ascii_lowercase();
value.contains("xpath") || value.starts_with("//")
}) {
return Err(ProgramError::Invalid(
"XPath is not a TestProgram identity".into(),
));
}
if let Some(scope) = &self.scope {
scope.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaultSpec {
Abort {
url_contains: String,
},
HttpResponse {
url_contains: String,
status: u16,
#[serde(default)]
body: String,
#[serde(default)]
headers: BTreeMap<String, String>,
},
Delay {
url_contains: String,
delay_ms: u32,
},
}
impl FaultSpec {
fn validate(&self) -> Result<(), ProgramError> {
let (url, status, delay) = match self {
Self::Abort { url_contains } => (url_contains, None, None),
Self::HttpResponse {
url_contains,
status,
..
} => (url_contains, Some(*status), None),
Self::Delay {
url_contains,
delay_ms,
} => (url_contains, None, Some(*delay_ms)),
};
if url.trim().is_empty() {
return Err(ProgramError::Invalid(
"fault URL fragment must be non-empty".into(),
));
}
if status.is_some_and(|status| !(100..=599).contains(&status)) {
return Err(ProgramError::Invalid(
"fault HTTP status must be between 100 and 599".into(),
));
}
if delay.is_some_and(|delay| delay == 0 || delay > 30_000) {
return Err(ProgramError::Invalid(
"fault delay_ms must be between 1 and 30000".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApiOperation {
pub method: String,
pub path: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
}
impl ApiOperation {
fn validate(&self) -> Result<(), ProgramError> {
if !matches!(
self.method.as_str(),
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"
) {
return Err(ProgramError::Invalid(format!(
"unsupported API method `{}`",
self.method
)));
}
if !self.path.starts_with('/') || self.path.starts_with("//") {
return Err(ProgramError::Invalid(
"API operation path must be root-relative".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WaitCondition {
Visible {
target: Target,
},
Url {
route: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum TestAction {
Navigate {
route: String,
},
Activate {
target: Target,
},
Fill {
target: Target,
value: String,
},
Select {
target: Target,
value: String,
},
Press {
#[serde(default)]
target: Option<Target>,
key: String,
},
Wait {
condition: WaitCondition,
},
SetFeatureFlag {
key: String,
value: String,
},
InjectFault {
fault: String,
},
ApiCall {
operation: String,
input: String,
},
Hover {
target: Target,
},
Scroll {
target: Target,
},
Drag {
target: Target,
to: Target,
},
Upload {
target: Target,
fixture: String,
},
Download {
target: Target,
},
Popup {
target: Target,
},
SwitchTab {
route: String,
},
Assert {
obligation: ObligationId,
},
}
impl TestAction {
pub(crate) fn validate(&self) -> Result<(), ProgramError> {
match self {
Self::Navigate { route } if route.is_empty() => Err(ProgramError::Invalid(
"navigate route must be non-empty".into(),
)),
Self::Activate { target }
| Self::Fill { target, .. }
| Self::Select { target, .. }
| Self::Hover { target }
| Self::Scroll { target }
| Self::Download { target }
| Self::Popup { target }
| Self::Wait {
condition: WaitCondition::Visible { target },
} => target.validate(),
Self::Drag { target, to } => {
target.validate()?;
to.validate()
}
Self::Upload { target, fixture } => {
target.validate()?;
validate_registered_name("upload fixture", fixture)
}
Self::SwitchTab { route } if route.is_empty() => Err(ProgramError::Invalid(
"switch_tab route must be non-empty".into(),
)),
Self::Press { target, key } => {
if key.is_empty() {
return Err(ProgramError::Invalid("press key must be non-empty".into()));
}
target.as_ref().map_or(Ok(()), Target::validate)
}
Self::Wait {
condition: WaitCondition::Url { route },
} if route.is_empty() => {
Err(ProgramError::Invalid("wait url must be non-empty".into()))
}
Self::SetFeatureFlag { key, .. } if key.is_empty() => Err(ProgramError::Invalid(
"feature flag key must be non-empty".into(),
)),
Self::InjectFault { fault } if fault.is_empty() => {
Err(ProgramError::Invalid("fault id must be non-empty".into()))
}
Self::ApiCall { operation, .. } if operation.is_empty() => Err(ProgramError::Invalid(
"api operation must be non-empty".into(),
)),
_ => Ok(()),
}
}
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Navigate { .. } => "navigate",
Self::Activate { .. } => "activate",
Self::Fill { .. } => "fill",
Self::Select { .. } => "select",
Self::Press { .. } => "press",
Self::Wait { .. } => "wait",
Self::SetFeatureFlag { .. } => "set_feature_flag",
Self::InjectFault { .. } => "inject_fault",
Self::ApiCall { .. } => "api_call",
Self::Hover { .. } => "hover",
Self::Scroll { .. } => "scroll",
Self::Drag { .. } => "drag",
Self::Upload { .. } => "upload",
Self::Download { .. } => "download",
Self::Popup { .. } => "popup",
Self::SwitchTab { .. } => "switch_tab",
Self::Assert { .. } => "assert",
}
}
#[must_use]
pub fn semantic_target(&self) -> Option<&Target> {
match self {
Self::Activate { target }
| Self::Fill { target, .. }
| Self::Select { target, .. }
| Self::Hover { target }
| Self::Scroll { target }
| Self::Drag { target, .. }
| Self::Upload { target, .. }
| Self::Download { target }
| Self::Popup { target }
| Self::Wait {
condition: WaitCondition::Visible { target },
} => Some(target),
Self::Press { target, .. } => target.as_ref(),
_ => None,
}
}
}
const MAX_UPLOAD_TEXT_BYTES: usize = 64 * 1024;
fn validate_registered_name(label: &str, name: &str) -> Result<(), ProgramError> {
if name.is_empty() {
return Err(ProgramError::Invalid(format!("{label} must be non-empty")));
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(ProgramError::Invalid(format!(
"{label} must be a registered name, not a path"
)));
}
Ok(())
}
fn validate_upload_filename(name: &str) -> Result<(), ProgramError> {
if name.is_empty() || name == "." || name == ".." {
return Err(ProgramError::Invalid(
"upload filename must be a basename".into(),
));
}
let path = Path::new(name);
if path.components().count() != 1 || path.file_name() != Some(std::ffi::OsStr::new(name)) {
return Err(ProgramError::Invalid(
"upload filename must be a basename".into(),
));
}
Ok(())
}
fn validate_upload_fixture(
fixture: &str,
data: &BTreeMap<String, serde_json::Value>,
) -> Result<(), ProgramError> {
validate_registered_name("upload fixture", fixture)?;
let Some(value) = data.get(fixture) else {
return Err(ProgramError::Invalid(format!(
"upload names unknown data `{fixture}`"
)));
};
let Some(object) = value.as_object() else {
return Err(ProgramError::Invalid(
"upload fixture must be an object with filename and text".into(),
));
};
if object.keys().any(|key| key != "filename" && key != "text") {
return Err(ProgramError::Invalid(
"upload fixture has unknown fields".into(),
));
}
let Some(filename) = object.get("filename").and_then(serde_json::Value::as_str) else {
return Err(ProgramError::Invalid(
"upload fixture needs a filename".into(),
));
};
let Some(text) = object.get("text").and_then(serde_json::Value::as_str) else {
return Err(ProgramError::Invalid("upload fixture needs text".into()));
};
validate_upload_filename(filename)?;
if text.len() > MAX_UPLOAD_TEXT_BYTES {
return Err(ProgramError::Invalid(
"upload fixture text exceeds 64KiB".into(),
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TestProgram {
pub schema_v: u32,
pub id: ProgramId,
pub source: ProgramSource,
pub obligations: Vec<ObligationId>,
#[serde(default)]
pub preconditions: Vec<TestAction>,
pub steps: Vec<TestAction>,
#[serde(default)]
pub data: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub faults: BTreeMap<String, FaultSpec>,
#[serde(default)]
pub api_operations: BTreeMap<String, ApiOperation>,
#[serde(default)]
pub evidence_policy: EvidencePolicy,
#[serde(default)]
pub deterministic_seed: Option<u64>,
}
impl TestProgram {
pub fn from_json(raw: &str) -> Result<Self, ProgramError> {
if raw.contains("\"xpath\"") {
return Err(ProgramError::Invalid(
"XPath is not a TestProgram identity".into(),
));
}
let program: Self =
serde_json::from_str(raw).map_err(|err| ProgramError::Malformed(err.to_string()))?;
program.validate()?;
Ok(program)
}
pub fn validate(&self) -> Result<(), ProgramError> {
if self.schema_v != 1 {
return Err(ProgramError::UnknownSchema(self.schema_v));
}
if self.obligations.is_empty() {
return Err(ProgramError::Invalid(
"TestProgram needs at least one obligation".into(),
));
}
if self.steps.is_empty() {
return Err(ProgramError::Invalid(
"TestProgram needs at least one step".into(),
));
}
for fault in self.faults.values() {
fault.validate()?;
}
for operation in self.api_operations.values() {
operation.validate()?;
}
if self
.preconditions
.iter()
.any(|action| matches!(action, TestAction::Assert { .. }))
{
return Err(ProgramError::Invalid(
"preconditions cannot assert an obligation".into(),
));
}
let mut asserted = std::collections::BTreeSet::new();
for step in self.preconditions.iter().chain(&self.steps) {
step.validate()?;
match step {
TestAction::Assert { obligation } if !self.obligations.contains(obligation) => {
return Err(ProgramError::Invalid(format!(
"assert names undeclared obligation `{obligation}`"
)));
}
TestAction::Assert { obligation } => {
asserted.insert(obligation.clone());
}
TestAction::InjectFault { fault } if !self.faults.contains_key(fault) => {
return Err(ProgramError::Invalid(format!(
"inject_fault names unknown fault `{fault}`"
)));
}
TestAction::ApiCall { operation, input }
if !self.api_operations.contains_key(operation)
|| !self.data.contains_key(input) =>
{
return Err(ProgramError::Invalid(format!(
"api_call requires registered operation `{operation}` and data `{input}`"
)));
}
TestAction::Upload { fixture, .. } => {
validate_upload_fixture(fixture, &self.data)?;
}
_ => {}
}
}
if let Some(missing) = self
.obligations
.iter()
.find(|obligation| !asserted.contains(*obligation))
{
return Err(ProgramError::Invalid(format!(
"declared obligation `{missing}` is never asserted"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkRequestObservation {
pub sequence: u64,
pub method: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphql_operation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphql_query_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphql_variables_digest: Option<String>,
}
impl NetworkRequestObservation {
#[must_use]
pub fn identity_key(&self) -> String {
let mut identity = crate::identify_request(
&self.method,
&self.url,
self.content_type.as_deref().unwrap_or(""),
None,
);
if let Some(content_type) = &self.content_type {
identity.content_type.clone_from(content_type);
}
identity.body_digest.clone_from(&self.body_digest);
if self.graphql_query_digest.is_some() || self.graphql_variables_digest.is_some() {
identity.graphql = Some(crate::GraphqlIdentity {
operation_name: self.graphql_operation.clone(),
query_digest: self.graphql_query_digest.clone().unwrap_or_default(),
variables_digest: self.graphql_variables_digest.clone().unwrap_or_default(),
});
identity.body_digest = None;
}
identity.key()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Observation {
#[serde(default)]
pub route: Option<String>,
#[serde(default)]
pub a11y_digest: Option<String>,
#[serde(default)]
pub network: Vec<String>,
#[serde(default)]
pub network_requests: Vec<NetworkRequestObservation>,
#[serde(default)]
pub network_requests_truncated: bool,
#[serde(default)]
pub console: Vec<String>,
#[serde(default)]
pub storage: BTreeMap<String, String>,
#[serde(default)]
pub storage_available: bool,
#[serde(default)]
pub viewport: Option<String>,
#[serde(default)]
pub screenshot_handle: Option<String>,
#[serde(default)]
pub visual_digest: Option<String>,
#[serde(default)]
pub visual_surface: Option<String>,
}
#[must_use]
pub fn filter_observation(
mut observation: Observation,
policy: &EvidencePolicy,
failed: bool,
) -> Observation {
if !policy.allow_screenshot(failed) {
observation.screenshot_handle = None;
observation.visual_digest = None;
observation.visual_surface = None;
}
if matches!(policy.network, CaptureWhen::Never)
|| (matches!(policy.network, CaptureWhen::OnFailure) && !failed)
{
observation.network.clear();
observation.network_requests.clear();
observation.network_requests_truncated = false;
}
if matches!(policy.console, CaptureWhen::Never)
|| (matches!(policy.console, CaptureWhen::OnFailure) && !failed)
{
observation.console.clear();
}
if matches!(policy.storage, CaptureWhen::Never)
|| (matches!(policy.storage, CaptureWhen::OnFailure) && !failed)
{
observation.storage.clear();
}
observation
}