Skip to main content

rdesktop_dev/
agent_api.rs

1//! Agent API endpoints.
2//!
3//! These endpoints allow AI agents to interact with the running application
4//! through structured HTTP requests, without needing native desktop control.
5//!
6//! ## How it works
7//!
8//! 1. The browser app includes the rdesktop bridge script
9//! 2. The bridge script periodically sends DOM snapshots to the server
10//! 3. Agents query the server for DOM/state information
11//! 4. Agents send actions to the server, which forwards them to the browser
12//!
13//! ## Design Philosophy
14//!
15//! Instead of requiring agents to take screenshots and use vision models to
16//! understand the UI, the Agent API provides direct DOM access and structured
17//! state information. This is:
18//!
19//! - **Faster**: No screenshot encoding/decoding overhead
20//! - **More reliable**: Exact element selectors, not pixel coordinates
21//! - **More informative**: Full DOM tree, computed styles, accessibility info
22//! - **Easier to test**: Standard HTTP endpoints, can be scripted
23
24use axum::body::Bytes;
25use axum::extract::{Query, State};
26use axum::http::{header, HeaderMap, StatusCode};
27use axum::response::IntoResponse;
28use axum::Json;
29use rdesktop_core::ipc::{IpcMessage, IpcResponse};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::sync::atomic::{AtomicU64, Ordering};
33
34use crate::server::{
35    DevServerState, PublishedScreenshot, RecordingSnapshot, RecordingStatus,
36    DEFAULT_RECORDING_MAX_DURATION_SECONDS, MAX_RECORDING_MAX_DURATION_SECONDS,
37};
38
39/// Query parameters for element selection.
40#[derive(Debug, Deserialize)]
41pub struct ElementQuery {
42    /// CSS selector to query elements
43    pub selector: Option<String>,
44
45    /// Text content to search for
46    pub text: Option<String>,
47
48    /// Role attribute to filter by
49    pub role: Option<String>,
50}
51
52/// An action that an agent can execute on the UI.
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct AgentAction {
55    /// Server-assigned ID used to correlate bridge execution receipts.
56    #[serde(default)]
57    pub id: Option<String>,
58
59    /// The type of action
60    pub action: ActionType,
61
62    /// CSS selector of the target element
63    pub selector: String,
64
65    /// Value for type/fill actions
66    pub value: Option<String>,
67
68    /// Coordinates for scroll actions
69    pub coordinates: Option<(f64, f64)>,
70
71    /// Optional destination element for a drag action.
72    pub target_selector: Option<String>,
73
74    /// Optional source point for coordinate-driven drag actions.
75    pub from: Option<(f64, f64)>,
76
77    /// Optional destination point for coordinate-driven drag actions.
78    pub to: Option<(f64, f64)>,
79
80    /// Optional duration for a drag action in milliseconds.
81    pub duration_ms: Option<u64>,
82}
83
84/// Types of actions agents can execute.
85#[derive(Debug, Clone, Deserialize, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ActionType {
88    Click,
89    DoubleClick,
90    RightClick,
91    Type,
92    Fill,
93    Clear,
94    Scroll,
95    Hover,
96    Focus,
97    Select,
98    Drag,
99    Press,
100}
101
102#[derive(Debug, Deserialize, Default)]
103pub struct ActionQuery {
104    /// When true, wait until the native renderer publishes a newer frame.
105    pub wait: Option<bool>,
106}
107
108/// Response from a DOM query.
109#[derive(Debug, Serialize)]
110pub struct DomSnapshot {
111    /// The full HTML content
112    pub html: String,
113
114    /// The page URL
115    pub url: String,
116
117    /// The page title
118    pub title: String,
119
120    /// Timestamp of the snapshot
121    pub timestamp: String,
122}
123
124/// Response from an element query.
125#[derive(Debug, Serialize)]
126pub struct ElementInfo {
127    /// CSS selector that uniquely identifies this element
128    pub selector: String,
129
130    /// Tag name
131    pub tag: String,
132
133    /// Text content
134    pub text: String,
135
136    /// Element attributes
137    pub attributes: HashMap<String, String>,
138
139    /// Whether the element is visible
140    pub visible: bool,
141
142    /// Whether the element is enabled (for interactive elements)
143    pub enabled: bool,
144
145    /// Accessibility role
146    pub role: Option<String>,
147
148    /// Accessibility label
149    pub label: Option<String>,
150}
151
152/// Result of an action execution.
153#[derive(Debug, Clone, Serialize)]
154pub struct ActionResult {
155    /// Whether the action succeeded
156    pub success: bool,
157
158    /// Error message if the action failed
159    pub error: Option<String>,
160
161    /// Any side effects (e.g., navigation that occurred)
162    pub side_effects: Vec<String>,
163}
164
165#[derive(Debug, Deserialize, Default)]
166pub struct ActionResultReport {
167    pub id: String,
168    pub success: bool,
169    pub error: Option<String>,
170    #[serde(default)]
171    pub side_effects: Vec<String>,
172}
173
174static NEXT_ACTION_ID: AtomicU64 = AtomicU64::new(1);
175
176/// Optional request body for starting a recording. The server owns the
177/// recording identity; agents may safely send `{}` more than once.
178#[derive(Debug, Deserialize, Default)]
179pub struct RecordingStartRequest {
180    pub fps: Option<u32>,
181    /// Safety limit for forgotten recordings. Defaults to five minutes.
182    pub max_duration_seconds: Option<u64>,
183}
184
185/// Optional session guard for stopping a recording.
186#[derive(Debug, Deserialize, Default)]
187pub struct RecordingStopRequest {
188    pub session_id: Option<String>,
189}
190
191#[derive(Debug, Deserialize)]
192pub struct RecordingStartedRequest {
193    pub session_id: String,
194    pub mime_type: String,
195}
196
197#[derive(Debug, Deserialize)]
198pub struct RecordingCompleteRequest {
199    pub session_id: String,
200    pub mime_type: Option<String>,
201}
202
203#[derive(Debug, Deserialize)]
204pub struct RecordingErrorRequest {
205    pub session_id: String,
206    pub error: String,
207}
208
209/// GET /__rdesktop__/agent/dom
210///
211/// Returns a full DOM snapshot of the current page.
212/// The snapshot is collected from the browser via the bridge script.
213pub async fn get_dom(State(state): State<DevServerState>) -> impl IntoResponse {
214    let snapshot = state.last_dom_snapshot.read().await;
215
216    let html = snapshot.clone().unwrap_or_else(|| {
217        r#"<!DOCTYPE html>
218<html>
219<head><title>rdesktop</title></head>
220<body>
221  <p>No DOM snapshot available yet. Make sure the app is loaded in the browser.</p>
222  <p>The bridge script will send DOM updates automatically.</p>
223</body>
224</html>"#
225            .to_string()
226    });
227
228    let dom = DomSnapshot {
229        html,
230        url: "http://localhost".to_string(),
231        title: "rdesktop App".to_string(),
232        timestamp: timestamp(),
233    };
234
235    Json(dom).into_response()
236}
237
238/// GET /__rdesktop__/agent/elements?selector=...
239///
240/// Query elements matching a CSS selector or text content.
241pub async fn query_elements(
242    State(state): State<DevServerState>,
243    Query(query): Query<ElementQuery>,
244) -> impl IntoResponse {
245    let snapshot = state.last_dom_snapshot.read().await;
246
247    // Parse the DOM and find matching elements
248    let elements: Vec<ElementInfo> = if let Some(ref html) = *snapshot {
249        find_elements(html, &query)
250    } else {
251        vec![]
252    };
253
254    Json(serde_json::json!({
255        "query": {
256            "selector": query.selector,
257            "text": query.text,
258            "role": query.role,
259        },
260        "count": elements.len(),
261        "elements": elements,
262    }))
263    .into_response()
264}
265
266/// POST /__rdesktop__/agent/action
267///
268/// Execute a UI action (click, type, scroll, etc.)
269/// The action is stored and picked up by the bridge script.
270pub async fn execute_action(
271    State(state): State<DevServerState>,
272    Query(query): Query<ActionQuery>,
273    Json(action): Json<AgentAction>,
274) -> impl IntoResponse {
275    let action_id = format!(
276        "action-{}-{}",
277        timestamp(),
278        NEXT_ACTION_ID.fetch_add(1, Ordering::Relaxed)
279    );
280    let mut queued_action = action.clone();
281    queued_action.id = Some(action_id.clone());
282
283    tracing::info!(
284        action = ?action.action,
285        selector = %action.selector,
286        action_id = %action_id,
287        "Agent action received"
288    );
289
290    let before_generation = state.screenshot_publisher.generation();
291    let wait_for_paint = query.wait.unwrap_or(false);
292    if wait_for_paint {
293        state.action_waiters.lock().await.insert(action_id.clone());
294    }
295    state.pending_actions.lock().await.push(queued_action);
296
297    let bridge_result = if wait_for_paint {
298        wait_for_action_result(&state, &action_id, std::time::Duration::from_secs(5)).await
299    } else {
300        None
301    };
302    if wait_for_paint {
303        state.action_waiters.lock().await.remove(&action_id);
304    }
305
306    let painted = if let Some(result) = bridge_result.as_ref() {
307        if !result.success {
308            false
309        } else {
310            // The bridge receipt proves that the DOM event was applied. Wait
311            // for a frame after that receipt as well, so wait=true means the
312            // native window has had an opportunity to paint the side effect.
313            let receipt_generation = state.screenshot_publisher.generation();
314            state
315                .screenshot_publisher
316                .wait_for_next(receipt_generation, std::time::Duration::from_secs(1))
317                .await
318                .is_some()
319        }
320    } else {
321        !wait_for_paint
322            || state
323                .screenshot_publisher
324                .wait_for_next(before_generation, std::time::Duration::from_secs(1))
325                .await
326                .is_some()
327    };
328
329    let result = bridge_result
330        .map(|mut result| {
331            if result.success && !painted {
332                result.success = false;
333                result.error = Some("bridge 已执行,但未在 1 秒内收到后续原生画面".to_string());
334            }
335            result
336        })
337        .unwrap_or_else(|| ActionResult {
338            success: painted,
339            error: if painted {
340                None
341            } else {
342                Some("动作已排队,但未在 5 秒内收到原生 bridge 回执".to_string())
343            },
344            side_effects: if painted {
345                vec![format!(
346                    "Action {:?} on '{}' queued and painted",
347                    action.action, action.selector
348                )]
349            } else {
350                vec![format!(
351                    "Action {:?} on '{}' queued",
352                    action.action, action.selector
353                )]
354            },
355        });
356
357    Json(result).into_response()
358}
359
360/// GET /__rdesktop__/agent/action/pending
361///
362/// Drain actions queued by agents. The bridge polls this endpoint.
363pub async fn pending_actions(State(state): State<DevServerState>) -> impl IntoResponse {
364    let mut actions = state.pending_actions.lock().await;
365    Json(std::mem::take(&mut *actions)).into_response()
366}
367
368/// POST /__rdesktop__/agent/action/result
369///
370/// Receive a real execution receipt from the injected bridge. Receipts are
371/// retained only for callers that explicitly requested `wait=true`.
372pub async fn report_action_result(
373    State(state): State<DevServerState>,
374    Json(report): Json<ActionResultReport>,
375) -> impl IntoResponse {
376    if state.action_waiters.lock().await.contains(&report.id) {
377        state.action_results.lock().await.insert(
378            report.id,
379            ActionResult {
380                success: report.success,
381                error: report.error,
382                side_effects: report.side_effects,
383            },
384        );
385        state.action_result_notify.notify_waiters();
386    }
387    Json(serde_json::json!({ "ok": true })).into_response()
388}
389
390async fn wait_for_action_result(
391    state: &DevServerState,
392    action_id: &str,
393    timeout: std::time::Duration,
394) -> Option<ActionResult> {
395    let deadline = tokio::time::Instant::now() + timeout;
396    loop {
397        if let Some(result) = state.action_results.lock().await.remove(action_id) {
398            return Some(result);
399        }
400        let notified = state.action_result_notify.notified();
401        if let Some(result) = state.action_results.lock().await.remove(action_id) {
402            return Some(result);
403        }
404        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
405        if remaining.is_zero() {
406            return None;
407        }
408        if tokio::time::timeout(remaining, notified).await.is_err() {
409            return None;
410        }
411    }
412}
413
414/// GET /__rdesktop__/agent/state
415///
416/// Get the current application state.
417pub async fn get_state(State(state): State<DevServerState>) -> impl IntoResponse {
418    let app_state = state.last_app_state.read().await;
419
420    match app_state.as_ref() {
421        Some(state) => Json(state.clone()).into_response(),
422        None => Json(serde_json::json!({
423            "message": "No application state available yet.",
424            "hint": "Use fetch('/__rdesktop__/state', { method: 'POST', body: JSON.stringify(state) }) from your app."
425        }))
426        .into_response(),
427    }
428}
429
430/// POST /__rdesktop__/agent/ipc
431///
432/// Send an IPC message from the agent to the app.
433pub async fn send_ipc(
434    State(state): State<DevServerState>,
435    Json(message): Json<serde_json::Value>,
436) -> impl IntoResponse {
437    if let Some(handler) = state.ipc_handler.as_ref() {
438        let response = match serde_json::from_value::<IpcMessage>(message) {
439            Ok(message) => handler.handle(message),
440            Err(error) => IpcResponse {
441                id: "0".to_string(),
442                success: false,
443                data: serde_json::json!({ "error": format!("Invalid IPC message: {error}") }),
444            },
445        };
446        return Json(response).into_response();
447    }
448
449    let cmd = message["cmd"].as_str().unwrap_or("unknown");
450    let payload = message["payload"].clone();
451    let id = message["id"].as_str().unwrap_or("0");
452
453    tracing::info!(cmd = cmd, "Agent IPC message received");
454
455    // In a full implementation, this would forward to the Rust IPC handler.
456    // For now, handle basic commands directly.
457    let response = match cmd {
458        "greet" => {
459            let name = payload["name"].as_str().unwrap_or("World");
460            serde_json::json!({
461                "id": id,
462                "success": true,
463                "data": { "message": format!("Hello, {}!", name) }
464            })
465        }
466        "ping" => {
467            serde_json::json!({
468                "id": id,
469                "success": true,
470                "data": { "pong": true }
471            })
472        }
473        _ => {
474            serde_json::json!({
475                "id": id,
476                "success": false,
477                "data": { "error": format!("Unknown command: {}", cmd) }
478            })
479        }
480    };
481
482    Json(response).into_response()
483}
484
485/// GET /__rdesktop__/agent/screenshot
486///
487/// Query parameters for native screenshot retrieval.
488#[derive(Debug, Deserialize, Default)]
489pub struct ScreenshotQuery {
490    /// Wait for a newer frame than `after`.
491    pub wait: Option<bool>,
492    pub after: Option<u64>,
493}
494
495/// Capture the latest complete native PNG frame.
496pub async fn take_screenshot(
497    State(state): State<DevServerState>,
498    Query(query): Query<ScreenshotQuery>,
499) -> impl IntoResponse {
500    let frame = if query.wait.unwrap_or(false) {
501        let after = query
502            .after
503            .unwrap_or_else(|| state.screenshot_publisher.generation());
504        state
505            .screenshot_publisher
506            .wait_for_next(after, std::time::Duration::from_secs(5))
507            .await
508    } else {
509        state.screenshot_publisher.latest().await
510    };
511
512    let frame = match frame {
513        Some(frame) => Some(frame),
514        None if !query.wait.unwrap_or(false) => read_persisted_screenshot(&state).await,
515        None => None,
516    };
517
518    let Some(PublishedScreenshot { generation, png }) = frame else {
519        return json_error(
520            StatusCode::NOT_FOUND,
521            "no complete native screenshot frame is available yet".to_string(),
522        );
523    };
524
525    axum::response::Response::builder()
526        .status(StatusCode::OK)
527        .header(header::CONTENT_TYPE, "image/png")
528        .header("cache-control", "no-store")
529        .header("x-rdesktop-screenshot-generation", generation.to_string())
530        .body(axum::body::Body::from(png))
531        .expect("screenshot response is valid")
532        .into_response()
533}
534
535async fn read_persisted_screenshot(state: &DevServerState) -> Option<PublishedScreenshot> {
536    let path = state.screenshot_path.as_ref()?;
537    let metadata = tokio::fs::metadata(path).await.ok()?;
538    if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 16 * 1024 * 1024 {
539        return None;
540    }
541    let png = tokio::fs::read(path).await.ok()?;
542    Some(PublishedScreenshot { generation: 0, png })
543}
544
545/// GET /__rdesktop__/agent/recording
546///
547/// Return the one recording session owned by this dev server.
548pub async fn get_recording(State(state): State<DevServerState>) -> impl IntoResponse {
549    Json(state.recording.snapshot().await).into_response()
550}
551
552/// GET /__rdesktop__/agent/recording/poll
553///
554/// Alias used by the browser bridge to discover start/stop commands.
555pub async fn poll_recording(State(state): State<DevServerState>) -> impl IntoResponse {
556    Json(state.recording.snapshot().await).into_response()
557}
558
559/// POST /__rdesktop__/agent/recording/start
560///
561/// Start the single recording, or return the existing session when recording
562/// is already active. This is intentionally idempotent.
563pub async fn start_recording(
564    State(state): State<DevServerState>,
565    request: Option<Json<RecordingStartRequest>>,
566) -> impl IntoResponse {
567    let request = request.map(|Json(request)| request).unwrap_or_default();
568    let fps = request.fps.unwrap_or(30).clamp(1, 60);
569    let max_duration_seconds = request
570        .max_duration_seconds
571        .unwrap_or(DEFAULT_RECORDING_MAX_DURATION_SECONDS)
572        .clamp(1, MAX_RECORDING_MAX_DURATION_SECONDS);
573    let max_duration = std::time::Duration::from_secs(max_duration_seconds);
574    match state.recording.start_with_options(fps, max_duration).await {
575        Ok((recording, reused)) => {
576            if !reused {
577                if let Some(session_id) = recording.session_id.clone() {
578                    let recording_store = state.recording.clone();
579                    tokio::spawn(async move {
580                        tokio::time::sleep(max_duration).await;
581                        if let Err(error) = recording_store.stop(Some(&session_id)).await {
582                            tracing::warn!(%error, "recording auto-stop failed");
583                        }
584                    });
585                }
586            }
587            Json(serde_json::json!({
588                "ok": true,
589                "reused": reused,
590                "auto_stop_seconds": max_duration_seconds,
591                "recording": recording,
592            }))
593            .into_response()
594        }
595        Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
596    }
597}
598
599/// POST /__rdesktop__/agent/recording/stop
600///
601/// Stop and finalize the native recorder, or request the browser bridge to
602/// flush and finalize its MediaRecorder. Repeating this call is safe.
603pub async fn stop_recording(
604    State(state): State<DevServerState>,
605    request: Option<Json<RecordingStopRequest>>,
606) -> impl IntoResponse {
607    let session_id = request.and_then(|Json(request)| request.session_id);
608    match state.recording.stop(session_id.as_deref()).await {
609        Ok(recording) => Json(serde_json::json!({
610            "ok": true,
611            "recording": recording,
612        }))
613        .into_response(),
614        Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
615    }
616}
617
618/// POST /__rdesktop__/agent/recording/started
619///
620/// Tell the server which browser MediaRecorder MIME type was selected.
621pub async fn recording_started(
622    State(state): State<DevServerState>,
623    Json(request): Json<RecordingStartedRequest>,
624) -> impl IntoResponse {
625    match state
626        .recording
627        .mark_started(&request.session_id, &request.mime_type)
628        .await
629    {
630        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
631        Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
632    }
633}
634
635/// POST /__rdesktop__/agent/recording/chunk
636///
637/// Append one MediaRecorder Blob to the single `.partial` file. Chunks are
638/// serialized by the store so concurrent browser callbacks cannot interleave.
639pub async fn recording_chunk(
640    State(state): State<DevServerState>,
641    headers: HeaderMap,
642    body: Bytes,
643) -> impl IntoResponse {
644    let Some(session_id) = header_value(&headers, "x-rdesktop-recording-id") else {
645        return json_error(
646            StatusCode::BAD_REQUEST,
647            "missing recording session header".to_string(),
648        );
649    };
650    if body.is_empty() {
651        return Json(serde_json::json!({ "ok": true, "bytes": 0 })).into_response();
652    }
653    match state.recording.append_chunk(&session_id, &body).await {
654        Ok(bytes) => Json(serde_json::json!({ "ok": true, "bytes": bytes })).into_response(),
655        Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
656    }
657}
658
659/// POST /__rdesktop__/agent/recording/complete
660pub async fn recording_complete(
661    State(state): State<DevServerState>,
662    Json(request): Json<RecordingCompleteRequest>,
663) -> impl IntoResponse {
664    match state
665        .recording
666        .complete(&request.session_id, request.mime_type.as_deref())
667        .await
668    {
669        Ok(recording) => recording_response(recording),
670        Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
671    }
672}
673
674/// POST /__rdesktop__/agent/recording/error
675pub async fn recording_error(
676    State(state): State<DevServerState>,
677    Json(request): Json<RecordingErrorRequest>,
678) -> impl IntoResponse {
679    match state
680        .recording
681        .fail(&request.session_id, request.error)
682        .await
683    {
684        Ok(recording) => recording_response(recording),
685        Err(error) => json_error(StatusCode::CONFLICT, error.to_string()),
686    }
687}
688
689/// GET /__rdesktop__/agent/recording/file
690pub async fn recording_file(State(state): State<DevServerState>) -> impl IntoResponse {
691    let recording = state.recording.snapshot().await;
692    if recording.status != RecordingStatus::Completed {
693        return json_error(
694            StatusCode::NOT_FOUND,
695            format!("recording is not complete: {:?}", recording.status),
696        );
697    }
698
699    match tokio::fs::read(&recording.path).await {
700        Ok(bytes) => axum::response::Response::builder()
701            .status(StatusCode::OK)
702            .header(
703                header::CONTENT_TYPE,
704                recording.mime_type.as_deref().unwrap_or("video/webm"),
705            )
706            .header(
707                header::CONTENT_DISPOSITION,
708                if recording
709                    .mime_type
710                    .as_deref()
711                    .map(|mime| mime.starts_with("video/mp4"))
712                    .unwrap_or(false)
713                {
714                    "attachment; filename=recording.mp4"
715                } else {
716                    "attachment; filename=recording.webm"
717                },
718            )
719            .body(axum::body::Body::from(bytes))
720            .expect("recording response is valid")
721            .into_response(),
722        Err(error) => json_error(StatusCode::NOT_FOUND, error.to_string()),
723    }
724}
725
726fn recording_response(recording: RecordingSnapshot) -> axum::response::Response {
727    Json(serde_json::json!({
728        "ok": recording.status == RecordingStatus::Completed,
729        "recording": recording,
730    }))
731    .into_response()
732}
733
734fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
735    headers
736        .get(name)
737        .and_then(|value| value.to_str().ok())
738        .map(str::to_owned)
739}
740
741fn json_error(status: StatusCode, error: String) -> axum::response::Response {
742    (
743        status,
744        Json(serde_json::json!({ "ok": false, "error": error })),
745    )
746        .into_response()
747}
748
749/// Simple timestamp helper.
750fn timestamp() -> String {
751    let now = std::time::SystemTime::now()
752        .duration_since(std::time::UNIX_EPOCH)
753        .unwrap_or_default();
754    format!("{}", now.as_secs())
755}
756
757/// Find elements in HTML matching the query.
758/// This is a simple text-based search, not a full DOM parser.
759fn find_elements(html: &str, query: &ElementQuery) -> Vec<ElementInfo> {
760    let mut elements = vec![];
761
762    if let Some(ref selector) = query.selector {
763        // Simple tag selector matching (e.g., "button", "input", "h1")
764        let tag = selector.trim_start_matches('<').trim_end_matches('>');
765        let open_tag = format!("<{}", tag);
766
767        let mut start = 0;
768        while let Some(pos) = html[start..].find(&open_tag) {
769            let abs_pos = start + pos;
770            let end = html[abs_pos..].find('>').unwrap_or(0);
771            let _tag_content = &html[abs_pos..abs_pos + end + 1];
772
773            // Extract text content between tags
774            let close_tag = format!("</{}>", tag);
775            let text_start = abs_pos + end + 1;
776            let text = if let Some(text_end) = html[text_start..].find(&close_tag) {
777                html[text_start..text_start + text_end].trim().to_string()
778            } else {
779                String::new()
780            };
781
782            elements.push(ElementInfo {
783                selector: format!("{}:nth-of-type({})", tag, elements.len() + 1),
784                tag: tag.to_string(),
785                text,
786                attributes: HashMap::new(),
787                visible: true,
788                enabled: true,
789                role: None,
790                label: None,
791            });
792
793            start = abs_pos + end + 1;
794        }
795    }
796
797    if let Some(ref text_query) = query.text {
798        // Search for text content
799        let lower_html = html.to_lowercase();
800        let lower_query = text_query.to_lowercase();
801        if lower_html.contains(&lower_query) {
802            elements.push(ElementInfo {
803                selector: format!("*:contains(\"{}\")", text_query),
804                tag: "*".to_string(),
805                text: text_query.clone(),
806                attributes: HashMap::new(),
807                visible: true,
808                enabled: true,
809                role: None,
810                label: None,
811            });
812        }
813    }
814
815    elements
816}