browser_oxide 0.1.3

Stealth headless browser engine in Rust: real HTML/CSS/DOM/JS, V8 via deno_core, own BoringSSL TLS/JA4 fingerprint, no Chromium, no CDP — for anti-bot web scraping, archival, and AI agents
Documentation
//! Canvas 2D path operations — `arc`, `arcTo`, `bezierCurveTo`,
//! `quadraticCurveTo`, `closePath`, `setTransform`, `resetTransform`,
//! `ellipse`, and `strokeText` are wired to Skia-backed implementations
//! in `crates/canvas/src/canvas2d.rs`. `arcTo` uses Skia's
//! `arc_to_tangent` (matches Chrome's `Path::arcTo`). `ellipse` uses a
//! bezier approximation (4·tan(seg/4) per segment, ⌈|sweep|/(π/2)⌉
//! segments) that matches Blink's `Path::AddEllipse` algorithm.
//! These tests verify both that the ops execute without throwing AND
//! that the resulting raster has the right pixel coverage — the bar for
//! anti-bot canvas fingerprint probes (challenge-vendor sensor scripts
//! and open-source FP suites such as CreepJS `paintCanvas`).

use browser_oxide::Page;

async fn evaluate(js: &str) -> String {
    let mut page = Page::from_html(
        "<!DOCTYPE html><html><body></body></html>",
        None::<browser_oxide::stealth::StealthProfile>,
    )
    .await
    .unwrap();
    page.evaluate(js).unwrap_or_else(|e| format!("ERROR: {e}"))
}

#[tokio::test]
async fn arc_does_not_throw() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.beginPath();
        ctx.arc(50, 50, 25, 0, Math.PI * 2, false);
        ctx.fill();
        'ok'
        ",
    )
    .await;
    assert_eq!(r, "ok");
}

#[tokio::test]
async fn bezier_curve_to_does_not_throw() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.beginPath();
        ctx.moveTo(10, 10);
        ctx.bezierCurveTo(30, 0, 70, 0, 90, 50);
        ctx.stroke();
        'ok'
        ",
    )
    .await;
    assert_eq!(r, "ok");
}

#[tokio::test]
async fn quadratic_curve_to_does_not_throw() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.beginPath();
        ctx.moveTo(10, 10);
        ctx.quadraticCurveTo(50, 0, 90, 50);
        ctx.stroke();
        'ok'
        ",
    )
    .await;
    assert_eq!(r, "ok");
}

#[tokio::test]
async fn close_path_does_not_throw() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.beginPath();
        ctx.moveTo(10, 10);
        ctx.lineTo(50, 10);
        ctx.lineTo(50, 50);
        ctx.closePath();
        ctx.fill();
        'ok'
        ",
    )
    .await;
    assert_eq!(r, "ok");
}

#[tokio::test]
async fn set_transform_does_not_throw() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.setTransform(2, 0, 0, 2, 10, 20);
        ctx.fillRect(0, 0, 30, 30);
        ctx.resetTransform();
        ctx.fillRect(0, 0, 10, 10);
        'ok'
        ",
    )
    .await;
    assert_eq!(r, "ok");
}

#[tokio::test]
async fn stroke_text_does_not_throw() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 200; c.height = 50;
        const ctx = c.getContext('2d');
        ctx.font = '14px Arial';
        ctx.strokeText('Hello', 10, 30);
        'ok'
        ",
    )
    .await;
    assert_eq!(r, "ok");
}

/// arcTo executes via op_canvas_arc_to → Skia's arc_to_tangent (matches
/// Chrome's Path::arcTo at the Skia layer). Must produce a non-blank
/// raster — the previous lineTo-approximation produced a thin polygon,
/// the real arc fills a curved region.
#[tokio::test]
#[allow(non_snake_case, reason = "mirrors JS API name under test")]
async fn arcTo_renders_arc_pixels() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.lineWidth = 4;
        ctx.beginPath();
        ctx.moveTo(20, 20);
        ctx.arcTo(80, 20, 80, 80, 20);
        ctx.lineTo(80, 80);
        ctx.stroke();
        const id = ctx.getImageData(0, 0, 100, 100);
        let nonzero = 0;
        for (let i = 3; i < id.data.length; i += 4) if (id.data[i] > 0) nonzero++;
        // The stroked rounded-corner path should mark several hundred pixels.
        nonzero > 200
        ",
    )
    .await;
    assert_eq!(r, "true");
}

/// Ellipse executes via op_canvas_ellipse → bezier-approximated rotated
/// ellipse. Filling a 30x20 ellipse at center (50,50) should mark
/// approximately π·30·20 ≈ 1885 pixels (with anti-aliasing slightly
/// inflating the count).
#[tokio::test]
async fn ellipse_filled_marks_expected_pixel_area() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 100; c.height = 100;
        const ctx = c.getContext('2d');
        ctx.fillStyle = '#000';
        ctx.beginPath();
        ctx.ellipse(50, 50, 30, 20, 0, 0, Math.PI * 2);
        ctx.fill();
        const id = ctx.getImageData(0, 0, 100, 100);
        let nonzero = 0;
        for (let i = 3; i < id.data.length; i += 4) if (id.data[i] > 0) nonzero++;
        // π·rx·ry = π·30·20 ≈ 1885. Allow ±25% for rasterization edges.
        nonzero > 1400 && nonzero < 2400
        ",
    )
    .await;
    assert_eq!(r, "true");
}

/// Rotated ellipse: a 30x10 ellipse rotated 90° should occupy a different
/// pixel set than the unrotated one (the bounding box differs).
#[tokio::test]
async fn ellipse_rotation_changes_bounding_box() {
    let r = evaluate(
        "
        function fillCount(rotation) {
            const c = document.createElement('canvas');
            c.width = 100; c.height = 100;
            const ctx = c.getContext('2d');
            ctx.fillStyle = '#000';
            ctx.beginPath();
            ctx.ellipse(50, 50, 30, 10, rotation, 0, Math.PI * 2);
            ctx.fill();
            // Sample a vertical line through the center: rotated ellipse
            // should fill more rows here than unrotated.
            const id = ctx.getImageData(50, 0, 1, 100);
            let n = 0;
            for (let i = 3; i < id.data.length; i += 4) if (id.data[i] > 0) n++;
            return n;
        }
        // Unrotated: y-extent = ±ry = ±10 → ~20 rows on the center column.
        // Rotated 90°: y-extent = ±rx = ±30 → ~60 rows.
        const unrotated = fillCount(0);
        const rotated = fillCount(Math.PI / 2);
        rotated > unrotated + 20
        ",
    )
    .await;
    assert_eq!(r, "true");
}

/// strokeText must trace glyph outlines (via ttf-parser) and stroke
/// them with the current strokeStyle/lineWidth — NOT alias to fillText.
/// A bot detector that calls both at the same position and compares
/// pixel counts catches a fillText alias trivially. This test renders
/// both and asserts the pixel sets are non-trivially different.
#[tokio::test]
async fn stroke_text_pixels_differ_from_fill_text() {
    let r = evaluate(
        "
        function render(method) {
            const c = document.createElement('canvas');
            c.width = 200; c.height = 50;
            const ctx = c.getContext('2d');
            ctx.font = '32px Arial';
            ctx.fillStyle = '#000';
            ctx.strokeStyle = '#000';
            ctx.lineWidth = 2;
            ctx[method]('Hello', 10, 35);
            const data = ctx.getImageData(0, 0, 200, 50).data;
            let nonzero = 0;
            for (let i = 3; i < data.length; i += 4) if (data[i] > 0) nonzero++;
            return nonzero;
        }
        const filled = render('fillText');
        const stroked = render('strokeText');
        // If aliased, stroked === filled. Real strokeText traces only
        // the contour at lineWidth=2 — for 'Hello' at 32px this is
        // visibly different (typically more outline pixels because the
        // stroke is 2 px wide on both sides of every edge, vs filled
        // interior which has hollow centers in 'l', 'o', 'e'). Assert
        // the absolute difference is at least 20% of the smaller count.
        const diff = Math.abs(stroked - filled);
        const smaller = Math.min(stroked, filled);
        const ratio = diff / Math.max(smaller, 1);
        ratio > 0.2 ? 'differs' : ('similar:filled=' + filled + ',stroked=' + stroked)
        ",
    )
    .await;
    assert_eq!(
        r, "differs",
        "strokeText must produce a visibly different pixel set than fillText"
    );
}

/// strokeText must respond to `lineWidth` — wider stroke produces more
/// pixels. This proves the stroke is genuinely tracing contours with
/// the current paint width, not just rendering filled glyphs.
#[tokio::test]
async fn stroke_text_responds_to_line_width() {
    let r = evaluate(
        "
        function render(lineWidth) {
            const c = document.createElement('canvas');
            c.width = 200; c.height = 50;
            const ctx = c.getContext('2d');
            ctx.font = '32px Arial';
            ctx.strokeStyle = '#000';
            ctx.lineWidth = lineWidth;
            ctx.strokeText('Hello', 10, 35);
            const data = ctx.getImageData(0, 0, 200, 50).data;
            let nonzero = 0;
            for (let i = 3; i < data.length; i += 4) if (data[i] > 0) nonzero++;
            return nonzero;
        }
        const thin = render(0.5);
        const thick = render(4);
        // Wider stroke must produce more covered pixels.
        thick > thin * 1.3 ? 'thicker' : ('not-thicker:thin=' + thin + ',thick=' + thick)
        ",
    )
    .await;
    assert_eq!(r, "thicker");
}

/// Composite test: full CreepJS-style scene with paths + text. Asserts
/// `toDataURL()` produces a non-trivial PNG (length > 1000 bytes).
#[tokio::test]
async fn complex_path_scene_produces_pixels() {
    let r = evaluate(
        "
        const c = document.createElement('canvas');
        c.width = 220; c.height = 30;
        const ctx = c.getContext('2d');
        ctx.textBaseline = 'top';
        ctx.font = \"14px 'Arial'\";
        ctx.textBaseline = 'alphabetic';
        ctx.fillStyle = '#f60';
        ctx.fillRect(125, 1, 62, 20);
        ctx.fillStyle = '#069';
        ctx.fillText('Cwm fjordbank glyphs vext quiz', 2, 15);
        ctx.beginPath();
        ctx.arc(50, 15, 10, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
        ctx.fill();
        const url = c.toDataURL();
        url.length > 100 && url.startsWith('data:image/png')
        ",
    )
    .await;
    assert_eq!(r, "true");
}