teasr-core 0.13.0

Core orchestration and capture for teasr
use anyhow::{Context, Result};
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::input::{
    DispatchMouseEventParams, DispatchMouseEventType,
};
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use futures::StreamExt;
use std::time::Duration;
use tokio::task::JoinHandle;
use crate::backend::CaptureBackend;
use crate::types::{CapturedFrame, Interaction, ViewportConfig};

pub struct WebBackend {
    url: String,
    viewport: ViewportConfig,
    frame_duration: u64,
    full_page: bool,
    browser: Option<Browser>,
    page: Option<chromiumoxide::Page>,
    handler: Option<JoinHandle<()>>,
}

impl WebBackend {
    pub fn new(url: String, viewport: ViewportConfig, frame_duration: u64, full_page: bool) -> Self {
        Self {
            url,
            viewport,
            frame_duration,
            full_page,
            browser: None,
            page: None,
            handler: None,
        }
    }

    async fn take_screenshot(&self) -> Result<Vec<u8>> {
        let page = self.page.as_ref().unwrap();
        page.screenshot(
            chromiumoxide::page::ScreenshotParams::builder()
                .format(CaptureScreenshotFormat::Png)
                .full_page(self.full_page)
                .build(),
        )
        .await
        .context("failed to take screenshot")
    }
}

#[async_trait::async_trait]
impl CaptureBackend for WebBackend {
    fn mode_name(&self) -> &'static str {
        "web"
    }

    async fn setup(&mut self) -> Result<()> {
        let config = BrowserConfig::builder()
            .window_size(self.viewport.width, self.viewport.height)
            .no_sandbox()
            .build()
            .map_err(|e| anyhow::anyhow!("browser config error: {e}"))?;

        let (browser, mut handler) = Browser::launch(config)
            .await
            .context("failed to launch browser")?;

        let handle = tokio::spawn(async move {
            while let Some(_event) = handler.next().await {}
        });

        let page = browser
            .new_page("about:blank")
            .await
            .context("failed to create page")?;

        page.goto(&self.url).await.context("navigation failed")?;
        tokio::time::sleep(Duration::from_millis(1000)).await;

        self.browser = Some(browser);
        self.page = Some(page);
        self.handler = Some(handle);

        Ok(())
    }

    async fn execute(&mut self, interaction: &Interaction) -> Result<Vec<CapturedFrame>> {
        let page = self.page.as_ref().unwrap();

        match interaction {
            Interaction::Click { selector } => {
                if let Some(sel) = selector {
                    page.find_element(sel)
                        .await
                        .context("element not found")?
                        .click()
                        .await
                        .context("click failed")?;
                }
                Ok(vec![])
            }
            Interaction::Hover { selector } => {
                if let Some(sel) = selector {
                    let js = format!(
                        "(() => {{ const r = document.querySelector('{}').getBoundingClientRect(); return [r.x + r.width/2, r.y + r.height/2]; }})()",
                        sel.replace('\'', "\\'")
                    );
                    let coords: Vec<f64> = page
                        .evaluate(js)
                        .await
                        .context("failed to get element position")?
                        .into_value()
                        .context("failed to parse coordinates")?;
                    page.execute(
                        DispatchMouseEventParams::builder()
                            .r#type(DispatchMouseEventType::MouseMoved)
                            .x(coords[0])
                            .y(coords[1])
                            .build()
                            .unwrap(),
                    )
                    .await
                    .context("hover dispatch failed")?;
                    tokio::time::sleep(Duration::from_millis(100)).await;
                }
                Ok(vec![])
            }
            Interaction::ScrollTo { selector } => {
                if let Some(sel) = selector {
                    let js = format!(
                        "document.querySelector('{}').scrollIntoView({{behavior:'smooth'}})",
                        sel.replace('\'', "\\'")
                    );
                    page.evaluate(js).await.context("scroll failed")?;
                }
                Ok(vec![])
            }
            Interaction::Wait { duration, .. } => {
                tokio::time::sleep(Duration::from_millis(*duration)).await;
                Ok(vec![])
            }
            Interaction::Snapshot { .. } => {
                let png_data = self.take_screenshot().await?;
                Ok(vec![CapturedFrame {
                    png_data,
                    duration_ms: self.frame_duration,
                }])
            }
            Interaction::Type { text, speed } => {
                let delay = speed.unwrap_or(50);
                for ch in text.chars() {
                    page.evaluate(format!(
                        "document.activeElement && document.activeElement.dispatchEvent(new KeyboardEvent('keypress', {{key: '{}'}}))",
                        ch
                    ))
                    .await
                    .ok();
                    tokio::time::sleep(Duration::from_millis(delay)).await;
                }
                Ok(vec![])
            }
            Interaction::Key { key } => {
                page.evaluate(format!(
                    "document.activeElement && document.activeElement.dispatchEvent(new KeyboardEvent('keydown', {{key: '{}'}}))",
                    key
                ))
                .await
                .ok();
                Ok(vec![])
            }
        }
    }

    async fn snapshot(&mut self) -> Result<CapturedFrame> {
        let png_data = self.take_screenshot().await?;
        Ok(CapturedFrame {
            png_data,
            duration_ms: self.frame_duration,
        })
    }

    async fn teardown(&mut self) -> Result<()> {
        if let Some(mut browser) = self.browser.take() {
            browser.close().await.ok();
        }
        if let Some(handle) = self.handler.take() {
            handle.await.ok();
        }
        Ok(())
    }
}