use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
pub const SCRIPT_SPEC_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptSpec {
pub version: u32,
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub selectors: IndexMap<String, String>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub captures: Vec<Capture>,
#[serde(default)]
pub assertions: Vec<Assertion>,
#[serde(default)]
pub exports: IndexMap<String, Export>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Defaults {
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_strict")]
pub strict_locators: bool,
#[serde(default)]
pub max_wall_ms: Option<u64>,
}
fn default_timeout_ms() -> u64 {
10_000
}
fn default_strict() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Step {
Goto(GotoStep),
WaitFor(WaitForStep),
WaitMs { ms: u64 },
Click(ClickStep),
Type(TypeStep),
Press { key: String },
Scroll { dy: f64 },
Eval { script: String },
Submit { locator: Locator },
Screenshot(ScreenshotStep),
Snapshot(SnapshotStep),
Extract(ExtractStep),
Assert(Assertion),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GotoStep {
pub url: String,
#[serde(default)]
pub wait_until: Option<WaitUntil>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WaitUntil {
Load,
DomContentLoaded,
NetworkIdle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaitForStep {
pub locator: Locator,
#[serde(default)]
pub state: Option<LocatorState>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocatorState {
Attached,
Detached,
Visible,
Hidden,
Stable,
Enabled,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClickStep {
pub locator: Locator,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypeStep {
pub locator: Locator,
pub text: String,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub clear: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Locator {
Raw(String),
}
impl Locator {
pub fn resolve<'a>(&'a self, named: &'a IndexMap<String, String>) -> &'a str {
match self {
Self::Raw(s) => {
if let Some(name) = s.strip_prefix('@') {
named.get(name).map(|s| s.as_str()).unwrap_or(s)
} else {
s
}
}
}
}
pub fn ax_ref(&self) -> Option<&str> {
let Self::Raw(s) = self;
let rest = s.strip_prefix("@e")?;
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
Some(s.as_str())
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScreenshotStep {
#[serde(default)]
pub mode: ScreenshotMode,
#[serde(default)]
pub locator: Option<Locator>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub format: ScreenshotFormat,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotMode {
#[default]
Viewport,
FullPage,
Element,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotFormat {
#[default]
Png,
Jpeg,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotStep {
pub kind: SnapshotKind,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotKind {
ResponseBody,
DomSnapshot,
PostJsHtml,
State,
PwaState,
AxTree,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractStep {
pub fields: IndexMap<String, Export>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Export {
BareLocator(String),
Spec(ExportSpec),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportSpec {
pub locator: Locator,
#[serde(default)]
pub kind: ExportKind,
#[serde(default)]
pub attr: Option<String>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub as_list: bool,
#[serde(default)]
pub origin: ExportOrigin,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportKind {
#[default]
Text,
Html,
Attribute,
Links,
JsonLd,
Regex,
Script,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportOrigin {
Static,
#[default]
Rendered,
StaticThenRendered,
RenderedThenStatic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Assertion {
Exists { locator: Locator },
NotExists { locator: Locator },
Contains { locator: Locator, text: String },
HasUrl { pattern: String },
HasTitle { pattern: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capture {
Screenshot(ScreenshotStep),
Snapshot(SnapshotStep),
Network,
Console,
Metrics,
Seo,
}
#[derive(Debug, thiserror::Error)]
pub enum ScriptLoadError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("unsupported script format for path {0}")]
UnsupportedFormat(String),
#[error("unsupported spec version: got {got}, expected {expected}")]
VersionMismatch { got: u32, expected: u32 },
}
impl ScriptSpec {
pub fn from_json(data: &[u8]) -> Result<Self, ScriptLoadError> {
let s: Self = serde_json::from_slice(data)?;
s.check_version()?;
Ok(s)
}
fn check_version(&self) -> Result<(), ScriptLoadError> {
if self.version != SCRIPT_SPEC_VERSION {
return Err(ScriptLoadError::VersionMismatch {
got: self.version,
expected: SCRIPT_SPEC_VERSION,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ax_ref_matches_at_e_digits_only() {
assert_eq!(Locator::Raw("@e1".into()).ax_ref(), Some("@e1"));
assert_eq!(Locator::Raw("@e42".into()).ax_ref(), Some("@e42"));
assert_eq!(Locator::Raw("@email".into()).ax_ref(), None);
assert_eq!(Locator::Raw("#id".into()).ax_ref(), None);
assert_eq!(Locator::Raw("@e".into()).ax_ref(), None);
assert_eq!(Locator::Raw("@e1a".into()).ax_ref(), None);
}
#[test]
fn snapshot_kind_axtree_round_trips() {
let json = serde_json::to_string(&SnapshotKind::AxTree).unwrap();
assert_eq!(json, "\"ax_tree\"");
let back: SnapshotKind = serde_json::from_str(&json).unwrap();
assert!(matches!(back, SnapshotKind::AxTree));
}
#[test]
fn snapshot_kind_pwa_state_round_trips() {
let json = serde_json::to_string(&SnapshotKind::PwaState).unwrap();
assert_eq!(json, "\"pwa_state\"");
let back: SnapshotKind = serde_json::from_str(&json).unwrap();
assert!(matches!(back, SnapshotKind::PwaState));
}
}