Skip to main content

a3s_use_browser/
session.rs

1//! Typed, in-process Browser sessions and semantic element references.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7
8use a3s_use_core::{Artifact, UseError, UseResult, UseSessionId};
9use chromiumoxide::Page;
10use serde::{Deserialize, Serialize};
11use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock};
12use url::Url;
13
14use crate::pool::{browser_error, BrowserPool};
15use crate::renderer::{apply_wait_condition, capture_screenshot};
16use crate::WaitCondition;
17
18const MAX_SNAPSHOT_TEXT_BYTES: usize = 50_000;
19const MAX_SNAPSHOT_ELEMENTS: usize = 1_000;
20
21const SNAPSHOT_SCRIPT: &str = r#"(() => {
22  const visible = (element) => {
23    const style = window.getComputedStyle(element);
24    const rect = element.getBoundingClientRect();
25    return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
26  };
27  const escape = (value) => window.CSS && CSS.escape
28    ? CSS.escape(value)
29    : value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`);
30  const selectorFor = (element) => {
31    if (element.id) return `#${escape(element.id)}`;
32    const parts = [];
33    let current = element;
34    while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
35      let part = current.tagName.toLowerCase();
36      const parent = current.parentElement;
37      if (parent) {
38        const siblings = Array.from(parent.children).filter((candidate) => candidate.tagName === current.tagName);
39        if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(current) + 1})`;
40      }
41      parts.unshift(part);
42      current = parent;
43    }
44    return `html > ${parts.join(' > ')}`;
45  };
46  const roleFor = (element) => {
47    if (element.getAttribute('role')) return element.getAttribute('role');
48    const tag = element.tagName.toLowerCase();
49    const type = (element.getAttribute('type') || '').toLowerCase();
50    if (tag === 'a') return 'link';
51    if (tag === 'button' || (tag === 'input' && ['button', 'submit', 'reset'].includes(type))) return 'button';
52    if (tag === 'textarea' || (tag === 'input' && !['checkbox', 'radio', 'range', 'color', 'file'].includes(type))) return 'textbox';
53    if (tag === 'select') return 'combobox';
54    if (tag === 'input' && type === 'checkbox') return 'checkbox';
55    if (tag === 'input' && type === 'radio') return 'radio';
56    return tag;
57  };
58  const nameFor = (element) => (
59    element.getAttribute('aria-label') ||
60    element.getAttribute('title') ||
61    element.getAttribute('placeholder') ||
62    element.innerText ||
63    element.value ||
64    ''
65  ).trim().slice(0, 500);
66  const candidates = Array.from(document.querySelectorAll(
67    'a[href],button,input:not([type="hidden"]),textarea,select,[role="button"],[role="link"],[contenteditable="true"]'
68  )).filter(visible).slice(0, 1000);
69  return {
70    url: window.location.href,
71    title: document.title || '',
72    text: ((document.body && document.body.innerText) || '').slice(0, 50000),
73    elements: candidates.map((element) => ({
74      role: roleFor(element),
75      name: nameFor(element),
76      selector: selectorFor(element),
77      value: typeof element.value === 'string' ? element.value.slice(0, 2000) : null,
78      disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true')
79    }))
80  };
81})()"#;
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct OpenSessionRequest {
85    pub session: UseSessionId,
86    pub url: Url,
87    pub timeout_ms: u64,
88    pub wait: WaitCondition,
89    pub user_agent: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct BrowserSessionInfo {
95    pub session: UseSessionId,
96    pub url: Url,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct BrowserSnapshot {
102    pub session: UseSessionId,
103    pub generation: u64,
104    pub url: Url,
105    pub title: String,
106    pub text: String,
107    pub elements: Vec<SnapshotElement>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct SnapshotElement {
113    pub reference: String,
114    pub role: String,
115    pub name: String,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub value: Option<String>,
118    pub disabled: bool,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct BrowserActionResult {
124    pub session: UseSessionId,
125    pub action: String,
126    pub url: Url,
127}
128
129pub struct BrowserSessions {
130    pool: Arc<BrowserPool>,
131    sessions: RwLock<HashMap<UseSessionId, Arc<Mutex<SessionState>>>>,
132    closed: AtomicBool,
133}
134
135struct SessionState {
136    page: Page,
137    generation: u64,
138    references: HashMap<String, String>,
139    _permit: OwnedSemaphorePermit,
140}
141
142impl BrowserSessions {
143    pub fn new(pool: Arc<BrowserPool>) -> Self {
144        Self {
145            pool,
146            sessions: RwLock::new(HashMap::new()),
147            closed: AtomicBool::new(false),
148        }
149    }
150
151    pub async fn open(&self, request: OpenSessionRequest) -> UseResult<BrowserSnapshot> {
152        if self.closed.load(Ordering::Acquire) {
153            return Err(sessions_closed_error());
154        }
155        validate_http_url(&request.url)?;
156        if self.sessions.read().await.contains_key(&request.session) {
157            return Err(UseError::new(
158                "use.browser.session_exists",
159                format!(
160                    "Browser session '{}' already exists.",
161                    request.session.as_str()
162                ),
163            ));
164        }
165        let timeout = std::time::Duration::from_millis(request.timeout_ms.max(1));
166        match tokio::time::timeout(timeout, self.open_inner(request)).await {
167            Ok(result) => result,
168            Err(_) => Err(UseError::new(
169                "use.browser.timeout",
170                format!("Browser session open exceeded {} ms.", timeout.as_millis()),
171            )),
172        }
173    }
174
175    async fn open_inner(&self, request: OpenSessionRequest) -> UseResult<BrowserSnapshot> {
176        let permit = Arc::clone(self.pool.tab_semaphore())
177            .acquire_owned()
178            .await
179            .map_err(|error| browser_error(format!("Tab limit is closed: {error}")))?;
180        let browser = self.pool.acquire_browser().await?;
181        let page = browser
182            .new_page("about:blank")
183            .await
184            .map_err(|error| browser_error(format!("Failed to open browser tab: {error}")))?;
185        let mut opening_page = OpeningPageGuard::new(page);
186        let page = opening_page.page()?;
187        if let Some(user_agent) = request.user_agent.as_deref() {
188            page.set_user_agent(
189                chromiumoxide::cdp::browser_protocol::network::SetUserAgentOverrideParams::new(
190                    user_agent,
191                ),
192            )
193            .await
194            .map_err(|error| browser_error(format!("Failed to set browser user agent: {error}")))?;
195        }
196        page.goto(request.url.as_str())
197            .await
198            .map_err(|error| browser_error(format!("Browser navigation failed: {error}")))?;
199        apply_wait_condition(page, &request.wait).await?;
200        let state = Arc::new(Mutex::new(SessionState {
201            page: opening_page.take()?,
202            generation: 0,
203            references: HashMap::new(),
204            _permit: permit,
205        }));
206        let snapshot = match snapshot_state(&request.session, &state).await {
207            Ok(snapshot) => snapshot,
208            Err(error) => {
209                close_state(state).await;
210                return Err(error);
211            }
212        };
213        let mut sessions = self.sessions.write().await;
214        if self.closed.load(Ordering::Acquire) || sessions.contains_key(&request.session) {
215            let closed = self.closed.load(Ordering::Acquire);
216            drop(sessions);
217            close_state(state).await;
218            return if closed {
219                Err(sessions_closed_error())
220            } else {
221                Err(UseError::new(
222                    "use.browser.session_exists",
223                    format!(
224                        "Browser session '{}' already exists.",
225                        request.session.as_str()
226                    ),
227                ))
228            };
229        }
230        sessions.insert(request.session, state);
231        Ok(snapshot)
232    }
233
234    pub async fn list(&self) -> UseResult<Vec<BrowserSessionInfo>> {
235        let states = self
236            .sessions
237            .read()
238            .await
239            .iter()
240            .map(|(session, state)| (session.clone(), Arc::clone(state)))
241            .collect::<Vec<_>>();
242        let mut result = Vec::with_capacity(states.len());
243        for (session, state) in states {
244            let state = state.lock().await;
245            result.push(BrowserSessionInfo {
246                session,
247                url: current_url(&state.page).await?,
248            });
249        }
250        result.sort_by(|left, right| left.session.as_str().cmp(right.session.as_str()));
251        Ok(result)
252    }
253
254    pub async fn navigate(
255        &self,
256        session: &UseSessionId,
257        url: Url,
258        wait: WaitCondition,
259        timeout_ms: u64,
260    ) -> UseResult<BrowserSnapshot> {
261        validate_http_url(&url)?;
262        let state = self.state(session).await?;
263        let timeout = std::time::Duration::from_millis(timeout_ms.max(1));
264        match tokio::time::timeout(timeout, async {
265            let mut state = state.lock().await;
266            invalidate_references(&mut state);
267            state
268                .page
269                .goto(url.as_str())
270                .await
271                .map_err(|error| browser_error(format!("Browser navigation failed: {error}")))?;
272            apply_wait_condition(&state.page, &wait).await?;
273            snapshot_locked(session, &mut state).await
274        })
275        .await
276        {
277            Ok(result) => result,
278            Err(_) => Err(UseError::new(
279                "use.browser.timeout",
280                format!("Browser navigation exceeded {} ms.", timeout.as_millis()),
281            )),
282        }
283    }
284
285    pub async fn snapshot(&self, session: &UseSessionId) -> UseResult<BrowserSnapshot> {
286        snapshot_state(session, &self.state(session).await?).await
287    }
288
289    pub async fn click(
290        &self,
291        session: &UseSessionId,
292        reference: &str,
293    ) -> UseResult<BrowserActionResult> {
294        let state = self.state(session).await?;
295        let mut state = state.lock().await;
296        let selector = resolve_reference(&state, reference)?;
297        invalidate_references(&mut state);
298        let element =
299            state.page.find_element(selector).await.map_err(|error| {
300                browser_error(format!("Browser element lookup failed: {error}"))
301            })?;
302        element
303            .click()
304            .await
305            .map_err(|error| browser_error(format!("Browser click failed: {error}")))?;
306        action_result(session, "click", &state.page).await
307    }
308
309    pub async fn type_text(
310        &self,
311        session: &UseSessionId,
312        reference: &str,
313        text: &str,
314    ) -> UseResult<BrowserActionResult> {
315        let state = self.state(session).await?;
316        let mut state = state.lock().await;
317        let selector = resolve_reference(&state, reference)?;
318        invalidate_references(&mut state);
319        let element =
320            state.page.find_element(selector).await.map_err(|error| {
321                browser_error(format!("Browser element lookup failed: {error}"))
322            })?;
323        element
324            .click()
325            .await
326            .map_err(|error| browser_error(format!("Browser focus failed: {error}")))?;
327        element
328            .type_str(text)
329            .await
330            .map_err(|error| browser_error(format!("Browser typing failed: {error}")))?;
331        action_result(session, "type", &state.page).await
332    }
333
334    pub async fn press_key(
335        &self,
336        session: &UseSessionId,
337        reference: &str,
338        key: &str,
339    ) -> UseResult<BrowserActionResult> {
340        let state = self.state(session).await?;
341        let mut state = state.lock().await;
342        let selector = resolve_reference(&state, reference)?;
343        invalidate_references(&mut state);
344        let element =
345            state.page.find_element(selector).await.map_err(|error| {
346                browser_error(format!("Browser element lookup failed: {error}"))
347            })?;
348        element
349            .click()
350            .await
351            .map_err(|error| browser_error(format!("Browser focus failed: {error}")))?;
352        element
353            .press_key(key)
354            .await
355            .map_err(|error| browser_error(format!("Browser key press failed: {error}")))?;
356        action_result(session, "press", &state.page).await
357    }
358
359    pub async fn select(
360        &self,
361        session: &UseSessionId,
362        reference: &str,
363        value: &str,
364    ) -> UseResult<BrowserActionResult> {
365        let state = self.state(session).await?;
366        let mut state = state.lock().await;
367        let selector = resolve_reference(&state, reference)?;
368        invalidate_references(&mut state);
369        let selector = serde_json::to_string(&selector).map_err(snapshot_encoding_error)?;
370        let value = serde_json::to_string(value).map_err(snapshot_encoding_error)?;
371        let script = format!(
372            "(() => {{ const element = document.querySelector({selector}); if (!element) throw new Error('element not found'); element.value = {value}; element.dispatchEvent(new Event('input', {{ bubbles: true }})); element.dispatchEvent(new Event('change', {{ bubbles: true }})); return true; }})()"
373        );
374        state
375            .page
376            .evaluate(script)
377            .await
378            .map_err(|error| browser_error(format!("Browser select failed: {error}")))?;
379        action_result(session, "select", &state.page).await
380    }
381
382    pub async fn scroll(
383        &self,
384        session: &UseSessionId,
385        x: i64,
386        y: i64,
387    ) -> UseResult<BrowserActionResult> {
388        let state = self.state(session).await?;
389        let mut state = state.lock().await;
390        invalidate_references(&mut state);
391        state
392            .page
393            .evaluate(format!("window.scrollBy({x}, {y}); true"))
394            .await
395            .map_err(|error| browser_error(format!("Browser scroll failed: {error}")))?;
396        action_result(session, "scroll", &state.page).await
397    }
398
399    pub async fn screenshot(
400        &self,
401        session: &UseSessionId,
402        path: impl AsRef<Path>,
403    ) -> UseResult<Artifact> {
404        let state = self.state(session).await?;
405        let state = state.lock().await;
406        capture_screenshot(&state.page, path.as_ref()).await
407    }
408
409    pub async fn close(&self, session: &UseSessionId) -> UseResult<bool> {
410        let state = self.sessions.write().await.remove(session);
411        let Some(state) = state else {
412            return Ok(false);
413        };
414        close_state(state).await;
415        Ok(true)
416    }
417
418    pub async fn shutdown(&self) {
419        self.closed.store(true, Ordering::Release);
420        let states = self
421            .sessions
422            .write()
423            .await
424            .drain()
425            .map(|(_, state)| state)
426            .collect::<Vec<_>>();
427        for state in states {
428            close_state(state).await;
429        }
430        self.pool.shutdown().await;
431    }
432
433    async fn state(&self, session: &UseSessionId) -> UseResult<Arc<Mutex<SessionState>>> {
434        self.sessions
435            .read()
436            .await
437            .get(session)
438            .cloned()
439            .ok_or_else(|| {
440                UseError::new(
441                    "use.browser.session_missing",
442                    format!("Browser session '{}' is not open.", session.as_str()),
443                )
444            })
445    }
446}
447
448struct OpeningPageGuard {
449    page: Option<Page>,
450}
451
452impl OpeningPageGuard {
453    fn new(page: Page) -> Self {
454        Self { page: Some(page) }
455    }
456
457    fn page(&self) -> UseResult<&Page> {
458        self.page.as_ref().ok_or_else(|| {
459            UseError::new(
460                "use.browser.page_closed",
461                "The Browser page closed before its session was activated.",
462            )
463        })
464    }
465
466    fn take(&mut self) -> UseResult<Page> {
467        self.page.take().ok_or_else(|| {
468            UseError::new(
469                "use.browser.page_closed",
470                "The Browser page closed before its session was activated.",
471            )
472        })
473    }
474}
475
476impl Drop for OpeningPageGuard {
477    fn drop(&mut self) {
478        let Some(page) = self.page.take() else {
479            return;
480        };
481        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
482            runtime.spawn(async move {
483                let _ = page.close().await;
484            });
485        }
486    }
487}
488
489async fn snapshot_state(
490    session: &UseSessionId,
491    state: &Arc<Mutex<SessionState>>,
492) -> UseResult<BrowserSnapshot> {
493    let mut state = state.lock().await;
494    snapshot_locked(session, &mut state).await
495}
496
497async fn snapshot_locked(
498    session: &UseSessionId,
499    state: &mut SessionState,
500) -> UseResult<BrowserSnapshot> {
501    let raw: RawSnapshot = state
502        .page
503        .evaluate(SNAPSHOT_SCRIPT)
504        .await
505        .map_err(|error| browser_error(format!("Browser snapshot failed: {error}")))?
506        .into_value()
507        .map_err(|error| browser_error(format!("Browser snapshot was invalid: {error}")))?;
508    state.generation = state.generation.saturating_add(1);
509    state.references.clear();
510    let mut elements = Vec::with_capacity(raw.elements.len().min(MAX_SNAPSHOT_ELEMENTS));
511    for (index, element) in raw
512        .elements
513        .into_iter()
514        .take(MAX_SNAPSHOT_ELEMENTS)
515        .enumerate()
516    {
517        let reference = format!("@e{}", index + 1);
518        state.references.insert(reference.clone(), element.selector);
519        elements.push(SnapshotElement {
520            reference,
521            role: element.role,
522            name: element.name,
523            value: element.value,
524            disabled: element.disabled,
525        });
526    }
527    let url = Url::parse(&raw.url)
528        .map_err(|error| browser_error(format!("Browser returned an invalid URL: {error}")))?;
529    Ok(BrowserSnapshot {
530        session: session.clone(),
531        generation: state.generation,
532        url,
533        title: raw.title,
534        text: truncate_utf8(raw.text, MAX_SNAPSHOT_TEXT_BYTES),
535        elements,
536    })
537}
538
539fn resolve_reference(state: &SessionState, reference: &str) -> UseResult<String> {
540    state.references.get(reference).cloned().ok_or_else(|| {
541        UseError::new(
542            "use.browser.reference_stale",
543            format!(
544                "Element reference '{reference}' is missing or stale for snapshot generation {}.",
545                state.generation
546            ),
547        )
548        .with_suggestion("Take a new browser snapshot and use one of its references.")
549    })
550}
551
552fn invalidate_references(state: &mut SessionState) {
553    state.references.clear();
554}
555
556async fn action_result(
557    session: &UseSessionId,
558    action: &str,
559    page: &Page,
560) -> UseResult<BrowserActionResult> {
561    Ok(BrowserActionResult {
562        session: session.clone(),
563        action: action.to_string(),
564        url: current_url(page).await?,
565    })
566}
567
568async fn current_url(page: &Page) -> UseResult<Url> {
569    let value = page
570        .url()
571        .await
572        .map_err(|error| browser_error(format!("Failed to read Browser URL: {error}")))?
573        .unwrap_or_else(|| "about:blank".to_string());
574    Url::parse(&value).map_err(|error| browser_error(format!("Browser URL is invalid: {error}")))
575}
576
577async fn close_state(state: Arc<Mutex<SessionState>>) {
578    let page = state.lock().await.page.clone();
579    let _ = page.close().await;
580}
581
582fn validate_http_url(url: &Url) -> UseResult<()> {
583    if matches!(url.scheme(), "http" | "https") {
584        Ok(())
585    } else {
586        Err(UseError::new(
587            "use.browser.url_scheme_unsupported",
588            format!(
589                "Browser sessions accept HTTP(S) URLs, not '{}'.",
590                url.scheme()
591            ),
592        ))
593    }
594}
595
596fn sessions_closed_error() -> UseError {
597    UseError::new(
598        "use.browser.sessions_closed",
599        "The Browser session manager has already shut down.",
600    )
601}
602
603fn truncate_utf8(mut value: String, max_bytes: usize) -> String {
604    if value.len() <= max_bytes {
605        return value;
606    }
607    let mut boundary = max_bytes;
608    while !value.is_char_boundary(boundary) {
609        boundary -= 1;
610    }
611    value.truncate(boundary);
612    value
613}
614
615fn snapshot_encoding_error(error: serde_json::Error) -> UseError {
616    browser_error(format!("Failed to encode Browser interaction: {error}"))
617}
618
619#[derive(Debug, Deserialize)]
620#[serde(rename_all = "camelCase")]
621struct RawSnapshot {
622    url: String,
623    title: String,
624    text: String,
625    elements: Vec<RawSnapshotElement>,
626}
627
628#[derive(Debug, Deserialize)]
629#[serde(rename_all = "camelCase")]
630struct RawSnapshotElement {
631    role: String,
632    name: String,
633    selector: String,
634    value: Option<String>,
635    disabled: bool,
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use crate::BrowserPoolConfig;
642    #[cfg(not(windows))]
643    use crate::BrowserProvider;
644    #[cfg(not(windows))]
645    use tokio::io::{AsyncReadExt, AsyncWriteExt};
646
647    #[test]
648    fn snapshot_text_truncation_preserves_utf8_boundaries() {
649        let value = format!("{}界", "a".repeat(MAX_SNAPSHOT_TEXT_BYTES - 1));
650        let truncated = truncate_utf8(value, MAX_SNAPSHOT_TEXT_BYTES);
651        assert_eq!(truncated.len(), MAX_SNAPSHOT_TEXT_BYTES - 1);
652        assert!(truncated.is_char_boundary(truncated.len()));
653    }
654
655    #[test]
656    fn public_session_types_are_send_and_sync() {
657        fn assert_send_sync<T: Send + Sync>() {}
658        assert_send_sync::<BrowserSessions>();
659    }
660
661    #[tokio::test]
662    async fn shutdown_prevents_new_sessions_without_launching_a_provider() {
663        let sessions =
664            BrowserSessions::new(Arc::new(BrowserPool::new(BrowserPoolConfig::default())));
665        sessions.shutdown().await;
666        let error = sessions
667            .open(OpenSessionRequest {
668                session: UseSessionId::parse("after-shutdown").unwrap(),
669                url: Url::parse("https://example.com").unwrap(),
670                timeout_ms: 1_000,
671                wait: WaitCondition::Load,
672                user_agent: None,
673            })
674            .await
675            .unwrap_err();
676        assert_eq!(error.code, "use.browser.sessions_closed");
677    }
678
679    #[cfg(not(windows))]
680    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
681    async fn discovered_chrome_keeps_typed_session_state_when_available() {
682        let Some(executable) = crate::detect_chrome() else {
683            return;
684        };
685        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
686        let address = listener.local_addr().unwrap();
687        let fixture = tokio::spawn(async move {
688            loop {
689                let (mut stream, _) = listener.accept().await.unwrap();
690                let mut request = vec![0; 4_096];
691                let _ = stream.read(&mut request).await.unwrap();
692                let body = r#"<!doctype html><html><head><title>A3S fixture</title></head><body><label for="query">Query</label><input id="query" aria-label="Query"></body></html>"#;
693                let response = format!(
694                    "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
695                    body.len()
696                );
697                stream.write_all(response.as_bytes()).await.unwrap();
698                stream.shutdown().await.unwrap();
699            }
700        });
701        let pool = Arc::new(BrowserPool::new(BrowserPoolConfig {
702            provider: BrowserProvider::ChromeExecutable(executable),
703            ..BrowserPoolConfig::default()
704        }));
705        let sessions = BrowserSessions::new(pool);
706        let session = UseSessionId::parse("typed-session").unwrap();
707        let result: UseResult<()> = async {
708            let opened = sessions
709                .open(OpenSessionRequest {
710                    session: session.clone(),
711                    url: Url::parse(&format!("http://{address}/fixture")).unwrap(),
712                    timeout_ms: 10_000,
713                    wait: WaitCondition::Load,
714                    user_agent: None,
715                })
716                .await?;
717            assert_eq!(opened.generation, 1);
718            assert_eq!(opened.title, "A3S fixture");
719            let textbox = opened
720                .elements
721                .iter()
722                .find(|element| element.role == "textbox" && element.name == "Query")
723                .expect("the fixture textbox should have a semantic reference");
724            let reference = textbox.reference.clone();
725
726            sessions.type_text(&session, &reference, "a3s use").await?;
727            let stale = sessions.type_text(&session, &reference, " stale").await;
728            assert_eq!(stale.unwrap_err().code, "use.browser.reference_stale");
729
730            let updated = sessions.snapshot(&session).await?;
731            assert_eq!(updated.generation, 2);
732            assert_eq!(
733                updated
734                    .elements
735                    .iter()
736                    .find(|element| element.role == "textbox")
737                    .and_then(|element| element.value.as_deref()),
738                Some("a3s use")
739            );
740            assert!(sessions.close(&session).await?);
741            assert!(!sessions.close(&session).await?);
742            Ok(())
743        }
744        .await;
745        sessions.shutdown().await;
746        fixture.abort();
747        let _ = fixture.await;
748        result.unwrap();
749    }
750}