use devicerail_client::protocol::{
ActionOutcome as WireActionOutcome, EventSequence, MAX_EVENTS_LIST_PAGE_SIZE, TestEvent,
TestEventPayload,
};
use uuid::Uuid;
pub(crate) const SCAN_PAGE_LIMIT: u32 = MAX_EVENTS_LIST_PAGE_SIZE;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CallFate {
Completed(WireActionOutcome),
StartedNoTerminal,
NoTrace,
}
pub(crate) async fn scan_for_call<F, Fut, E>(
call_id: Uuid,
floor: Option<EventSequence>,
mut fetch_page: F,
) -> Result<CallFate, E>
where
F: FnMut(Option<EventSequence>) -> Fut,
Fut: Future<Output = Result<Vec<TestEvent>, E>>,
{
let mut after: Option<EventSequence> = floor;
let mut started = false;
loop {
let page = fetch_page(after).await?;
for event in &page {
match &event.payload {
TestEventPayload::ActionStarted { call } if call.id == call_id => {
started = true;
}
TestEventPayload::ActionCompleted {
call_id: completed_id,
outcome,
} if *completed_id == call_id => {
return Ok(CallFate::Completed(outcome.clone()));
}
_ => {}
}
}
match page.last() {
None => break,
Some(last) => after = Some(last.sequence),
}
if page.len() < SCAN_PAGE_LIMIT as usize {
break;
}
}
Ok(if started {
CallFate::StartedNoTerminal
} else {
CallFate::NoTrace
})
}
pub(crate) async fn latest_sequence<F, Fut, E>(
from: Option<EventSequence>,
mut fetch_page: F,
) -> Result<Option<EventSequence>, E>
where
F: FnMut(Option<EventSequence>) -> Fut,
Fut: Future<Output = Result<Vec<TestEvent>, E>>,
{
let mut after = from;
loop {
let page = fetch_page(after).await?;
let Some(last) = page.last() else {
return Ok(after);
};
after = Some(last.sequence);
if page.len() < SCAN_PAGE_LIMIT as usize {
return Ok(after);
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use devicerail_client::ClientError;
use devicerail_client::protocol::{ErrorInfo, EventId, RecordedActionCall, SessionId};
use serde_json::json;
use super::*;
fn event(sequence: u64, payload: TestEventPayload) -> TestEvent {
TestEvent {
event_id: EventId::new(),
session_id: SessionId::from(Uuid::nil()),
sequence: EventSequence::new(sequence).expect("one-based sequence"),
request_id: None,
device_id: None,
at_ms: sequence,
payload,
}
}
fn started(sequence: u64, call_id: Uuid) -> TestEvent {
event(
sequence,
TestEventPayload::ActionStarted {
call: RecordedActionCall {
id: call_id,
name: "tap".to_owned(),
arguments: json!({ "x": 1, "y": 2 }),
arguments_redacted: false,
},
},
)
}
fn completed(sequence: u64, call_id: Uuid) -> TestEvent {
event(
sequence,
TestEventPayload::ActionCompleted {
call_id,
outcome: WireActionOutcome::Failed {
error: ErrorInfo {
code: "element_not_found".to_owned(),
message: "not found".to_owned(),
retryable: false,
details: None,
},
},
},
)
}
type ReadyPage = std::future::Ready<Result<Vec<TestEvent>, ClientError>>;
type CursorLog = Arc<Mutex<Vec<Option<u64>>>>;
fn pager(
pages: Vec<Vec<TestEvent>>,
) -> (impl FnMut(Option<EventSequence>) -> ReadyPage, CursorLog) {
let cursors = Arc::new(Mutex::new(Vec::new()));
let observed = Arc::clone(&cursors);
let mut pages = pages.into_iter();
(
move |after: Option<EventSequence>| {
observed
.lock()
.expect("cursor log")
.push(after.map(EventSequence::get));
std::future::ready(Ok(pages.next().unwrap_or_default()))
},
cursors,
)
}
fn full_filler_page(base: u64) -> Vec<TestEvent> {
(0..u64::from(SCAN_PAGE_LIMIT))
.map(|offset| event(base + offset, TestEventPayload::SessionStarted))
.collect()
}
#[tokio::test]
async fn scan_follows_pagination_and_advances_the_one_based_cursor() {
let call_id = Uuid::new_v4();
let mut second_page = full_filler_page(1001);
second_page.push(started(2001, call_id));
let third_page = vec![completed(2002, call_id)];
let (fetch, cursors) = pager(vec![full_filler_page(1), second_page, third_page]);
let fate = scan_for_call(call_id, None, fetch).await.expect("scan");
assert!(matches!(fate, CallFate::Completed(_)));
assert_eq!(*cursors.lock().unwrap(), vec![None, Some(1000), Some(2001)]);
}
#[tokio::test]
async fn completed_terminal_is_adopted_verbatim() {
let call_id = Uuid::new_v4();
let (fetch, _) = pager(vec![vec![started(1, call_id), completed(2, call_id)]]);
let fate = scan_for_call(call_id, None, fetch).await.expect("scan");
let CallFate::Completed(outcome) = fate else {
panic!("expected completed, got {fate:?}");
};
let WireActionOutcome::Failed { error } = outcome else {
panic!("archived failed terminal must come back unfolded");
};
assert_eq!(error.code, "element_not_found");
}
#[tokio::test]
async fn started_without_terminal_and_no_trace_are_distinguished() {
let call_id = Uuid::new_v4();
let (fetch, _) = pager(vec![vec![started(1, call_id)]]);
assert_eq!(
scan_for_call(call_id, None, fetch).await.expect("scan"),
CallFate::StartedNoTerminal
);
let (fetch, _) = pager(vec![vec![
started(1, Uuid::new_v4()),
completed(2, Uuid::new_v4()),
]]);
assert_eq!(
scan_for_call(call_id, None, fetch).await.expect("scan"),
CallFate::NoTrace
);
}
#[tokio::test]
async fn scan_errors_propagate() {
let call_id = Uuid::new_v4();
let failing = |_: Option<EventSequence>| {
std::future::ready(Err::<Vec<TestEvent>, _>(ClientError::Closed))
};
assert!(scan_for_call(call_id, None, failing).await.is_err());
}
#[tokio::test]
async fn latest_sequence_follows_to_the_end() {
let (fetch, cursors) = pager(vec![
full_filler_page(1),
vec![event(1001, TestEventPayload::SessionStarted)],
]);
let latest = latest_sequence(None, fetch).await.expect("scan");
assert_eq!(latest.map(EventSequence::get), Some(1001));
assert_eq!(*cursors.lock().unwrap(), vec![None, Some(1000)]);
let (fetch, _) = pager(vec![]);
let unchanged = latest_sequence(EventSequence::new(7), fetch)
.await
.expect("scan");
assert_eq!(unchanged.map(EventSequence::get), Some(7));
}
}