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");
}
#[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");
}
#[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");
}
#[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");
}
#[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"
);
}
#[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");
}
#[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");
}