playwright_rs/testing.rs
1//! Opt-in test fakes for browser APIs Playwright cannot drive natively.
2//!
3//! The File System Access API (`window.showSaveFilePicker` /
4//! `showOpenFilePicker`) opens native OS dialogs with no DOM presence, so no
5//! locator or dialog handler can reach them. The standard cross-binding
6//! pattern is to install a deterministic fake before the app's JS runs;
7//! [`FakeFileSystem`] packages that pattern so consumers don't hand-roll it.
8//!
9//! Nothing here is installed unless a test asks for it: a page that never
10//! calls [`Page::fake_file_system`](crate::protocol::Page::fake_file_system)
11//! keeps the browser's real (or absent) picker functions, so
12//! feature-detection and fallback paths stay testable.
13//!
14//! # Example
15//!
16//! ```no_run
17//! # use playwright_rs::Playwright;
18//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
19//! # let pw = Playwright::launch().await?;
20//! # let page = pw.chromium().launch().await?.new_page().await?;
21//! let fs = page.fake_file_system().await?;
22//!
23//! // Seed a file the app's Open dialog will "pick":
24//! fs.set_open_file("plan.json", br#"{"rooms": []}"#).await?;
25//!
26//! // ... drive the app's Save As flow, then assert what it wrote:
27//! page.goto("https://localhost:8080", None).await?;
28//! let saved = fs.last_saved_bytes().await?;
29//! assert!(saved.is_some());
30//! # Ok(())
31//! # }
32//! ```
33
34use crate::error::Result;
35use crate::protocol::Page;
36use base64::Engine as _;
37use base64::engine::general_purpose::STANDARD as BASE64;
38
39/// The JS shim. Installed both as an init script (so it survives
40/// navigations) and evaluated immediately (so it works on the current
41/// document). All state lives on `window.__pwRsFakeFs`; the guard on the
42/// first line makes double-installation a no-op.
43const SHIM: &str = r#"(() => {
44 if (window.__pwRsFakeFs) return;
45 const state = {
46 saves: [], // { name, b64 }
47 openFiles: new Map(), // name -> b64
48 permission: 'granted',
49 };
50 const b64encode = (bytes) => {
51 let s = '';
52 bytes.forEach((b) => { s += String.fromCharCode(b); });
53 return btoa(s);
54 };
55 const b64decode = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
56 const toBytes = async (data) => {
57 if (typeof data === 'string') return new TextEncoder().encode(data);
58 if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
59 if (data instanceof ArrayBuffer) return new Uint8Array(data);
60 if (ArrayBuffer.isView(data)) {
61 return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
62 }
63 // FileSystemWriteChunkType object form: { type: 'write', data }
64 if (data && data.type === 'write') return toBytes(data.data);
65 throw new TypeError('fake fs: unsupported write payload');
66 };
67 const makeHandle = (name) => ({
68 // Marks the object as a fake handle so the IndexedDB interception below
69 // can swap it for a serializable placeholder before structuredClone.
70 __pwRsFakeFsHandle: true,
71 kind: 'file',
72 name,
73 isSameEntry: async (other) => !!other && other.name === name,
74 queryPermission: async () => state.permission,
75 requestPermission: async () => {
76 if (state.permission === 'prompt') state.permission = 'granted';
77 return state.permission;
78 },
79 getFile: async () => {
80 const b64 = state.openFiles.get(name) ?? '';
81 return new File([b64decode(b64)], name);
82 },
83 createWritable: async () => {
84 const chunks = [];
85 return {
86 write: async (data) => { chunks.push(await toBytes(data)); },
87 seek: async () => {},
88 truncate: async () => {},
89 abort: async () => {},
90 close: async () => {
91 let total = 0;
92 chunks.forEach((c) => { total += c.length; });
93 const all = new Uint8Array(total);
94 let offset = 0;
95 chunks.forEach((c) => { all.set(c, offset); offset += c.length; });
96 const b64 = b64encode(all);
97 state.saves.push({ name, b64 });
98 state.openFiles.set(name, b64);
99 },
100 };
101 },
102 });
103 window.__pwRsFakeFs = {
104 lastSaved: () => (state.saves.length ? state.saves[state.saves.length - 1] : null),
105 setOpenFile: (name, b64) => { state.openFiles.set(name, b64); },
106 setPermission: (p) => { state.permission = p; },
107 };
108 // Real FileSystemFileHandle is [Serializable], so apps persist it in
109 // IndexedDB and re-query permission on the next load. Our fake carries
110 // methods that structuredClone rejects (DataCloneError), which would break
111 // that flow. Intercept put/add to store a serializable placeholder keyed by
112 // name, and get to rehydrate it into a live handle. Non-handle values pass
113 // through untouched, so the app's other IndexedDB usage is unaffected.
114 if (window.IDBObjectStore) {
115 const MARK = '__pwRsFakeFsHandleName';
116 const placeholder = (v) =>
117 (v && typeof v === 'object' && v.__pwRsFakeFsHandle) ? { [MARK]: v.name } : v;
118 const wrapWrite = (orig) =>
119 function (value, ...rest) { return orig.call(this, placeholder(value), ...rest); };
120 IDBObjectStore.prototype.put = wrapWrite(IDBObjectStore.prototype.put);
121 IDBObjectStore.prototype.add = wrapWrite(IDBObjectStore.prototype.add);
122 const realGet = IDBObjectStore.prototype.get;
123 IDBObjectStore.prototype.get = function (...args) {
124 const req = realGet.apply(this, args);
125 // Registered here, before the caller sets onsuccess, so the
126 // rehydrated value is in place when their handler reads req.result.
127 req.addEventListener('success', () => {
128 const val = req.result;
129 if (val && typeof val === 'object' && MARK in val) {
130 Object.defineProperty(req, 'result', {
131 configurable: true,
132 value: makeHandle(val[MARK]),
133 });
134 }
135 });
136 return req;
137 };
138 }
139 window.showSaveFilePicker = async (options) => {
140 if (state.permission === 'denied') {
141 throw new DOMException('fake fs: permission denied', 'NotAllowedError');
142 }
143 return makeHandle((options && options.suggestedName) || 'untitled');
144 };
145 window.showOpenFilePicker = async () => {
146 if (state.permission === 'denied') {
147 throw new DOMException('fake fs: permission denied', 'NotAllowedError');
148 }
149 const names = [...state.openFiles.keys()];
150 if (names.length === 0) {
151 throw new DOMException('fake fs: no open file seeded', 'AbortError');
152 }
153 return [makeHandle(names[names.length - 1])];
154 };
155})()"#;
156
157/// Handle to the fake File System Access API installed on a [`Page`] by
158/// [`Page::fake_file_system`](crate::protocol::Page::fake_file_system).
159///
160/// See the [module docs](self) for the pattern and a usage example. Scope of
161/// the fake: `showSaveFilePicker`, `showOpenFilePicker`, per-handle
162/// `getFile`/`createWritable`/`queryPermission`/`requestPermission`, and
163/// persisting a handle to IndexedDB — apps that stash the picker handle in
164/// IndexedDB and re-`queryPermission` on the next load (the standard
165/// startup-reopen pattern) work, because the fake stores a serializable
166/// placeholder in place of the method-bearing handle and rehydrates it on read
167/// (real `FileSystemFileHandle` is `[Serializable]`; the fake is not, so a raw
168/// `put` would otherwise throw `DataCloneError`). The in-memory file *content*
169/// and permission state still live in page JS, so they reset on a full page
170/// reload. To test an app that reads its last file on *startup* (reading before
171/// any test code could re-seed), use
172/// [`seed_on_navigation`](Self::seed_on_navigation), which re-establishes
173/// content and permission before the app mounts on the next navigation;
174/// [`set_open_file`](Self::set_open_file) only takes effect at call time.
175/// `showDirectoryPicker` is not faked.
176///
177/// IndexedDB access requires a real origin: install the fake, then
178/// [`goto`](crate::protocol::Page::goto) an `http(s)://` page rather than using
179/// `set_content` (opaque origins deny IndexedDB) if the flow under test
180/// persists handles.
181#[derive(Debug, Clone)]
182pub struct FakeFileSystem {
183 page: Page,
184}
185
186impl FakeFileSystem {
187 /// Install the fake on `page` (init script + current document).
188 pub(crate) async fn install(page: &Page) -> Result<Self> {
189 page.add_init_script(SHIM).await?;
190 page.evaluate_expression(SHIM).await?;
191 Ok(Self { page: page.clone() })
192 }
193
194 /// The file name passed to the most recent completed save, or `None` if
195 /// nothing has been saved.
196 ///
197 /// # Errors
198 ///
199 /// Returns an error if the page is closed or the evaluation fails.
200 pub async fn last_saved_name(&self) -> Result<Option<String>> {
201 self.page
202 .evaluate(
203 "() => { const s = window.__pwRsFakeFs.lastSaved(); return s ? s.name : null; }",
204 None::<&()>,
205 )
206 .await
207 }
208
209 /// The bytes written by the most recent completed save (everything
210 /// written between `createWritable()` and `close()`), or `None` if
211 /// nothing has been saved.
212 ///
213 /// # Errors
214 ///
215 /// Returns an error if the page is closed, the evaluation fails, or the
216 /// shim returns malformed base64 (which indicates a bug in the shim, not
217 /// the caller).
218 pub async fn last_saved_bytes(&self) -> Result<Option<Vec<u8>>> {
219 let b64: Option<String> = self
220 .page
221 .evaluate(
222 "() => { const s = window.__pwRsFakeFs.lastSaved(); return s ? s.b64 : null; }",
223 None::<&()>,
224 )
225 .await?;
226 b64.map(|s| {
227 BASE64
228 .decode(s)
229 .map_err(|e| crate::error::Error::ProtocolError(format!("fake fs base64: {e}")))
230 })
231 .transpose()
232 }
233
234 /// Seed a file that the app's next `showOpenFilePicker()` call will
235 /// "pick". Seeding the same name again replaces the content.
236 ///
237 /// # Errors
238 ///
239 /// Returns an error if the page is closed or the evaluation fails.
240 pub async fn set_open_file(&self, name: &str, bytes: &[u8]) -> Result<()> {
241 let arg = (name, BASE64.encode(bytes));
242 let _: Option<()> = self
243 .page
244 .evaluate(
245 "([name, b64]) => { window.__pwRsFakeFs.setOpenFile(name, b64); }",
246 Some(&arg),
247 )
248 .await?;
249 Ok(())
250 }
251
252 /// Set the permission state reported by the fake handles'
253 /// `queryPermission` / `requestPermission`: `"granted"`, `"prompt"`, or
254 /// `"denied"`. Defaults to `"granted"`. In the `"prompt"` state,
255 /// `requestPermission` upgrades to `"granted"`, mirroring the real API's
256 /// user-approval path; in `"denied"`, the pickers throw
257 /// `NotAllowedError`.
258 ///
259 /// # Errors
260 ///
261 /// Returns an error if the page is closed or the evaluation fails.
262 pub async fn set_permission(&self, state: &str) -> Result<()> {
263 let _: Option<()> = self
264 .page
265 .evaluate(
266 "(state) => { window.__pwRsFakeFs.setPermission(state); }",
267 Some(&state),
268 )
269 .await?;
270 Ok(())
271 }
272
273 /// Shorthand for [`set_permission("granted")`](Self::set_permission).
274 ///
275 /// # Errors
276 ///
277 /// Returns an error if the page is closed or the evaluation fails.
278 pub async fn grant_permission(&self) -> Result<()> {
279 self.set_permission("granted").await
280 }
281
282 /// Re-establishes openable-file content and permission state *before the app
283 /// mounts on the next navigation* (including `reload`).
284 ///
285 /// [`set_open_file`](Self::set_open_file) and
286 /// [`set_permission`](Self::set_permission) take effect at call time, which
287 /// is too late for an app that reads its last file on startup: a full page
288 /// reload clears the fake's in-memory content and resets permission to
289 /// `"granted"`, and by the time a test could re-seed, the app has already
290 /// mounted and read. This registers an init script (running after the fake's
291 /// own, so `window` state exists) that re-seeds the given file and
292 /// permission on every subsequent navigation — making the "reopen the last
293 /// file on startup" flow testable across a reload. The persisted *handle*
294 /// already survives a reload on its own (the fake stores it in IndexedDB and
295 /// rehydrates it); only content and permission need re-seeding.
296 ///
297 /// The seed persists for all later navigations in this context, so seed the
298 /// state the app should observe on its next load. `permission` takes the
299 /// same values as [`set_permission`](Self::set_permission).
300 ///
301 /// # Errors
302 ///
303 /// Returns an error if the page is closed or registering the script fails.
304 pub async fn seed_on_navigation(
305 &self,
306 name: &str,
307 bytes: &[u8],
308 permission: &str,
309 ) -> Result<()> {
310 // JSON-encode each value into a safe JS string literal (handles quotes
311 // and other characters in names). Serializing a &str/String to JSON is
312 // infallible for valid UTF-8, which Rust strings always are.
313 let to_js = |s: &str| {
314 serde_json::to_string(s).map_err(|e| {
315 crate::error::Error::ProtocolError(format!("fake fs seed encode: {e}"))
316 })
317 };
318 let name = to_js(name)?;
319 let b64 = to_js(&BASE64.encode(bytes))?;
320 let perm = to_js(permission)?;
321 let script = format!(
322 "(() => {{ const fs = window.__pwRsFakeFs; if (!fs) return; \
323 fs.setOpenFile({name}, {b64}); fs.setPermission({perm}); }})()"
324 );
325 self.page.add_init_script(&script).await
326 }
327}