Skip to main content

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}