diff --git a/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs b/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs index 5113ec06..910c5524 100644 --- a/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs +++ b/crates/tinycomputer-browser/src/convert/convert_tests/session_tests.rs @@ -1,7 +1,9 @@ //! Tests for the launch a session starts with. use serde_json::json; -use tinycomputer_bus::browser::{SessionOptions, Viewport}; +use tinycomputer_bus::browser::{ + Screen, SessionOptions, UserAgentBrand, UserAgentMetadata, Viewport, +}; use crate::convert::{launch, viewport}; @@ -46,3 +48,105 @@ fn every_session_option_reaches_its_launch_field() { json!({"action": "viewport", "width": 800, "height": 600, "deviceScaleFactor": 1.0, "mobile": false}) ); } + +#[test] +fn every_identity_option_reaches_its_launch_field() { + let options = SessionOptions { + timezone: Some("Asia/Tokyo".to_owned()), + locale: Some("ja-JP".to_owned()), + accept_language: Some("ja-JP,ja".to_owned()), + user_agent_metadata: Some(UserAgentMetadata { + brands: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140".to_owned(), + }]), + full_version_list: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140.0.7339.0".to_owned(), + }]), + full_version: None, + platform: "macOS".to_owned(), + platform_version: "14.5.0".to_owned(), + architecture: "arm".to_owned(), + model: String::new(), + mobile: false, + bitness: Some("64".to_owned()), + wow64: Some(false), + }), + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + init_scripts: vec!["window.marker = 1;".to_owned()], + stealth: true, + ..SessionOptions::default() + }; + assert_eq!( + launch(&options), + json!({ + "action": "launch", + "headless": true, + "args": [], + "timezone": "Asia/Tokyo", + "locale": "ja-JP", + "acceptLanguage": "ja-JP,ja", + "userAgentMetadata": { + "brands": [{"brand": "Chromium", "version": "140"}], + "fullVersionList": [{"brand": "Chromium", "version": "140.0.7339.0"}], + "platform": "macOS", + "platformVersion": "14.5.0", + "architecture": "arm", + "model": "", + "mobile": false, + "bitness": "64", + "wow64": false, + }, + "screen": {"width": 1920, "height": 1080, "deviceScaleFactor": 2.0}, + "initScriptSources": ["window.marker = 1;"], + "stealth": true, + }) + ); +} + +#[test] +fn a_session_that_asks_for_no_identity_sends_none() { + // `stealth: false` and no init scripts are the defaults: none is sent, so + // an engine that predates the fields never sees them. + let command = launch(&SessionOptions::default()); + let sent = command.as_object().expect("an object"); + for key in [ + "timezone", + "locale", + "acceptLanguage", + "userAgentMetadata", + "screen", + "initScriptSources", + "stealth", + ] { + assert!(!sent.contains_key(key), "{key} was sent"); + } +} + +#[test] +fn a_screen_sets_the_pixel_ratio_the_viewport_would_otherwise_replace() { + // The viewport is applied after the launch and replaces its pixel ratio, + // so a session that asked for a 2x screen would report 1x. + let options = SessionOptions { + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + ..SessionOptions::default() + }; + assert_eq!( + viewport(&options), + json!({"action": "viewport", "width": 1280, "height": 800, "deviceScaleFactor": 2.0, "mobile": false}) + ); + assert_eq!( + viewport(&SessionOptions::default())["deviceScaleFactor"], + 1.0, + "without a screen the viewport's own ratio stands" + ); +} diff --git a/crates/tinycomputer-browser/src/convert/session.rs b/crates/tinycomputer-browser/src/convert/session.rs index d3f1ce13..db2157d5 100644 --- a/crates/tinycomputer-browser/src/convert/session.rs +++ b/crates/tinycomputer-browser/src/convert/session.rs @@ -6,7 +6,7 @@ //! (`crate::origins`). use serde_json::{Value, json}; -use tinycomputer_bus::browser::SessionOptions; +use tinycomputer_bus::browser::{SessionOptions, UserAgentBrand, UserAgentMetadata}; /// The explicit `launch` every session starts with. /// @@ -26,23 +26,83 @@ pub(crate) fn launch(options: &SessionOptions) -> Value { ("userAgent", options.user_agent.as_ref()), ("profile", options.user_data_dir.as_ref()), ("downloadPath", options.download_dir.as_ref()), + ("timezone", options.timezone.as_ref()), + ("locale", options.locale.as_ref()), + ("acceptLanguage", options.accept_language.as_ref()), ]; for (key, value) in fields { if let Some(value) = value { command[key] = json!(value); } } + if let Some(metadata) = &options.user_agent_metadata { + command["userAgentMetadata"] = user_agent_metadata(metadata); + } + if let Some(screen) = &options.screen { + command["screen"] = json!({ + "width": screen.width, + "height": screen.height, + "deviceScaleFactor": screen.device_scale_factor, + }); + } + if !options.init_scripts.is_empty() { + command["initScriptSources"] = json!(options.init_scripts); + } + if options.stealth { + command["stealth"] = json!(true); + } command } +/// The client hints in the engine's field names; fields left unset are left +/// out, as the engine reads an absent list differently from an empty one. +fn user_agent_metadata(metadata: &UserAgentMetadata) -> Value { + let brands = |list: &[UserAgentBrand]| -> Value { + list.iter() + .map(|entry| json!({"brand": entry.brand, "version": entry.version})) + .collect() + }; + let mut value = json!({ + "platform": metadata.platform, + "platformVersion": metadata.platform_version, + "architecture": metadata.architecture, + "model": metadata.model, + "mobile": metadata.mobile, + }); + if let Some(list) = &metadata.brands { + value["brands"] = brands(list); + } + if let Some(list) = &metadata.full_version_list { + value["fullVersionList"] = brands(list); + } + if let Some(version) = &metadata.full_version { + value["fullVersion"] = json!(version); + } + if let Some(bitness) = &metadata.bitness { + value["bitness"] = json!(bitness); + } + if let Some(wow64) = metadata.wow64 { + value["wow64"] = json!(wow64); + } + value +} + /// The viewport a session applies straight after launching. +/// +/// It replaces the launch's pixel ratio, so a session with a screen takes the +/// screen's: what `devicePixelRatio` reports is part of that screen. #[must_use] pub(crate) fn viewport(options: &SessionOptions) -> Value { + let scale = options + .screen + .map_or(options.viewport.device_scale_factor, |screen| { + screen.device_scale_factor + }); json!({ "action": "viewport", "width": options.viewport.width, "height": options.viewport.height, - "deviceScaleFactor": options.viewport.device_scale_factor, + "deviceScaleFactor": scale, "mobile": options.viewport.mobile, }) } diff --git a/crates/tinycomputer-browser/src/sessions/sessions_tests.rs b/crates/tinycomputer-browser/src/sessions/sessions_tests.rs index e9275a0f..fe6730fa 100644 --- a/crates/tinycomputer-browser/src/sessions/sessions_tests.rs +++ b/crates/tinycomputer-browser/src/sessions/sessions_tests.rs @@ -19,6 +19,7 @@ mod deep_keys_tests; mod deep_page; mod deep_tests; mod focus_tests; +mod identity_tests; mod lifecycle_tests; mod origins_tests; mod page_tests; diff --git a/crates/tinycomputer-browser/src/sessions/sessions_tests/identity_tests.rs b/crates/tinycomputer-browser/src/sessions/sessions_tests/identity_tests.rs new file mode 100644 index 00000000..d9d32dc2 --- /dev/null +++ b/crates/tinycomputer-browser/src/sessions/sessions_tests/identity_tests.rs @@ -0,0 +1,147 @@ +//! Tests for the identity a session asks its browser to present: the launch +//! command carries it, and a real browser shows it to the page (gated on the +//! `agent-browser` feature and `TINYCOMPUTER_LIVE_BROWSER=1`, since CI has no +//! browser to launch). + +use tinycomputer_bus::browser::{Screen, SessionOptions, UserAgentBrand, UserAgentMetadata}; + +use crate::fake::Fake; +use crate::sessions::Browser; + +use super::scratch; + +fn tokyo() -> SessionOptions { + SessionOptions { + timezone: Some("Asia/Tokyo".to_owned()), + locale: Some("ja-JP".to_owned()), + accept_language: Some("ja-JP,ja".to_owned()), + user_agent: Some( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like \ + Gecko) Chrome/140.0.0.0 Safari/537.36" + .to_owned(), + ), + user_agent_metadata: Some(UserAgentMetadata { + brands: Some(vec![UserAgentBrand { + brand: "Chromium".to_owned(), + version: "140".to_owned(), + }]), + full_version_list: None, + full_version: Some("140.0.0.0".to_owned()), + platform: "macOS".to_owned(), + platform_version: "14.5.0".to_owned(), + architecture: "arm".to_owned(), + model: String::new(), + mobile: false, + bitness: Some("64".to_owned()), + wow64: None, + }), + screen: Some(Screen { + width: 1920, + height: 1080, + device_scale_factor: 2.0, + }), + init_scripts: vec!["window.fromInitScript = 'ran first';".to_owned()], + stealth: true, + ..SessionOptions::default() + } +} + +#[tokio::test] +async fn opening_a_session_hands_the_engine_every_identity_field_in_its_launch() { + let fake = Fake::new(); + let browser = Browser::with_scratch(std::sync::Arc::new(fake.clone()), scratch("identity")); + browser.open_session(tokyo()).await.unwrap(); + + let launch = fake.last("launch"); + assert_eq!(launch["timezone"], "Asia/Tokyo"); + assert_eq!(launch["locale"], "ja-JP"); + assert_eq!(launch["acceptLanguage"], "ja-JP,ja"); + assert_eq!(launch["userAgentMetadata"]["platform"], "macOS"); + assert_eq!(launch["screen"]["width"], 1920); + assert_eq!( + launch["initScriptSources"][0], + "window.fromInitScript = 'ran first';" + ); + assert_eq!(launch["stealth"], true); +} + +/// A page on `localhost`, served to the first few requests. +#[cfg(feature = "agent-browser")] +fn serve_a_page() -> String { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for stream in listener.incoming().take(8) { + let Ok(mut stream) = stream else { continue }; + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let body = "
identity
"; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + format!("http://localhost:{port}/") +} + +#[cfg(feature = "agent-browser")] +#[tokio::test] +async fn live_a_page_sees_the_identity_its_session_was_opened_with() { + use std::sync::Arc; + + use serde_json::json; + + if std::env::var("TINYCOMPUTER_LIVE_BROWSER").as_deref() != Ok("1") { + return; + } + let browser = Browser::new(Arc::new(crate::AgentBrowser)); + let options = SessionOptions { + executable: std::env::var("TINYCOMPUTER_BROWSER_EXECUTABLE").ok(), + ..tokyo() + }; + let info = browser + .open_session(options) + .await + .expect("a browser launches when live runs are asked for"); + // Init scripts run in a document loaded after launch, not the blank tab, + // and client hints exist only in a secure context, which a page on + // localhost is and a `data:` page is not. + let url = serve_a_page(); + browser + .command(&info.id, json!({"action": "navigate", "url": url})) + .await + .expect("the page loads"); + let read = |expression: &'static str| { + let browser = &browser; + let id = &info.id; + async move { + browser + .command(id, json!({"action": "evaluate", "script": expression})) + .await + .expect("the expression runs")["result"] + .clone() + } + }; + + assert_eq!( + read("Intl.DateTimeFormat().resolvedOptions().timeZone").await, + "Asia/Tokyo" + ); + assert_eq!(read("navigator.language").await, "ja-JP"); + assert_eq!(read("navigator.languages.join(',')").await, "ja-JP,ja"); + assert_eq!(read("navigator.userAgentData.platform").await, "macOS"); + assert_eq!( + read("navigator.userAgentData.brands.some(b => b.brand === 'Chromium' && b.version === '140')") + .await, + true + ); + assert_eq!(read("screen.width").await, 1920); + assert_eq!(read("screen.height").await, 1080); + assert_eq!(read("window.devicePixelRatio").await, 2); + assert_eq!(read("window.fromInitScript").await, "ran first"); + browser.close_session(&info.id).await.unwrap(); +} diff --git a/crates/tinycomputer-bus/src/browser/mod.rs b/crates/tinycomputer-bus/src/browser/mod.rs index 974421e4..600adfe5 100644 --- a/crates/tinycomputer-bus/src/browser/mod.rs +++ b/crates/tinycomputer-bus/src/browser/mod.rs @@ -96,6 +96,9 @@ pub use page::{ EvaluateRequest, NavigateRequest, PageState, PageText, ReadFormat, ReadRequest, WaitUntil, }; pub use profile::{ClaimProfileRequest, ClaimProfileResponse, ProfileClaim, ReleaseProfileRequest}; -pub use session::{SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, Viewport}; +pub use session::{ + Screen, SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, UserAgentBrand, + UserAgentMetadata, Viewport, +}; pub use snapshot::{ElementRef, Snapshot, SnapshotRequest}; pub use tool::{ToolAction, ToolArgError, ToolArgs}; diff --git a/crates/tinycomputer-bus/src/browser/session/mod.rs b/crates/tinycomputer-bus/src/browser/session/mod.rs index 16bae046..2b8c15af 100644 --- a/crates/tinycomputer-bus/src/browser/session/mod.rs +++ b/crates/tinycomputer-bus/src/browser/session/mod.rs @@ -14,7 +14,10 @@ mod types; -pub use types::{SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, Viewport}; +pub use types::{ + Screen, SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, UserAgentBrand, + UserAgentMetadata, Viewport, +}; #[cfg(test)] mod session_tests; diff --git a/crates/tinycomputer-bus/src/browser/session/session_tests.rs b/crates/tinycomputer-bus/src/browser/session/session_tests.rs index 30cff8f7..85122f10 100644 --- a/crates/tinycomputer-bus/src/browser/session/session_tests.rs +++ b/crates/tinycomputer-bus/src/browser/session/session_tests.rs @@ -6,7 +6,10 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use super::{SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, Viewport}; +use super::{ + Screen, SessionId, SessionInfo, SessionOptions, SessionRef, SessionRequest, UserAgentBrand, + UserAgentMetadata, Viewport, +}; use crate::browser::{Action, NavigateRequest, Target}; use serde_json::json; @@ -59,6 +62,7 @@ fn options_serialize_with_the_documented_field_names() { assert_eq!( keys, [ + "accept_language", "allowed_origins", "args", "default_timeout_ms", @@ -66,13 +70,121 @@ fn options_serialize_with_the_documented_field_names() { "endpoint", "executable", "headless", + "init_scripts", + "locale", + "screen", + "stealth", + "timezone", "user_agent", + "user_agent_metadata", "user_data_dir", "viewport", ] ); } +#[test] +fn a_host_that_predates_the_identity_fields_still_opens_a_session() { + // The wire form of `SessionOptions` before the identity fields: every + // field a host could send, and none of the new ones. + let old: SessionOptions = serde_json::from_value(json!({ + "endpoint": null, + "executable": "/usr/bin/chromium", + "headless": false, + "viewport": {"width": 800, "height": 600, "device_scale_factor": 2.0, "mobile": false}, + "user_agent": "agent", + "user_data_dir": "/profiles/trip", + "download_dir": "/downloads", + "args": ["--lang=en"], + "allowed_origins": ["https://example.com"], + "default_timeout_ms": 5000, + })) + .expect("an old client's options deserialize"); + + assert_eq!(old.timezone, None); + assert_eq!(old.locale, None); + assert_eq!(old.accept_language, None); + assert_eq!(old.user_agent_metadata, None); + assert_eq!(old.screen, None); + assert_eq!(old.init_scripts, Vec::