Skip to main content

ferridriver_script/bindings/
browser.rs

1//! `BrowserJs`: JS wrapper around [`ferridriver::Browser`].
2//!
3//! Exposes `browser.newContext(options?)` so scripts can exercise
4//! [`ferridriver::options::BrowserContextOptions`] at its natural
5//! Playwright entry point. The `browser` global is installed by
6//! [`crate::bindings::install_browser`] when the run context carries a
7//! `Browser` handle (see `engine::RunContext`). Tests that only need
8//! the ambient `context` can continue to ignore it.
9//!
10//! Playwright reference:
11//! `/tmp/playwright/packages/playwright-core/types/types.d.ts:22229`
12//! (`BrowserContextOptions`) and `:9851` (`browser.newContext`).
13
14use std::sync::Arc;
15
16use ferridriver::Browser;
17use rquickjs::function::Opt;
18use rquickjs::{Ctx, JsLifetime, Value, class::Class, class::Trace};
19
20use super::context::BrowserContextJs;
21use crate::bindings::convert::{FerriResultExt, serde_from_js};
22
23#[derive(JsLifetime, Trace)]
24#[rquickjs::class(rename = "Browser")]
25pub struct BrowserJs {
26  #[qjs(skip_trace)]
27  inner: Arc<Browser>,
28}
29
30impl BrowserJs {
31  #[must_use]
32  pub fn new(inner: Arc<Browser>) -> Self {
33    Self { inner }
34  }
35}
36
37#[rquickjs::methods]
38impl BrowserJs {
39  /// Playwright: `browser.newContext(options?)` —
40  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:9851`.
41  /// Accepts the full `BrowserContextOptions` bag via the
42  /// isomorphic serde lowering.
43  #[qjs(rename = "newContext")]
44  pub fn new_context<'js>(&self, ctx: Ctx<'js>, options: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
45    let core_opts = match options.0 {
46      None => None,
47      Some(v) if v.is_undefined() || v.is_null() => None,
48      Some(v) => {
49        let parsed: JsBrowserContextOptions = serde_from_js(&ctx, v)?;
50        Some(parsed.into_core())
51      },
52    };
53    let ctx_ref = Arc::new(self.inner.new_context(core_opts));
54    let wrapper = BrowserContextJs::new(ctx_ref);
55    let instance = Class::instance(ctx.clone(), wrapper)?;
56    rquickjs::IntoJs::into_js(instance, &ctx)
57  }
58
59  /// Playwright: `browser.version()` —
60  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts` on
61  /// `Browser`. Returns the product version string captured at launch.
62  #[qjs(rename = "version")]
63  pub fn version(&self) -> String {
64    self.inner.version().to_string()
65  }
66
67  /// Playwright: `browser.isConnected(): boolean` (sync).
68  #[qjs(rename = "isConnected")]
69  pub fn is_connected(&self) -> bool {
70    self.inner.is_connected()
71  }
72
73  /// Playwright: `browser.close()`. Accepts no options here — the
74  /// `BrowserCloseOptions { reason }` form can be added once a script
75  /// case demands it.
76  #[qjs(rename = "close")]
77  pub async fn close(&self) -> rquickjs::Result<()> {
78    self
79      .inner
80      .close(None)
81      .await
82      .map_err(|e| crate::bindings::convert::to_rq_error(&e))?;
83    Ok(())
84  }
85
86  /// Playwright: `browser.contexts()`. Returns the list of contexts as
87  /// JS handles.
88  #[qjs(rename = "contexts")]
89  pub fn contexts<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
90    let contexts = self.inner.contexts();
91    let arr = rquickjs::Array::new(ctx.clone())?;
92    for (i, c) in contexts.into_iter().enumerate() {
93      let wrapper = BrowserContextJs::new(std::sync::Arc::new(c));
94      let instance = Class::instance(ctx.clone(), wrapper)?;
95      arr.set(i, instance)?;
96    }
97    rquickjs::IntoJs::into_js(arr, &ctx)
98  }
99
100  /// Playwright: `browser.newPage()`. Creates a page in the default context.
101  #[qjs(rename = "newPage")]
102  pub async fn new_page<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
103    let page = self.inner.new_page().await.into_js()?;
104    let wrapper = crate::bindings::page::pagejs_for_ctx(&ctx, page);
105    let instance = Class::instance(ctx.clone(), wrapper)?;
106    rquickjs::IntoJs::into_js(instance, &ctx)
107  }
108
109  /// The active page of the default context (mirrors NAPI `browser.page()`).
110  #[qjs(rename = "page")]
111  pub async fn page<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
112    let page = self.inner.page().await.into_js()?;
113    let wrapper = crate::bindings::page::pagejs_for_ctx(&ctx, page);
114    let instance = Class::instance(ctx.clone(), wrapper)?;
115    rquickjs::IntoJs::into_js(instance, &ctx)
116  }
117}
118
119/// JS-side shape for the options bag. Deserialised via serde-through-JSON
120/// so aliased field names, null/undefined handling, and nested shapes
121/// all match Playwright's client-side parsing. Convert with
122/// [`Self::into_core`].
123///
124/// `pub(super)` so `super::browser_type::BrowserTypeJs` can reuse the
125/// same parser for `launchPersistentContext`'s merged options bag.
126#[derive(serde::Deserialize, Default)]
127#[serde(rename_all = "camelCase", default)]
128pub(super) struct JsBrowserContextOptions {
129  accept_downloads: Option<bool>,
130  #[serde(rename = "baseURL")]
131  base_url: Option<String>,
132  #[serde(rename = "bypassCSP")]
133  bypass_csp: Option<bool>,
134  color_scheme: Option<serde_json::Value>,
135  contrast: Option<serde_json::Value>,
136  device_scale_factor: Option<f64>,
137  #[serde(rename = "extraHTTPHeaders")]
138  extra_http_headers: Option<rustc_hash::FxHashMap<String, String>>,
139  forced_colors: Option<serde_json::Value>,
140  geolocation: Option<JsGeolocation>,
141  has_touch: Option<bool>,
142  http_credentials: Option<JsHttpCredentials>,
143  #[serde(rename = "ignoreHTTPSErrors")]
144  ignore_https_errors: Option<bool>,
145  is_mobile: Option<bool>,
146  java_script_enabled: Option<bool>,
147  locale: Option<String>,
148  offline: Option<bool>,
149  permissions: Option<Vec<String>>,
150  proxy: Option<JsProxyConfig>,
151  record_video: Option<JsRecordVideoOptions>,
152  reduced_motion: Option<serde_json::Value>,
153  screen: Option<JsScreenSize>,
154  service_workers: Option<String>,
155  /// `string` = file path; `object` = inline state JSON. Playwright:
156  /// `storageState: string | { cookies, origins }`.
157  storage_state: Option<serde_json::Value>,
158  strict_selectors: Option<bool>,
159  timezone_id: Option<String>,
160  user_agent: Option<String>,
161  /// JS `null` → explicit opt-out; omitted → browser default.
162  viewport: Option<serde_json::Value>,
163}
164
165#[derive(serde::Deserialize)]
166struct JsGeolocation {
167  latitude: f64,
168  longitude: f64,
169  accuracy: Option<f64>,
170}
171
172#[derive(serde::Deserialize)]
173#[serde(rename_all = "camelCase")]
174struct JsHttpCredentials {
175  username: String,
176  password: String,
177  origin: Option<String>,
178  send: Option<String>,
179}
180
181#[derive(serde::Deserialize)]
182struct JsProxyConfig {
183  server: String,
184  bypass: Option<String>,
185  username: Option<String>,
186  password: Option<String>,
187}
188
189#[derive(serde::Deserialize)]
190struct JsScreenSize {
191  width: i64,
192  height: i64,
193}
194
195#[derive(serde::Deserialize)]
196#[serde(rename_all = "camelCase")]
197struct JsRecordVideoOptions {
198  dir: String,
199  size: Option<JsVideoSize>,
200}
201
202#[derive(serde::Deserialize)]
203struct JsVideoSize {
204  width: f64,
205  height: f64,
206}
207
208#[derive(serde::Deserialize)]
209struct JsViewportSize {
210  width: i64,
211  height: i64,
212}
213
214fn lower_media(v: Option<serde_json::Value>) -> ferridriver::options::MediaOverride {
215  match v {
216    Some(serde_json::Value::Null) => ferridriver::options::MediaOverride::Disabled,
217    Some(serde_json::Value::String(s)) => ferridriver::options::MediaOverride::Set(s),
218    None | Some(_) => ferridriver::options::MediaOverride::Unchanged,
219  }
220}
221
222impl JsBrowserContextOptions {
223  #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
224  pub(super) fn into_core(self) -> ferridriver::options::BrowserContextOptions {
225    use ferridriver::options as fo;
226
227    let viewport = match self.viewport {
228      None => fo::ViewportOption::Default,
229      Some(serde_json::Value::Null) => fo::ViewportOption::Null,
230      Some(v) => {
231        let parsed: Result<JsViewportSize, _> = serde_json::from_value(v);
232        match parsed {
233          Ok(vp) => fo::ViewportOption::Size {
234            width: vp.width,
235            height: vp.height,
236          },
237          Err(_) => fo::ViewportOption::Default,
238        }
239      },
240    };
241
242    let http_credentials = self.http_credentials.map(|c| fo::HttpCredentials {
243      username: c.username,
244      password: c.password,
245      origin: c.origin,
246      send: c.send.and_then(|s| match s.as_str() {
247        "always" => Some(fo::HttpCredentialsSend::Always),
248        "unauthorized" => Some(fo::HttpCredentialsSend::Unauthorized),
249        _ => None,
250      }),
251    });
252    let proxy = self.proxy.map(|p| fo::ProxyConfig {
253      server: p.server,
254      bypass: p.bypass,
255      username: p.username,
256      password: p.password,
257    });
258    let record_video = self.record_video.map(|rv| fo::RecordVideoOptions {
259      dir: std::path::PathBuf::from(rv.dir),
260      size: rv.size.map(|s| fo::VideoSize {
261        width: s.width.max(0.0) as u32,
262        height: s.height.max(0.0) as u32,
263      }),
264    });
265    let screen = self.screen.map(|s| fo::ScreenSize {
266      width: s.width,
267      height: s.height,
268    });
269    let service_workers = self.service_workers.and_then(|s| match s.as_str() {
270      "allow" => Some(fo::ServiceWorkerPolicy::Allow),
271      "block" => Some(fo::ServiceWorkerPolicy::Block),
272      _ => None,
273    });
274
275    fo::BrowserContextOptions {
276      accept_downloads: self.accept_downloads,
277      base_url: self.base_url,
278      bypass_csp: self.bypass_csp,
279      color_scheme: lower_media(self.color_scheme),
280      contrast: lower_media(self.contrast),
281      device_scale_factor: self.device_scale_factor,
282      extra_http_headers: self.extra_http_headers,
283      forced_colors: lower_media(self.forced_colors),
284      geolocation: self.geolocation.map(|g| fo::Geolocation {
285        latitude: g.latitude,
286        longitude: g.longitude,
287        accuracy: g.accuracy.unwrap_or(0.0),
288      }),
289      has_touch: self.has_touch,
290      http_credentials,
291      ignore_https_errors: self.ignore_https_errors,
292      is_mobile: self.is_mobile,
293      java_script_enabled: self.java_script_enabled,
294      locale: self.locale,
295      offline: self.offline,
296      permissions: self.permissions,
297      proxy,
298      record_har: None,
299      record_video,
300      reduced_motion: lower_media(self.reduced_motion),
301      screen,
302      service_workers,
303      storage_state: self.storage_state.map(|v| match v {
304        serde_json::Value::String(path) => fo::StorageStateInput::Path(std::path::PathBuf::from(path)),
305        other => fo::StorageStateInput::Inline(other),
306      }),
307      strict_selectors: self.strict_selectors,
308      timezone_id: self.timezone_id,
309      user_agent: self.user_agent,
310      viewport,
311    }
312  }
313}