use browser_oxide::Page;
use std::time::Duration;
fn html(body: &str) -> String {
format!(
"<!DOCTYPE html><html><head></head><body>{}</body></html>",
body
)
}
#[tokio::test]
async fn html_to_dom_to_js_roundtrip() {
let mut page = Page::from_html(
&html(
r#"
<div id="data" data-count="3">original</div>
<script>
const el = document.getElementById('data');
const count = parseInt(el.getAttribute('data-count'));
const items = [];
for (let i = 0; i < count; i++) {
const span = document.createElement('span');
span.className = 'generated';
span.textContent = 'item-' + i;
el.appendChild(span);
}
el.setAttribute('data-processed', 'true');
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.querySelectorAll('.generated').length")
.unwrap(),
"3"
);
assert_eq!(
page.evaluate("document.getElementById('data').getAttribute('data-processed')")
.unwrap(),
"true"
);
assert!(page
.evaluate("document.getElementById('data').textContent")
.unwrap()
.contains("item-2"));
}
#[tokio::test]
async fn dom_mutations_are_visible_to_queries() {
let mut page = Page::from_html(
&html(
r#"
<ul id="list">
<li id="a">A</li>
<li id="b">B</li>
<li id="c">C</li>
</ul>
<script>
// Remove middle element
document.getElementById('b').remove();
// Add new element
const d = document.createElement('li');
d.id = 'd';
d.textContent = 'D';
document.getElementById('list').appendChild(d);
// Replace first with new
const e = document.createElement('li');
e.id = 'e';
e.textContent = 'E';
document.getElementById('a').replaceWith(e);
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.getElementById('b')").unwrap(),
"null"
);
assert_eq!(
page.evaluate("document.getElementById('a')").unwrap(),
"null"
);
assert_eq!(
page.evaluate("document.getElementById('e').textContent")
.unwrap(),
"E"
);
assert_eq!(
page.evaluate("document.getElementById('d').textContent")
.unwrap(),
"D"
);
assert_eq!(
page.evaluate("document.getElementById('c').textContent")
.unwrap(),
"C"
);
assert_eq!(
page.evaluate("document.querySelectorAll('#list li').length")
.unwrap(),
"3"
);
assert_eq!(
page.evaluate("document.getElementById('list').textContent")
.unwrap()
.trim()
.replace(|c: char| c.is_whitespace(), ""),
"ECD"
);
}
#[tokio::test]
async fn events_bubble_through_dom_tree() {
let mut page = Page::from_html(
&html(
r#"
<div id="outer">
<div id="inner">
<button id="btn">Click</button>
</div>
</div>
<script>
globalThis.log = [];
document.getElementById('outer').addEventListener('click', () => log.push('outer'));
document.getElementById('inner').addEventListener('click', () => log.push('inner'));
document.getElementById('btn').addEventListener('click', () => log.push('btn'));
document.getElementById('btn').dispatchEvent(new Event('click', { bubbles: true }));
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("JSON.stringify(log)").unwrap(),
r#"["btn","inner","outer"]"#
);
}
#[tokio::test]
async fn event_listener_removal_works() {
let mut page = Page::from_html(
&html(
r#"
<div id="el"></div>
<script>
globalThis.count = 0;
function handler() { count++; }
const el = document.getElementById('el');
el.addEventListener('test', handler);
el.dispatchEvent(new Event('test'));
el.removeEventListener('test', handler);
el.dispatchEvent(new Event('test'));
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("count").unwrap(), "1");
}
#[tokio::test]
async fn custom_event_with_detail() {
let mut page = Page::from_html(
&html(
r#"
<div id="el"></div>
<script>
globalThis.received = null;
document.getElementById('el').addEventListener('myevent', (e) => {
globalThis.received = e.detail;
});
document.getElementById('el').dispatchEvent(
new CustomEvent('myevent', { detail: { key: 'value' } })
);
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("received.key").unwrap(), "value");
}
#[tokio::test]
async fn async_chain_mutates_dom() {
let mut page = Page::from_html(
&html(
r#"
<div id="status">start</div>
<script>
const el = document.getElementById('status');
setTimeout(() => {
el.textContent = 'timeout1';
Promise.resolve().then(() => {
el.textContent = 'promise1';
setTimeout(() => {
el.textContent = 'final';
}, 10);
});
}, 10);
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.getElementById('status').textContent")
.unwrap(),
"final"
);
}
#[tokio::test]
async fn set_interval_fires_multiple_times() {
let mut page = Page::from_html(
&html(
r#"
<script>
globalThis.ticks = 0;
const id = setInterval(() => {
ticks++;
if (ticks >= 3) clearInterval(id);
}, 20);
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let ticks: i32 = page.evaluate("ticks").unwrap().parse().unwrap();
assert!(ticks >= 3, "expected >= 3 ticks, got {}", ticks);
}
#[tokio::test]
async fn cookie_read_write() {
let mut page = Page::from_html(
&html(
r#"
<script>
document.cookie = 'session=abc123; path=/';
document.cookie = 'theme=dark; path=/';
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let _cookies = page.evaluate("document.cookie").unwrap();
assert_eq!(page.evaluate("typeof document.cookie").unwrap(), "string");
}
#[tokio::test]
async fn localstorage_persists() {
let mut page = Page::from_html(
&html(
r#"
<script>
localStorage.setItem('user', 'alice');
localStorage.setItem('count', '42');
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("localStorage.getItem('user')").unwrap(),
"alice"
);
assert_eq!(
page.evaluate("localStorage.getItem('count')").unwrap(),
"42"
);
assert_eq!(
page.evaluate("localStorage.getItem('nonexistent')")
.unwrap(),
"null"
);
page.evaluate("localStorage.removeItem('user')").unwrap();
assert_eq!(
page.evaluate("localStorage.getItem('user')").unwrap(),
"null"
);
}
#[tokio::test]
async fn style_mutations_reflect_in_dom() {
let mut page = Page::from_html(
&html(
r#"
<div id="box"></div>
<script>
const box = document.getElementById('box');
box.style.backgroundColor = 'red';
box.style.width = '100px';
box.style.setProperty('border', '1px solid black');
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.getElementById('box').style.backgroundColor")
.unwrap(),
"red"
);
assert_eq!(
page.evaluate("document.getElementById('box').style.width")
.unwrap(),
"100px"
);
let attr = page
.evaluate("document.getElementById('box').getAttribute('style')")
.unwrap();
assert!(attr.contains("background-color"), "style attr: {}", attr);
assert!(attr.contains("100px"), "style attr: {}", attr);
}
#[tokio::test]
async fn clone_and_modify_independently() {
let mut page = Page::from_html(
&html(
r#"
<template id="tpl">
<div class="card"><span class="name"></span></div>
</template>
<div id="container"></div>
<script>
const tpl = document.getElementById('tpl');
const container = document.getElementById('container');
// Template content is just our innerHTML for now
for (const name of ['Alice', 'Bob', 'Charlie']) {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = '<span class="name">' + name + '</span>';
container.appendChild(card);
}
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let count = page
.evaluate("document.getElementById('container').querySelectorAll('.card').length")
.unwrap();
assert_eq!(count, "3", "expected 3 cards in container");
let bob = page
.evaluate(
r#"
(() => {
const names = document.getElementById('container').querySelectorAll('.name');
return names.item(1).textContent;
})()
"#,
)
.unwrap();
assert_eq!(bob, "Bob");
}
#[tokio::test]
async fn insert_adjacent_html_builds_complex_dom() {
let mut page = Page::from_html(
&html(
r#"
<div id="target">middle</div>
<script>
const el = document.getElementById('target');
el.insertAdjacentHTML('beforebegin', '<p id="before">before</p>');
el.insertAdjacentHTML('afterend', '<p id="after">after</p>');
el.insertAdjacentHTML('afterbegin', '<span>start-</span>');
el.insertAdjacentHTML('beforeend', '<span>-end</span>');
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.getElementById('before').textContent")
.unwrap(),
"before"
);
assert_eq!(
page.evaluate("document.getElementById('after').textContent")
.unwrap(),
"after"
);
let content = page
.evaluate("document.getElementById('target').textContent")
.unwrap();
assert!(content.starts_with("start-"), "content: {}", content);
assert!(content.ends_with("-end"), "content: {}", content);
}
#[tokio::test]
async fn stealth_profile_wired_end_to_end() {
let profile = browser_oxide::stealth::presets::chrome_148_windows();
let mut page = Page::with_profile(
"<!DOCTYPE html><html><head></head><body></body></html>",
"https://example.com",
profile,
)
.await
.unwrap();
let ua = page.evaluate("navigator.userAgent").unwrap();
assert!(
ua.contains("Chrome/") && ua.contains("Safari/537.36"),
"UA: {ua}"
);
assert!(ua.contains("Windows NT 10.0"), "UA: {}", ua);
assert_eq!(page.evaluate("navigator.platform").unwrap(), "Win32");
assert_eq!(page.evaluate("navigator.vendor").unwrap(), "Google Inc.");
assert_eq!(
page.evaluate("typeof navigator.webdriver").unwrap(),
"boolean"
);
assert_eq!(page.evaluate("navigator.webdriver").unwrap(), "false");
assert_eq!(page.evaluate("navigator.cookieEnabled").unwrap(), "true");
assert_eq!(page.evaluate("navigator.language").unwrap(), "en-US");
assert_eq!(page.evaluate("navigator.hardwareConcurrency").unwrap(), "8");
assert_eq!(page.evaluate("navigator.deviceMemory").unwrap(), "8");
assert_eq!(page.evaluate("navigator.javaEnabled()").unwrap(), "false");
assert_eq!(page.evaluate("screen.width").unwrap(), "1920");
assert_eq!(page.evaluate("screen.height").unwrap(), "1080");
assert_eq!(page.evaluate("screen.colorDepth").unwrap(), "24");
assert_eq!(page.evaluate("innerWidth").unwrap(), "1920");
assert_eq!(page.evaluate("innerHeight").unwrap(), "969");
assert_eq!(page.evaluate("devicePixelRatio").unwrap(), "1");
assert_eq!(page.evaluate("typeof window.chrome").unwrap(), "object");
assert_eq!(
page.evaluate("typeof window.chrome.runtime").unwrap(),
"undefined"
);
assert_eq!(
page.evaluate("navigator.userAgentData.mobile").unwrap(),
"false"
);
assert_eq!(
page.evaluate("navigator.userAgentData.brands.length > 0")
.unwrap(),
"true"
);
}
#[tokio::test]
async fn russian_locale_profile() {
let profile = browser_oxide::stealth::presets::chrome_148_ru();
let mut page = Page::with_profile(
"<!DOCTYPE html><html><head></head><body></body></html>",
"https://ya.ru",
profile,
)
.await
.unwrap();
assert_eq!(page.evaluate("navigator.language").unwrap(), "ru-RU");
let langs = page
.evaluate("JSON.stringify(navigator.languages)")
.unwrap();
assert!(langs.contains("ru-RU"), "languages: {}", langs);
assert!(
langs.contains("en-US"),
"should also contain en-US: {}",
langs
);
}
#[tokio::test]
async fn chinese_locale_profile() {
let profile = browser_oxide::stealth::presets::chrome_148_cn();
let mut page = Page::with_profile(
"<!DOCTYPE html><html><head></head><body></body></html>",
"https://baidu.com",
profile,
)
.await
.unwrap();
assert_eq!(page.evaluate("navigator.language").unwrap(), "zh-CN");
}
#[tokio::test]
async fn canvas_renders_and_exports() {
let mut page = Page::from_html(
&html(
r#"
<script>
const c = document.createElement('canvas');
c.width = 200;
c.height = 100;
const ctx = c.getContext('2d');
ctx.fillStyle = '#ff0000';
ctx.fillRect(10, 10, 50, 50);
ctx.fillStyle = '#0000ff';
ctx.font = '20px Arial';
ctx.fillText('Hello', 70, 50);
globalThis.dataUrl = c.toDataURL();
globalThis.textWidth = ctx.measureText('Hello').width;
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let data_url = page.evaluate("dataUrl").unwrap();
assert!(
data_url.starts_with("data:image/png;base64,"),
"not a PNG data URL"
);
assert!(
data_url.len() > 100,
"data URL too short — probably empty canvas"
);
let width: f64 = page.evaluate("textWidth").unwrap().parse().unwrap();
assert!(width > 0.0, "measureText should return positive width");
}
#[tokio::test]
async fn webgl_context_and_parameters() {
let mut page = Page::from_html(
&html(
r#"
<script>
const gl = document.createElement('canvas').getContext('webgl');
globalThis.vendor = gl.getParameter(gl.VENDOR);
globalThis.renderer = '';
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (ext) globalThis.renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
globalThis.maxTexture = gl.getParameter(0x0D33); // GL_MAX_TEXTURE_SIZE
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let vendor = page.evaluate("vendor").unwrap();
assert!(!vendor.is_empty(), "WebGL vendor should not be empty");
let renderer = page.evaluate("renderer").unwrap();
assert!(!renderer.is_empty(), "WebGL renderer should not be empty");
let max_tex_raw = page.evaluate("String(maxTexture)").unwrap();
let max_tex: f64 = max_tex_raw.parse().unwrap_or(0.0);
assert!(
max_tex >= 4096.0,
"max texture size should be >= 4096, got {}",
max_tex_raw
);
}
#[tokio::test]
async fn audio_context_exists() {
let mut page = Page::from_html(&html(""), None::<browser_oxide::stealth::StealthProfile>)
.await
.unwrap();
assert_eq!(page.evaluate("typeof AudioContext").unwrap(), "function");
assert_eq!(
page.evaluate("typeof OfflineAudioContext").unwrap(),
"function"
);
}
#[tokio::test]
async fn layout_returns_dimensions() {
let mut page = Page::from_html(
&html(
r#"
<div id="box" style="width:200px; height:100px;"></div>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let w = page
.evaluate("document.getElementById('box').offsetWidth")
.unwrap();
assert_ne!(w, "0", "offsetWidth should not be 0");
let rect = page
.evaluate("JSON.stringify(document.getElementById('box').getBoundingClientRect())")
.unwrap();
assert!(rect.contains("width"), "rect should have width: {}", rect);
}
#[tokio::test]
async fn history_navigation() {
let mut page = Page::from_html(
&html(
r#"
<script>
history.pushState({ page: 1 }, '', '/page1');
history.pushState({ page: 2 }, '', '/page2');
history.pushState({ page: 3 }, '', '/page3');
history.back();
history.back();
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("history.state.page").unwrap(), "1");
assert_eq!(page.evaluate("history.length").unwrap(), "4"); }
#[tokio::test]
async fn url_parsing() {
let mut page = Page::from_html(&html(""), None::<browser_oxide::stealth::StealthProfile>)
.await
.unwrap();
assert_eq!(
page.evaluate("new URL('https://example.com/path?q=1#hash').hostname")
.unwrap(),
"example.com"
);
assert_eq!(
page.evaluate("new URL('https://example.com/path?q=1#hash').pathname")
.unwrap(),
"/path"
);
assert_eq!(
page.evaluate("new URL('https://example.com/path?q=1#hash').search")
.unwrap(),
"?q=1"
);
assert_eq!(
page.evaluate("new URL('/relative', 'https://base.com').href")
.unwrap(),
"https://base.com/relative"
);
}
#[tokio::test]
async fn abort_controller_lifecycle() {
let mut page = Page::from_html(
&html(
r#"
<script>
const ac = new AbortController();
globalThis.events = [];
ac.signal.addEventListener('abort', () => events.push('aborted'));
events.push('before:' + ac.signal.aborted);
ac.abort();
events.push('after:' + ac.signal.aborted);
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("JSON.stringify(events)").unwrap(),
r#"["before:false","aborted","after:true"]"#
);
}
#[tokio::test]
async fn domparser_parses_html() {
let mut page = Page::from_html(
&html(
r#"
<script>
const parser = new DOMParser();
const doc = parser.parseFromString(
'<div><p id="inner">parsed</p></div>', 'text/html'
);
globalThis.text = doc.querySelector('#inner').textContent;
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("text").unwrap(), "parsed");
}
#[tokio::test]
async fn formdata_and_urlsearchparams() {
let mut page = Page::from_html(
&html(
r#"
<script>
const fd = new FormData();
fd.append('name', 'test');
fd.append('value', '42');
globalThis.fdGet = fd.get('name');
globalThis.fdHas = fd.has('value');
const usp = new URLSearchParams('a=1&b=2&c=3');
usp.set('b', '20');
usp.delete('c');
usp.append('d', '4');
globalThis.uspStr = usp.toString();
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("fdGet").unwrap(), "test");
assert_eq!(page.evaluate("fdHas").unwrap(), "true");
assert_eq!(page.evaluate("uspStr").unwrap(), "a=1&b=20&d=4");
}
#[tokio::test]
async fn match_media_evaluates_queries() {
let mut page = Page::from_html(&html(""), None::<browser_oxide::stealth::StealthProfile>)
.await
.unwrap();
assert_eq!(
page.evaluate("matchMedia('(min-width: 768px)').matches")
.unwrap(),
"true"
);
assert_eq!(
page.evaluate("matchMedia('(max-width: 100px)').matches")
.unwrap(),
"false"
);
assert_eq!(
page.evaluate("typeof matchMedia('screen').media").unwrap(),
"string"
);
}
#[tokio::test]
async fn document_write_injects_content() {
let mut page = Page::from_html(
&html(
r#"
<div id="before">before</div>
<script>
document.write('<div id="written">injected</div>');
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.getElementById('before').textContent")
.unwrap(),
"before"
);
assert_eq!(
page.evaluate("document.getElementById('written').textContent")
.unwrap(),
"injected"
);
}
#[tokio::test]
async fn spa_like_render_cycle() {
let mut page = Page::from_html(&html(r#"
<div id="app"></div>
<script>
// Simulate a SPA: parse JSON data, render components, handle events
const data = JSON.parse('{"users":[{"name":"Alice","age":30},{"name":"Bob","age":25}]}');
function render(users) {
const app = document.getElementById('app');
app.replaceChildren(); // clear
const ul = document.createElement('ul');
ul.id = 'user-list';
for (const user of users) {
const li = document.createElement('li');
li.className = 'user-item';
li.dataset.age = String(user.age);
li.textContent = user.name + ' (' + user.age + ')';
li.addEventListener('click', () => {
li.classList.add('selected');
});
ul.appendChild(li);
}
app.appendChild(ul);
}
render(data.users);
// Simulate click on first item
const firstItem = document.querySelector('.user-item');
firstItem.dispatchEvent(new Event('click'));
</script>
"#), None::<browser_oxide::stealth::StealthProfile>).await.unwrap();
assert_eq!(
page.evaluate("document.querySelectorAll('.user-item').length")
.unwrap(),
"2"
);
assert_eq!(
page.evaluate("document.querySelector('.user-item').dataset.age")
.unwrap(),
"30"
);
assert_eq!(
page.evaluate("document.querySelector('.user-item').classList.contains('selected')")
.unwrap(),
"true"
);
assert!(page
.evaluate("document.getElementById('user-list').textContent")
.unwrap()
.contains("Bob (25)"));
}
#[tokio::test]
async fn is_connected_tracks_dom_attachment() {
let mut page = Page::from_html(
&html(
r#"
<script>
const div = document.createElement('div');
globalThis.step1 = div.isConnected; // false — not in tree
document.body.appendChild(div);
globalThis.step2 = div.isConnected; // true — in tree
div.remove();
globalThis.step3 = div.isConnected; // false — removed
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("step1").unwrap(), "false");
assert_eq!(page.evaluate("step2").unwrap(), "true");
assert_eq!(page.evaluate("step3").unwrap(), "false");
}
#[tokio::test]
#[ignore]
async fn full_pipeline_hacker_news() {
let profile = browser_oxide::stealth::chrome_148_linux();
let mut page = Page::navigate("https://news.ycombinator.com", profile, 0)
.await
.expect("navigate failed");
let title = page.title();
assert_eq!(title, "Hacker News");
let has_stories = page
.evaluate("document.querySelectorAll('.titleline').length > 0")
.unwrap();
assert_eq!(has_stories, "true");
let story = page
.evaluate("document.querySelector('.titleline').textContent")
.unwrap();
assert!(!story.is_empty(), "first story should have text");
}
#[tokio::test]
#[ignore]
async fn full_pipeline_wikipedia() {
let profile = browser_oxide::stealth::chrome_148_linux();
let mut page = Page::navigate(
"https://en.wikipedia.org/wiki/Rust_(programming_language)",
profile,
0,
)
.await
.expect("navigate failed");
let title = page.title();
assert!(
title.contains("Rust"),
"title should mention Rust: {}",
title
);
let text = page.text_content();
assert!(
text.len() > 10000,
"Wikipedia article should be substantial"
);
}
#[tokio::test]
async fn e2e_style_block_to_computed_style() {
let mut page = Page::from_html(
r#"<!DOCTYPE html>
<html><head>
<style>
.highlight { color: red; font-size: 24px; }
#main { background-color: blue; }
</style>
</head><body>
<div id="main" class="highlight"></div>
<script>
const el = document.getElementById('main');
const cs = getComputedStyle(el);
globalThis.color = cs.color;
globalThis.fontSize = cs.fontSize;
globalThis.bg = cs.backgroundColor;
</script>
</body></html>"#,
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("color").unwrap(), "red");
assert_eq!(page.evaluate("fontSize").unwrap(), "24px");
assert_eq!(page.evaluate("bg").unwrap(), "blue");
}
#[tokio::test]
async fn e2e_canvas_gradient_renders_pixels() {
let mut page = Page::from_html(
&html(
r#"
<canvas id="c" width="100" height="100"></canvas>
<script>
const ctx = document.getElementById('c').getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 100, 0);
grad.addColorStop(0, 'red');
grad.addColorStop(1, 'blue');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 100, 100);
globalThis.dataUrl = document.getElementById('c').toDataURL();
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
let url = page.evaluate("dataUrl").unwrap();
assert!(url.starts_with("data:image/png;base64,"));
assert!(
url.len() > 200,
"gradient should produce substantial pixel data, got len={}",
url.len()
);
}
#[tokio::test]
async fn e2e_mutation_observer_in_pipeline() {
let mut page = Page::from_html(&html(r#"
<div id="target"></div>
<script>
globalThis.mutations = [];
const observer = new MutationObserver((records) => {
for (const r of records) mutations.push(r.type);
});
observer.observe(document.getElementById('target'), { childList: true, attributes: true });
// Trigger mutations
document.getElementById('target').appendChild(document.createElement('span'));
document.getElementById('target').setAttribute('data-x', '1');
</script>
"#), None::<browser_oxide::stealth::StealthProfile>).await.unwrap();
page.evaluate_async("void 0", Duration::from_millis(50))
.await
.ok();
let mutations = page.evaluate("JSON.stringify(mutations)").unwrap();
assert!(
mutations.contains("childList"),
"should detect child mutation: {}",
mutations
);
assert!(
mutations.contains("attributes"),
"should detect attr mutation: {}",
mutations
);
}
#[tokio::test]
async fn e2e_websocket_constructor_stores_url() {
let mut page = Page::from_html(
&html(
r#"
<script>
const ws = new WebSocket('wss://echo.websocket.org');
globalThis.wsUrl = ws.url;
globalThis.wsState = ws.readyState;
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("wsUrl").unwrap(), "wss://echo.websocket.org");
assert_eq!(page.evaluate("wsState").unwrap(), "0");
}
#[tokio::test]
async fn e2e_iframe_content_window_exists() {
let mut page = Page::from_html(
&html(
r#"
<script>
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
globalThis.hasWindow = typeof iframe.contentWindow === 'object';
globalThis.hasDoc = typeof iframe.contentDocument === 'object';
globalThis.parentIsWindow = iframe.contentWindow.parent === window;
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("hasWindow").unwrap(), "true");
assert_eq!(page.evaluate("hasDoc").unwrap(), "true");
assert_eq!(page.evaluate("parentIsWindow").unwrap(), "true");
}
#[tokio::test]
async fn e2e_spa_with_css_cascade() {
let mut page = Page::from_html(
r#"<!DOCTYPE html>
<html><head>
<style>
.card { color: navy; font-size: 14px; }
.card.active { color: red; }
.hidden { display: none; }
</style>
</head><body>
<div id="app"></div>
<script>
function render(items) {
const app = document.getElementById('app');
app.replaceChildren();
for (const item of items) {
const card = document.createElement('div');
card.className = 'card';
card.textContent = item.name;
card.dataset.id = String(item.id);
if (item.active) card.classList.add('active');
if (item.hidden) card.classList.add('hidden');
card.addEventListener('click', () => {
card.classList.toggle('active');
});
app.appendChild(card);
}
}
render([
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
{ id: 3, name: 'Charlie', hidden: true },
]);
// Verify cascade works
const alice = document.querySelector('.card.active');
globalThis.aliceColor = getComputedStyle(alice).color;
const allCards = document.querySelectorAll('.card');
const bob = allCards.item(1);
globalThis.bobColor = getComputedStyle(bob).color;
globalThis.bobClass = bob.className;
const charlie = document.querySelector('.hidden');
globalThis.charlieDisplay = getComputedStyle(charlie).display;
// Simulate click on Bob
bob.dispatchEvent(new Event('click'));
globalThis.bobActive = bob.classList.contains('active');
</script>
</body></html>"#,
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("aliceColor").unwrap(),
"red",
"active card should be red"
);
assert_eq!(
page.evaluate("bobColor").unwrap(),
"navy",
"inactive card should be navy"
);
assert_eq!(
page.evaluate("charlieDisplay").unwrap(),
"none",
"hidden card should be display:none"
);
assert_eq!(
page.evaluate("bobActive").unwrap(),
"true",
"click should toggle active"
);
assert_eq!(
page.evaluate("document.querySelectorAll('.card').length")
.unwrap(),
"3"
);
}
#[tokio::test]
#[ignore] async fn e2e_websocket_tls_connects() {
let mut page = Page::from_html(
&html(
r#"
<script>
globalThis.wsResult = 'pending';
globalThis.wsError = '';
try {
const ws = new WebSocket('wss://echo.websocket.events');
ws.onopen = () => { ws.send('hello'); };
ws.onmessage = (e) => { globalThis.wsResult = 'received:' + e.data; ws.close(); };
ws.onerror = () => { globalThis.wsResult = 'error'; };
} catch (e) {
globalThis.wsError = e.toString();
}
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
page.evaluate_async("void 0", Duration::from_secs(5))
.await
.ok();
let err = page.evaluate("wsError").unwrap();
assert!(err.is_empty(), "wss:// should not throw: {}", err);
}
#[tokio::test]
#[ignore] async fn e2e_linked_stylesheet_fetched() {
let profile = browser_oxide::stealth::chrome_148_linux();
match Page::navigate("https://news.ycombinator.com", profile, 0).await {
Ok(mut page) => {
let title = page.title();
assert_eq!(title, "Hacker News");
let bg = page
.evaluate("getComputedStyle(document.body).backgroundColor")
.unwrap();
println!("[linked CSS] body bg: {bg}");
}
Err(e) => eprintln!("navigate failed: {e}"),
}
}
#[tokio::test]
async fn e2e_stylesheet_collector_finds_link_tags() {
let dom = browser_oxide::html_parser::parse_html(
r#"<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/style.css">
<style>.test { color: green; }</style>
</head><body></body></html>"#,
);
let entries = browser_oxide::stylesheet_collector::find_stylesheets(&dom);
assert_eq!(
entries.len(),
2,
"should find link + style, got {}",
entries.len()
);
assert!(
matches!(&entries[0], browser_oxide::stylesheet_collector::StylesheetEntry::External(h) if h == "/style.css")
);
assert!(
matches!(&entries[1], browser_oxide::stylesheet_collector::StylesheetEntry::Inline(c) if c.contains("color: green"))
);
}
#[tokio::test]
async fn e2e_webgl_renders_in_pipeline() {
let mut page = Page::from_html(
&html(
r#"
<canvas id="c" width="64" height="64"></canvas>
<script>
const gl = document.getElementById('c').getContext('webgl');
// Verify constants exist
globalThis.hasCBB = gl.COLOR_BUFFER_BIT === 0x4000;
// Clear to green
gl.clearColor(0.0, 1.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Read pixels
const pixels = new Uint8Array(4);
gl.readPixels(32, 32, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
globalThis.green = pixels[1];
// toDataURL should have content
globalThis.hasContent = document.getElementById('c').toDataURL().length > 100;
</script>
"#,
),
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("hasCBB").unwrap(),
"true",
"WebGL constants should be on instance"
);
assert_eq!(
page.evaluate("green").unwrap(),
"255",
"green channel should be 255 after clearColor(0,1,0,1)"
);
assert_eq!(
page.evaluate("hasContent").unwrap(),
"true",
"toDataURL should have pixel data"
);
}
#[tokio::test]
async fn e2e_iframe_srcdoc_isolated() {
let mut page = Page::from_html(
r#"<!DOCTYPE html><html><body>
<script>globalThis.scope = 'parent';</script>
<iframe srcdoc="<script>globalThis.scope = 'child';</script>"></iframe>
</body></html>"#,
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(page.evaluate("scope").unwrap(), "parent");
assert_eq!(page.child_iframe_count(), 1);
assert_eq!(
page.child_iframe(0).unwrap().evaluate("scope").unwrap(),
"child"
);
}
#[tokio::test]
async fn e2e_iframe_dom_isolated() {
let mut page = Page::from_html(
r#"<!DOCTYPE html><html><body>
<div id="p">parent-dom</div>
<iframe srcdoc="<div id='c'>child-dom</div>"></iframe>
</body></html>"#,
None::<browser_oxide::stealth::StealthProfile>,
)
.await
.unwrap();
assert_eq!(
page.evaluate("document.getElementById('p').textContent")
.unwrap(),
"parent-dom"
);
assert_eq!(
page.evaluate("document.getElementById('c')").unwrap(),
"null"
);
assert_eq!(
page.child_iframe(0).unwrap().query_text("#c").unwrap(),
"child-dom"
);
}
#[tokio::test]
#[ignore] async fn challenge_wildberries() {
let profile = browser_oxide::stealth::chrome_148_linux();
match Page::navigate("https://www.wildberries.ru", profile, 2).await {
Ok(mut page) => {
let title = page.title();
let url = page.url().to_string();
let body_len = page.content().len();
let errors = page
.evaluate("JSON.stringify(window.__scriptErrors || [])")
.unwrap_or_default();
println!("[wildberries] title: {title}");
println!("[wildberries] url: {url}");
println!("[wildberries] body: {body_len} bytes");
println!("[wildberries] errors: {errors}");
assert!(
!title.contains("Почти готово"),
"Challenge not solved, still on challenge page: {}",
title
);
}
Err(e) => {
eprintln!("[wildberries] navigate failed: {e}");
panic!("wildberries navigation failed: {e}");
}
}
}