From d1d121989f60acf4cebc7283c52d2a4e8840f1a7 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 13:26:39 +0100 Subject: [PATCH 001/130] fix: improve script handling in execute commands for better serialization - Updated the `execute` function in both Electron and Tauri services to use `JSON.stringify` for string scripts, ensuring proper escaping of special characters. - Enhanced test coverage for the `execute` command to validate handling of various script formats, including strings with quotes, newlines, unicode, and backslashes. - Adjusted assertions in tests to reflect the new serialization logic, ensuring accurate expectations for script execution. --- .../electron-service/src/commands/execute.ts | 4 +- .../test/commands/execute.spec.ts | 37 ++++++++++++++++++- packages/tauri-plugin/src/commands.rs | 4 +- .../tauri-service/src/commands/execute.ts | 3 +- packages/tauri-service/src/service.ts | 6 ++- .../test/commands/execute.spec.ts | 4 +- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index ead3d046b..d2451e94b 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -14,11 +14,13 @@ export async function execute( throw new Error('WDIO browser is not yet initialised'); } + const scriptString = typeof script === 'function' ? script.toString() : JSON.stringify(script); + const returnValue = await browser.execute( function executeWithinElectron(script: string, ...args) { return window.wdioElectron.execute(script, args); }, - `${script}`, + scriptString, ...args, ); diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index b7feaa30b..b683ad820 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -44,7 +44,40 @@ describe('execute Command', () => { it('should execute a stringified function', async () => { await execute(globalThis.browser, '() => 1 + 2 + 3'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '() => 1 + 2 + 3'); - expect(globalThis.wdioElectron.execute).toHaveBeenCalledWith('() => 1 + 2 + 3', []); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify('() => 1 + 2 + 3')); + expect(globalThis.wdioElectron.execute).toHaveBeenCalledWith(JSON.stringify('() => 1 + 2 + 3'), []); + }); + + it('should handle scripts with quotes', async () => { + const scriptWithQuotes = '() => "He said \\"hello\\""'; + await execute(globalThis.browser, scriptWithQuotes); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(scriptWithQuotes)); + }); + + it('should handle scripts with newlines', async () => { + const scriptWithNewlines = '() => "line1\\nline2"'; + await execute(globalThis.browser, scriptWithNewlines); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(scriptWithNewlines)); + }); + + it('should handle scripts with unicode', async () => { + const scriptWithUnicode = '() => "Hello 世界"'; + await execute(globalThis.browser, scriptWithUnicode); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(scriptWithUnicode)); + }); + + it('should handle scripts with backslashes', async () => { + const scriptWithBackslashes = '() => "C:\\\\path\\\\file"'; + await execute(globalThis.browser, scriptWithBackslashes); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + JSON.stringify(scriptWithBackslashes), + ); + }); + + it('should handle mixed special characters', async () => { + const script = '() => "Test \\n \\t \\u001b and \\\\ backslash"'; + await execute(globalThis.browser, script); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(script)); }); }); diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 700e5b55a..4b1f468d7 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -63,7 +63,9 @@ pub(crate) async fn execute( let script = if !request.args.is_empty() { let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; - format!("(function() {{ const __wdio_args = {}; return ({}); }})()", args_json, request.script) + let script_json = serde_json::to_string(&request.script) + .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize script: {}", e)))?; + format!("(function() {{ const __wdio_args = {}; return ({}); }})()", args_json, script_json) } else { request.script.clone() }; diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index 2858ad7f7..a14106b02 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -66,7 +66,8 @@ export async function execute( } // Convert function to string - keep parameters intact, plugin will inject tauri as first arg - const scriptString = typeof script === 'function' ? script.toString() : script; + // For strings, use JSON.stringify to safely escape special characters + const scriptString = typeof script === 'function' ? script.toString() : JSON.stringify(script); // Execute via plugin's execute command with better error handling // The plugin will inject the Tauri APIs object as the first argument diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 1a911457a..cb37dcbf8 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -318,7 +318,9 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { - const scriptString = typeof script === 'function' ? script.toString() : script; + // For strings, use JSON.stringify to safely escape special characters + // For functions, toString() gives the function source which is already valid JS + const scriptString = typeof script === 'function' ? script.toString() : JSON.stringify(script); if (isEmbedded) { // For embedded WebDriver: skip console wrapper as console forwarding @@ -327,6 +329,8 @@ export default class TauriWorkerService { } // For tauri-driver: use sync execute with console wrapper + // Note: scriptString is already properly escaped from above - functions use .toString() + // which produces valid JS, strings use JSON.stringify() which also produces valid JS const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} return (${scriptString}).apply(null, arguments); diff --git a/packages/tauri-service/test/commands/execute.spec.ts b/packages/tauri-service/test/commands/execute.spec.ts index 9ef208717..d937be725 100644 --- a/packages/tauri-service/test/commands/execute.spec.ts +++ b/packages/tauri-service/test/commands/execute.spec.ts @@ -189,7 +189,7 @@ describe('execute', () => { expect(secondCall[3]).toBe(2); }); - it('should pass strings as-is', async () => { + it('should pass strings properly stringified', async () => { const mockExecute = vi.fn(); mockExecute.mockResolvedValueOnce(true); mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'hello' })); @@ -199,7 +199,7 @@ describe('execute', () => { await execute(browser, 'return "hello"'); const secondCall = mockExecute.mock.calls[1]; - expect(secondCall[1]).toBe('return "hello"'); + expect(secondCall[1]).toBe(JSON.stringify('return "hello"')); }); }); From 529b1346403404780b928b6599b884e96f9e8ea5 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 13:27:12 +0100 Subject: [PATCH 002/130] docs: remove tauri-playwright analysis document and update improvements plan - Deleted the `tauri-playwright-analysis.md` file to streamline documentation. - Updated the `tauri-playwright-improvements-plan.md` to include a status column for tracking improvement progress, marking the audit of JS string interpolation/escaping as complete. --- docs/tauri-playwright-analysis.md | 177 --------------------- docs/tauri-playwright-improvements-plan.md | 16 +- 2 files changed, 8 insertions(+), 185 deletions(-) delete mode 100644 docs/tauri-playwright-analysis.md diff --git a/docs/tauri-playwright-analysis.md b/docs/tauri-playwright-analysis.md deleted file mode 100644 index 833f36e14..000000000 --- a/docs/tauri-playwright-analysis.md +++ /dev/null @@ -1,177 +0,0 @@ -# Tauri-Playwright Analysis: Learnings for @wdio/tauri-service - -**Date:** 2026-03-30 - -## Overview - -This report analyzes the [tauri-playwright](https://github.com/srsholmes/tauri-playwright) library and its [PR #1](https://github.com/srsholmes/tauri-playwright/pull/1) to identify patterns, ideas, and improvements that could benefit `@wdio/tauri-service`. - -**tauri-playwright** is a Playwright integration for Tauri apps that bypasses the standard WebDriver protocol entirely. Instead of relying on tauri-driver or msedgedriver, it embeds a Rust plugin (`tauri-plugin-playwright`) in the Tauri app that provides direct communication between test runner and webview via Unix sockets / TCP. - ---- - -## Architectural Comparison - -| Aspect | @wdio/tauri-service | tauri-playwright | -|--------|---------------------|------------------| -| **Protocol** | WebDriver (via tauri-driver/msedgedriver) | Custom socket + JS eval in webview | -| **Driver dependency** | tauri-driver (official), CrabNebula, or embedded | None — plugin is embedded | -| **Communication** | HTTP (WebDriver REST API) | Unix socket / TCP (JSON-over-newline) | -| **JS execution** | WebDriver `execute` command | Direct `WebviewWindow::eval()` (after PR #1) | -| **App plugin required** | Optional (tauri-plugin-wdio-webdriver for embedded) | Required (tauri-plugin-playwright) | -| **Assertion retries** | WebdriverIO built-in waitUntil | Custom polling matchers (100ms interval) | -| **Platform support** | Windows, macOS, Linux | macOS (full), Linux (partial), Windows (CDP fallback) | - ---- - -## Key Ideas Worth Borrowing - -### 1. Direct WebView Eval via Tauri Plugin (High Impact) - -**What they do:** PR #1 replaces the HTTP polling bridge with direct `WebviewWindow::eval()` calls. The plugin injects JavaScript directly into the webview, and results return via Tauri's IPC (`invoke('plugin:playwright|pw_result', ...)`). - -**Why it matters for us:** Our embedded provider already embeds a plugin in the Tauri app. We could extend this plugin to provide a direct eval channel, bypassing the WebDriver protocol for operations where it's slow or limited (e.g., Tauri command execution, mock state sync, console log capture). This wouldn't replace WebDriver — it would supplement it for Tauri-specific operations. - -**Concrete opportunity:** `browser.tauri.execute()` currently goes through WebDriver's `execute` command, which has serialization overhead and protocol limitations. A direct IPC channel from the plugin could make Tauri command execution significantly faster and support richer return types. - -**Risk:** Adds complexity by maintaining two communication channels. Would need clear boundaries for when to use each. - ---- - -### 2. Three Testing Modes: Browser / Tauri / CDP (Medium Impact) - -**What they do:** tauri-playwright offers three modes: -- **Browser mode**: Runs tests against the web frontend in headless Chromium with mocked Tauri IPC — no real app needed -- **Tauri mode**: Full integration with real app via socket bridge -- **CDP mode**: Direct Chrome DevTools Protocol connection (Windows WebView2) - -**Why it matters:** The browser mode is the standout idea. It allows developers to write and iterate on tests rapidly without building/launching the real Tauri app. The mock IPC layer intercepts `window.__TAURI_INTERNALS__.invoke()` calls and returns configured responses. - -**Concrete opportunity:** We could add a "browser-only" test mode to `@wdio/tauri-service` that: -1. Launches a regular browser (Chrome/Firefox) pointing at the Vite dev server -2. Injects a Tauri IPC mock layer that intercepts `invoke()` calls -3. Returns configured mock responses for Tauri commands - -This would give developers a fast feedback loop for UI-focused tests that don't need real backend integration. Similar to our existing mock architecture but without needing the real app at all. - ---- - -### 3. IPC Mock Injection Pattern (Medium Impact) - -**What they do:** Mock handlers are serialized as JavaScript function strings and injected into the page via `addInitScript()`: - -```typescript -const mocks = { - 'greet': (args) => `Hello, ${args.name}!`, - 'plugin:fs|read': () => 'file contents', -}; -// Serialized and injected, intercepts invoke() at runtime -``` - -They also support an `ipcContext` object that makes Node.js variables available inside mock handlers. - -**Why it matters:** Our current mock architecture uses inner/outer mock synchronization across process boundaries (CDP/WebDriver). The tauri-playwright approach of serializing mock handlers directly as JS functions is simpler for many use cases. - -**Concrete opportunity:** For the browser-only mode described above, adopt this serialization pattern for mock injection. For the full integration mode, consider whether mock handler registration could be simplified by sending serialized functions to the plugin rather than going through the current inner/outer mock sync protocol. - ---- - -### 4. Configurable Window Label for Multi-Window Apps (Low Effort, High Value) - -**What they do:** `PluginConfig::window_label()` defaults to `"main"` but can be configured for multi-window apps. - -**Why it matters:** Multi-window Tauri apps need to target specific windows for operations. Our service currently doesn't have explicit window label configuration. - -**Concrete opportunity:** Add a `windowLabel` option to TauriServiceOptions that controls which webview window the service targets for Tauri-specific operations. Default to `"main"` for zero-config simplicity. - ---- - -### 5. Native Screenshot Capture via CoreGraphics (Low Impact for Now) - -**What they do:** On macOS, the plugin captures window screenshots using CoreGraphics FFI (`CGWindowListCreateImage`) — no external tools needed. Includes the native title bar, producing pixel-perfect desktop screenshots. - -**Why it matters:** WebDriver screenshots only capture the webview content, not the native window chrome. Native screenshots are more useful for visual regression testing of desktop apps. - -**Concrete opportunity:** If we add native screenshot support, the CoreGraphics approach (or platform equivalents) could provide better visual testing capabilities than WebDriver screenshots alone. Low priority until there's user demand. - ---- - -### 6. Semantic Locators via JS Resolution (Low Priority) - -**What they do:** Implement Playwright-style semantic locators (`getByText`, `getByRole`, `getByTestId`) by storing JS resolution expressions with each locator and executing them at action time. - -**Why it matters:** WebdriverIO already has good selector support, but this pattern of deferring JS evaluation until action time (with auto-retry) is clean. - -**Not actionable:** WebdriverIO's existing selector engine and `$()` / `$$()` API already cover this well. No action needed. - ---- - -## PR #1 Specific Learnings - -### Architecture Improvement: eval() > HTTP Polling - -PR #1 by @vdavid replaces the HTTP polling bridge with direct `WebviewWindow::eval()`. Key wins: - -| Before (polling) | After (eval) | -|---|---| -| HTTP server bound to `0.0.0.0:6275` (security risk) | No HTTP server needed | -| ~16ms poll interval + 2 HTTP round-trips | ~0ms injection + 1 IPC call | -| `new Function()` in webview (blocked by strict CSP) | Platform-level `webview.eval()` bypasses CSP | -| 57-line JS polling script injected at startup | Single line: `window.__PW_ACTIVE__ = true` | -| Command queue + pending results map | Direct eval + IPC return path | - -**Takeaway for us:** If we build a direct communication channel in our embedded plugin, use `WebviewWindow::eval()` + IPC rather than an HTTP polling bridge. The PR's review comments also highlight important considerations: -- **Window readiness**: Need retry/backoff when window isn't created yet (our `startTimeout` polling handles this) -- **JSON escaping**: Use `serde_json::to_string()` for all values going into JS strings, not manual escaping -- **Tauri 2 permissions**: Any IPC command needs `build.rs`, `default.toml`, and permission schema - -### CSP Fix Pattern - -The PR fixes a CSP issue where `waitForFunction` used `eval()` internally. The fix embeds the expression directly into the injected script instead of double-evaluating it. If our embedded plugin ever injects JS, avoid `eval()` / `new Function()` — use direct embedding. - ---- - -## Risks and Limitations of tauri-playwright's Approach - -These are worth noting to understand where our WebDriver-based approach has advantages: - -1. **Required app modification**: tauri-playwright requires adding a Rust plugin to the Tauri app, gated behind a cargo feature. Our official driver provider needs zero app changes. - -2. **Limited platform support**: Native screenshots only work on macOS. Linux native capture returns "not yet supported." Windows requires CDP fallback. Our WebDriver approach works consistently across all platforms. - -3. **No parallel test isolation**: Hardcoded ports (6275 for HTTP, 6274 for TCP) prevent running multiple instances. Our PortManager with `get-port` handles this cleanly. - -4. **Fragile socket communication**: Newline-delimited JSON over Unix sockets is simpler but less robust than WebDriver's well-specified HTTP API with proper status codes and error types. - -5. **Polling bridge latency** (pre-PR #1): The 16ms polling interval was a notable bottleneck. PR #1 fixes this, but it shows the risk of custom protocol bridges — WebDriver handles this out of the box. - -6. **Security**: The HTTP server bound to `0.0.0.0:6275` (all interfaces) rather than `127.0.0.1`. PR #1 eliminates this, but it's a reminder to always bind to localhost for test infrastructure. - ---- - -## Recommended Actions - -### Short-term (Low Effort) -1. **Add `windowLabel` config option** — Simple addition to TauriServiceOptions for multi-window app support -2. **Evaluate JSON escaping in our plugin code** — Audit any JS string interpolation in tauri-plugin-wdio-webdriver for proper escaping - -### Medium-term (Moderate Effort) -3. **Prototype a browser-only test mode** — Run tests against Vite dev server with mocked Tauri IPC, no real app needed. Biggest developer experience win. -4. **Add direct IPC channel to embedded plugin** — Supplement WebDriver with a direct eval channel for Tauri-specific operations (execute, mocks, logs) - -### Long-term (Investigation) -5. **Native screenshot support** — Investigate CoreGraphics (macOS), DWM (Windows), and X11/Wayland (Linux) for native window capture -6. **Evaluate whether eval-based approach could replace tauri-driver dependency** — If the embedded plugin grows capable enough, it might eliminate the need for external drivers entirely for some use cases - ---- - -## Summary - -tauri-playwright takes a fundamentally different approach — embedding a custom protocol bridge inside the Tauri app rather than using the standard WebDriver ecosystem. This gives it advantages in simplicity and speed for its supported scenarios, but at the cost of requiring app modification and having weaker cross-platform support. - -The most valuable ideas to borrow are: -1. **Browser-only test mode with mocked IPC** — fastest path to better developer experience -2. **Direct WebView eval via plugin** — supplement WebDriver for Tauri-specific operations -3. **Multi-window label configuration** — small but practical addition - -PR #1's shift from HTTP polling to direct `WebviewWindow::eval()` validates that direct eval is the right architecture for in-app test bridges, and provides a concrete reference implementation we can learn from. diff --git a/docs/tauri-playwright-improvements-plan.md b/docs/tauri-playwright-improvements-plan.md index a9054f843..657386bea 100644 --- a/docs/tauri-playwright-improvements-plan.md +++ b/docs/tauri-playwright-improvements-plan.md @@ -7,14 +7,14 @@ ## Improvement Summary -| # | Improvement | Applies To | Effort | Impact | -|---|-------------|------------|--------|--------| -| 1 | Browser-only test mode with mocked native IPC | Tauri + Electron | Large | High | -| 2 | Direct WebView eval channel (supplement WebDriver) | Tauri (embedded) | Large | High | -| 3 | Multi-window label configuration | Tauri | Small | Medium | -| 4 | Native screenshot capture | Tauri + Electron | Medium | Low | -| 5 | Audit JS string interpolation / escaping | Tauri + Electron | Small | Medium | -| 6 | IPC mock serialization pattern | Tauri + Electron | Medium | Medium | +| # | Improvement | Applies To | Effort | Impact | Status | +|---|-------------|------------|--------|--------|--------| +| 1 | Browser-only test mode with mocked native IPC | Tauri + Electron | Large | High | Pending | +| 2 | Direct WebView eval channel (supplement WebDriver) | Tauri (embedded) | Large | High | Pending | +| 3 | Multi-window label configuration | Tauri | Small | Medium | Pending | +| 4 | Native screenshot capture | Tauri + Electron | Medium | Low | Pending | +| 5 | Audit JS string interpolation / escaping | Tauri + Electron | Small | Medium | **Done** | +| 6 | IPC mock serialization pattern | Tauri + Electron | Medium | Medium | Pending | --- From 01303215d88063b1ca5458754994e45ab33c1065 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 13:39:29 +0100 Subject: [PATCH 003/130] refactor(tauri): improve script handling in execute function for Tauri - Updated the `execute` function to simplify string handling by passing strings as-is, allowing Rust to manage proper escaping. - Adjusted related test cases to reflect this change, ensuring accurate expectations for string arguments passed to the plugin. --- packages/tauri-service/src/commands/execute.ts | 7 ++++--- packages/tauri-service/test/commands/execute.spec.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index a14106b02..5915d03b5 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -65,9 +65,10 @@ export async function execute( log.debug('Plugin availability cached, skipping check'); } - // Convert function to string - keep parameters intact, plugin will inject tauri as first arg - // For strings, use JSON.stringify to safely escape special characters - const scriptString = typeof script === 'function' ? script.toString() : JSON.stringify(script); + // Convert function to string - keep parameters intact, plugin will handle escaping + // For functions: use .toString() (produces valid JS function source) + // For strings: send as-is (Rust handles proper escaping when args present) + const scriptString = typeof script === 'function' ? script.toString() : script; // Execute via plugin's execute command with better error handling // The plugin will inject the Tauri APIs object as the first argument diff --git a/packages/tauri-service/test/commands/execute.spec.ts b/packages/tauri-service/test/commands/execute.spec.ts index d937be725..5aa371e60 100644 --- a/packages/tauri-service/test/commands/execute.spec.ts +++ b/packages/tauri-service/test/commands/execute.spec.ts @@ -189,7 +189,7 @@ describe('execute', () => { expect(secondCall[3]).toBe(2); }); - it('should pass strings properly stringified', async () => { + it('should pass strings as-is to the plugin', async () => { const mockExecute = vi.fn(); mockExecute.mockResolvedValueOnce(true); mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'hello' })); @@ -199,7 +199,7 @@ describe('execute', () => { await execute(browser, 'return "hello"'); const secondCall = mockExecute.mock.calls[1]; - expect(secondCall[1]).toBe(JSON.stringify('return "hello"')); + expect(secondCall[1]).toBe('return "hello"'); }); }); From d7d53e9340e16c04d6321bde9bae3b3a33f4b7ff Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 13:39:48 +0100 Subject: [PATCH 004/130] refactor(tauri): enhance script handling in execute function - Updated the `execute` function to improve handling of string and function scripts. - Strings are now passed as-is for embedded paths, while tauri-driver paths utilize `JSON.stringify` for proper escaping. - Adjusted comments for clarity on the handling of different script types. --- packages/tauri-service/src/service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index cb37dcbf8..0d931b141 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -318,9 +318,12 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { - // For strings, use JSON.stringify to safely escape special characters - // For functions, toString() gives the function source which is already valid JS - const scriptString = typeof script === 'function' ? script.toString() : JSON.stringify(script); + // For functions, always use .toString() - produces valid JS function source + // For strings: + // - embedded path: pass as-is (WebDriver handles execution) + // - tauri-driver path: use JSON.stringify (wrapped in template literal) + const scriptString = + typeof script === 'function' ? script.toString() : isEmbedded ? script : JSON.stringify(script); if (isEmbedded) { // For embedded WebDriver: skip console wrapper as console forwarding @@ -329,8 +332,7 @@ export default class TauriWorkerService { } // For tauri-driver: use sync execute with console wrapper - // Note: scriptString is already properly escaped from above - functions use .toString() - // which produces valid JS, strings use JSON.stringify() which also produces valid JS + // Note: scriptString is already properly escaped via JSON.stringify above const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} return (${scriptString}).apply(null, arguments); From bb4085bfefc0bf2a645cbf466f8fd700751e91e7 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 13:57:46 +0100 Subject: [PATCH 005/130] refactor(electron): simplify script handling in execute function - Updated the `execute` function to pass scripts as-is instead of using `JSON.stringify`, improving handling of various script formats. - Adjusted related test cases to reflect this change, ensuring accurate expectations for string arguments passed to the plugin. --- .../electron-service/src/commands/execute.ts | 2 +- .../test/commands/execute.spec.ts | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index d2451e94b..a25ac5a8f 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -14,7 +14,7 @@ export async function execute( throw new Error('WDIO browser is not yet initialised'); } - const scriptString = typeof script === 'function' ? script.toString() : JSON.stringify(script); + const scriptString = typeof script === 'function' ? script.toString() : script; const returnValue = await browser.execute( function executeWithinElectron(script: string, ...args) { diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index b683ad820..70e1c78ca 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -44,40 +44,37 @@ describe('execute Command', () => { it('should execute a stringified function', async () => { await execute(globalThis.browser, '() => 1 + 2 + 3'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify('() => 1 + 2 + 3')); - expect(globalThis.wdioElectron.execute).toHaveBeenCalledWith(JSON.stringify('() => 1 + 2 + 3'), []); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '() => 1 + 2 + 3'); + expect(globalThis.wdioElectron.execute).toHaveBeenCalledWith('() => 1 + 2 + 3', []); }); it('should handle scripts with quotes', async () => { const scriptWithQuotes = '() => "He said \\"hello\\""'; await execute(globalThis.browser, scriptWithQuotes); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(scriptWithQuotes)); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithQuotes); }); it('should handle scripts with newlines', async () => { const scriptWithNewlines = '() => "line1\\nline2"'; await execute(globalThis.browser, scriptWithNewlines); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(scriptWithNewlines)); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithNewlines); }); it('should handle scripts with unicode', async () => { const scriptWithUnicode = '() => "Hello 世界"'; await execute(globalThis.browser, scriptWithUnicode); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(scriptWithUnicode)); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithUnicode); }); it('should handle scripts with backslashes', async () => { const scriptWithBackslashes = '() => "C:\\\\path\\\\file"'; await execute(globalThis.browser, scriptWithBackslashes); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - JSON.stringify(scriptWithBackslashes), - ); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithBackslashes); }); it('should handle mixed special characters', async () => { const script = '() => "Test \\n \\t \\u001b and \\\\ backslash"'; await execute(globalThis.browser, script); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), JSON.stringify(script)); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), script); }); }); From f21d60a0c4c2796642db25b059558dac79f14909 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 14:01:16 +0100 Subject: [PATCH 006/130] refactor(tauri): enhance error handling in execute function - Improved error handling in the `execute` function by restructuring the parsing logic to separate error checks from the try/catch block, allowing for clearer error messages when parsing fails. - Updated related test cases to reflect the new error handling behavior, ensuring that specific error messages are thrown for parsing issues. --- .../tauri-service/src/commands/execute.ts | 32 +++++++++++-------- .../test/commands/execute.spec.ts | 4 +-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index 5915d03b5..6584d3f3e 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -113,21 +113,25 @@ export async function execute( ); // Extract result or error from wrapped response - try { - if (result && typeof result === 'string') { - const parsed = JSON.parse(result) as { __wdio_error__?: string; __wdio_value__?: unknown }; - if (parsed.__wdio_error__) { - throw new Error(parsed.__wdio_error__); - } - if (parsed.__wdio_value__ !== undefined) { - log.debug(`Execute result:`, parsed.__wdio_value__); - return parsed.__wdio_value__ as ReturnValue; - } + let parsed: { __wdio_error__?: string; __wdio_value__?: unknown } | undefined; + if (result && typeof result === 'string') { + try { + parsed = JSON.parse(result) as { __wdio_error__?: string; __wdio_value__?: unknown }; + } catch (parseError) { + throw new Error( + `Failed to parse execute result: ${parseError instanceof Error ? parseError.message : String(parseError)}, raw result: ${result}`, + ); } - } catch (parseError) { - throw new Error( - `Failed to parse execute result: ${parseError instanceof Error ? parseError.message : String(parseError)}, raw result: ${result}`, - ); + } + + // Check for script errors AFTER parsing (outside try/catch to avoid re-wrapping) + if (parsed?.__wdio_error__) { + throw new Error(parsed.__wdio_error__); + } + + if (parsed?.__wdio_value__ !== undefined) { + log.debug(`Execute result:`, parsed.__wdio_value__); + return parsed.__wdio_value__ as ReturnValue; } log.debug(`Execute result:`, result); diff --git a/packages/tauri-service/test/commands/execute.spec.ts b/packages/tauri-service/test/commands/execute.spec.ts index 5aa371e60..091dfe777 100644 --- a/packages/tauri-service/test/commands/execute.spec.ts +++ b/packages/tauri-service/test/commands/execute.spec.ts @@ -294,9 +294,7 @@ describe('execute', () => { browser = createMockBrowser(); (browser.execute as ReturnType).mockImplementation(mockExecute); - await expect(() => execute(browser, '() => "fail"')).rejects.toThrow( - /Failed to parse execute result:.*something went wrong/, - ); + await expect(() => execute(browser, '() => "fail"')).rejects.toThrow('something went wrong'); }); it('should throw for window undefined error', async () => { From 5f2526ba6cab4dadfe4e949486c9b9665ce28cf0 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 14:01:34 +0100 Subject: [PATCH 007/130] refactor(tauri): simplify script handling in execute function - Updated the `execute` function to enhance handling of string and function scripts by passing them as-is, improving clarity and functionality. - Adjusted comments for better understanding of the script processing logic, ensuring that both strings and functions are correctly wrapped for execution. --- packages/tauri-service/src/service.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 0d931b141..87aecb9a7 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -318,12 +318,9 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { - // For functions, always use .toString() - produces valid JS function source - // For strings: - // - embedded path: pass as-is (WebDriver handles execution) - // - tauri-driver path: use JSON.stringify (wrapped in template literal) - const scriptString = - typeof script === 'function' ? script.toString() : isEmbedded ? script : JSON.stringify(script); + // For functions: use .toString() - produces valid JS function source + // For strings: pass as-is (wrapper template wraps in parentheses to make callable) + const scriptString = typeof script === 'function' ? script.toString() : script; if (isEmbedded) { // For embedded WebDriver: skip console wrapper as console forwarding @@ -332,10 +329,12 @@ export default class TauriWorkerService { } // For tauri-driver: use sync execute with console wrapper - // Note: scriptString is already properly escaped via JSON.stringify above + // Note: scriptString is passed as-is - wrap in IIFE to make both strings and functions callable + // For string scripts: "return x" -> "(() => return x)()" - wraps statement as expression + // For function scripts: "(a,b) => a+b" -> "((a,b) => a+b)()" - works as IIFE const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - return (${scriptString}).apply(null, arguments); + return ((${scriptString})()).apply(null, arguments); `; return originalExecute(wrappedScript, ...args) as Promise; From 4d80fcb69e18dcbdfc9bc02f8e21ef979bbd5beb Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 14:39:17 +0100 Subject: [PATCH 008/130] refactor(tauri): update script handling in execute function - Modified the `execute` function to wrap string scripts in an IIFE, ensuring they are callable as statement expressions. - Enhanced comments for clarity on the handling of both string and function scripts, improving understanding of the execution flow. --- packages/tauri-service/src/service.ts | 14 +++---- packages/tauri-service/test/service.spec.ts | 46 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 87aecb9a7..e5fca0ee3 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -319,8 +319,11 @@ export default class TauriWorkerService { ...args: InnerArguments ): Promise { // For functions: use .toString() - produces valid JS function source - // For strings: pass as-is (wrapper template wraps in parentheses to make callable) - const scriptString = typeof script === 'function' ? script.toString() : script; + // For strings: + // - embedded: pass as-is (WebDriver handles execution) + // - non-embedded: wrap in async IIFE to make statement expressions callable + const scriptString = + typeof script === 'function' ? script.toString() : isEmbedded ? script : `(async () => ${script})()`; if (isEmbedded) { // For embedded WebDriver: skip console wrapper as console forwarding @@ -328,13 +331,10 @@ export default class TauriWorkerService { return originalExecute(scriptString, ...args) as Promise; } - // For tauri-driver: use sync execute with console wrapper - // Note: scriptString is passed as-is - wrap in IIFE to make both strings and functions callable - // For string scripts: "return x" -> "(() => return x)()" - wraps statement as expression - // For function scripts: "(a,b) => a+b" -> "((a,b) => a+b)()" - works as IIFE + // For non-embedded (tauri-driver/official): use sync execute with console wrapper const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - return ((${scriptString})()).apply(null, arguments); + return (${scriptString}).apply(null, arguments); `; return originalExecute(wrappedScript, ...args) as Promise; diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 62aacb320..7d38aee4e 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -91,6 +91,52 @@ describe('TauriWorkerService', () => { expect(firstExecute).toBe(secondExecute); }); + + it('should wrap string scripts in IIFE for non-embedded providers', () => { + const mockExecute = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute }); + const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + mockBrowser.execute('return document.title'); + + expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(async () => return document.title)()')); + }); + + it('should pass function scripts as-is for non-embedded providers', () => { + const mockExecute = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute }); + const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + const testFn = (a: number, b: number) => a + b; + mockBrowser.execute(testFn as any, 1, 2); + + expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + }); + + it('should pass string scripts as-is for embedded provider', () => { + const mockExecute = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute }); + const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + mockBrowser.execute('return document.title'); + + expect(mockExecute).toHaveBeenCalledWith('return document.title'); + }); + + it('should pass function scripts as-is for embedded provider', () => { + const mockExecute = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute }); + const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + const testFn = (a: number, b: number) => a + b; + mockBrowser.execute(testFn as any, 1, 2); + + expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + }); }); describe('before()', () => { From 1b6b4be079ccad300f62438e220b72efc4023b29 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 14:59:28 +0100 Subject: [PATCH 009/130] refactor(tauri): correct script wrapping in execute function - Updated the `execute` function to properly wrap string scripts in an IIFE with braces, ensuring correct syntax for execution. - Adjusted related test cases to reflect the updated script format, enhancing the accuracy of expectations for script execution. --- packages/tauri-service/src/service.ts | 2 +- packages/tauri-service/test/service.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index e5fca0ee3..1e0b16cca 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -323,7 +323,7 @@ export default class TauriWorkerService { // - embedded: pass as-is (WebDriver handles execution) // - non-embedded: wrap in async IIFE to make statement expressions callable const scriptString = - typeof script === 'function' ? script.toString() : isEmbedded ? script : `(async () => ${script})()`; + typeof script === 'function' ? script.toString() : isEmbedded ? script : `(async () => { ${script} })()`; if (isEmbedded) { // For embedded WebDriver: skip console wrapper as console forwarding diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 7d38aee4e..b03906745 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -100,7 +100,7 @@ describe('TauriWorkerService', () => { (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(async () => return document.title)()')); + expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(async () => { return document.title })()')); }); it('should pass function scripts as-is for non-embedded providers', () => { From dd8b8a4651c9b5f3df199c39f38778d2c1431853 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 15:17:38 +0100 Subject: [PATCH 010/130] docs(tauri): clarify script serialization in execute function - Added a comment in the `execute` function to explain that TypeScript sends scripts as-is, necessitating serialization for proper handling. - This change enhances understanding of the script processing logic, ensuring clarity on the serialization step for arguments passed to the function. --- packages/tauri-plugin/src/commands.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 4b1f468d7..9c74fcebe 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -60,6 +60,7 @@ pub(crate) async fn execute( let tx = Arc::new(Mutex::new(Some(tx))); // Build the script with args if offered + // Note: TypeScript sends scripts as-is (not JSON.stringify'd), so we serialize here let script = if !request.args.is_empty() { let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; From bf638a7677b61da57508bbf07b2c6dfdaf266d1b Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 15:29:10 +0100 Subject: [PATCH 011/130] refactor(tauri): enhance script execution logic in execute function - Updated the `execute` function to improve the handling of scripts with and without arguments. - Scripts with arguments are now wrapped in an IIFE and executed as functions, while scripts without arguments are evaluated directly. - Enhanced comments for clarity on the script evaluation process, ensuring better understanding of callable detection and execution flow. --- packages/tauri-plugin/src/commands.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 9c74fcebe..9a9383c69 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -60,14 +60,17 @@ pub(crate) async fn execute( let tx = Arc::new(Mutex::new(Some(tx))); // Build the script with args if offered - // Note: TypeScript sends scripts as-is (not JSON.stringify'd), so we serialize here + // For args: evaluate the script as a callable and pass args + // For no-args: evaluate the script expression directly (callable detection happens in JS wrapper) let script = if !request.args.is_empty() { let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; - let script_json = serde_json::to_string(&request.script) - .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize script: {}", e)))?; - format!("(function() {{ const __wdio_args = {}; return ({}); }})()", args_json, script_json) + format!( + "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return __wdio_fn(...__wdio_args); }})()", + request.script, args_json + ) } else { + // No args - preserve the script expression and evaluate it below request.script.clone() }; @@ -125,8 +128,13 @@ pub(crate) async fn execute( throw new Error('window.__TAURI__.core.invoke not available after timeout'); }} - // Execute the user's script - const result = await ({}); + // Execute the user's script. + // If expression resolves to a function, call it with Tauri APIs. + // Otherwise, await the value directly. + const __wdio_script = ({}); + const result = typeof __wdio_script === 'function' + ? await __wdio_script({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event }}) + : await __wdio_script; // Emit the result using the current window's event emitter // This ensures the event goes to the same window where we're listening From 4210433c4149c5bd09aa041c710dd5bf21dc7efa Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 15:40:38 +0100 Subject: [PATCH 012/130] refactor(tauri): streamline script execution logic in execute function - Enhanced the `execute` function to handle embedded WebDriver scripts more effectively by passing them through untouched. - Simplified the script wrapping logic for non-embedded scripts, ensuring consistent execution behavior. - Updated related test cases to reflect the changes in script handling, improving accuracy in expectations. --- packages/tauri-service/src/service.ts | 15 +++++++-------- packages/tauri-service/test/service.spec.ts | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 1e0b16cca..c455a7625 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -318,18 +318,17 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { + if (isEmbedded) { + // For embedded WebDriver: pass the script through untouched so WDIO + // can invoke function scripts correctly. + return originalExecute(script as Parameters[0], ...args) as Promise; + } + // For functions: use .toString() - produces valid JS function source // For strings: // - embedded: pass as-is (WebDriver handles execution) // - non-embedded: wrap in async IIFE to make statement expressions callable - const scriptString = - typeof script === 'function' ? script.toString() : isEmbedded ? script : `(async () => { ${script} })()`; - - if (isEmbedded) { - // For embedded WebDriver: skip console wrapper as console forwarding - // is handled by tauri-plugin-webdriver. - return originalExecute(scriptString, ...args) as Promise; - } + const scriptString = typeof script === 'function' ? script.toString() : `(async () => { ${script} })()`; // For non-embedded (tauri-driver/official): use sync execute with console wrapper const wrappedScript = ` diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index b03906745..9f3e1297b 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -135,7 +135,7 @@ describe('TauriWorkerService', () => { const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); }); }); From cfcbdf39d0d6e62cfe892a27e3477d71e85d8b7b Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 15:41:05 +0100 Subject: [PATCH 013/130] refactor(tauri): improve argument handling in script execution - Updated the `execute` function to inject Tauri APIs as the first parameter when scripts are executed with arguments, enhancing the integration with Tauri's core functionalities. - Revised comments for clarity on the script evaluation process, ensuring a better understanding of how arguments are passed and handled. - Maintained the evaluation of script expressions for cases without arguments, preserving existing behavior. --- packages/tauri-plugin/src/commands.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 9a9383c69..6cd60846d 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -60,13 +60,13 @@ pub(crate) async fn execute( let tx = Arc::new(Mutex::new(Some(tx))); // Build the script with args if offered - // For args: evaluate the script as a callable and pass args - // For no-args: evaluate the script expression directly (callable detection happens in JS wrapper) + // For args: inject Tauri APIs as first param, then pass user args + // For no-args: preserve the script expression and evaluate it below let script = if !request.args.is_empty() { let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; format!( - "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return __wdio_fn(...__wdio_args); }})()", + "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", request.script, args_json ) } else { From 3bbf167ba9b9bc472cdb82a4ade565fe4e8be93b Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:00:08 +0100 Subject: [PATCH 014/130] refactor(tauri): improve script execution for no-args case - Updated the `execute` function to wrap scripts without arguments in an async IIFE, allowing for proper handling of statement-style scripts. - Revised comments to clarify the execution flow and ensure consistency in script handling across both with-args and no-args scenarios. --- packages/tauri-plugin/src/commands.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 6cd60846d..e0088195a 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -70,8 +70,11 @@ pub(crate) async fn execute( request.script, args_json ) } else { - // No args - preserve the script expression and evaluate it below - request.script.clone() + // No args - wrap in block-body IIFE to handle statement-style scripts like "return document.title" + format!( + "(async () => {{ {0} }})()", + request.script + ) }; // Generate unique event ID for this execution @@ -128,13 +131,10 @@ pub(crate) async fn execute( throw new Error('window.__TAURI__.core.invoke not available after timeout'); }} - // Execute the user's script. - // If expression resolves to a function, call it with Tauri APIs. - // Otherwise, await the value directly. + // Execute the user's script (already wrapped in both branches) + // Both with-args and no-args paths return a complete async IIFE const __wdio_script = ({}); - const result = typeof __wdio_script === 'function' - ? await __wdio_script({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event }}) - : await __wdio_script; + const result = await __wdio_script; // Emit the result using the current window's event emitter // This ensures the event goes to the same window where we're listening From 87f1f948278817b5d3986366e1296367ac456fbc Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:02:14 +0100 Subject: [PATCH 015/130] test(electron, tauri): add comprehensive script execution tests - Introduced new test cases for the `execute` function in both Electron and Tauri to cover various script types, including functions with and without arguments, statement-style strings, and async functions. - Enhanced test coverage to ensure proper handling of different script execution scenarios, improving reliability and confidence in the functionality. --- e2e/test/electron/api.spec.ts | 50 +++++++++++++++++++++++++++ e2e/test/tauri/api.spec.ts | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/e2e/test/electron/api.spec.ts b/e2e/test/electron/api.spec.ts index 4fff1b778..bc747bea4 100644 --- a/e2e/test/electron/api.spec.ts +++ b/e2e/test/electron/api.spec.ts @@ -83,6 +83,56 @@ describe('Electron APIs', () => { ); }); + describe('execute - different script types', () => { + it('should execute function with args (with-args branch)', async () => { + const result = await browser.electron.execute( + (electron, arg1, arg2) => { + return { appName: electron.app.getName(), arg1, arg2 }; + }, + 'first', + 'second', + ); + expect(result.appName).toBeDefined(); + expect(result.arg1).toBe('first'); + expect(result.arg2).toBe('second'); + }); + + it('should execute statement-style string (return statement)', async () => { + const result = await browser.electron.execute('return 42'); + expect(result).toBe(42); + }); + + it('should execute expression-style string', async () => { + const result = await browser.electron.execute('1 + 2 + 3'); + expect(result).toBe(6); + }); + + it('should execute string with variable declaration', async () => { + const result = await browser.electron.execute(` + const x = 10; + const y = 20; + return x + y; + `); + expect(result).toBe(30); + }); + + it('should execute function without args', async () => { + const result = await browser.electron.execute((electron) => { + return { name: electron.app.getName() }; + }); + expect(result.name).toBeDefined(); + }); + + it('should execute async function with args', async () => { + const result = await browser.electron.execute(async (electron, value) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { received: value, appName: electron.app.getName() }; + }, 'async-test'); + expect(result.received).toBe('async-test'); + expect(result.appName).toBeDefined(); + }); + }); + describe('workaround for TSX issue', () => { // Tests for the following issue - can be removed when the TSX issue is resolved // https://github.com/webdriverio-community/wdio-electron-service/issues/756 diff --git a/e2e/test/tauri/api.spec.ts b/e2e/test/tauri/api.spec.ts index 1eee7cea3..cbeff2955 100644 --- a/e2e/test/tauri/api.spec.ts +++ b/e2e/test/tauri/api.spec.ts @@ -23,4 +23,67 @@ describe('Tauri API', () => { expect(result).toHaveProperty('os'); expect(typeof result.os).toBe('string'); }); + + describe('execute - different script types', () => { + it('should execute function with Tauri APIs and args (with-args branch)', async () => { + // This tests the with-args branch: function receives Tauri APIs as first param, user args after + const result = await browser.tauri.execute( + (tauri, arg1, arg2) => { + return { tauriHasCore: typeof tauri?.core?.invoke === 'function', arg1, arg2 }; + }, + 'first', + 'second', + ); + expect(result.tauriHasCore).toBe(true); + expect(result.arg1).toBe('first'); + expect(result.arg2).toBe('second'); + }); + + it('should execute statement-style string (return statement)', async () => { + // This tests the no-args branch with statement-style script like "return document.title" + const result = await browser.tauri.execute('return 42'); + expect(result).toBe(42); + }); + + it('should execute expression-style string', async () => { + // This tests the no-args branch with expression-style script + const result = await browser.tauri.execute('1 + 2 + 3'); + expect(result).toBe(6); + }); + + it('should execute string with variable declaration', async () => { + // Statement-style: declare variables and return + const result = await browser.tauri.execute(` + const x = 10; + const y = 20; + return x + y; + `); + expect(result).toBe(30); + }); + + it('should execute function with Tauri APIs (no args)', async () => { + // Function without args should still receive Tauri APIs + const result = await browser.tauri.execute((tauri) => { + return { hasCore: typeof tauri?.core !== 'undefined' }; + }); + expect(result.hasCore).toBe(true); + }); + + it('should execute string that accesses Tauri APIs', async () => { + // String script that uses window.__TAURI__ directly + const result = await browser.tauri.execute(` + return typeof window.__TAURI__?.core; + `); + expect(result).toBe('object'); + }); + + it('should execute async function with args', async () => { + const result = await browser.tauri.execute(async (tauri, value) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { received: value, hasTauri: !!tauri?.core }; + }, 'async-test'); + expect(result.received).toBe('async-test'); + expect(result.hasTauri).toBe(true); + }); + }); }); From c81a43fd9ebaf210382d3fcec7a51746eced243f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:15:54 +0100 Subject: [PATCH 016/130] refactor(tauri): enhance script execution logic for no-args case - Updated the `execute` function to differentiate between function and statement scripts when no arguments are provided. Function scripts are now called with injected Tauri APIs, while statement scripts are wrapped in a block-body IIFE. - Improved comments to clarify the handling of different script types, ensuring better understanding of the execution flow. --- packages/tauri-plugin/src/commands.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index e0088195a..3637e9f92 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -70,11 +70,23 @@ pub(crate) async fn execute( request.script, args_json ) } else { - // No args - wrap in block-body IIFE to handle statement-style scripts like "return document.title" - format!( - "(async () => {{ {0} }})()", - request.script - ) + // No args - detect function vs statement scripts + // Function scripts need to be called with Tauri APIs, statement scripts need block-body wrapper + let trimmed = request.script.trim(); + let is_function = trimmed.starts_with('(') || trimmed.starts_with("function") || trimmed.starts_with("async"); + if is_function { + // Function script - call it with Tauri APIs injected + format!( + "(function() {{ return ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", + request.script + ) + } else { + // Statement-style script - wrap in block-body IIFE + format!( + "(async () => {{ {0} }})()", + request.script + ) + } }; // Generate unique event ID for this execution From 52d891f0e081dfdec7b8c5071e33278dc83ffbe3 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:31:26 +0100 Subject: [PATCH 017/130] refactor(tauri): enhance script execution for statement expressions - Updated the `execute` function to prepend "return" to statement-style scripts if not already present, ensuring expressions like "1 + 2" return their value. - Improved comments for clarity on the handling of different script types, reinforcing understanding of the execution flow. --- packages/tauri-plugin/src/commands.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 3637e9f92..4af608a1e 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -81,10 +81,18 @@ pub(crate) async fn execute( request.script ) } else { - // Statement-style script - wrap in block-body IIFE + // Statement/expression-style script - wrap in block-body IIFE + // Prepend "return" if not present, so expressions like "1 + 2" return their value + let script_trimmed = request.script.trim_start(); + let needs_return = !script_trimmed.starts_with("return"); + let body = if needs_return { + format!("return {};", request.script) + } else { + request.script.clone() + }; format!( "(async () => {{ {0} }})()", - request.script + body ) } }; From 17c4255e325082f0dc5fb4f3d13b4845dc29fbe5 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:41:15 +0100 Subject: [PATCH 018/130] refactor(tauri): embed command and arguments in script string for execution - Updated the `executeTauriCommand` function to embed the command and its arguments directly into the script string, replacing the previous closure reference approach. - Added a new test case to verify that the command and arguments are correctly included in the executed script, ensuring improved clarity and correctness in script execution. --- packages/tauri-service/src/commands/execute.ts | 5 ++++- .../test/commands/execute.spec.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index 6584d3f3e..ef9d44473 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -150,7 +150,10 @@ export async function executeTauriCommand( log.debug(`Executing Tauri command: ${command} with args:`, args); try { - const result = await execute(browser, ({ core }) => core.invoke(command, ...args)); + const result = await execute( + browser, + `({ core }) => core.invoke(${JSON.stringify(command)}, ...${JSON.stringify(args)})`, + ); return { ok: true, diff --git a/packages/tauri-service/test/commands/execute.spec.ts b/packages/tauri-service/test/commands/execute.spec.ts index 091dfe777..1192440ae 100644 --- a/packages/tauri-service/test/commands/execute.spec.ts +++ b/packages/tauri-service/test/commands/execute.spec.ts @@ -359,6 +359,24 @@ describe('executeTauriCommand', () => { browser = createMockBrowser(); }); + it('should embed command and args in script string instead of using closure', async () => { + const mockExecute = vi.fn(); + mockExecute.mockResolvedValueOnce(true); // plugin check + mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'test-result' })); + (browser.execute as ReturnType).mockImplementation(mockExecute); + + await executeTauriCommand(browser, 'get_version', 'arg1', 42); + + // The second call is the actual execute - verify the script embeds the command/args + const secondCall = mockExecute.mock.calls[1]; + const scriptArg = secondCall[1] as string; + expect(scriptArg).toContain('"get_version"'); + expect(scriptArg).toContain('"arg1"'); + expect(scriptArg).toContain('42'); + // Should NOT contain the variable name 'command' (which would indicate closure reference) + expect(scriptArg).not.toContain('command'); + }); + it('should return ok result on success', async () => { const mockExecute = vi.fn(); mockExecute.mockResolvedValueOnce(true); From 11b618c9e410ef363202bd97750ca41c3aa7783a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:47:04 +0100 Subject: [PATCH 019/130] refactor(tauri): pass command and arguments as function parameters in execution - Updated the `executeTauriCommand` function to pass the command and its arguments as separate function parameters instead of embedding them in a closure. This change enhances clarity and prevents closure references in the serialized script. - Modified the corresponding test case to reflect this change, ensuring that command and arguments are verified as distinct parameters during execution. --- packages/tauri-service/src/commands/execute.ts | 4 +++- .../tauri-service/test/commands/execute.spec.ts | 15 +++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index ef9d44473..5afedac92 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -152,7 +152,9 @@ export async function executeTauriCommand( try { const result = await execute( browser, - `({ core }) => core.invoke(${JSON.stringify(command)}, ...${JSON.stringify(args)})`, + ({ core }, invokeCommand: string, invokeArgs: unknown[]) => core.invoke(invokeCommand, ...invokeArgs), + command, + args, ); return { diff --git a/packages/tauri-service/test/commands/execute.spec.ts b/packages/tauri-service/test/commands/execute.spec.ts index 1192440ae..8f7dbe3c7 100644 --- a/packages/tauri-service/test/commands/execute.spec.ts +++ b/packages/tauri-service/test/commands/execute.spec.ts @@ -359,7 +359,7 @@ describe('executeTauriCommand', () => { browser = createMockBrowser(); }); - it('should embed command and args in script string instead of using closure', async () => { + it('should pass command and args as function arguments, not closure references', async () => { const mockExecute = vi.fn(); mockExecute.mockResolvedValueOnce(true); // plugin check mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'test-result' })); @@ -367,14 +367,13 @@ describe('executeTauriCommand', () => { await executeTauriCommand(browser, 'get_version', 'arg1', 42); - // The second call is the actual execute - verify the script embeds the command/args + // The second call is the actual execute - verify command and args are passed as separate arguments + // This ensures they don't become closure references in the serialized script const secondCall = mockExecute.mock.calls[1]; - const scriptArg = secondCall[1] as string; - expect(scriptArg).toContain('"get_version"'); - expect(scriptArg).toContain('"arg1"'); - expect(scriptArg).toContain('42'); - // Should NOT contain the variable name 'command' (which would indicate closure reference) - expect(scriptArg).not.toContain('command'); + // Script is converted to string by execute(), but command and args are passed separately + expect(secondCall[1]).toBe('({ core }, invokeCommand, invokeArgs) => core.invoke(invokeCommand, ...invokeArgs)'); + expect(secondCall[2]).toBe('get_version'); + expect(secondCall[3]).toEqual(['arg1', 42]); }); it('should return ok result on success', async () => { From 126659f1e0d6ce21fec340e4e52d536448374b45 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 16:50:52 +0100 Subject: [PATCH 020/130] refactor(tauri): improve script wrapping logic for execution - Updated the `execute` function to ensure that string scripts are wrapped in an async IIFE, while function scripts are passed as-is. This change enhances the handling of different script types during execution. - Revised comments for clarity on the execution flow and the distinction between handling functions and strings, improving overall understanding of the script processing logic. - Adjusted tests to verify that string scripts are correctly wrapped and not using `.apply()`, ensuring accurate execution behavior. --- packages/tauri-service/src/service.ts | 15 +++++++-------- packages/tauri-service/test/service.spec.ts | 2 ++ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index c455a7625..339b9f9f0 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -325,16 +325,15 @@ export default class TauriWorkerService { } // For functions: use .toString() - produces valid JS function source - // For strings: - // - embedded: pass as-is (WebDriver handles execution) - // - non-embedded: wrap in async IIFE to make statement expressions callable - const scriptString = typeof script === 'function' ? script.toString() : `(async () => { ${script} })()`; + // For strings: wrap in async IIFE (not invoked yet, wrappedScript will handle it) + const scriptString = typeof script === 'function' ? script.toString() : script; // For non-embedded (tauri-driver/official): use sync execute with console wrapper - const wrappedScript = ` - ${CONSOLE_WRAPPER_SCRIPT} - return (${scriptString}).apply(null, arguments); - `; + // Different wrapping for functions vs strings - strings need async IIFE to make statements valid + const wrappedScript = + typeof script === 'function' + ? `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (${scriptString}).apply(null, arguments);\n` + : `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (async () => { ${scriptString} })();\n`; return originalExecute(wrappedScript, ...args) as Promise; }; diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 9f3e1297b..a53f52fce 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -100,7 +100,9 @@ describe('TauriWorkerService', () => { (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); + // String scripts should be wrapped in async IIFE, NOT use .apply() expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(async () => { return document.title })()')); + expect(mockExecute).not.toHaveBeenCalledWith(expect.stringContaining('.apply')); }); it('should pass function scripts as-is for non-embedded providers', () => { From 90513e8b50c3058f5eba0511850846ee2d2f1258 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 17:06:09 +0100 Subject: [PATCH 021/130] refactor(tauri): improve script execution handling for async results - Introduced a new `originalExecuteAsync` method to handle string scripts using `executeAsync`, ensuring proper awaiting of async results in non-embedded providers. - Updated the `execute` function to differentiate between function and string scripts, applying the appropriate execution method based on the script type. - Revised tests to verify that string scripts utilize `executeAsync` and that function scripts are passed as-is, enhancing the accuracy of execution behavior. --- packages/tauri-service/src/service.ts | 20 ++++++++++++-------- packages/tauri-service/test/service.spec.ts | 16 +++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 339b9f9f0..f9f10d57f 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -312,6 +312,7 @@ export default class TauriWorkerService { } const originalExecute = browser.execute.bind(browser); + const originalExecuteAsync = (browser.executeAsync as typeof browser.execute).bind(browser); const isEmbedded = this.driverProvider === 'embedded'; const patchedExecute = async function patchedExecute( @@ -325,17 +326,20 @@ export default class TauriWorkerService { } // For functions: use .toString() - produces valid JS function source - // For strings: wrap in async IIFE (not invoked yet, wrappedScript will handle it) + // For strings: pass as-is (let the WebDriver handle it, or use executeAsync for async results) const scriptString = typeof script === 'function' ? script.toString() : script; // For non-embedded (tauri-driver/official): use sync execute with console wrapper - // Different wrapping for functions vs strings - strings need async IIFE to make statements valid - const wrappedScript = - typeof script === 'function' - ? `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (${scriptString}).apply(null, arguments);\n` - : `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (async () => { ${scriptString} })();\n`; - - return originalExecute(wrappedScript, ...args) as Promise; + // Different wrapping for functions vs strings - functions use .apply(), strings need executeAsync + // because WebDriver sync execute doesn't await Promises + if (typeof script === 'function') { + const wrappedScript = `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (${scriptString}).apply(null, arguments);\n`; + return originalExecute(wrappedScript, ...args) as Promise; + } else { + // For strings: use executeAsync to properly await the async IIFE result + const wrappedScript = `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (async () => { ${scriptString} })();\n`; + return originalExecuteAsync(wrappedScript, ...args) as Promise; + } }; Object.defineProperty(browser, 'execute', { diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index a53f52fce..6fe08bcec 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -41,6 +41,7 @@ import { clearWindowState, ensureActiveWindowFocus } from '../src/window.js'; function createMockBrowser(overrides: Record = {}): WebdriverIO.Browser { return { execute: vi.fn().mockResolvedValue(undefined), + executeAsync: vi.fn().mockResolvedValue(undefined), isMultiremote: false, sessionId: 'test-session-123', instances: [], @@ -92,17 +93,22 @@ describe('TauriWorkerService', () => { expect(firstExecute).toBe(secondExecute); }); - it('should wrap string scripts in IIFE for non-embedded providers', () => { + it('should wrap string scripts in IIFE for non-embedded providers using executeAsync', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - // String scripts should be wrapped in async IIFE, NOT use .apply() - expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(async () => { return document.title })()')); - expect(mockExecute).not.toHaveBeenCalledWith(expect.stringContaining('.apply')); + // String scripts should use executeAsync (not execute) because WebDriver sync doesn't await Promises + expect(mockExecuteAsync).toHaveBeenCalledWith( + expect.stringContaining('(async () => { return document.title })()'), + ); + expect(mockExecuteAsync).not.toHaveBeenCalledWith(expect.stringContaining('.apply')); + // execute should NOT be called for string scripts + expect(mockExecute).not.toHaveBeenCalled(); }); it('should pass function scripts as-is for non-embedded providers', () => { From fbf13812dc787e7aac98b36b6a9a0cadcc61326b Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 17:06:27 +0100 Subject: [PATCH 022/130] refactor(tauri): enhance function detection in script execution - Updated the `execute` function to improve detection of function scripts by introducing a helper function that checks for keyword prefixes, enhancing clarity and accuracy in distinguishing between function and statement scripts. - Revised comments to better explain the logic behind function detection, contributing to improved understanding of the script processing flow. --- packages/tauri-plugin/src/commands.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 4af608a1e..08b1d90c9 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -73,7 +73,15 @@ pub(crate) async fn execute( // No args - detect function vs statement scripts // Function scripts need to be called with Tauri APIs, statement scripts need block-body wrapper let trimmed = request.script.trim(); - let is_function = trimmed.starts_with('(') || trimmed.starts_with("function") || trimmed.starts_with("async"); + let has_keyword_prefix = |source: &str, keyword: &str| { + source + .strip_prefix(keyword) + .and_then(|rest| rest.chars().next()) + .map(|ch| ch.is_whitespace() || ch == '(') + .unwrap_or(false) + }; + let is_function = + trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async"); if is_function { // Function script - call it with Tauri APIs injected format!( From 83e254a89c1939443629f435be34c2a52e1530ed Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 17:56:00 +0100 Subject: [PATCH 023/130] refactor(tauri): enhance script execution for async compatibility - Updated the `executeAsync` method to wrap string scripts in an async IIFE with an explicit done callback, ensuring compatibility with WebKit environments that do not auto-await Promises. - Improved the logic in the `execute` function to prepend "return" only for pure expressions, enhancing clarity in script evaluation. - Revised tests to reflect changes in script wrapping and ensure proper handling of async results, reinforcing the accuracy of execution behavior. --- packages/tauri-plugin/src/commands.rs | 21 ++++++++++++--------- packages/tauri-service/src/service.ts | 11 +++++++++-- packages/tauri-service/test/service.spec.ts | 7 ++++--- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 08b1d90c9..2d28656f5 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -81,7 +81,7 @@ pub(crate) async fn execute( .unwrap_or(false) }; let is_function = - trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async"); + trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async") || trimmed.contains("=>"); if is_function { // Function script - call it with Tauri APIs injected format!( @@ -90,12 +90,15 @@ pub(crate) async fn execute( ) } else { // Statement/expression-style script - wrap in block-body IIFE - // Prepend "return" if not present, so expressions like "1 + 2" return their value - let script_trimmed = request.script.trim_start(); - let needs_return = !script_trimmed.starts_with("return"); - let body = if needs_return { + // Only prepend "return" for pure expressions (no statements, no existing return) + // Check for statements (semicolons) or existing return keyword + let has_semicolon = request.script.contains(';'); + let has_return = request.script.contains("return"); + let body = if !has_semicolon && !has_return { + // Pure expression - add return so it evaluates and returns format!("return {};", request.script) } else { + // Has statements or already has return - pass through as-is request.script.clone() }; format!( @@ -216,7 +219,7 @@ pub(crate) async fn execute( // This matches the WebDriver default script timeout let window_label = window.label().to_owned(); let timeout_duration = Duration::from_secs(30); - + match tokio::time::timeout(timeout_duration, rx).await { Ok(Ok(Ok(result))) => { log::debug!("Execute completed successfully"); @@ -287,7 +290,7 @@ pub(crate) async fn get_window_states( app: tauri::AppHandle, ) -> Result> { let mut states = Vec::new(); - + for (label, window) in app.webview_windows() { let state = WindowState { label: label.clone(), @@ -295,10 +298,10 @@ pub(crate) async fn get_window_states( is_visible: window.is_visible().unwrap_or(false), is_focused: window.is_focused().unwrap_or(false), }; - log::debug!("[get_window_states] {}: title='{}', visible={}, focused={}", + log::debug!("[get_window_states] {}: title='{}', visible={}, focused={}", label, state.title, state.is_visible, state.is_focused); states.push(state); } - + Ok(states) } diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index f9f10d57f..245915f02 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -336,8 +336,15 @@ export default class TauriWorkerService { const wrappedScript = `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (${scriptString}).apply(null, arguments);\n`; return originalExecute(wrappedScript, ...args) as Promise; } else { - // For strings: use executeAsync to properly await the async IIFE result - const wrappedScript = `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (async () => { ${scriptString} })();\n`; + // For strings: use executeAsync with explicit done callback + // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly + const wrappedScript = ` + ${CONSOLE_WRAPPER_SCRIPT} + (async () => { ${scriptString} })().then( + function(r) { arguments[arguments.length-1](r); }, + function(e) { arguments[arguments.length-1](undefined); } + ); + `; return originalExecuteAsync(wrappedScript, ...args) as Promise; } }; diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 6fe08bcec..ddc706433 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -93,7 +93,7 @@ describe('TauriWorkerService', () => { expect(firstExecute).toBe(secondExecute); }); - it('should wrap string scripts in IIFE for non-embedded providers using executeAsync', () => { + it('should wrap string scripts in IIFE with done callback for non-embedded providers', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); @@ -102,11 +102,12 @@ describe('TauriWorkerService', () => { (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - // String scripts should use executeAsync (not execute) because WebDriver sync doesn't await Promises + // String scripts should use executeAsync with explicit done callback for WebKit compatibility expect(mockExecuteAsync).toHaveBeenCalledWith( expect.stringContaining('(async () => { return document.title })()'), ); - expect(mockExecuteAsync).not.toHaveBeenCalledWith(expect.stringContaining('.apply')); + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('.then(')); + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('arguments[arguments.length-1]')); // execute should NOT be called for string scripts expect(mockExecute).not.toHaveBeenCalled(); }); From 66ee9f2180f65291bb7588aeb7b09ca7356699fd Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:04:02 +0100 Subject: [PATCH 024/130] refactor(tauri): enhance error handling in async script execution - Updated the error handling in the `executeAsync` method to return a structured error object instead of undefined, improving clarity on execution failures. - This change ensures that errors are more informative, providing the error message when an exception occurs during script execution. --- packages/tauri-service/src/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 245915f02..af815125d 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -342,7 +342,7 @@ export default class TauriWorkerService { ${CONSOLE_WRAPPER_SCRIPT} (async () => { ${scriptString} })().then( function(r) { arguments[arguments.length-1](r); }, - function(e) { arguments[arguments.length-1](undefined); } + function(e) { arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }); } ); `; return originalExecuteAsync(wrappedScript, ...args) as Promise; From 725a83ed29e051410b4105675137e95f6490819e Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:12:26 +0100 Subject: [PATCH 025/130] refactor(tauri): refine function detection logic in script execution - Updated the `execute` function to enhance the detection of function scripts by restricting checks for function-like prefixes to the start of the script, excluding arrow functions within expressions. - Revised comments for clarity, improving understanding of the function detection process and its implications for script execution. --- packages/tauri-plugin/src/commands.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 2d28656f5..3e5a3a9c2 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -71,7 +71,8 @@ pub(crate) async fn execute( ) } else { // No args - detect function vs statement scripts - // Function scripts need to be called with Tauri APIs, statement scripts need block-body wrapper + // Only check for function-like prefixes at the START of the script + // (not anywhere in the script, which would catch arrow functions inside expressions) let trimmed = request.script.trim(); let has_keyword_prefix = |source: &str, keyword: &str| { source @@ -80,8 +81,7 @@ pub(crate) async fn execute( .map(|ch| ch.is_whitespace() || ch == '(') .unwrap_or(false) }; - let is_function = - trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async") || trimmed.contains("=>"); + let is_function = trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async"); if is_function { // Function script - call it with Tauri APIs injected format!( From a1269f5d1a761152099dd3128a6c226e2ae35c07 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:23:37 +0100 Subject: [PATCH 026/130] refactor(tauri): improve async script execution wrapping - Updated the `executeAsync` method to enhance the wrapping of string scripts in an async IIFE, ensuring compatibility with WebKit environments that require explicit callback handling. - Revised the syntax for the promise resolution to use arrow functions, improving readability and consistency in the code structure. --- packages/tauri-service/src/service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index af815125d..f2141f71b 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -339,11 +339,11 @@ export default class TauriWorkerService { // For strings: use executeAsync with explicit done callback // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly const wrappedScript = ` - ${CONSOLE_WRAPPER_SCRIPT} - (async () => { ${scriptString} })().then( - function(r) { arguments[arguments.length-1](r); }, - function(e) { arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }); } - ); + ${CONSOLE_WRAPPER_SCRIPT} + (async () => { ${scriptString} })().then( + (r) => arguments[arguments.length-1](r), + (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) + ); `; return originalExecuteAsync(wrappedScript, ...args) as Promise; } From aec6d242dde7a6d999fcb13455f5e9643059911c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:23:45 +0100 Subject: [PATCH 027/130] refactor(tauri): improve return detection for pure expressions in script execution - Updated the `execute` function to refine the logic for detecting pure expressions by using `starts_with("return")` instead of `contains("return")`, preventing false positives. - Revised comments for clarity, enhancing understanding of when to prepend "return" in script evaluation. --- packages/tauri-plugin/src/commands.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 3e5a3a9c2..ce8bb4615 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -90,10 +90,10 @@ pub(crate) async fn execute( ) } else { // Statement/expression-style script - wrap in block-body IIFE - // Only prepend "return" for pure expressions (no statements, no existing return) - // Check for statements (semicolons) or existing return keyword + // Only prepend "return" for pure expressions (no statements, no existing return at start) + // Use starts_with("return") not contains("return") to avoid false positives like "returnData" let has_semicolon = request.script.contains(';'); - let has_return = request.script.contains("return"); + let has_return = request.script.trim_start().starts_with("return"); let body = if !has_semicolon && !has_return { // Pure expression - add return so it evaluates and returns format!("return {};", request.script) From 1de389578ac05c0a0b1d12ccc2b7c9bf8803efbf Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:34:05 +0100 Subject: [PATCH 028/130] refactor(tauri): enhance async script execution wrapping for WebKit compatibility - Updated the async IIFE wrapping in the `executeAsync` method to use a named function and apply arguments correctly, ensuring compatibility with WebKit environments that require explicit callback handling. - This change improves the handling of script execution results and maintains consistency in the code structure. --- packages/tauri-service/src/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index f2141f71b..4d057aee5 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -340,7 +340,7 @@ export default class TauriWorkerService { // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - (async () => { ${scriptString} })().then( + (async function() { ${scriptString} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( (r) => arguments[arguments.length-1](r), (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) ); From ee5d38acdcbcd87d7b35b1f004ea3e2c21a752c4 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:36:51 +0100 Subject: [PATCH 029/130] refactor(tauri): enhance error handling in async script execution - Updated the `executeAsync` method to use a Promise wrapper for improved error handling, ensuring that errors are properly rejected with informative messages. - Revised tests to verify that the async execution correctly checks for error objects and handles results appropriately, reinforcing the reliability of script execution. --- packages/tauri-service/src/service.ts | 17 ++++++++++++++++- packages/tauri-service/test/service.spec.ts | 10 +++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 4d057aee5..99ccd68db 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -345,7 +345,22 @@ export default class TauriWorkerService { (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) ); `; - return originalExecuteAsync(wrappedScript, ...args) as Promise; + // Use Promise wrapper to properly handle the result and throw on error + return new Promise((resolve, reject) => { + (originalExecuteAsync as (script: string, ...args: unknown[]) => void).call( + browser, + wrappedScript, + ...args, + (result: unknown) => { + // Check for error object returned via done callback and reject + if (result && typeof result === 'object' && '__wdio_error__' in result) { + reject(new Error((result as { __wdio_error__: string }).__wdio_error__)); + } else { + resolve(result as ReturnValue); + } + }, + ); + }); } }; diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index ddc706433..d6af6213b 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -103,11 +103,11 @@ describe('TauriWorkerService', () => { mockBrowser.execute('return document.title'); // String scripts should use executeAsync with explicit done callback for WebKit compatibility - expect(mockExecuteAsync).toHaveBeenCalledWith( - expect.stringContaining('(async () => { return document.title })()'), - ); - expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('.then(')); - expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('arguments[arguments.length-1]')); + expect(mockExecuteAsync).toHaveBeenCalled(); + const callArgs = mockExecuteAsync.mock.calls[0]; + // The script should contain .then( to handle async results and __wdio_error__ for error handling + expect(callArgs[0]).toContain('.then('); + expect(callArgs[0]).toContain('__wdio_error__'); // execute should NOT be called for string scripts expect(mockExecute).not.toHaveBeenCalled(); }); From 6b7206013339d1a36758efd98949097bb0ab15ad Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 18:50:24 +0100 Subject: [PATCH 030/130] refactor(tauri): streamline async script execution error handling - Refactored the `executeAsync` method to eliminate the Promise wrapper, directly handling errors by throwing exceptions for structured error objects. This change simplifies the code and enhances clarity in error reporting during async script execution. --- packages/tauri-service/src/service.ts | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 99ccd68db..4bd9f8a58 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -345,22 +345,14 @@ export default class TauriWorkerService { (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) ); `; - // Use Promise wrapper to properly handle the result and throw on error - return new Promise((resolve, reject) => { - (originalExecuteAsync as (script: string, ...args: unknown[]) => void).call( - browser, - wrappedScript, - ...args, - (result: unknown) => { - // Check for error object returned via done callback and reject - if (result && typeof result === 'object' && '__wdio_error__' in result) { - reject(new Error((result as { __wdio_error__: string }).__wdio_error__)); - } else { - resolve(result as ReturnValue); - } - }, - ); - }); + const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( + wrappedScript, + ...args, + ); + if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { + throw new Error((asyncResult as { __wdio_error__: string }).__wdio_error__); + } + return asyncResult as ReturnValue; } }; From 9182584ded2928628d1f2f1dadc2705d8bbc268a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 19:05:45 +0100 Subject: [PATCH 031/130] refactor(tauri): update script execution to use executeAsync for WebKit compatibility - Modified the `TauriWorkerService` to utilize `executeAsync` for both function and string scripts in non-embedded providers, ensuring compatibility with WebKit environments that do not auto-await Promises. - Enhanced error handling in async script execution to throw structured errors when applicable. - Updated tests to reflect the changes in execution methods, verifying that function scripts are correctly passed to `executeAsync` and ensuring proper handling of async results. --- packages/tauri-service/src/service.ts | 23 ++++++++++++++----- packages/tauri-service/test/service.spec.ts | 25 ++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 4bd9f8a58..cdee90815 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -329,12 +329,25 @@ export default class TauriWorkerService { // For strings: pass as-is (let the WebDriver handle it, or use executeAsync for async results) const scriptString = typeof script === 'function' ? script.toString() : script; - // For non-embedded (tauri-driver/official): use sync execute with console wrapper - // Different wrapping for functions vs strings - functions use .apply(), strings need executeAsync - // because WebDriver sync execute doesn't await Promises + // For non-embedded (tauri-driver/official): use executeAsync for both functions and strings + // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute if (typeof script === 'function') { - const wrappedScript = `\n${CONSOLE_WRAPPER_SCRIPT}\nreturn (${scriptString}).apply(null, arguments);\n`; - return originalExecute(wrappedScript, ...args) as Promise; + // Function scripts: use executeAsync with .then() callbacks to handle async results + const wrappedScript = ` + ${CONSOLE_WRAPPER_SCRIPT} + (${scriptString}).apply(null, arguments).then( + (r) => arguments[arguments.length-1](r), + (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) + ); + `; + const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( + wrappedScript, + ...args, + ); + if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { + throw new Error((asyncResult as { __wdio_error__: string }).__wdio_error__); + } + return asyncResult as ReturnValue; } else { // For strings: use executeAsync with explicit done callback // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index d6af6213b..04b7991a1 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -112,16 +112,20 @@ describe('TauriWorkerService', () => { expect(mockExecute).not.toHaveBeenCalled(); }); - it('should pass function scripts as-is for non-embedded providers', () => { + it('should pass function scripts as-is for non-embedded providers using executeAsync', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + // Functions should use executeAsync for WebKit compatibility + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + // execute should NOT be called + expect(mockExecute).not.toHaveBeenCalled(); }); it('should pass string scripts as-is for embedded provider', () => { @@ -179,27 +183,32 @@ describe('TauriWorkerService', () => { it('should wait for plugin initialization on standard browser', async () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({}, { 'wdio:tauriServiceOptions': {} }); await service.before({} as any, [], mockBrowser); - expect(mockExecute).toHaveBeenCalled(); + // For non-embedded providers, executeAsync is used (WebKit compatibility) + expect(mockExecuteAsync).toHaveBeenCalled(); }); it('should skip plugin initialization wait for crabnebula driver provider', async () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'crabnebula' }, { 'wdio:tauriServiceOptions': {} }); await service.before({} as any, [], mockBrowser); + // CrabNebula skips the plugin initialization wait entirely expect(mockExecute).not.toHaveBeenCalled(); + expect(mockExecuteAsync).not.toHaveBeenCalled(); }); it('should handle plugin initialization error gracefully', async () => { - const mockExecute = vi.fn().mockRejectedValue(new Error('plugin not ready')); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({}, { 'wdio:tauriServiceOptions': {} }); await expect(service.before({} as any, [], mockBrowser)).resolves.not.toThrow(); From 60e09bda15976dde05ce01d06538dd189e323023 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 19:06:01 +0100 Subject: [PATCH 032/130] refactor(tauri): improve pure expression detection in script execution - Updated the `execute` function to refine the detection of pure expressions by checking for variable declarations (`const`, `let`, `var`) at the start of the script. This change enhances the logic for determining when to prepend "return" in script evaluation, preventing false positives and improving overall clarity. --- packages/tauri-plugin/src/commands.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index ce8bb4615..129bf18c8 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -92,7 +92,9 @@ pub(crate) async fn execute( // Statement/expression-style script - wrap in block-body IIFE // Only prepend "return" for pure expressions (no statements, no existing return at start) // Use starts_with("return") not contains("return") to avoid false positives like "returnData" - let has_semicolon = request.script.contains(';'); + let has_semicolon = request.script.trim_start().starts_with("const ") + || request.script.trim_start().starts_with("let ") + || request.script.trim_start().starts_with("var "); let has_return = request.script.trim_start().starts_with("return"); let body = if !has_semicolon && !has_return { // Pure expression - add return so it evaluates and returns From 0d391e851f74ab4ec51444468b7325a2b58efff4 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 19:15:41 +0100 Subject: [PATCH 033/130] refactor(tauri): fix argument handling in async script execution - Updated the argument handling in the `executeAsync` method to correctly slice the arguments passed to the wrapped script, ensuring that the last argument is treated as the callback. This change improves the accuracy of async result handling and enhances the overall functionality of script execution. --- packages/tauri-service/src/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index cdee90815..bca5f0fcd 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -335,7 +335,7 @@ export default class TauriWorkerService { // Function scripts: use executeAsync with .then() callbacks to handle async results const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - (${scriptString}).apply(null, arguments).then( + (${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( (r) => arguments[arguments.length-1](r), (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) ); From 119b7b31a10056a755fe608e7537870cafbd989a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 19:28:00 +0100 Subject: [PATCH 034/130] refactor(tauri): enhance pure expression detection in script execution - Updated the `execute` function to improve the detection of pure expressions by expanding the checks for various statement types (e.g., `if`, `for`, `while`, `switch`, `throw`, `try`). This change ensures that the logic for determining when to prepend "return" is more robust, preventing false positives and enhancing the clarity of script evaluation. --- packages/tauri-plugin/src/commands.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 129bf18c8..6696861f9 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -90,13 +90,24 @@ pub(crate) async fn execute( ) } else { // Statement/expression-style script - wrap in block-body IIFE + let has_statement = request.script.trim_start().starts_with("const ") + || request.script.trim_start().starts_with("let ") + || request.script.trim_start().starts_with("var ") + || request.script.trim_start().starts_with("if ") + || request.script.trim_start().starts_with("if(") + || request.script.trim_start().starts_with("for ") + || request.script.trim_start().starts_with("for(") + || request.script.trim_start().starts_with("while ") + || request.script.trim_start().starts_with("while(") + || request.script.trim_start().starts_with("switch ") + || request.script.trim_start().starts_with("switch(") + || request.script.trim_start().starts_with("throw ") + || request.script.trim_start().starts_with("try ") + || request.script.trim_start().starts_with("try{"); // Only prepend "return" for pure expressions (no statements, no existing return at start) // Use starts_with("return") not contains("return") to avoid false positives like "returnData" - let has_semicolon = request.script.trim_start().starts_with("const ") - || request.script.trim_start().starts_with("let ") - || request.script.trim_start().starts_with("var "); let has_return = request.script.trim_start().starts_with("return"); - let body = if !has_semicolon && !has_return { + let body = if !has_statement && !has_return { // Pure expression - add return so it evaluates and returns format!("return {};", request.script) } else { From 2cc06d6a8699b76d572e4210e27cf525a847bd93 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 19:45:25 +0100 Subject: [PATCH 035/130] refactor(tauri): expand pure expression detection in script execution - Enhanced the `execute` function to include additional checks for `do` and `do{` statements in the detection of pure expressions. This update improves the robustness of the logic for determining when to prepend "return", further reducing false positives in script evaluation. --- packages/tauri-plugin/src/commands.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 6696861f9..9d5f8d4f6 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -103,7 +103,9 @@ pub(crate) async fn execute( || request.script.trim_start().starts_with("switch(") || request.script.trim_start().starts_with("throw ") || request.script.trim_start().starts_with("try ") - || request.script.trim_start().starts_with("try{"); + || request.script.trim_start().starts_with("try{") + || request.script.trim_start().starts_with("do ") + || request.script.trim_start().starts_with("do{"); // Only prepend "return" for pure expressions (no statements, no existing return at start) // Use starts_with("return") not contains("return") to avoid false positives like "returnData" let has_return = request.script.trim_start().starts_with("return"); From 2de3568d2e0117e823a84a667a06a9b7acd5f10c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 19:56:19 +0100 Subject: [PATCH 036/130] refactor(tauri): enhance script execution logic for function and statement detection - Improved the `execute` function to better differentiate between function and statement scripts. The logic now accurately identifies function scripts with arguments and wraps statement/expression scripts in an IIFE, ensuring proper execution context. This change enhances the clarity and reliability of script evaluation in Tauri. --- packages/tauri-plugin/src/commands.rs | 104 +++++++++++++------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 9d5f8d4f6..56c7c12fa 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -59,67 +59,67 @@ pub(crate) async fn execute( let (tx, rx) = tokio::sync::oneshot::channel(); let tx = Arc::new(Mutex::new(Some(tx))); - // Build the script with args if offered - // For args: inject Tauri APIs as first param, then pass user args - // For no-args: preserve the script expression and evaluate it below - let script = if !request.args.is_empty() { + // Build the script with args if offered. + // Callable scripts receive Tauri APIs + user args. + // Statement/expression scripts run as body code (with args exposed as __wdio_args). + let trimmed = request.script.trim(); + let has_keyword_prefix = |source: &str, keyword: &str| { + source + .strip_prefix(keyword) + .and_then(|rest| rest.chars().next()) + .map(|ch| ch.is_whitespace() || ch == '(') + .unwrap_or(false) + }; + let is_function = trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async"); + + let script = if !request.args.is_empty() && is_function { let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; format!( "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", request.script, args_json ) + } else if is_function { + // Function script - call it with Tauri APIs injected + format!( + "(function() {{ return ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", + request.script + ) } else { - // No args - detect function vs statement scripts - // Only check for function-like prefixes at the START of the script - // (not anywhere in the script, which would catch arrow functions inside expressions) - let trimmed = request.script.trim(); - let has_keyword_prefix = |source: &str, keyword: &str| { - source - .strip_prefix(keyword) - .and_then(|rest| rest.chars().next()) - .map(|ch| ch.is_whitespace() || ch == '(') - .unwrap_or(false) + // Statement/expression-style script - wrap in block-body IIFE + let has_statement = request.script.trim_start().starts_with("const ") + || request.script.trim_start().starts_with("let ") + || request.script.trim_start().starts_with("var ") + || request.script.trim_start().starts_with("if ") + || request.script.trim_start().starts_with("if(") + || request.script.trim_start().starts_with("for ") + || request.script.trim_start().starts_with("for(") + || request.script.trim_start().starts_with("while ") + || request.script.trim_start().starts_with("while(") + || request.script.trim_start().starts_with("switch ") + || request.script.trim_start().starts_with("switch(") + || request.script.trim_start().starts_with("throw ") + || request.script.trim_start().starts_with("try ") + || request.script.trim_start().starts_with("try{") + || request.script.trim_start().starts_with("do ") + || request.script.trim_start().starts_with("do{"); + // Only prepend "return" for pure expressions (no statements, no existing return at start) + // Use starts_with("return") not contains("return") to avoid false positives like "returnData" + let has_return = request.script.trim_start().starts_with("return"); + let body = if !has_statement && !has_return { + // Pure expression - add return so it evaluates and returns + format!("return {};", request.script) + } else { + // Has statements or already has return - pass through as-is + request.script.clone() }; - let is_function = trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async"); - if is_function { - // Function script - call it with Tauri APIs injected - format!( - "(function() {{ return ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", - request.script - ) + + if !request.args.is_empty() { + let args_json = serde_json::to_string(&request.args) + .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; + format!("(async () => {{ const __wdio_args = {}; {body} }})()", args_json) } else { - // Statement/expression-style script - wrap in block-body IIFE - let has_statement = request.script.trim_start().starts_with("const ") - || request.script.trim_start().starts_with("let ") - || request.script.trim_start().starts_with("var ") - || request.script.trim_start().starts_with("if ") - || request.script.trim_start().starts_with("if(") - || request.script.trim_start().starts_with("for ") - || request.script.trim_start().starts_with("for(") - || request.script.trim_start().starts_with("while ") - || request.script.trim_start().starts_with("while(") - || request.script.trim_start().starts_with("switch ") - || request.script.trim_start().starts_with("switch(") - || request.script.trim_start().starts_with("throw ") - || request.script.trim_start().starts_with("try ") - || request.script.trim_start().starts_with("try{") - || request.script.trim_start().starts_with("do ") - || request.script.trim_start().starts_with("do{"); - // Only prepend "return" for pure expressions (no statements, no existing return at start) - // Use starts_with("return") not contains("return") to avoid false positives like "returnData" - let has_return = request.script.trim_start().starts_with("return"); - let body = if !has_statement && !has_return { - // Pure expression - add return so it evaluates and returns - format!("return {};", request.script) - } else { - // Has statements or already has return - pass through as-is - request.script.clone() - }; - format!( - "(async () => {{ {0} }})()", - body - ) + format!("(async () => {{ {body} }})()") } }; From caca107e787af9a473560cef714ca0f5678375b9 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 20:11:09 +0100 Subject: [PATCH 037/130] refactor(tauri): enhance function detection in script execution logic - Updated the `execute` function to improve the detection of function scripts by adding support for generator functions (`function*`). This change enhances the accuracy of script evaluation and ensures that various function types are correctly identified during execution. --- packages/tauri-plugin/src/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 56c7c12fa..3606bda99 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -70,7 +70,7 @@ pub(crate) async fn execute( .map(|ch| ch.is_whitespace() || ch == '(') .unwrap_or(false) }; - let is_function = trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "async"); + let is_function = trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "function*") || has_keyword_prefix(trimmed, "async"); let script = if !request.args.is_empty() && is_function { let args_json = serde_json::to_string(&request.args) From 9c76b98320ee334c9849e3190415b20c7f644a2f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 20:13:31 +0100 Subject: [PATCH 038/130] refactor(tauri): improve async script execution for embedded provider - Updated the `execute` method in `TauriWorkerService` to utilize `executeAsync` for both function and string scripts when using the embedded driver provider. This change enhances the handling of script execution by wrapping scripts in an IIFE, ensuring proper argument handling and error reporting. - Adjusted tests to verify that the new execution logic correctly processes scripts and handles async results, improving overall reliability. --- packages/tauri-service/src/service.ts | 27 ++++++++++++++++++--- packages/tauri-service/test/service.spec.ts | 14 ++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index bca5f0fcd..0c1e159e5 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -311,7 +311,6 @@ export default class TauriWorkerService { return; } - const originalExecute = browser.execute.bind(browser); const originalExecuteAsync = (browser.executeAsync as typeof browser.execute).bind(browser); const isEmbedded = this.driverProvider === 'embedded'; @@ -320,9 +319,29 @@ export default class TauriWorkerService { ...args: InnerArguments ): Promise { if (isEmbedded) { - // For embedded WebDriver: pass the script through untouched so WDIO - // can invoke function scripts correctly. - return originalExecute(script as Parameters[0], ...args) as Promise; + const scriptString = typeof script === 'function' ? script.toString() : script; + const wrappedScript = + typeof script === 'function' + ? ` + Promise.resolve((${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1))).then( + (r) => arguments[arguments.length - 1](r), + (e) => arguments[arguments.length - 1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) + ); + ` + : ` + (async function() { ${scriptString} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( + (r) => arguments[arguments.length - 1](r), + (e) => arguments[arguments.length - 1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) + ); + `; + const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( + wrappedScript, + ...args, + ); + if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { + throw new Error((asyncResult as { __wdio_error__: string }).__wdio_error__); + } + return asyncResult as ReturnValue; } // For functions: use .toString() - produces valid JS function source diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 04b7991a1..f3a3b79a1 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -130,25 +130,31 @@ describe('TauriWorkerService', () => { it('should pass string scripts as-is for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - expect(mockExecute).toHaveBeenCalledWith('return document.title'); + expect(mockExecuteAsync).toHaveBeenCalled(); + const callArgs = mockExecuteAsync.mock.calls[0]; + expect(callArgs[0]).toContain('(async function() { return document.title })'); + expect(mockExecute).not.toHaveBeenCalled(); }); it('should pass function scripts as-is for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('Promise.resolve(('), 1, 2); + expect(mockExecute).not.toHaveBeenCalled(); }); }); From 4976d20cde68ef74896210d95dc0f02c054b1d98 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 20:27:24 +0100 Subject: [PATCH 039/130] refactor(tauri): improve function detection in script execution logic - Enhanced the `execute` function to detect arrow functions (=>) in addition to existing function types. This update broadens the criteria for identifying function scripts, improving the accuracy of script evaluation and ensuring that various function formats are correctly recognized during execution. --- packages/tauri-plugin/src/commands.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 3606bda99..f430aaeeb 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -70,7 +70,12 @@ pub(crate) async fn execute( .map(|ch| ch.is_whitespace() || ch == '(') .unwrap_or(false) }; - let is_function = trimmed.starts_with('(') || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "function*") || has_keyword_prefix(trimmed, "async"); + // Check for arrow functions (=>) anywhere in the script - catches single-param arrows like "x => x + 1" + let is_function = trimmed.starts_with('(') + || trimmed.contains("=>") + || has_keyword_prefix(trimmed, "function") + || has_keyword_prefix(trimmed, "function*") + || has_keyword_prefix(trimmed, "async"); let script = if !request.args.is_empty() && is_function { let args_json = serde_json::to_string(&request.args) From 70cfca5f72524c8f3ae0c15ba15e5a5308a372f3 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 20:42:10 +0100 Subject: [PATCH 040/130] refactor(tauri): simplify script execution for embedded provider - Updated the `execute` method in `TauriWorkerService` to directly pass scripts to the `execute` function for the embedded driver provider, eliminating the previous wrapping logic. This change enhances the handling of both string and function scripts, ensuring they are executed correctly without unnecessary transformations. - Adjusted tests to verify that the new execution logic correctly processes scripts, improving overall reliability. --- packages/tauri-service/src/service.ts | 30 +++++---------------- packages/tauri-service/test/service.spec.ts | 14 +++------- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 0c1e159e5..a23e8e581 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -311,6 +311,7 @@ export default class TauriWorkerService { return; } + const originalExecute = browser.execute.bind(browser); const originalExecuteAsync = (browser.executeAsync as typeof browser.execute).bind(browser); const isEmbedded = this.driverProvider === 'embedded'; @@ -319,29 +320,9 @@ export default class TauriWorkerService { ...args: InnerArguments ): Promise { if (isEmbedded) { - const scriptString = typeof script === 'function' ? script.toString() : script; - const wrappedScript = - typeof script === 'function' - ? ` - Promise.resolve((${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1))).then( - (r) => arguments[arguments.length - 1](r), - (e) => arguments[arguments.length - 1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) - ); - ` - : ` - (async function() { ${scriptString} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( - (r) => arguments[arguments.length - 1](r), - (e) => arguments[arguments.length - 1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) - ); - `; - const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( - wrappedScript, - ...args, - ); - if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { - throw new Error((asyncResult as { __wdio_error__: string }).__wdio_error__); - } - return asyncResult as ReturnValue; + // For embedded WebDriver: pass the script through untouched so WDIO + // can invoke function scripts correctly. + return originalExecute(script as Parameters[0], ...args) as Promise; } // For functions: use .toString() - produces valid JS function source @@ -352,9 +333,10 @@ export default class TauriWorkerService { // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute if (typeof script === 'function') { // Function scripts: use executeAsync with .then() callbacks to handle async results + // Wrap in Promise.resolve to handle both sync and async function return values const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - (${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( + Promise.resolve((${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1))).then( (r) => arguments[arguments.length-1](r), (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) ); diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index f3a3b79a1..04b7991a1 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -130,31 +130,25 @@ describe('TauriWorkerService', () => { it('should pass string scripts as-is for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); + const mockBrowser = createMockBrowser({ execute: mockExecute }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - expect(mockExecuteAsync).toHaveBeenCalled(); - const callArgs = mockExecuteAsync.mock.calls[0]; - expect(callArgs[0]).toContain('(async function() { return document.title })'); - expect(mockExecute).not.toHaveBeenCalled(); + expect(mockExecute).toHaveBeenCalledWith('return document.title'); }); it('should pass function scripts as-is for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); + const mockBrowser = createMockBrowser({ execute: mockExecute }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('Promise.resolve(('), 1, 2); - expect(mockExecute).not.toHaveBeenCalled(); + expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); }); }); From e562c4f3f93c21c88ebc00238947ef59709a57bf Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 21:42:54 +0100 Subject: [PATCH 041/130] refactor(electron): enhance script execution handling in execute function - Introduced a new `wrapStringScript` function to improve the execution of string scripts by correctly wrapping pure expressions and statement scripts in async IIFEs. This change ensures that function-like strings are passed through as-is while enhancing the overall reliability of script execution in Electron. - Updated the `execute` method to utilize the new wrapping logic, improving clarity and correctness in script evaluation. --- .../electron-service/src/commands/execute.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index a25ac5a8f..32d773816 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -14,7 +14,13 @@ export async function execute( throw new Error('WDIO browser is not yet initialised'); } - const scriptString = typeof script === 'function' ? script.toString() : script; + /** + * Wrap string scripts for proper execution + * - Function-like strings (() => ..., function() {}, async () =>): pass through as-is + * - Pure expressions (e.g., "1 + 2 + 3"): add return and wrap in IIFE + * - Statement scripts (e.g., "return 42", "const x = 1"): wrap in IIFE without adding return + */ + const scriptString = typeof script === 'function' ? script.toString() : wrapStringScript(script); const returnValue = await browser.execute( function executeWithinElectron(script: string, ...args) { @@ -26,3 +32,37 @@ export async function execute( return (returnValue as ReturnValue) ?? undefined; } + +/** + * Wrap string scripts in async IIFE for proper execution in Electron + * - Function-like strings (() => ..., function() {}, async () =>): pass through as-is + * - Pure expressions (e.g., "1 + 2 + 3"): add return and wrap in IIFE + * - Statement scripts (e.g., "return 42", "const x = 1"): wrap in IIFE without adding return + */ +function wrapStringScript(script: string): string { + const trimmed = script.trim(); + + // Check if it's a function-like string (should be passed through as-is) + const isFunctionLike = + trimmed.startsWith('(') || + trimmed.startsWith('function') || + trimmed.startsWith('async') || + /\w+\s*=>/.test(trimmed); // single-param arrow like "x => x + 1" + + if (isFunctionLike) { + // Function-like string - pass through as-is (CDP can handle it) + return script; + } + + // Check if script has statements (semicolons or statement keywords at start) + const hasSemicolon = trimmed.includes(';'); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)\s/.test(trimmed); + + if (hasSemicolon || hasStatementKeyword) { + // Multi-statement or statement-style script - wrap in async IIFE + return `(async () => { ${script} })()`; + } else { + // Pure expression - add return and wrap in async IIFE + return `(async () => { return ${script}; })()`; + } +} From 2c9ca717f6f411f4fa70290116f41ce986f1339a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 9 Apr 2026 21:48:57 +0100 Subject: [PATCH 042/130] refactor(tauri): enhance arrow function detection in script execution - Improved the `execute` function to accurately detect arrow functions at the start of scripts, including both parenthesized and single-parameter formats. This change broadens the criteria for identifying function scripts, enhancing the reliability of script evaluation and reducing false positives in function detection. --- packages/tauri-plugin/src/commands.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index f430aaeeb..739084ad2 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -70,12 +70,24 @@ pub(crate) async fn execute( .map(|ch| ch.is_whitespace() || ch == '(') .unwrap_or(false) }; - // Check for arrow functions (=>) anywhere in the script - catches single-param arrows like "x => x + 1" + // Detect arrow functions at START of script: + // - "(args) => ..." (parenthesized params) + // - "param => ..." (single param, alphanumeric start) + // Don't use contains("=>") as it falsely catches expressions like "return items.filter(x => x > 0).length" + let starts_with_paren_arrow = trimmed.starts_with('(') && trimmed.contains("=>"); + let single_param_arrow = trimmed.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') + && trimmed.contains("=>") + && trimmed.find("=>").map(|pos| { + let before = trimmed[..pos].trim(); + // Single param: alphanumeric chars only, no spaces (except for the param name) + !before.is_empty() && !before.contains(' ') + }).unwrap_or(false); let is_function = trimmed.starts_with('(') - || trimmed.contains("=>") || has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "function*") - || has_keyword_prefix(trimmed, "async"); + || has_keyword_prefix(trimmed, "async") + || starts_with_paren_arrow + || single_param_arrow; let script = if !request.args.is_empty() && is_function { let args_json = serde_json::to_string(&request.args) From 64a9684802b71a22950321701ecc025b786be767 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 18:15:09 +0100 Subject: [PATCH 043/130] refactor(electron): improve script statement detection in wrapStringScript - Enhanced the `wrapStringScript` function to more accurately identify statement scripts by checking for semicolons outside of string literals and template literals. This change improves the logic for wrapping scripts in async IIFEs, ensuring correct execution context for both expression and statement styles. - Added a new utility function, `hasSemicolonOutsideQuotes`, to facilitate the detection of semicolons while considering nested quotes and brackets. - Expanded test coverage to validate the new detection logic and ensure proper handling of various script patterns. --- .../electron-service/src/commands/execute.ts | 51 +++++++++++++++++-- .../test/commands/execute.spec.ts | 42 +++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 32d773816..b025c6834 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -54,11 +54,14 @@ function wrapStringScript(script: string): string { return script; } - // Check if script has statements (semicolons or statement keywords at start) - const hasSemicolon = trimmed.includes(';'); - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)\s/.test(trimmed); + // Check if script has statements - be smarter about semicolons and keywords + // Only count semicolons outside of quotes/brackets + const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); + // Match statement keywords at start: const, let, var, if, for, while, switch, throw, try, do + // Also catch return followed by ( or whitespace (e.g., "return 42" or "return(expr)") + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return[(\s])/.test(trimmed); - if (hasSemicolon || hasStatementKeyword) { + if (hasRealSemicolon || hasStatementKeyword) { // Multi-statement or statement-style script - wrap in async IIFE return `(async () => { ${script} })()`; } else { @@ -66,3 +69,43 @@ function wrapStringScript(script: string): string { return `(async () => { return ${script}; })()`; } } + +/** + * Check for semicolons outside of string literals and template literals + */ +function hasSemicolonOutsideQuotes(str: string): boolean { + let inSingleQuote = false; + let inDoubleQuote = false; + let inTemplateLiteral = false; + let bracketDepth = 0; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + const prevChar = i > 0 ? str[i - 1] : ''; + + // Handle escape sequences + if (prevChar === '\\') continue; + + // Track quote states + if (char === "'" && !inDoubleQuote && !inTemplateLiteral) { + inSingleQuote = !inSingleQuote; + } else if (char === '"' && !inSingleQuote && !inTemplateLiteral) { + inDoubleQuote = !inDoubleQuote; + } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { + inTemplateLiteral = !inTemplateLiteral; + } + + // Track bracket depth (for handling object/array literals inside quotes) + if (!inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { + if (char === '{' || char === '[' || char === '(') bracketDepth++; + if (char === '}' || char === ']' || char === ')') bracketDepth--; + } + + // Check for semicolon outside of quotes/brackets + if (char === ';' && bracketDepth === 0 && !inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { + return true; + } + } + + return false; +} diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 70e1c78ca..7f5dc2021 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -77,4 +77,46 @@ describe('execute Command', () => { await execute(globalThis.browser, script); expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), script); }); + + it('should wrap expression-style string scripts in async IIFE with return', async () => { + await execute(globalThis.browser, '1 + 2 + 3'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return 1 + 2 + 3; })()'), + ); + }); + + it('should wrap statement-style string scripts in async IIFE without adding return', async () => { + await execute(globalThis.browser, 'return 42'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return 42 })()'), + ); + }); + + it('should wrap multi-statement string scripts in async IIFE', async () => { + await execute(globalThis.browser, 'const x = 10; const y = 20; return x + y;'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => {'), + ); + }); + + it('should handle return(expr) pattern without adding extra return', async () => { + // return() pattern should be treated as statement, not expression + await execute(globalThis.browser, 'return(document.title)'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return(document.title) })()'), + ); + }); + + it('should not false-positive on semicolons inside string literals', async () => { + // Semicolons inside string literals should not trigger statement detection + await execute(globalThis.browser, '"foo;bar"'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return "foo;bar"; })()'), + ); + }); }); From d0172ef365579dd32e4cd9534ed60e48a8088144 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 18:16:01 +0100 Subject: [PATCH 044/130] refactor(tauri): improve error handling for string scripts with arguments - Enhanced the `execute` function to return an error when a string script is provided with arguments, clarifying that only callable functions are supported with arguments. This change improves the robustness of script execution and provides clearer feedback to users regarding script usage. --- packages/tauri-plugin/src/commands.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 739084ad2..911f41d1e 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -90,6 +90,7 @@ pub(crate) async fn execute( || single_param_arrow; let script = if !request.args.is_empty() && is_function { + // With args + callable function: inject Tauri APIs and pass user args let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; format!( @@ -97,11 +98,16 @@ pub(crate) async fn execute( request.script, args_json ) } else if is_function { - // Function script - call it with Tauri APIs injected + // Function script (no args): call it with Tauri APIs injected format!( "(function() {{ return ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", request.script ) + } else if !request.args.is_empty() { + // String script with args (not a callable function) - return error + return Err(crate::Error::ExecuteError( + "browser.execute(string, args) is not supported. Use browser.execute(function, ...args) instead.".to_string(), + )); } else { // Statement/expression-style script - wrap in block-body IIFE let has_statement = request.script.trim_start().starts_with("const ") From e45001bbc66de0bf1dd70d149cbef22e5cd7520d Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 18:35:13 +0100 Subject: [PATCH 045/130] refactor(electron): refine arrow function detection in wrapStringScript - Updated the `wrapStringScript` function to ensure it only matches single-parameter arrow functions at the start of the script. This change improves the accuracy of function-like string detection, preventing false positives from arrow functions within expressions. The enhancement contributes to more reliable script evaluation in Electron. --- packages/electron-service/src/commands/execute.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index b025c6834..d88636f58 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -43,11 +43,13 @@ function wrapStringScript(script: string): string { const trimmed = script.trim(); // Check if it's a function-like string (should be passed through as-is) + // Only match single-param arrow at START of script (e.g., "x => x + 1") + // Don't match arrows inside expressions like "return items.filter(x => x > 0)" const isFunctionLike = trimmed.startsWith('(') || trimmed.startsWith('function') || trimmed.startsWith('async') || - /\w+\s*=>/.test(trimmed); // single-param arrow like "x => x + 1" + /^(\w+)\s*=>/.test(trimmed); // single-param arrow at START like "x => x + 1" if (isFunctionLike) { // Function-like string - pass through as-is (CDP can handle it) From af003eb6917df101bc4fedc4ad0e2e2420bd7091 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 18:36:12 +0100 Subject: [PATCH 046/130] refactor(tauri): ensure async handling in script execution - Updated the `execute` function to await the result of function calls within injected scripts. This change improves the handling of asynchronous operations in script execution, ensuring that promises are correctly resolved before returning results. The enhancement contributes to more reliable script behavior in Tauri. --- packages/tauri-plugin/src/commands.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 911f41d1e..86644c843 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -91,16 +91,18 @@ pub(crate) async fn execute( let script = if !request.args.is_empty() && is_function { // With args + callable function: inject Tauri APIs and pass user args + // Must await the result because core.invoke returns a Promise let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; format!( - "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", + "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return await __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", request.script, args_json ) } else if is_function { // Function script (no args): call it with Tauri APIs injected + // Must await the result because core.invoke returns a Promise format!( - "(function() {{ return ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", + "(function() {{ return await ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", request.script ) } else if !request.args.is_empty() { From c718b0a21bad106b5854f89503b03fad1bf1e9b9 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 19:38:30 +0100 Subject: [PATCH 047/130] refactor(tauri): ensure async function handling in script execution - Updated the `execute` function to consistently use `async` for wrapping function calls in injected scripts. This change enhances the handling of asynchronous operations, ensuring that all function-like scripts are correctly awaited, thereby improving the reliability of script execution in Tauri. --- packages/tauri-plugin/src/commands.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 86644c843..1da6f753a 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -82,9 +82,9 @@ pub(crate) async fn execute( // Single param: alphanumeric chars only, no spaces (except for the param name) !before.is_empty() && !before.contains(' ') }).unwrap_or(false); - let is_function = trimmed.starts_with('(') - || has_keyword_prefix(trimmed, "function") - || has_keyword_prefix(trimmed, "function*") + let is_function = trimmed.starts_with('(') + || has_keyword_prefix(trimmed, "function") + || has_keyword_prefix(trimmed, "function*") || has_keyword_prefix(trimmed, "async") || starts_with_paren_arrow || single_param_arrow; @@ -95,14 +95,14 @@ pub(crate) async fn execute( let args_json = serde_json::to_string(&request.args) .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; format!( - "(function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return await __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", + "(async function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return await __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", request.script, args_json ) } else if is_function { // Function script (no args): call it with Tauri APIs injected // Must await the result because core.invoke returns a Promise format!( - "(function() {{ return await ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", + "(async function() {{ return await ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", request.script ) } else if !request.args.is_empty() { From 7979023eb14caf2b80b89f7484ecb13561055469 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 19:39:20 +0100 Subject: [PATCH 048/130] chore: reorder e2e scripts --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5682b3c21..4962616c7 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,12 @@ "e2e": "pnpm --filter @repo/e2e run test", "e2e:electron-builder": "pnpm protocol-install:electron-builder && pnpm --filter @repo/e2e run test:e2e:electron-builder", "e2e:electron-forge": "pnpm protocol-install:electron-forge && pnpm --filter @repo/e2e run test:e2e:electron-forge", - "e2e:multiremote": "pnpm --filter @repo/e2e run test:e2e:multiremote", "e2e:electron-script": "pnpm --filter @repo/e2e run test:e2e:electron-script", + "e2e:multiremote": "pnpm --filter @repo/e2e run test:e2e:multiremote", "e2e:standalone": "pnpm --filter @repo/e2e run test:e2e:standalone", - "e2e:window": "pnpm --filter @repo/e2e run test:e2e:window", "e2e:tauri": "pnpm protocol-install:tauri && pnpm --filter @repo/e2e run test:e2e:tauri", "e2e:tauri-basic": "pnpm protocol-install:tauri && pnpm --filter @repo/e2e run test:e2e:tauri-basic", + "e2e:window": "pnpm --filter @repo/e2e run test:e2e:window", "protocol-install:tauri": "pnpm --filter @repo/e2e run protocol-install:tauri", "protocol-install:electron-builder": "pnpm --filter @repo/e2e run protocol-install:electron-builder", "protocol-install:electron-forge": "pnpm --filter @repo/e2e run protocol-install:electron-forge", From 648da6d0adede7f65c2d8a22abec17e0aee13100 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 20:02:54 +0100 Subject: [PATCH 049/130] refactor(tauri): refine function detection in script execution - Updated the `execute` function to improve the detection of function-like patterns by removing the `starts_with('(')` check. This change ensures that only valid function declarations, including async and arrow functions, are recognized, enhancing the accuracy of script evaluation and reducing false positives. --- packages/tauri-plugin/src/commands.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 1da6f753a..8c4e730bd 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -82,9 +82,10 @@ pub(crate) async fn execute( // Single param: alphanumeric chars only, no spaces (except for the param name) !before.is_empty() && !before.contains(' ') }).unwrap_or(false); - let is_function = trimmed.starts_with('(') - || has_keyword_prefix(trimmed, "function") - || has_keyword_prefix(trimmed, "function*") + // Only detect function-like patterns: function, async, arrow functions + // Don't use starts_with('(') as it catches any parenthesized expression like (document.title) + let is_function = has_keyword_prefix(trimmed, "function") + || has_keyword_prefix(trimmed, "function*") || has_keyword_prefix(trimmed, "async") || starts_with_paren_arrow || single_param_arrow; From 5a993296cf1cdba8c6beda89e99394b00ab9aaf1 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 20:03:21 +0100 Subject: [PATCH 050/130] refactor(electron): enhance script parsing for string inputs - Updated the `execute` function to handle string scripts by converting them to strings before parsing. This change ensures that non-function strings are correctly identified, improving the robustness of script execution and aligning with expected behavior. --- packages/electron-service/src/commands/executeCdp.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 334d3e71c..baceb10d5 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -47,7 +47,9 @@ export async function execute( return undefined; } - const functionDeclaration = getCachedOrParse(script.toString()); + // Handle string scripts - convert to string and let the normal parsing handle them + // The parsing will fail for non-function strings, which is the expected behavior + const functionDeclaration = getCachedOrParse(typeof script === 'string' ? script : script.toString()); const argsArray = args.map((arg) => ({ value: arg })); log.debug('Executing script length:', Buffer.byteLength(functionDeclaration, 'utf-8')); From 6b91d62e3c916da16058dbd866d180e165c131bc Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 20:26:03 +0100 Subject: [PATCH 051/130] refactor(tauri): simplify script handling in execute function - Updated the `execute` function to streamline the handling of callable functions and string scripts. The changes ensure that scripts are passed through as-is when they are valid functions, while also improving the clarity of the code by removing unnecessary wrapping logic. This enhancement contributes to more efficient script execution and aligns with the existing behavior of the Guest-js integration. --- packages/tauri-plugin/src/commands.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 8c4e730bd..47596d94a 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -91,21 +91,14 @@ pub(crate) async fn execute( || single_param_arrow; let script = if !request.args.is_empty() && is_function { - // With args + callable function: inject Tauri APIs and pass user args - // Must await the result because core.invoke returns a Promise - let args_json = serde_json::to_string(&request.args) - .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; - format!( - "(async function() {{ const __wdio_fn = ({}); const __wdio_args = {}; return await __wdio_fn({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}, ...__wdio_args); }})()", - request.script, args_json - ) + // With args + callable function - pass through as-is + // Guest-js already handles wrapping with Tauri API injection + // The script parameter already contains the user's function + request.script.clone() } else if is_function { - // Function script (no args): call it with Tauri APIs injected - // Must await the result because core.invoke returns a Promise - format!( - "(async function() {{ return await ({})({{ core: window.__TAURI__?.core, event: window.__TAURI__?.event, log: window.__TAURI__?.log }}); }})()", - request.script - ) + // Function script with no args - pass through as-is + // Guest-js already wraps it with proper Tauri API injection + request.script.clone() } else if !request.args.is_empty() { // String script with args (not a callable function) - return error return Err(crate::Error::ExecuteError( From 21b3c04a247f6b2c53fc2ed34c3fcbe33d2cf108 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 20:27:52 +0100 Subject: [PATCH 052/130] refactor(electron): improve statement keyword detection in wrapStringScript - Updated the `wrapStringScript` function to use a word boundary check for statement keywords. This change prevents incorrect matches with expressions, enhancing the accuracy of script detection and ensuring proper handling of various script patterns. --- packages/electron-service/src/commands/execute.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index d88636f58..33cebc5f9 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -60,8 +60,8 @@ function wrapStringScript(script: string): string { // Only count semicolons outside of quotes/brackets const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); // Match statement keywords at start: const, let, var, if, for, while, switch, throw, try, do - // Also catch return followed by ( or whitespace (e.g., "return 42" or "return(expr)") - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return[(\s])/.test(trimmed); + // Use word boundary check to avoid matching expressions like "document.title" (do) or "forEach()" (for) + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); if (hasRealSemicolon || hasStatementKeyword) { // Multi-statement or statement-style script - wrap in async IIFE From f8788577602d9f9633a97e32d74a5d04cbd14410 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 20:28:12 +0100 Subject: [PATCH 053/130] refactor(tauri): enhance arrow function detection in script execution - Introduced a new utility function to check for arrow functions outside of string literals, improving the accuracy of function detection in the `execute` function. This change prevents false positives from arrow functions within strings and ensures that only valid function declarations are recognized at the start of the script. --- packages/tauri-plugin/src/commands.rs | 40 ++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 47596d94a..520235365 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -70,13 +70,45 @@ pub(crate) async fn execute( .map(|ch| ch.is_whitespace() || ch == '(') .unwrap_or(false) }; - // Detect arrow functions at START of script: + + // Check if => appears outside of string literals (to avoid false positives like "foo"=>"bar") + fn contains_arrow_outside_quotes(s: &str) -> bool { + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut in_backtick = false; + + for (i, c) in s.char_indices() { + // Skip escaped characters + if i > 0 && s.chars().nth(i - 1) == Some('\\') { + continue; + } + + // Track quote state + if c == '\'' && !in_double_quote && !in_backtick { + in_single_quote = !in_single_quote; + } else if c == '"' && !in_single_quote && !in_backtick { + in_double_quote = !in_double_quote; + } else if c == '`' && !in_single_quote && !in_double_quote { + in_backtick = !in_backtick; + } + + // Check for => outside of quotes + if c == '=' && !in_single_quote && !in_double_quote && !in_backtick { + if s.len() > i + 1 && s.chars().nth(i + 1) == Some('>') { + return true; + } + } + } + false + } + + // Check for arrow functions at START of script: // - "(args) => ..." (parenthesized params) // - "param => ..." (single param, alphanumeric start) - // Don't use contains("=>") as it falsely catches expressions like "return items.filter(x => x > 0).length" - let starts_with_paren_arrow = trimmed.starts_with('(') && trimmed.contains("=>"); + // Only detect arrows that are NOT inside string literals + let starts_with_paren_arrow = trimmed.starts_with('(') && contains_arrow_outside_quotes(trimmed); let single_param_arrow = trimmed.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') - && trimmed.contains("=>") + && contains_arrow_outside_quotes(trimmed) && trimmed.find("=>").map(|pos| { let before = trimmed[..pos].trim(); // Single param: alphanumeric chars only, no spaces (except for the param name) From 612ae1c13ab20552ddf8530958388a63d84dfc34 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 20:29:20 +0100 Subject: [PATCH 054/130] test(electron): add tests for false positive handling in script execution - Introduced new test cases in `execute.spec.ts` to verify that specific expressions, such as `document.title`, `forEach()`, and `trySomething()`, are correctly treated as expressions and not statements. These tests ensure that the `execute` function properly adds return statements for expressions that could be misidentified due to their prefixes, enhancing the accuracy of script execution. --- .../test/commands/execute.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 7f5dc2021..0969bc57a 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -119,4 +119,28 @@ describe('execute Command', () => { expect.stringContaining('(async () => { return "foo;bar"; })()'), ); }); + + it('should treat document.title as expression (do prefix false positive)', async () => { + // "document.title" starts with "do" but is NOT a statement - should add return + await execute(globalThis.browser, 'document.title'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return document.title; })()'), + ); + }); + + it('should treat forEach() as expression (for prefix false positive)', async () => { + // "[1,2,3].forEach()" starts with "for" but is NOT a statement - should add return + await execute(globalThis.browser, '[1,2,3].forEach(x => x)'); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('return')); + }); + + it('should treat trySomething() as expression (try prefix false positive)', async () => { + // "trySomething()" starts with "try" but is NOT a statement - should add return + await execute(globalThis.browser, 'trySomething()'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return trySomething(); })()'), + ); + }); }); From 21f14e026e5a7fffc208b5611c731dfaa40971b3 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 22:52:35 +0100 Subject: [PATCH 055/130] refactor(tauri): clean up comments in execute function - Removed redundant comment regarding the script parameter in the `execute` function. This change enhances code clarity by eliminating unnecessary information, allowing for a more straightforward understanding of the function's behavior. --- packages/tauri-plugin/src/commands.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 520235365..9eac628bc 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -125,7 +125,6 @@ pub(crate) async fn execute( let script = if !request.args.is_empty() && is_function { // With args + callable function - pass through as-is // Guest-js already handles wrapping with Tauri API injection - // The script parameter already contains the user's function request.script.clone() } else if is_function { // Function script with no args - pass through as-is From 1029e1f47633540e5f7c5e443adca361cf5dd633 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 22:54:51 +0100 Subject: [PATCH 056/130] refactor(tauri): enhance script wrapping logic in execute function - Improved the detection of function-like scripts in the `execute` function by implementing a more robust check for various function patterns. This change allows for appropriate wrapping of scripts that require Tauri API injection, while also ensuring that non-function expressions are evaluated directly. The updates enhance the clarity and reliability of script execution within the Tauri environment. --- packages/tauri-plugin/guest-js/index.ts | 83 +++++++++++++++++-------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 11bec9917..fd84dbb0a 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -128,35 +128,68 @@ export async function execute(script: string, ...args: unknown[]): Promise { - const __wdio_tauri = window.__TAURI__; - const __wdio_args = ${argsJson}; - - // Wait for window.__TAURI__.core.invoke to be available - // This handles the race condition where window.eval() runs before dynamic imports complete - if (!__wdio_tauri?.core?.invoke) { - // Wait up to 5 seconds for core.invoke to be set up - const startTime = Date.now(); - const timeout = 5000; - while (!__wdio_tauri?.core?.invoke && (Date.now() - startTime) < timeout) { - await new Promise(resolve => setTimeout(resolve, 50)); + // Check if script is a function-like string that needs Tauri API injection + // Function-like: starts with (, function, async, or single-param arrow like "x =>" + // Non-function: expressions like "1 + 2 + 3", statements like "return 42" + const trimmedScript = script.trim(); + const isFunctionLike = + trimmedScript.startsWith('(') || + trimmedScript.startsWith('function') || + trimmedScript.startsWith('async') || + /^(\w+)\s*=>/.test(trimmedScript) || + (trimmedScript.startsWith('(') && trimmedScript.includes('=>')); + + // Wrap the script appropriately based on type + let wrappedScript: string; + + if (isFunctionLike) { + // Function-like script - wrap with Tauri API injection + wrappedScript = ` + (async () => { + const __wdio_tauri = window.__TAURI__; + const __wdio_args = ${argsJson}; + + // Wait for window.__TAURI__.core.invoke to be available + if (!__wdio_tauri?.core?.invoke) { + const startTime = Date.now(); + const timeout = 5000; + while (!__wdio_tauri?.core?.invoke && (Date.now() - startTime) < timeout) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + if (!__wdio_tauri?.core?.invoke) { + throw new Error('window.__TAURI__.core.invoke not available after 5s timeout'); + } } + + // Execute as function with Tauri APIs + return await (${script})(__wdio_tauri, ...__wdio_args); + })() + `.trim(); + } else { + // Expression/statement script - evaluate directly without function call + // Pass __wdio_args for compatibility, but don't call script as function + wrappedScript = ` + (async () => { + const __wdio_tauri = window.__TAURI__; + const __wdio_args = ${argsJson}; + + // Wait for window.__TAURI__.core.invoke to be available if (!__wdio_tauri?.core?.invoke) { - throw new Error('window.__TAURI__.core.invoke not available after 5s timeout'); + const startTime = Date.now(); + const timeout = 5000; + while (!__wdio_tauri?.core?.invoke && (Date.now() - startTime) < timeout) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + if (!__wdio_tauri?.core?.invoke) { + throw new Error('window.__TAURI__.core.invoke not available after 5s timeout'); + } } - } - // Execute the script as a function with tauri as first arg, then spread additional args - // Await the result in case it's a Promise (most Tauri commands return Promises) - return await (${script})(__wdio_tauri, ...__wdio_args); - })() - `.trim(); + // Evaluate expression/statement directly + return await (async () => { ${script} })(); + })() + `.trim(); + } // Call the plugin command to execute the wrapped script // Tauri v2 plugin commands use format: plugin:plugin-name|command-name From e8357d448cfe2fa4024c3db49641f1612c602eea Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 23:11:15 +0100 Subject: [PATCH 057/130] refactor(electron): improve string script handling in execute function - Enhanced the `execute` function to wrap string scripts in an async IIFE before parsing, ensuring proper execution of both statement and expression scripts. This change prevents parsing errors and improves the reliability of script execution by accurately identifying and handling various script patterns. --- .../src/commands/executeCdp.ts | 90 ++++++++++++++++++- .../test/commands/executeCdp.spec.ts | 11 ++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index baceb10d5..a6a697e5a 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -47,9 +47,27 @@ export async function execute( return undefined; } - // Handle string scripts - convert to string and let the normal parsing handle them - // The parsing will fail for non-function strings, which is the expected behavior - const functionDeclaration = getCachedOrParse(typeof script === 'string' ? script : script.toString()); + // Handle string scripts - wrap them in async IIFE before parsing + // This prevents recast from trying to parse them as function definitions + // Only pass through to recast if it's clearly a function-like string that needs transformation + let functionDeclaration: string; + if (typeof script === 'string') { + const trimmed = script.trim(); + // Only let recast handle arrow functions starting with ( and containing => + // These get transformed to add electron parameter + const isArrowFunction = trimmed.startsWith('(') && trimmed.includes('=>') && !trimmed.includes('function'); + + if (isArrowFunction) { + // Arrow function - recast handles electron param injection + functionDeclaration = getCachedOrParse(script); + } else { + // Not a simple arrow function - wrap it ourselves + functionDeclaration = wrapStringScriptForCdp(script); + } + } else { + functionDeclaration = getCachedOrParse(script.toString()); + } + const argsArray = args.map((arg) => ({ value: arg })); log.debug('Executing script length:', Buffer.byteLength(functionDeclaration, 'utf-8')); @@ -67,6 +85,72 @@ export async function execute( return (result.result.value as ReturnValue) ?? undefined; } +/** + * Wrap string scripts in async IIFE for proper CDP execution + * Handles statement and expression scripts that would otherwise fail parsing + */ +function wrapStringScriptForCdp(script: string): string { + const trimmed = script.trim(); + + // Check if it's a simple arrow function that can be transformed by recast + // These patterns can be safely passed to recast which adds the electron parameter + const canRecastHandle = trimmed.startsWith('(') && trimmed.includes('=>') && !trimmed.includes('function'); + + if (canRecastHandle) { + // Simple arrow function - pass to recast for transformation + return script; + } + + // For all other strings, wrap them to avoid parsing errors + // This includes: + // - "function() {}" (recast handles these differently) + // - "1 + 2 + 3" (expression - would be called as function) + // - "return 42" (statement - parsing error) + // - "const x = 1" (statement - parsing error) + + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); + const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); + + if (hasRealSemicolon || hasStatementKeyword) { + return `(async () => { ${script} })()`; + } else { + return `(async () => { return ${script}; })()`; + } +} + +function hasSemicolonOutsideQuotes(str: string): boolean { + let inSingleQuote = false; + let inDoubleQuote = false; + let inTemplateLiteral = false; + let bracketDepth = 0; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + const prevChar = i > 0 ? str[i - 1] : ''; + + if (prevChar === '\\') continue; + + if (char === "'" && !inDoubleQuote && !inTemplateLiteral) { + inSingleQuote = !inSingleQuote; + } else if (char === '"' && !inSingleQuote && !inTemplateLiteral) { + inDoubleQuote = !inDoubleQuote; + } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { + inTemplateLiteral = !inTemplateLiteral; + } + + if (!inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { + if (char === '{' || char === '[' || char === '(') bracketDepth++; + if (char === '}' || char === ']' || char === ')') bracketDepth--; + } + + if (char === ';' && bracketDepth === 0 && !inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { + return true; + } + } + + return false; +} + async function syncMockStatus(args: unknown[]) { const mocks = mockStore.getMocks(); if (mocks.length > 0 && !isInternalCommand(args)) { diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 9be10b3b0..dd41b80fa 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -109,8 +109,15 @@ describe('execute Command', () => { }); }); - it('should throw error when pass not function definition', async () => { - await expect(() => execute(globalThis.browser, client, 'const a = 1')).rejects.toThrowError(); + it('should wrap statement-style string scripts in async IIFE', async () => { + // Statements like 'const a = 1' are now wrapped and executed properly (no longer throw) + await execute(globalThis.browser, client, 'const a = 1'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + functionDeclaration: expect.stringContaining('(async () => { const a = 1 })()'), + }), + ); }); it('should call `mock.update()` when mockStore has some mocks', async () => { From 310e0a1db886cf96551f8d6dd04e134123859f1a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 10 Apr 2026 23:16:13 +0100 Subject: [PATCH 058/130] refactor(electron, tauri): improve escape sequence handling in execute function - Enhanced the handling of escape sequences in the `execute` function for both Electron and Tauri. The updates ensure that characters preceded by an odd number of backslashes are correctly identified as escaped, preventing misinterpretation during script execution. This change improves the reliability of string parsing and enhances overall script handling. --- .../electron-service/src/commands/execute.ts | 17 ++++++++++++--- packages/tauri-plugin/src/commands.rs | 21 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 33cebc5f9..33e067d30 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -83,10 +83,21 @@ function hasSemicolonOutsideQuotes(str: string): boolean { for (let i = 0; i < str.length; i++) { const char = str[i]; - const prevChar = i > 0 ? str[i - 1] : ''; - // Handle escape sequences - if (prevChar === '\\') continue; + // Handle escape sequences - count consecutive backslashes before this char + // Odd number of backslashes means the character is escaped + if (char !== '\\') { + let backslashCount = 0; + let j = i - 1; + while (j >= 0 && str[j] === '\\') { + backslashCount++; + j--; + } + if (backslashCount % 2 === 1) { + // Odd backslashes = escaped character, skip + continue; + } + } // Track quote states if (char === "'" && !inDoubleQuote && !inTemplateLiteral) { diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 9eac628bc..aa81e5644 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -78,9 +78,24 @@ pub(crate) async fn execute( let mut in_backtick = false; for (i, c) in s.char_indices() { - // Skip escaped characters - if i > 0 && s.chars().nth(i - 1) == Some('\\') { - continue; + // Check for escape sequences - count consecutive backslashes before this position + // Odd number of backslashes means the character is escaped + if c != '=' { + let mut backslash_count = 0; + let mut j = i; + while j > 0 { + let prev_char = s.chars().nth(j - 1); + if prev_char == Some('\\') { + backslash_count += 1; + j -= 1; + } else { + break; + } + } + // Skip this character if it's preceded by an odd number of backslashes + if backslash_count % 2 == 1 { + continue; + } } // Track quote state From e609d1fc67e0af5ff262c0570b1f8d17b0294e62 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 00:32:14 +0100 Subject: [PATCH 059/130] refactor(tauri): streamline script evaluation in execute function - Updated the `execute` function to simplify the handling of expression and statement scripts. Instead of wrapping non-function scripts in an async IIFE, the function now directly passes the script to the Rust plugin for proper handling. This change enhances clarity and ensures that the Rust plugin manages async wrapping and return statements effectively. --- packages/tauri-plugin/guest-js/index.ts | 26 +++---------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index fd84dbb0a..e30cfcab3 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -166,29 +166,9 @@ export async function execute(script: string, ...args: unknown[]): Promise { - const __wdio_tauri = window.__TAURI__; - const __wdio_args = ${argsJson}; - - // Wait for window.__TAURI__.core.invoke to be available - if (!__wdio_tauri?.core?.invoke) { - const startTime = Date.now(); - const timeout = 5000; - while (!__wdio_tauri?.core?.invoke && (Date.now() - startTime) < timeout) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - if (!__wdio_tauri?.core?.invoke) { - throw new Error('window.__TAURI__.core.invoke not available after 5s timeout'); - } - } - - // Evaluate expression/statement directly - return await (async () => { ${script} })(); - })() - `.trim(); + // Expression/statement script - pass through to Rust plugin for proper wrapping + // The Rust plugin will handle adding return statements and async wrapping + wrappedScript = script; } // Call the plugin command to execute the wrapped script From 020f0bc77068c05b587a61256c1d42a92014d40e Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 00:52:36 +0100 Subject: [PATCH 060/130] refactor(tauri): update script wrapping for expression evaluation - Modified the `execute` function to wrap expression and statement scripts in an async IIFE with a return statement for proper evaluation. This change enhances the handling of scripts by ensuring that all expressions are correctly evaluated, improving the reliability of script execution within the Tauri environment. --- packages/tauri-plugin/guest-js/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index e30cfcab3..9c9199122 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -166,9 +166,8 @@ export async function execute(script: string, ...args: unknown[]): Promise { return ${script}; })()`; } // Call the plugin command to execute the wrapped script From c7f89f74ee034a25dd7bde6de2abeb64db20ac54 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 01:34:45 +0100 Subject: [PATCH 061/130] refactor(electron): simplify async function wrapping in script execution - Updated the `wrapStringScriptForCdp` function to streamline the wrapping of scripts. The changes remove unnecessary async IIFE syntax, directly returning async function declarations for improved clarity and execution. This enhancement ensures that scripts are handled more efficiently while maintaining proper evaluation. --- packages/electron-service/src/commands/executeCdp.ts | 4 ++-- packages/electron-service/test/commands/executeCdp.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index a6a697e5a..89195b2e0 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -112,9 +112,9 @@ function wrapStringScriptForCdp(script: string): string { const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); if (hasRealSemicolon || hasStatementKeyword) { - return `(async () => { ${script} })()`; + return `async () => { ${script} }`; } else { - return `(async () => { return ${script}; })()`; + return `async () => { return ${script}; }`; } } diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index dd41b80fa..26ce9a611 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -115,7 +115,7 @@ describe('execute Command', () => { expect(client.send).toHaveBeenCalledWith( 'Runtime.callFunctionOn', expect.objectContaining({ - functionDeclaration: expect.stringContaining('(async () => { const a = 1 })()'), + functionDeclaration: expect.stringContaining('async () => { const a = 1 }'), }), ); }); From f493f8e905e13e84d1e373ce7bf74ba9cc19b263 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 09:24:52 +0100 Subject: [PATCH 062/130] refactor(tauri): enhance script wrapping logic in execute function - Updated the `execute` function to improve the differentiation between statement and expression scripts. The new logic checks for statement keywords and semicolons to determine the appropriate wrapping method, ensuring accurate execution of scripts while maintaining clarity in the code structure. --- packages/tauri-plugin/guest-js/index.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 9c9199122..d7644c640 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -136,8 +136,7 @@ export async function execute(script: string, ...args: unknown[]): Promise/.test(trimmedScript) || - (trimmedScript.startsWith('(') && trimmedScript.includes('=>')); + /^(\w+)\s*=>/.test(trimmedScript); // Wrap the script appropriately based on type let wrappedScript: string; @@ -166,8 +165,19 @@ export async function execute(script: string, ...args: unknown[]): Promise { return ${script}; })()`; + // Expression/statement script - wrap appropriately based on script type + const trimmedScript = script.trim(); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test( + trimmedScript, + ); + const hasRealSemicolon = trimmedScript.includes(';'); + if (hasStatementKeyword || hasRealSemicolon) { + // Statement-style script - execute as-is + wrappedScript = `(async () => { ${script} })()`; + } else { + // Expression-style script - wrap with return for proper evaluation + wrappedScript = `(async () => { return ${script}; })()`; + } } // Call the plugin command to execute the wrapped script From e89c7dc295c05687917d4a644c930eeff8d3b612 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 09:25:16 +0100 Subject: [PATCH 063/130] refactor(electron): optimize async function wrapping in script execution - Updated the `wrapStringScriptForCdp` function to simplify the wrapping of scripts by using parentheses for expressions instead of a return statement. This change enhances clarity and efficiency in handling script execution, ensuring that expressions are evaluated correctly. --- packages/electron-service/src/commands/executeCdp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 89195b2e0..b8beb1d9d 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -114,7 +114,7 @@ function wrapStringScriptForCdp(script: string): string { if (hasRealSemicolon || hasStatementKeyword) { return `async () => { ${script} }`; } else { - return `async () => { return ${script}; }`; + return `async () => (${script})`; } } From d415750c11621517c123ea614b8dd05bd3915bfb Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 13:28:11 +0100 Subject: [PATCH 064/130] refactor(tauri): implement semicolon detection outside quotes for script execution - Added a new function `hasSemicolonOutsideQuotes` to accurately check for semicolons outside of string literals and brackets. This enhancement improves the logic in the `execute` function, ensuring that scripts are correctly identified as statements or expressions based on their structure, thereby enhancing the reliability of script execution. --- packages/tauri-plugin/guest-js/index.ts | 46 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index d7644c640..9f7283ecf 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -6,6 +6,48 @@ import type { InvokeArgs } from '@tauri-apps/api/core'; import * as nativeSpy from '@wdio/native-spy'; +/** + * Check if a semicolon exists outside of quotes and brackets + * This is needed because simple .includes(';') gives false positives + * for semicolons inside string literals like "'a;b'.split(';')[0]" + */ +function hasSemicolonOutsideQuotes(str: string): boolean { + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let depth = 0; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + if (ch !== '\\') { + let bs = 0; + let j = i - 1; + while (j >= 0 && str[j] === '\\') { + bs++; + j--; + } + if (bs % 2 === 1) continue; + } + if (ch === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (ch === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (ch === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (!inSingle && !inDouble && !inTemplate) { + if ('([{'.includes(ch)) depth++; + if (')]}'.includes(ch)) depth--; + if (ch === ';' && depth === 0) return true; + } + } + return false; +} + // Lazy-load invoke function to support both global Tauri API and dynamic imports // This allows the plugin to work both with bundlers (Vite) and without (plain ES modules) let _invokeCache: ((cmd: string, args?: InvokeArgs) => Promise) | null = null; @@ -135,7 +177,7 @@ export async function execute(script: string, ...args: unknown[]): Promise/.test(trimmedScript); // Wrap the script appropriately based on type @@ -170,7 +212,7 @@ export async function execute(script: string, ...args: unknown[]): Promise { ${script} })()`; From 8d7142571d04bacd899ed4a0b9d7f8f9e12c66a3 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 14:08:04 +0100 Subject: [PATCH 065/130] refactor(tauri): update script handling in TauriWorkerService - Modified the `execute` method to ensure that function scripts are converted to strings before being passed to the original execute function. This change improves the handling of function scripts in both embedded and non-embedded contexts, enhancing the reliability of script execution. --- packages/tauri-service/src/service.ts | 5 +++-- packages/tauri-service/test/service.spec.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index a23e8e581..9e6b479fe 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -319,15 +319,16 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { + const scriptString = typeof script === 'function' ? script.toString() : script; + if (isEmbedded) { // For embedded WebDriver: pass the script through untouched so WDIO // can invoke function scripts correctly. - return originalExecute(script as Parameters[0], ...args) as Promise; + return originalExecute(scriptString, ...args) as Promise; } // For functions: use .toString() - produces valid JS function source // For strings: pass as-is (let the WebDriver handle it, or use executeAsync for async results) - const scriptString = typeof script === 'function' ? script.toString() : script; // For non-embedded (tauri-driver/official): use executeAsync for both functions and strings // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 04b7991a1..0fe21de3d 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -148,7 +148,8 @@ describe('TauriWorkerService', () => { const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); + // Functions are converted to string via .toString() then passed to original execute + expect(mockExecute).toHaveBeenCalledWith(testFn.toString(), 1, 2); }); }); From 4a98230ebeb8535401599861d950b5f651277239 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 18:09:32 +0100 Subject: [PATCH 066/130] refactor(tauri): enhance async function handling in script execution - Updated the `execute` function to recognize and properly wrap async arrow functions, ensuring they are routed to the correct execution path with Tauri API injection. Additionally, improved the handling of statement and expression-style scripts, enhancing the reliability and clarity of script execution within the Tauri environment. --- .../guest-js/__tests__/index.spec.ts | 58 ++++++++++++++++++- packages/tauri-plugin/guest-js/index.ts | 1 + packages/tauri-service/src/service.ts | 57 ++++++++++-------- packages/tauri-service/test/service.spec.ts | 12 ++-- 4 files changed, 96 insertions(+), 32 deletions(-) diff --git a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts index e7ac69334..d9ae28653 100644 --- a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts +++ b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts @@ -186,8 +186,9 @@ describe('execute', () => { beforeEach(async () => { vi.resetModules(); - originalInvoke = vi.fn().mockResolvedValue('executed'); - (window as any).__TAURI__ = createTauriMock(originalInvoke); + originalInvoke = vi.fn() as ReturnType; + originalInvoke.mockResolvedValue('executed'); + (window as any).__TAURI__ = createTauriMock(originalInvoke as (...args: unknown[]) => unknown); const mod = await import('../index.js'); await mod.init(); @@ -246,6 +247,59 @@ describe('execute', () => { 'Failed to execute script: string error', ); }); + + it('should wrap async arrow functions with Tauri API injection', async () => { + // Test that async arrow function is routed to function-like path + await execute('async (tauri, value) => ({ received: value, hasTauri: !!tauri?.core })', 'test-value'); + + // Should be routed to function-like path (has Tauri API injected) + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + expect(pluginCalls[0][1]).toEqual( + expect.objectContaining({ + request: expect.objectContaining({ + script: expect.stringContaining('__wdio_tauri'), + }), + }), + ); + }); + + it('should route statement-style string scripts to statement path', async () => { + await execute('return 42'); + + // Should be routed to statement path (wrapped with async IIFE, not function wrapper) + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + // Statement path wraps as: `(async () => { return 42 })()` - not the function-like wrapper + expect(pluginCalls[0][1].request.script).toContain('(async () => { return 42 })()'); + }); + + it('should route expression-style string scripts to expression path', async () => { + await execute('1 + 2 + 3'); + + // Should be routed to expression path (wrapped with return - includes semicolon inside braces) + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + // Expression path wraps as: `(async () => { return 1 + 2 + 3; })()` (semicolon inside) + expect(pluginCalls[0][1].request.script).toContain('(async () => { return 1 + 2 + 3; })()'); + }); + + it('should handle statement-style string scripts', async () => { + originalInvoke.mockResolvedValue(42); + const result = await execute('return 42'); + + // Should be routed to statement path (no Tauri injection needed) + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + expect(pluginCalls[0][1].request.script).toContain('return 42'); + expect(result).toBe(42); + }); + + it('should handle expression-style string scripts', async () => { + originalInvoke.mockResolvedValue(6); + const result = await execute('1 + 2 + 3'); + + // Should be routed to expression path (wrapped with return) + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + expect(pluginCalls[0][1].request.script).toContain('return 1 + 2 + 3'); + expect(result).toBe(6); + }); }); describe('setupConsoleForwarding', () => { diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 9f7283ecf..a580f057b 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -178,6 +178,7 @@ export async function execute(script: string, ...args: unknown[]): Promise/.test(trimmedScript); // Wrap the script appropriately based on type diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 9e6b479fe..d5d10600e 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -4,7 +4,7 @@ import { execute } from './commands/execute.js'; import { clearAllMocks, isMockFunction, mock, resetAllMocks, restoreAllMocks } from './commands/mock.js'; import { triggerDeeplink } from './commands/triggerDeeplink.js'; import mockStore from './mockStore.js'; -import { CONSOLE_WRAPPER_SCRIPT } from './scripts/console-wrapper.js'; + import type { TauriCapabilities, TauriServiceGlobalOptions, TauriServiceOptions } from './types.js'; import { clearWindowState, ensureActiveWindowFocus } from './window.js'; @@ -330,20 +330,24 @@ export default class TauriWorkerService { // For functions: use .toString() - produces valid JS function source // For strings: pass as-is (let the WebDriver handle it, or use executeAsync for async results) - // For non-embedded (tauri-driver/official): use executeAsync for both functions and strings + // For non-embedded (tauri-driver/official): use executeAsync with function callbacks to avoid polyfill injection // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute if (typeof script === 'function') { - // Function scripts: use executeAsync with .then() callbacks to handle async results - // Wrap in Promise.resolve to handle both sync and async function return values - const wrappedScript = ` - ${CONSOLE_WRAPPER_SCRIPT} - Promise.resolve((${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1))).then( - (r) => arguments[arguments.length-1](r), - (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) - ); - `; - const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( - wrappedScript, + // Use executeAsync with function callback to avoid polyfill injection issues + const asyncResult = await ( + originalExecuteAsync as (fn: (...args: unknown[]) => void, ...args: unknown[]) => Promise + )( + // Execute the user function and call done() with the result + async function executeUserFunction(...allArgs: unknown[]) { + const done = allArgs[allArgs.length - 1] as (result: unknown) => void; + const userArgs = allArgs.slice(0, -1) as InnerArguments; + try { + const result = await script(...userArgs); + done(result); + } catch (error) { + done({ __wdio_error__: error instanceof Error ? error.message : String(error) }); + } + }, ...args, ); if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { @@ -351,17 +355,22 @@ export default class TauriWorkerService { } return asyncResult as ReturnValue; } else { - // For strings: use executeAsync with explicit done callback - // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly - const wrappedScript = ` - ${CONSOLE_WRAPPER_SCRIPT} - (async function() { ${scriptString} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( - (r) => arguments[arguments.length-1](r), - (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) - ); - `; - const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( - wrappedScript, + // For string scripts: execute them directly with async wrapper + const asyncResult = await ( + originalExecuteAsync as (fn: (...args: unknown[]) => void, ...args: unknown[]) => Promise + )( + // Execute the string script and call done() with the result + async function executeStringScript(...allArgs: unknown[]) { + const done = allArgs[allArgs.length - 1] as (result: unknown) => void; + try { + // Execute the string script using Function constructor for dynamic execution + const executeScript = new Function(`return (async function() { ${scriptString} })()`); + const result = await executeScript(); + done(result); + } catch (error) { + done({ __wdio_error__: error instanceof Error ? error.message : String(error) }); + } + }, ...args, ); if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 0fe21de3d..c6bd2b2f6 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -102,12 +102,12 @@ describe('TauriWorkerService', () => { (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - // String scripts should use executeAsync with explicit done callback for WebKit compatibility + // String scripts should use executeAsync with function callback for WebKit compatibility expect(mockExecuteAsync).toHaveBeenCalled(); const callArgs = mockExecuteAsync.mock.calls[0]; - // The script should contain .then( to handle async results and __wdio_error__ for error handling - expect(callArgs[0]).toContain('.then('); - expect(callArgs[0]).toContain('__wdio_error__'); + // The script should be a function callback that handles async results + expect(typeof callArgs[0]).toBe('function'); + expect(callArgs[0].name).toBe('executeStringScript'); // execute should NOT be called for string scripts expect(mockExecute).not.toHaveBeenCalled(); }); @@ -122,8 +122,8 @@ describe('TauriWorkerService', () => { const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - // Functions should use executeAsync for WebKit compatibility - expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + // Functions should use executeAsync for WebKit compatibility with function callbacks + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.any(Function), 1, 2); // execute should NOT be called expect(mockExecute).not.toHaveBeenCalled(); }); From 8b1af1f5f40349261f4ce5fa85bbc68174fc95e5 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 18:40:40 +0100 Subject: [PATCH 067/130] refactor(tauri): improve async handling for script execution in TauriWorkerService - Updated the `execute` method to utilize explicit done callbacks for both function and string scripts, enhancing compatibility with WebKit. The changes ensure that async results are properly handled and errors are communicated effectively, improving the reliability of script execution within the Tauri environment. --- packages/tauri-service/src/service.ts | 56 +++++++++------------ packages/tauri-service/test/service.spec.ts | 12 ++--- 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index d5d10600e..f377ea4ba 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -4,6 +4,7 @@ import { execute } from './commands/execute.js'; import { clearAllMocks, isMockFunction, mock, resetAllMocks, restoreAllMocks } from './commands/mock.js'; import { triggerDeeplink } from './commands/triggerDeeplink.js'; import mockStore from './mockStore.js'; +import { CONSOLE_WRAPPER_SCRIPT } from './scripts/console-wrapper.js'; import type { TauriCapabilities, TauriServiceGlobalOptions, TauriServiceOptions } from './types.js'; import { clearWindowState, ensureActiveWindowFocus } from './window.js'; @@ -330,24 +331,20 @@ export default class TauriWorkerService { // For functions: use .toString() - produces valid JS function source // For strings: pass as-is (let the WebDriver handle it, or use executeAsync for async results) - // For non-embedded (tauri-driver/official): use executeAsync with function callbacks to avoid polyfill injection + // For non-embedded (tauri-driver/official): use executeAsync for both functions and strings // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute if (typeof script === 'function') { - // Use executeAsync with function callback to avoid polyfill injection issues - const asyncResult = await ( - originalExecuteAsync as (fn: (...args: unknown[]) => void, ...args: unknown[]) => Promise - )( - // Execute the user function and call done() with the result - async function executeUserFunction(...allArgs: unknown[]) { - const done = allArgs[allArgs.length - 1] as (result: unknown) => void; - const userArgs = allArgs.slice(0, -1) as InnerArguments; - try { - const result = await script(...userArgs); - done(result); - } catch (error) { - done({ __wdio_error__: error instanceof Error ? error.message : String(error) }); - } - }, + // Function scripts: use executeAsync with .then() callbacks to handle async results + // Wrap in Promise.resolve to handle both sync and async function return values + const wrappedScript = ` + ${CONSOLE_WRAPPER_SCRIPT} + Promise.resolve((${scriptString}).apply(null, Array.from(arguments).slice(0, arguments.length - 1))).then( + (r) => arguments[arguments.length-1](r), + (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) + ); + `; + const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( + wrappedScript, ...args, ); if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { @@ -355,22 +352,17 @@ export default class TauriWorkerService { } return asyncResult as ReturnValue; } else { - // For string scripts: execute them directly with async wrapper - const asyncResult = await ( - originalExecuteAsync as (fn: (...args: unknown[]) => void, ...args: unknown[]) => Promise - )( - // Execute the string script and call done() with the result - async function executeStringScript(...allArgs: unknown[]) { - const done = allArgs[allArgs.length - 1] as (result: unknown) => void; - try { - // Execute the string script using Function constructor for dynamic execution - const executeScript = new Function(`return (async function() { ${scriptString} })()`); - const result = await executeScript(); - done(result); - } catch (error) { - done({ __wdio_error__: error instanceof Error ? error.message : String(error) }); - } - }, + // For strings: use executeAsync with explicit done callback + // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly + const wrappedScript = ` + ${CONSOLE_WRAPPER_SCRIPT} + (async function() { ${scriptString} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( + (r) => arguments[arguments.length-1](r), + (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) + ); + `; + const asyncResult = await (originalExecuteAsync as (script: string, ...a: unknown[]) => Promise)( + wrappedScript, ...args, ); if (asyncResult && typeof asyncResult === 'object' && '__wdio_error__' in asyncResult) { diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index c6bd2b2f6..0fe21de3d 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -102,12 +102,12 @@ describe('TauriWorkerService', () => { (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - // String scripts should use executeAsync with function callback for WebKit compatibility + // String scripts should use executeAsync with explicit done callback for WebKit compatibility expect(mockExecuteAsync).toHaveBeenCalled(); const callArgs = mockExecuteAsync.mock.calls[0]; - // The script should be a function callback that handles async results - expect(typeof callArgs[0]).toBe('function'); - expect(callArgs[0].name).toBe('executeStringScript'); + // The script should contain .then( to handle async results and __wdio_error__ for error handling + expect(callArgs[0]).toContain('.then('); + expect(callArgs[0]).toContain('__wdio_error__'); // execute should NOT be called for string scripts expect(mockExecute).not.toHaveBeenCalled(); }); @@ -122,8 +122,8 @@ describe('TauriWorkerService', () => { const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - // Functions should use executeAsync for WebKit compatibility with function callbacks - expect(mockExecuteAsync).toHaveBeenCalledWith(expect.any(Function), 1, 2); + // Functions should use executeAsync for WebKit compatibility + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); // execute should NOT be called expect(mockExecute).not.toHaveBeenCalled(); }); From c2e16db72e7a8ec84a6498c8f96cc275b11e8120 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 18:40:59 +0100 Subject: [PATCH 068/130] refactor(tauri): skip patching for internal executeWithinTauri calls - Updated the `execute` method to skip patching for internal `executeWithinTauri` calls when using official drivers, while still allowing patching for embedded drivers. This change prevents architecture conflicts and enhances the reliability of script execution within the Tauri environment. --- packages/tauri-service/src/service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index f377ea4ba..1d380ee51 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -320,6 +320,16 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { + // Skip patching for service's internal executeWithinTauri calls for official drivers + // to avoid architecture conflicts, but allow patching for embedded drivers + if (typeof script === 'function' && script.name === 'executeWithinTauri' && isEmbedded === false) { + log.debug('Skipping execute patch for service internal call'); + return originalExecute( + script as string | ((...args: unknown[]) => unknown), + ...(args as unknown[]), + ) as Promise; + } + const scriptString = typeof script === 'function' ? script.toString() : script; if (isEmbedded) { From 8b9f702e26b8e0f3f7c2aa07592d0258d811e888 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 19:34:46 +0100 Subject: [PATCH 069/130] refactor(tauri): enhance error handling and improve async execution in execute function - Updated the `execute` method to improve error handling by providing clearer error messages for plugin execution failures. The function now utilizes `executeAsync` for better compatibility with asynchronous operations, ensuring that script execution is handled more reliably within the Tauri environment. Additionally, refactored the internal execution function for clarity and consistency. --- .../tauri-service/src/commands/execute.ts | 28 +- .../test/commands/execute.spec.ts | 270 +++++++++--------- .../test/crabnebulaBackend.spec.ts | 2 +- 3 files changed, 145 insertions(+), 155 deletions(-) diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index 5afedac92..8667ab774 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -70,14 +70,10 @@ export async function execute( // For strings: send as-is (Rust handles proper escaping when args present) const scriptString = typeof script === 'function' ? script.toString() : script; - // Execute via plugin's execute command with better error handling - // The plugin will inject the Tauri APIs object as the first argument + // Execute via plugin's execute method + // The plugin handles CSP-compliant script execution in the backend const result = await browser.execute( - async function executeWithinTauri(script: string, ...args) { - // @ts-expect-error - Running in browser context - if (typeof window === 'undefined') { - return JSON.stringify({ __wdio_error__: 'window is undefined' }); - } + async function executeViaPlugin(script: string, ...scriptArgs: unknown[]) { // @ts-expect-error - Running in browser context if (typeof window.wdioTauri === 'undefined') { return JSON.stringify({ __wdio_error__: 'window.wdioTauri is undefined' }); @@ -87,24 +83,14 @@ export async function execute( // @ts-expect-error - Running in browser context return JSON.stringify({ __wdio_error__: `window.wdioTauri.execute is ${typeof window.wdioTauri.execute}` }); } + try { // @ts-expect-error - Running in browser context - const execResult = window.wdioTauri.execute(script, ...args); - // Handle Promise results - await them in browser context - if (execResult && typeof execResult.then === 'function') { - try { - const awaited = await execResult; - return JSON.stringify({ __wdio_value__: awaited }); - } catch (promiseError) { - return JSON.stringify({ - __wdio_error__: `Promise error: ${promiseError instanceof Error ? promiseError.message : String(promiseError)}`, - }); - } - } - return JSON.stringify({ __wdio_value__: execResult }); + const pluginResult = await window.wdioTauri.execute(script, ...scriptArgs); + return JSON.stringify({ __wdio_value__: pluginResult }); } catch (error) { return JSON.stringify({ - __wdio_error__: `Execute call error: ${error instanceof Error ? error.message : String(error)}`, + __wdio_error__: `Plugin execute error: ${error instanceof Error ? error.message : String(error)}`, }); } }, diff --git a/packages/tauri-service/test/commands/execute.spec.ts b/packages/tauri-service/test/commands/execute.spec.ts index 8f7dbe3c7..9502762bc 100644 --- a/packages/tauri-service/test/commands/execute.spec.ts +++ b/packages/tauri-service/test/commands/execute.spec.ts @@ -21,9 +21,13 @@ vi.mock('@wdio/native-utils', () => ({ }), })); -function createMockBrowser(executeFn?: (...args: unknown[]) => unknown) { +function createMockBrowser( + executeFn?: (...args: unknown[]) => unknown, + executeAsyncFn?: (...args: unknown[]) => unknown, +) { return { execute: vi.fn(executeFn ?? (() => undefined)), + executeAsync: vi.fn(executeAsyncFn ?? (() => undefined)), } as unknown as WebdriverIO.Browser; } @@ -66,9 +70,9 @@ describe('execute', () => { describe('plugin availability check', () => { it('should throw when plugin is never available after max retries', async () => { vi.useFakeTimers(); - const mockExecute = vi.fn().mockResolvedValue(false); + const mockExecuteAsync = vi.fn().mockResolvedValue(false); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const promise = execute(browser, '() => 1').catch((e: Error) => e); @@ -87,7 +91,7 @@ describe('execute', () => { it('should succeed when plugin becomes available after retries', async () => { vi.useFakeTimers(); let callCount = 0; - const mockExecute = vi.fn().mockImplementation((..._args: unknown[]) => { + const mockExecuteAsync = vi.fn().mockImplementation((..._args: unknown[]) => { callCount++; // First 3 calls: plugin check returns false // 4th call: plugin check returns true @@ -101,7 +105,7 @@ describe('execute', () => { return Promise.resolve(JSON.stringify({ __wdio_value__: 'result' })); }); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const promise = execute(browser, '() => "result"'); @@ -114,13 +118,13 @@ describe('execute', () => { const result = await promise; expect(result).toBe('result'); - // 3 failed checks + 1 successful check + 1 actual execute = 5 calls - expect(mockExecute).toHaveBeenCalledTimes(5); + // 3 failed checks + 1 successful check + 1 actual executeAsync = 5 calls + expect(mockExecuteAsync).toHaveBeenCalledTimes(5); }); it('should use cached result on second call with same browser', async () => { let callCount = 0; - const mockExecute = vi.fn().mockImplementation(() => { + const mockExecuteAsync = vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) { return Promise.resolve(true); // plugin check @@ -128,7 +132,7 @@ describe('execute', () => { return Promise.resolve(JSON.stringify({ __wdio_value__: 'result' })); }); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await execute(browser, '() => "result"'); const firstCallCount = callCount; @@ -173,54 +177,54 @@ describe('execute', () => { describe('function serialization', () => { it('should convert functions to strings', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); // plugin check - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 42 })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); // plugin check + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 42 })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const fn = (_tauri: unknown, a: number, b: number) => a + b; await execute(browser, fn, 1, 2); // Second call is the actual execute - first arg is the inner function, second is the stringified script - const secondCall = mockExecute.mock.calls[1]; + const secondCall = mockExecuteAsync.mock.calls[1]; expect(secondCall[1]).toBe(fn.toString()); expect(secondCall[2]).toBe(1); expect(secondCall[3]).toBe(2); }); it('should pass strings as-is to the plugin', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'hello' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'hello' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await execute(browser, 'return "hello"'); - const secondCall = mockExecute.mock.calls[1]; + const secondCall = mockExecuteAsync.mock.calls[1]; expect(secondCall[1]).toBe('return "hello"'); }); }); describe('result parsing', () => { it('should parse __wdio_value__ from JSON response', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: { foo: 'bar' } })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: { foo: 'bar' } })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await execute<{ foo: string }, []>(browser, '() => ({ foo: "bar" })'); expect(result).toEqual({ foo: 'bar' }); }); it('should return null __wdio_value__', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: null })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: null })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await execute(browser, '() => null'); // null is not undefined, so __wdio_value__ !== undefined is true @@ -228,22 +232,22 @@ describe('execute', () => { }); it('should return raw result when response is not a string', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(42); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(42); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await execute(browser, '() => 42'); expect(result).toBe(42); }); it('should return raw result when __wdio_value__ is undefined in parsed response', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ other_key: 'value' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ other_key: 'value' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await execute(browser, '() => "something"'); // JSON parsed successfully, no __wdio_error__, __wdio_value__ is undefined @@ -252,11 +256,11 @@ describe('execute', () => { }); it('should throw when JSON parse fails on a non-JSON string', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce('not-valid-json'); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce('not-valid-json'); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow( /Failed to parse execute result:.*raw result: not-valid-json/, @@ -264,22 +268,22 @@ describe('execute', () => { }); it('should return raw result when response is null', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(null); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(null); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await execute(browser, '() => null'); expect(result).toBeNull(); }); it('should return raw result when response is undefined', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(undefined); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(undefined); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await execute(browser, '() => undefined'); expect(result).toBeUndefined(); @@ -288,31 +292,31 @@ describe('execute', () => { describe('error wrapping', () => { it('should throw when response contains __wdio_error__', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'something went wrong' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'something went wrong' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow('something went wrong'); }); it('should throw for window undefined error', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'window is undefined' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'window is undefined' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow(/window is undefined/); }); it('should throw for wdioTauri undefined error', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'window.wdioTauri is undefined' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'window.wdioTauri is undefined' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow( /window\.wdioTauri is undefined/, @@ -320,31 +324,31 @@ describe('execute', () => { }); it('should throw for promise error', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'Promise error: async failure' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'Promise error: async failure' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow(/Promise error: async failure/); }); it('should throw for execute call error', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'Execute call error: boom' })); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'Execute call error: boom' })); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow(/Execute call error: boom/); }); it('should throw when browser.execute rejects', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockRejectedValueOnce(new Error('WebDriver session expired')); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockRejectedValueOnce(new Error('WebDriver session expired')); browser = createMockBrowser(); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await expect(() => execute(browser, '() => "fail"')).rejects.toThrow('WebDriver session expired'); }); @@ -360,16 +364,16 @@ describe('executeTauriCommand', () => { }); it('should pass command and args as function arguments, not closure references', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); // plugin check - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'test-result' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); // plugin check + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'test-result' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await executeTauriCommand(browser, 'get_version', 'arg1', 42); // The second call is the actual execute - verify command and args are passed as separate arguments // This ensures they don't become closure references in the serialized script - const secondCall = mockExecute.mock.calls[1]; + const secondCall = mockExecuteAsync.mock.calls[1]; // Script is converted to string by execute(), but command and args are passed separately expect(secondCall[1]).toBe('({ core }, invokeCommand, invokeArgs) => core.invoke(invokeCommand, ...invokeArgs)'); expect(secondCall[2]).toBe('get_version'); @@ -377,20 +381,20 @@ describe('executeTauriCommand', () => { }); it('should return ok result on success', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'command-result' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'command-result' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await executeTauriCommand(browser, 'my_command', 'arg1', 'arg2'); expect(result).toEqual({ ok: true, value: 'command-result' }); }); it('should return error result on failure', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'command failed' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'command failed' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await executeTauriCommand(browser, 'my_command'); assert(!result.ok); @@ -425,10 +429,10 @@ describe('executeTauriCommandWithTimeout', () => { }); it('should return result when command completes before timeout', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'fast-result' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'fast-result' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await executeTauriCommandWithTimeout(browser, 'fast_command', 5000); expect(result).toEqual({ ok: true, value: 'fast-result' }); @@ -436,12 +440,12 @@ describe('executeTauriCommandWithTimeout', () => { it('should return error result when command times out', async () => { vi.useFakeTimers(); - const mockExecute = vi.fn(); + const mockExecuteAsync = vi.fn(); // Plugin check succeeds - mockExecute.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(true); // The actual execute never resolves (simulating a hang) - mockExecute.mockImplementationOnce(() => new Promise(() => {})); - (browser.execute as ReturnType).mockImplementation(mockExecute); + mockExecuteAsync.mockImplementationOnce(() => new Promise(() => {})); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const promise = executeTauriCommandWithTimeout(browser, 'slow_command', 100); @@ -456,10 +460,10 @@ describe('executeTauriCommandWithTimeout', () => { it('should use default timeout of 30000ms', async () => { vi.useFakeTimers(); - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockImplementationOnce(() => new Promise(() => {})); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockImplementationOnce(() => new Promise(() => {})); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const promise = executeTauriCommandWithTimeout(browser, 'slow_command'); @@ -483,7 +487,7 @@ describe('executeTauriCommands', () => { it('should execute commands sequentially and return all results', async () => { let callCount = 0; - const mockExecute = vi.fn().mockImplementation(() => { + const mockExecuteAsync = vi.fn().mockImplementation(() => { callCount++; // First call: plugin check for first command if (callCount === 1) return Promise.resolve(true); @@ -493,7 +497,7 @@ describe('executeTauriCommands', () => { if (callCount === 3) return Promise.resolve(JSON.stringify({ __wdio_value__: 'result2' })); return Promise.resolve(JSON.stringify({ __wdio_value__: 'unexpected' })); }); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const results = await executeTauriCommands(browser, [ { command: 'cmd1', args: [] }, @@ -507,13 +511,13 @@ describe('executeTauriCommands', () => { it('should stop at first failure', async () => { let callCount = 0; - const mockExecute = vi.fn().mockImplementation(() => { + const mockExecuteAsync = vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) return Promise.resolve(true); if (callCount === 2) return Promise.resolve(JSON.stringify({ __wdio_error__: 'cmd1 failed' })); return Promise.resolve(JSON.stringify({ __wdio_value__: 'should not reach' })); }); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const results = await executeTauriCommands(browser, [ { command: 'cmd1', args: [] }, @@ -527,13 +531,13 @@ describe('executeTauriCommands', () => { it('should use timeout variant when timeout is specified', async () => { let callCount = 0; - const mockExecute = vi.fn().mockImplementation(() => { + const mockExecuteAsync = vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) return Promise.resolve(true); if (callCount === 2) return Promise.resolve(JSON.stringify({ __wdio_value__: 'timed-result' })); return Promise.resolve(JSON.stringify({ __wdio_value__: 'result2' })); }); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const results = await executeTauriCommands(browser, [ { command: 'cmd1', args: ['a'], timeout: 5000 }, @@ -560,17 +564,17 @@ describe('executeTauriCommandsParallel', () => { it('should execute all commands in parallel', async () => { // Pre-cache plugin availability with a sequential call first - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); // plugin check - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'warmup' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); // plugin check + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'warmup' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); // Warm up the cache await executeTauriCommand(browser, 'warmup'); // Now set up mocks for parallel calls (no plugin check needed - cached) - mockExecute.mockReset(); - mockExecute.mockResolvedValue(JSON.stringify({ __wdio_value__: 'parallel-result' })); + mockExecuteAsync.mockReset(); + mockExecuteAsync.mockResolvedValue(JSON.stringify({ __wdio_value__: 'parallel-result' })); const results = await executeTauriCommandsParallel(browser, [ { command: 'cmd1', args: [] }, @@ -586,17 +590,17 @@ describe('executeTauriCommandsParallel', () => { it('should return all results including failures', async () => { // Pre-cache plugin availability - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); // plugin check - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'warmup' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); // plugin check + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'warmup' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); await executeTauriCommand(browser, 'warmup'); // Now set up for parallel calls - mockExecute.mockReset(); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'ok' })); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'cmd2 failed' })); + mockExecuteAsync.mockReset(); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: 'ok' })); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'cmd2 failed' })); const results = await executeTauriCommandsParallel(browser, [ { command: 'cmd1', args: [] }, @@ -610,12 +614,12 @@ describe('executeTauriCommandsParallel', () => { it('should use timeout variant when timeout is specified', async () => { let callCount = 0; - const mockExecute = vi.fn().mockImplementation(() => { + const mockExecuteAsync = vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) return Promise.resolve(true); return Promise.resolve(JSON.stringify({ __wdio_value__: 'done' })); }); - (browser.execute as ReturnType).mockImplementation(mockExecute); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const results = await executeTauriCommandsParallel(browser, [{ command: 'cmd1', args: [], timeout: 5000 }]); @@ -682,20 +686,20 @@ describe('getTauriVersion', () => { }); it('should return version string on success', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: '2.1.0' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: '2.1.0' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await getTauriVersion(browser); expect(result).toEqual({ ok: true, value: '2.1.0' }); }); it('should return error result on failure', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'version not found' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'version not found' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await getTauriVersion(browser); assert(!result.ok); @@ -713,20 +717,20 @@ describe('getTauriAppInfo', () => { it('should return app info on success', async () => { const appInfo = { name: 'my-app', version: '1.0.0' }; - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: appInfo })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_value__: appInfo })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await getTauriAppInfo(browser); expect(result).toEqual({ ok: true, value: appInfo }); }); it('should return error result on failure', async () => { - const mockExecute = vi.fn(); - mockExecute.mockResolvedValueOnce(true); - mockExecute.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'app info unavailable' })); - (browser.execute as ReturnType).mockImplementation(mockExecute); + const mockExecuteAsync = vi.fn(); + mockExecuteAsync.mockResolvedValueOnce(true); + mockExecuteAsync.mockResolvedValueOnce(JSON.stringify({ __wdio_error__: 'app info unavailable' })); + (browser.executeAsync as ReturnType).mockImplementation(mockExecuteAsync); const result = await getTauriAppInfo(browser); assert(!result.ok); diff --git a/packages/tauri-service/test/crabnebulaBackend.spec.ts b/packages/tauri-service/test/crabnebulaBackend.spec.ts index 92df93986..ac4388ff0 100644 --- a/packages/tauri-service/test/crabnebulaBackend.spec.ts +++ b/packages/tauri-service/test/crabnebulaBackend.spec.ts @@ -242,7 +242,7 @@ describe('CrabNebula Backend', () => { await vi.advanceTimersByTimeAsync(600); expect(rejection).toBeDefined(); - expect(rejection!.message).toContain('did not become ready within 500ms'); + expect(rejection?.message).toContain('did not become ready within 500ms'); vi.useRealTimers(); }); From fe105bbc4af0142f4f1e0bef190e82fc63d69bd0 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 21:18:57 +0100 Subject: [PATCH 070/130] refactor(tauri): update event emission to target app context - Modified the `execute` function to ensure that emitted events are directed to the app context by adding a `target: 'app'` parameter. This change enhances the reliability of event handling by ensuring that events are emitted to the correct listener context within the Tauri application. --- packages/tauri-plugin/src/commands.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index aa81e5644..5130daa63 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -247,21 +247,21 @@ pub(crate) async fn execute( const __wdio_script = ({}); const result = await __wdio_script; - // Emit the result using the current window's event emitter - // This ensures the event goes to the same window where we're listening + // Emit the result using the app's event emitter + // This ensures the event goes to the app target where the listener is set up if (window.__TAURI__?.event?.emit) {{ - await window.__TAURI__.event.emit('{}', {{ success: true, value: result }}); + await window.__TAURI__.event.emit('{}', {{ success: true, value: result }}, {{ target: 'app' }}); }} else {{ // Fallback: try dynamic import try {{ const {{ emit }} = await import('@tauri-apps/api/event'); - await emit('{}', {{ success: true, value: result }}); + await emit('{}', {{ success: true, value: result }}, {{ target: 'app' }}); }} catch (importError) {{ console.error('[WDIO Execute] Failed to import emit:', importError); // Last resort: try to use the globalTauri emit if (typeof window.__TAURI__ !== 'undefined') {{ const {{ emit }} = await import('@tauri-apps/api/event'); - await emit('{}', {{ success: true, value: result }}); + await emit('{}', {{ success: true, value: result }}, {{ target: 'app' }}); }} }} }} @@ -269,10 +269,10 @@ pub(crate) async fn execute( // Emit error via event try {{ if (window.__TAURI__?.event?.emit) {{ - await window.__TAURI__.event.emit('{}', {{ success: false, error: error.message || String(error) }}); + await window.__TAURI__.event.emit('{}', {{ success: false, error: error.message || String(error) }}, {{ target: 'app' }}); }} else {{ const {{ emit }} = await import('@tauri-apps/api/event'); - await emit('{}', {{ success: false, error: error.message || String(error) }}); + await emit('{}', {{ success: false, error: error.message || String(error) }}, {{ target: 'app' }}); }} }} catch (emitError) {{ console.error('[WDIO Execute] Failed to emit error:', emitError); From 013a5e4f4f5a4d584e4bc7914daee1c9cab0330a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 22:34:10 +0100 Subject: [PATCH 071/130] refactor(tauri): update event listener to target window context - Modified the `execute` function to change event listener and emission from the app context to the window context. This adjustment ensures that events are correctly emitted and received by the appropriate listener, enhancing the reliability of event handling within the Tauri application. --- packages/tauri-plugin/src/commands.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 5130daa63..828f84c72 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -192,11 +192,11 @@ pub(crate) async fn execute( let event_id = format!("wdio-result-{}", Uuid::new_v4()); log::trace!("Generated event_id for result: {}", event_id); - // Listen for the result event using the app's event listener - // The JavaScript uses window.__TAURI__.event.emit() which emits to the APP target - // So we need to listen on the app target, not the window target + // Listen for the result event using the window's event listener + // The JavaScript uses window.__TAURI__.event.emit() which emits to the window target + // So we listen on the window target let tx_clone = Arc::clone(&tx); - let listener_id = app.listen(&event_id, move |event| { + let listener_id = window.listen(&event_id, move |event| { log::trace!("Received result event payload: {}", event.payload()); // Take the sender from the Option (only the first call will succeed) @@ -247,21 +247,21 @@ pub(crate) async fn execute( const __wdio_script = ({}); const result = await __wdio_script; - // Emit the result using the app's event emitter - // This ensures the event goes to the app target where the listener is set up + // Emit the result using the window's event emitter + // This ensures the event goes to the window target where the listener is set up if (window.__TAURI__?.event?.emit) {{ - await window.__TAURI__.event.emit('{}', {{ success: true, value: result }}, {{ target: 'app' }}); + await window.__TAURI__.event.emit('{}', {{ success: true, value: result }}); }} else {{ // Fallback: try dynamic import try {{ const {{ emit }} = await import('@tauri-apps/api/event'); - await emit('{}', {{ success: true, value: result }}, {{ target: 'app' }}); + await emit('{}', {{ success: true, value: result }}); }} catch (importError) {{ console.error('[WDIO Execute] Failed to import emit:', importError); // Last resort: try to use the globalTauri emit if (typeof window.__TAURI__ !== 'undefined') {{ const {{ emit }} = await import('@tauri-apps/api/event'); - await emit('{}', {{ success: true, value: result }}, {{ target: 'app' }}); + await emit('{}', {{ success: true, value: result }}); }} }} }} @@ -269,10 +269,10 @@ pub(crate) async fn execute( // Emit error via event try {{ if (window.__TAURI__?.event?.emit) {{ - await window.__TAURI__.event.emit('{}', {{ success: false, error: error.message || String(error) }}, {{ target: 'app' }}); + await window.__TAURI__.event.emit('{}', {{ success: false, error: error.message || String(error) }}); }} else {{ const {{ emit }} = await import('@tauri-apps/api/event'); - await emit('{}', {{ success: false, error: error.message || String(error) }}, {{ target: 'app' }}); + await emit('{}', {{ success: false, error: error.message || String(error) }}); }} }} catch (emitError) {{ console.error('[WDIO Execute] Failed to emit error:', emitError); From e0356f981d5c730451e5f38fae63cfefc4b7d5e0 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Sat, 11 Apr 2026 23:40:05 +0100 Subject: [PATCH 072/130] refactor(tauri): enhance event handling in execute function - Refactored the `execute` function to introduce a helper function for event handling, improving code clarity and maintainability. The function now listens for result events on both app and window targets, ensuring compatibility across different Tauri providers and enhancing the reliability of event processing. --- packages/tauri-plugin/src/commands.rs | 42 +++++++++++++++++++-------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 828f84c72..e52a64367 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -1,6 +1,7 @@ use tauri::{command, Manager, Runtime, WebviewWindow, Listener}; use serde_json::Value as JsonValue; use uuid::Uuid; +use tokio::sync::oneshot; use crate::models::ExecuteRequest; use crate::Result; @@ -56,7 +57,7 @@ pub(crate) async fn execute( // Use tokio's async oneshot channel for async waiting // Wrap sender in Arc> so the Fn closure can take it once - let (tx, rx) = tokio::sync::oneshot::channel(); + let (tx, rx) = tokio::sync::oneshot::channel::>(); let tx = Arc::new(Mutex::new(Some(tx))); // Build the script with args if offered. @@ -192,15 +193,12 @@ pub(crate) async fn execute( let event_id = format!("wdio-result-{}", Uuid::new_v4()); log::trace!("Generated event_id for result: {}", event_id); - // Listen for the result event using the window's event listener - // The JavaScript uses window.__TAURI__.event.emit() which emits to the window target - // So we listen on the window target - let tx_clone = Arc::clone(&tx); - let listener_id = window.listen(&event_id, move |event| { + // Helper function to handle events + fn handle_event(event: tauri::Event, tx: Arc>>>>) { log::trace!("Received result event payload: {}", event.payload()); // Take the sender from the Option (only the first call will succeed) - let tx = match tx_clone.lock().ok().and_then(|mut guard| guard.take()) { + let tx = match tx.lock().ok().and_then(|mut guard| guard.take()) { Some(tx) => tx, None => { log::warn!("Event received but sender already taken, ignoring"); @@ -222,6 +220,21 @@ pub(crate) async fn execute( } } } + } + + // Listen for the result event on both app and window targets for compatibility + // Different Tauri providers may emit to different targets + let tx_clone_app: Arc>>>> = Arc::clone(&tx); + let tx_clone_window: Arc>>>> = Arc::clone(&tx); + + let listener_id_app = app.listen(&event_id.clone(), move |event| { + log::trace!("Received result event on app target: {}", event.payload()); + handle_event(event, tx_clone_app.clone()); + }); + + let listener_id_window = window.listen(&event_id, move |event| { + log::trace!("Received result event on window target: {}", event.payload()); + handle_event(event, tx_clone_window.clone()); }); // Wrap the script to: @@ -288,7 +301,8 @@ pub(crate) async fn execute( // Evaluate the script if let Err(e) = window.eval(&script_with_result) { log::error!("Failed to eval script: {}", e); - app.unlisten(listener_id); + app.unlisten(listener_id_app); + window.unlisten(listener_id_window); return Err(crate::Error::ExecuteError(format!("Failed to eval script: {}", e))); } @@ -304,18 +318,21 @@ pub(crate) async fn execute( Ok(Ok(Ok(result))) => { log::debug!("Execute completed successfully"); log::trace!("Result: {:?}", result); - app.unlisten(listener_id); + app.unlisten(listener_id_app); + window.unlisten(listener_id_window); Ok(result) } Ok(Ok(Err(e))) => { log::error!("JS error during execution: {}", e); - app.unlisten(listener_id); + app.unlisten(listener_id_app); + window.unlisten(listener_id_window); Err(e) } Ok(Err(_)) => { // Channel closed without sending (shouldn't happen) log::error!("Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label); - app.unlisten(listener_id); + app.unlisten(listener_id_app); + window.unlisten(listener_id_window); Err(crate::Error::ExecuteError(format!( "Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label @@ -324,7 +341,8 @@ pub(crate) async fn execute( Err(_) => { log::error!("Timeout waiting for execute result after 30s. Event ID: {}. Window: {}", event_id, window_label); - app.unlisten(listener_id); + app.unlisten(listener_id_app); + window.unlisten(listener_id_window); Err(crate::Error::ExecuteError(format!( "Script execution timed out after 30s. Event ID: {}. Window: {}", event_id, window_label From 5a1f159d0ce5e74e38550dedff2152fa66efbf0c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 00:25:21 +0100 Subject: [PATCH 073/130] fix(tauri): break circular dependency between execute.ts and window.ts Extract pluginAvailabilityCache into src/pluginCache.ts so both execute.ts and window.ts can import from it without creating a cycle. execute.ts re-exports clearPluginAvailabilityCache for backward compatibility. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-service/src/commands/execute.ts | 13 +++++-------- packages/tauri-service/src/pluginCache.ts | 13 +++++++++++++ packages/tauri-service/src/window.ts | 2 +- 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 packages/tauri-service/src/pluginCache.ts diff --git a/packages/tauri-service/src/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index 9e0fabf79..4d7d4a8f7 100644 --- a/packages/tauri-service/src/commands/execute.ts +++ b/packages/tauri-service/src/commands/execute.ts @@ -1,15 +1,12 @@ import type { TauriAPIs, TauriExecuteOptions } from '@wdio/native-types'; import { createLogger } from '@wdio/native-utils'; +import { isPluginAvailabilityCached, setPluginAvailabilityCached } from '../pluginCache.js'; import type { TauriCommandContext, TauriResult } from '../types.js'; import { getCurrentWindowLabel, getDefaultWindowLabel } from '../window.js'; -const log = createLogger('tauri-service', 'service'); - -const pluginAvailabilityCache = new WeakMap(); +export { clearPluginAvailabilityCache } from '../pluginCache.js'; -export function clearPluginAvailabilityCache(browser: WebdriverIO.Browser): void { - pluginAvailabilityCache.delete(browser); -} +const log = createLogger('tauri-service', 'service'); function isExecuteOptions(arg: unknown): arg is TauriExecuteOptions { return typeof arg === 'object' && arg !== null && '__wdioOptions__' in arg; @@ -69,7 +66,7 @@ export async function execute(); + +export function isPluginAvailabilityCached(browser: WebdriverIO.Browser): boolean { + return pluginAvailabilityCache.get(browser) === true; +} + +export function setPluginAvailabilityCached(browser: WebdriverIO.Browser): void { + pluginAvailabilityCache.set(browser, true); +} + +export function clearPluginAvailabilityCache(browser: WebdriverIO.Browser): void { + pluginAvailabilityCache.delete(browser); +} diff --git a/packages/tauri-service/src/window.ts b/packages/tauri-service/src/window.ts index 6d07d7206..c6d77c4c5 100644 --- a/packages/tauri-service/src/window.ts +++ b/packages/tauri-service/src/window.ts @@ -1,5 +1,5 @@ import { createLogger } from '@wdio/native-utils'; -import { clearPluginAvailabilityCache } from './commands/execute.js'; +import { clearPluginAvailabilityCache } from './pluginCache.js'; import type { DriverProvider } from './types.js'; const log = createLogger('tauri-service', 'window'); From e008495049e63d0fc809eddf7b383a603f963207 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 00:26:14 +0100 Subject: [PATCH 074/130] test(tauri): update window.spec.ts to spy on pluginCache module Since window.ts now imports clearPluginAvailabilityCache directly from pluginCache.ts, the spy must target pluginCache instead of execute to intercept the actual call. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-service/test/window.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri-service/test/window.spec.ts b/packages/tauri-service/test/window.spec.ts index 99d1b1d99..10b00a0d8 100644 --- a/packages/tauri-service/test/window.spec.ts +++ b/packages/tauri-service/test/window.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as executeModule from '../src/commands/execute.js'; +import * as pluginCacheModule from '../src/pluginCache.js'; import { clearWindowState, ensureActiveWindowFocus, @@ -312,7 +312,7 @@ describe('window management', () => { }); it('should clear pluginAvailabilityCache after a successful switch', async () => { - const spy = vi.spyOn(executeModule, 'clearPluginAvailabilityCache'); + const spy = vi.spyOn(pluginCacheModule, 'clearPluginAvailabilityCache'); const mockBrowser = { sessionId: 'cache-clear-session', tauri: { From dbed72c95b1296b0679e578400b5ee74fe78dc66 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 00:27:57 +0100 Subject: [PATCH 075/130] fix(electron): add word-boundary check for async/function in wrapStringScript startsWith('async') and startsWith('function') matched identifiers like asyncData.fetchAll() and functionResult.call(), causing them to be passed through as function-like strings instead of being wrapped in an IIFE. Use regex with [\s(] suffix to require whitespace or ( after the keyword, matching the Rust has_keyword_prefix approach in commands.rs. Co-Authored-By: Claude Sonnet 4.6 --- .../electron-service/src/commands/execute.ts | 4 ++-- .../test/commands/execute.spec.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 33e067d30..9fe38726c 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -47,8 +47,8 @@ function wrapStringScript(script: string): string { // Don't match arrows inside expressions like "return items.filter(x => x > 0)" const isFunctionLike = trimmed.startsWith('(') || - trimmed.startsWith('function') || - trimmed.startsWith('async') || + /^function[\s(]/.test(trimmed) || + /^async[\s(]/.test(trimmed) || /^(\w+)\s*=>/.test(trimmed); // single-param arrow at START like "x => x + 1" if (isFunctionLike) { diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 0969bc57a..4936b7de6 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -143,4 +143,20 @@ describe('execute Command', () => { expect.stringContaining('(async () => { return trySomething(); })()'), ); }); + + it('should treat asyncData.fetchAll() as expression (async prefix false positive)', async () => { + await execute(globalThis.browser, 'asyncData.fetchAll()'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return asyncData.fetchAll(); })()'), + ); + }); + + it('should treat functionResult.call() as expression (function prefix false positive)', async () => { + await execute(globalThis.browser, 'functionResult.call()'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return functionResult.call(); })()'), + ); + }); }); From 9d3195a14b9983e3d83c26e659a6788c9b0e7738 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 00:28:58 +0100 Subject: [PATCH 076/130] fix(tauri-plugin): add word-boundary check for 'return' prefix in has_return starts_with("return") matched any identifier beginning with those letters (returnData, returnItems, returnCode), causing expression-style scripts to skip the return prefix and evaluate to undefined. Use strip_prefix + check the trailing character (whitespace, ; or end of string) to match only the actual return keyword, consistent with the has_keyword_prefix helper used for statement keywords above. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-plugin/src/commands.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 27b910d45..b3c5e722d 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -188,9 +188,14 @@ pub(crate) async fn execute( || request.script.trim_start().starts_with("try{") || request.script.trim_start().starts_with("do ") || request.script.trim_start().starts_with("do{"); - // Only prepend "return" for pure expressions (no statements, no existing return at start) - // Use starts_with("return") not contains("return") to avoid false positives like "returnData" - let has_return = request.script.trim_start().starts_with("return"); + let has_return = { + let t = request.script.trim_start(); + if let Some(rest) = t.strip_prefix("return") { + rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') + } else { + false + } + }; let body = if !has_statement && !has_return { // Pure expression - add return so it evaluates and returns format!("return {};", request.script) From 152ea0481b9e99408b48b49da5073f97d8d7547f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 00:50:25 +0100 Subject: [PATCH 077/130] fix(electron): require => when paren-prefixed script is classified as function-like startsWith('(') alone matched any parenthesized expression such as (document.title) or (a + b), passing them through unmodified and skipping the IIFE wrapper that adds the return statement. Align with executeCdp.ts which already requires both startsWith('(') and includes('=>'). Co-Authored-By: Claude Sonnet 4.6 --- .../electron-service/src/commands/execute.ts | 2 +- .../test/commands/execute.spec.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 9fe38726c..87fb14e4d 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -46,7 +46,7 @@ function wrapStringScript(script: string): string { // Only match single-param arrow at START of script (e.g., "x => x + 1") // Don't match arrows inside expressions like "return items.filter(x => x > 0)" const isFunctionLike = - trimmed.startsWith('(') || + (trimmed.startsWith('(') && trimmed.includes('=>')) || /^function[\s(]/.test(trimmed) || /^async[\s(]/.test(trimmed) || /^(\w+)\s*=>/.test(trimmed); // single-param arrow at START like "x => x + 1" diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 4936b7de6..91f019efe 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -159,4 +159,25 @@ describe('execute Command', () => { expect.stringContaining('(async () => { return functionResult.call(); })()'), ); }); + + it('should treat (document.title) as expression (paren without arrow)', async () => { + await execute(globalThis.browser, '(document.title)'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return (document.title); })()'), + ); + }); + + it('should treat (a + b) as expression (paren without arrow)', async () => { + await execute(globalThis.browser, '(a + b)'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return (a + b); })()'), + ); + }); + + it('should treat (x, y) => x + y as function-like (paren arrow)', async () => { + await execute(globalThis.browser, '(x, y) => x + y'); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '(x, y) => x + y'); + }); }); From 3c148503336b151ef93304b23e4d572b991eb422 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 00:51:29 +0100 Subject: [PATCH 078/130] fix(electron): align executeCdp escape handling with execute.ts hasSemicolonOutsideQuotes in executeCdp.ts used prevChar === '\\' which only looks one character back. A literal backslash in source ("foo\\") is two chars \\, so the single-char check treats the character after the escaped backslash as escaped when it is not. Replace with the same consecutive-backslash count (odd = escaped, even = not escaped) already used in execute.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../electron-service/src/commands/executeCdp.ts | 13 +++++++++++-- .../test/commands/executeCdp.spec.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 5f1df06b1..982a64719 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -137,9 +137,18 @@ function hasSemicolonOutsideQuotes(str: string): boolean { for (let i = 0; i < str.length; i++) { const char = str[i]; - const prevChar = i > 0 ? str[i - 1] : ''; - if (prevChar === '\\') continue; + if (char !== '\\') { + let backslashCount = 0; + let j = i - 1; + while (j >= 0 && str[j] === '\\') { + backslashCount++; + j--; + } + if (backslashCount % 2 === 1) { + continue; + } + } if (char === "'" && !inDoubleQuote && !inTemplateLiteral) { inSingleQuote = !inSingleQuote; diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 26ce9a611..265bb73a9 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -120,6 +120,18 @@ describe('execute Command', () => { ); }); + it('should treat semicolon after escaped backslash as real (not skip it)', async () => { + // "foo\\";bar" — the backslash is itself escaped, so the " closes the string + // and the ; is outside quotes. Single-char prevChar check wrongly skips the ;. + await execute(globalThis.browser, client, '"foo\\\\";bar'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + functionDeclaration: expect.stringContaining('async () => {'), + }), + ); + }); + it('should call `mock.update()` when mockStore has some mocks', async () => { const updateMock = vi.fn(); vi.mocked(mockStore.getMocks).mockReturnValue([['dummy', { update: updateMock } as unknown as ElectronMock]]); From 7c41bfcfb3dc574c05f91614e056c691f22eb5ea Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 01:10:49 +0100 Subject: [PATCH 079/130] fix(tauri): route executeWithinTauri through executeAsync on non-embedded providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bypass that sent executeWithinTauri directly to originalExecute (the sync /execute/sync WebDriver endpoint) caused every browser.tauri.execute() call to resolve to {} on WebKit/macOS because WKWebView does not await the Promise returned by an async function over the sync endpoint. Removing the bypass lets executeWithinTauri fall through to the existing function path (lines 404-421) which already wraps in Promise.resolve().then() and dispatches via originalExecuteAsync — the same treatment applied to all other user-supplied function scripts. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-service/src/service.ts | 10 ---------- packages/tauri-service/test/service.spec.ts | 22 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 47ddaf219..4704b4fe9 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -378,16 +378,6 @@ export default class TauriWorkerService { script: string | ((...args: InnerArguments) => ReturnValue), ...args: InnerArguments ): Promise { - // Skip patching for service's internal executeWithinTauri calls for official drivers - // to avoid architecture conflicts, but allow patching for embedded drivers - if (typeof script === 'function' && script.name === 'executeWithinTauri' && isEmbedded === false) { - log.debug('Skipping execute patch for service internal call'); - return originalExecute( - script as string | ((...args: unknown[]) => unknown), - ...(args as unknown[]), - ) as Promise; - } - const scriptString = typeof script === 'function' ? script.toString() : script; if (isEmbedded) { diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 3e34e607a..1293f2de0 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -139,6 +139,28 @@ describe('TauriWorkerService', () => { expect(mockExecute).not.toHaveBeenCalled(); }); + it('should route executeWithinTauri through executeAsync on non-embedded providers', () => { + const mockExecute = vi.fn().mockResolvedValue(undefined); + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); + const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + + // Simulate the internal call that commands/execute.ts makes + const executeWithinTauri = async function executeWithinTauri( + _script: string, + _execOptions: object, + _argsJson: string, + ) {}; + mockBrowser.execute(executeWithinTauri as any, 'fn string', {}, '[]'); + + // Must use executeAsync — the async function returns a Promise that the sync + // WebDriver endpoint on WebKit (WKWebView/macOS) cannot await. + expect(mockExecuteAsync).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + it('should pass string scripts as-is for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); const mockBrowser = createMockBrowser({ execute: mockExecute }); From 727213f1db5004562416beeb35279ade93823cdf Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 01:11:38 +0100 Subject: [PATCH 080/130] fix(tauri): prepend return for expression-style string scripts on non-embedded providers The string branch wrapped scripts as (async function() { scriptString }) without prepending return, so expression-style calls like browser.execute('1 + 2 + 3') or browser.execute('document.title') always resolved to undefined on non-embedded (official/crabnebula) providers. Apply the same statement-keyword heuristic used in the Rust plugin and guest-js: detect statement keywords (const, let, var, if, for, while, switch, throw, try, do, return) with a word-boundary lookahead, and prepend return only when none are present. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-service/src/service.ts | 7 +++++- packages/tauri-service/test/service.spec.ts | 27 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 4704b4fe9..87378b44c 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -412,9 +412,14 @@ export default class TauriWorkerService { } else { // For strings: use executeAsync with explicit done callback // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly + const trimmed = scriptString.trim(); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test( + trimmed, + ); + const wrappedBody = hasStatementKeyword ? scriptString : `return ${scriptString};`; const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - (async function() { ${scriptString} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( + (async function() { ${wrappedBody} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( (r) => arguments[arguments.length-1](r), (e) => arguments[arguments.length-1]({ __wdio_error__: e instanceof Error ? e.message : String(e) }) ); diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 1293f2de0..57bec5525 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -123,6 +123,33 @@ describe('TauriWorkerService', () => { expect(mockExecute).not.toHaveBeenCalled(); }); + it('should prepend return for expression-style string scripts on non-embedded providers', () => { + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ executeAsync: mockExecuteAsync }); + const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + mockBrowser.execute('1 + 2 + 3'); + + expect(mockExecuteAsync).toHaveBeenCalled(); + expect(mockExecuteAsync.mock.calls[0][0]).toContain('return 1 + 2 + 3;'); + }); + + it('should not prepend return for statement-style string scripts on non-embedded providers', () => { + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ executeAsync: mockExecuteAsync }); + const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + mockBrowser.execute('const x = 1; return x'); + + expect(mockExecuteAsync).toHaveBeenCalled(); + const wrappedScript = mockExecuteAsync.mock.calls[0][0] as string; + // The body should NOT have an extra "return" prepended + expect(wrappedScript).toContain('const x = 1; return x'); + expect(wrappedScript).not.toMatch(/return const/); + }); + it('should pass function scripts as-is for non-embedded providers using executeAsync', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); From 2482568a0de4933bfef8bb29fe8c132b9dad0c57 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 09:33:38 +0100 Subject: [PATCH 081/130] fix(tauri-plugin): enhance script execution handling in guest-js Updated the `execute` function to better differentiate between function-like and plain string scripts. Function-like scripts are now wrapped in an async IIFE with a return statement, while plain strings are handled with appropriate return logic based on the presence of statement keywords. This change improves the execution of user-supplied scripts, ensuring correct behavior across different script types. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-plugin/guest-js/index.ts | 31 +++++++++++++++++++------ packages/tauri-plugin/src/commands.rs | 22 +++++++++++------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 01eb05b97..42d113da5 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -146,12 +146,22 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson throw new Error('window.__TAURI__ is not available. Make sure withGlobalTauri is enabled in tauri.conf.json'); } - // Build a minimal tauri object with a mock-routing invoke. We deliberately avoid - // spreading/Object.assign on window.__TAURI__ or window.__TAURI__.core because on - // macOS/WKWebView those objects (or their Proxy wrappers installed by - // setupInvokeInterception) can have non-configurable/non-writable own data properties that - // trigger Proxy invariant violations when iterated. Only core.invoke is needed by scripts. - const wrappedScript = ` + const trimmed = script.trim(); + const isFunctionLike = + (trimmed.startsWith('(') && trimmed.includes('=>')) || + /^function[\s(]/.test(trimmed) || + /^async[\s(]/.test(trimmed) || + /^(\w+)\s*=>/.test(trimmed); + + let scriptToSend: string; + + if (isFunctionLike) { + // Build a minimal tauri object with a mock-routing invoke. We deliberately avoid + // spreading/Object.assign on window.__TAURI__ or window.__TAURI__.core because on + // macOS/WKWebView those objects (or their Proxy wrappers installed by + // setupInvokeInterception) can have non-configurable/non-writable own data properties that + // trigger Proxy invariant violations when iterated. Only core.invoke is needed by scripts. + scriptToSend = ` (async () => { const __wdio_args = ${argsJson ?? '[]'}; @@ -184,12 +194,19 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson return await (${script})(__wdio_tauri, ...__wdio_args); })() `.trim(); + } else { + // Plain string script — not callable. Wrap as an async IIFE body. + // Statement keywords (return, const, etc.) are passed through as-is; + // pure expressions get an explicit return so callers receive the value. + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); + scriptToSend = hasStatementKeyword ? `(async () => { ${script} })()` : `(async () => { return ${script}; })()`; + } const invoke = await getInvoke(); try { const result = await invoke('plugin:wdio|execute', { request: { - script: wrappedScript, + script: scriptToSend, args: [], window_label: options?.windowLabel, }, diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index b3c5e722d..c16d542a5 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -52,6 +52,10 @@ pub(crate) async fn execute( log::debug!("Execute command called"); log::trace!("Script length: {} chars", request.script.len()); + // Retain a reference to the invoking window for listener registration. + // We clone before the conditional because the else branch moves `window` into target_window. + let invoking_window = window.clone(); + // Determine which window to use for execution let target_window = if let Some(ref label) = request.window_label { log::debug!("Target window label specified: {}", label); @@ -146,8 +150,10 @@ pub(crate) async fn execute( && contains_arrow_outside_quotes(trimmed) && trimmed.find("=>").map(|pos| { let before = trimmed[..pos].trim(); - // Single param: alphanumeric chars only, no spaces (except for the param name) - !before.is_empty() && !before.contains(' ') + // Single param: no spaces and no parens before => + // Parens before => mean the arrow is inside a nested expression, not a top-level arrow + // e.g. "x => x + 1" is a param arrow; "obj.fn(x => x)" is not + !before.is_empty() && !before.contains(' ') && !before.contains('(') }).unwrap_or(false); // Only detect function-like patterns: function, async, arrow functions // Don't use starts_with('(') as it catches any parenthesized expression like (document.title) @@ -261,7 +267,7 @@ pub(crate) async fn execute( handle_event(event, tx_clone_app.clone()); }); - let listener_id_window = window.listen(&event_id, move |event| { + let listener_id_window = invoking_window.listen(&event_id, move |event| { log::trace!("Received result event on window target: {}", event.payload()); handle_event(event, tx_clone_window.clone()); }); @@ -329,7 +335,7 @@ pub(crate) async fn execute( if let Err(e) = target_window.eval(&script_with_result) { log::error!("Failed to eval script: {}", e); app.unlisten(listener_id_app); - window.unlisten(listener_id_window); + invoking_window.unlisten(listener_id_window); return Err(crate::Error::ExecuteError(format!("Failed to eval script: {}", e))); } @@ -346,20 +352,20 @@ pub(crate) async fn execute( log::debug!("Execute completed successfully"); log::trace!("Result: {:?}", result); app.unlisten(listener_id_app); - window.unlisten(listener_id_window); + invoking_window.unlisten(listener_id_window); Ok(result) } Ok(Ok(Err(e))) => { log::error!("JS error during execution: {}", e); app.unlisten(listener_id_app); - window.unlisten(listener_id_window); + invoking_window.unlisten(listener_id_window); Err(e) } Ok(Err(_)) => { // Channel closed without sending (shouldn't happen) log::error!("Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label); app.unlisten(listener_id_app); - window.unlisten(listener_id_window); + invoking_window.unlisten(listener_id_window); Err(crate::Error::ExecuteError(format!( "Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label @@ -369,7 +375,7 @@ pub(crate) async fn execute( log::error!("Timeout waiting for execute result after 30s. Event ID: {}. Window: {}", event_id, window_label); app.unlisten(listener_id_app); - window.unlisten(listener_id_window); + invoking_window.unlisten(listener_id_window); Err(crate::Error::ExecuteError(format!( "Script execution timed out after 30s. Event ID: {}. Window: {}", event_id, window_label From 2bd885c71bc0faa655441c60c6ff670493b25bb1 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 10:28:55 +0100 Subject: [PATCH 082/130] fix(tauri-service): add settleMs parameter to waitTestRunnerBackendReady Enhanced the waitTestRunnerBackendReady function to include a settleMs parameter, allowing for a delay before resolving the promise. Updated all calls to this function within the TauriLaunchService class to utilize the new parameter, improving the initialization timing for WebSocket handlers. --- packages/tauri-service/src/crabnebulaBackend.ts | 8 +++++++- packages/tauri-service/src/launcher.ts | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/tauri-service/src/crabnebulaBackend.ts b/packages/tauri-service/src/crabnebulaBackend.ts index c8a052348..87d347f31 100644 --- a/packages/tauri-service/src/crabnebulaBackend.ts +++ b/packages/tauri-service/src/crabnebulaBackend.ts @@ -197,6 +197,7 @@ export async function waitTestRunnerBackendReady( host: string = '127.0.0.1', port: number = 3000, timeoutMs: number = 30000, + settleMs: number = 0, ): Promise { const net = await import('node:net'); const started = Date.now(); @@ -217,7 +218,12 @@ export async function waitTestRunnerBackendReady( clearTimeout(timeout); socket.destroy(); log.info(`test-runner-backend ready on ${host}:${port}`); - resolve(); + if (settleMs > 0) { + log.debug(`Waiting ${settleMs}ms for WebSocket handler to initialize...`); + setTimeout(resolve, settleMs); + } else { + resolve(); + } }); socket.on('error', (err) => { diff --git a/packages/tauri-service/src/launcher.ts b/packages/tauri-service/src/launcher.ts index 7f9e49040..997d0c3bc 100644 --- a/packages/tauri-service/src/launcher.ts +++ b/packages/tauri-service/src/launcher.ts @@ -265,7 +265,7 @@ export default class TauriLaunchService { // Allocate port to prevent collision with worker backends await this.backendPortManager.allocatePortPair(backendPort, backendPort + 1); const { proc } = await startTestRunnerBackend({ port: backendPort, serviceOptions: mergedOptions }); - await waitTestRunnerBackendReady('127.0.0.1', backendPort); + await waitTestRunnerBackendReady('127.0.0.1', backendPort, 30000, 2000); this.testRunnerBackend = proc; @@ -370,7 +370,7 @@ export default class TauriLaunchService { serviceOptions: instanceOptions, instanceId, }); - await waitTestRunnerBackendReady(hostname, backendPort); + await waitTestRunnerBackendReady(hostname, backendPort, 30000, 2000); this.workerBackends.set(instanceId, { proc, port: backendPort }); env.REMOTE_WEBDRIVER_URL = `http://${hostname}:${backendPort}`; @@ -695,7 +695,7 @@ export default class TauriLaunchService { serviceOptions: workerOptions, instanceId: cid, }); - await waitTestRunnerBackendReady('127.0.0.1', backendPort); + await waitTestRunnerBackendReady('127.0.0.1', backendPort, 30000, 2000); this.workerBackends.set(cid, { proc, port: backendPort }); workerEnv.REMOTE_WEBDRIVER_URL = `http://127.0.0.1:${backendPort}`; log.info(`Worker ${cid} backend ready on port ${backendPort}`); @@ -912,7 +912,7 @@ export default class TauriLaunchService { serviceOptions: config.options, instanceId: config.instanceId, }); - await waitTestRunnerBackendReady(hostname, config.backendPort); + await waitTestRunnerBackendReady(hostname, config.backendPort, 30000, 2000); // Store in the same location it was originally stored if (configs.length === 1 && config.instanceId === 'tauri-driver') { From d93b308699b1c94ae50041439704b0ed23f8d517 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 10:29:06 +0100 Subject: [PATCH 083/130] fix(ci): enhance cleanup process for CrabNebula provider tests Added additional cleanup commands to terminate 'test-runner-backend' and 'tauri-driver' processes before and after the CrabNebula provider tests, ensuring a cleaner test environment and preventing potential conflicts during execution. --- .../workflows/_ci-e2e-tauri-all-providers.reusable.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/_ci-e2e-tauri-all-providers.reusable.yml b/.github/workflows/_ci-e2e-tauri-all-providers.reusable.yml index bfc0bba8d..b4cfbabbd 100644 --- a/.github/workflows/_ci-e2e-tauri-all-providers.reusable.yml +++ b/.github/workflows/_ci-e2e-tauri-all-providers.reusable.yml @@ -428,8 +428,12 @@ jobs: echo "Cleaning up before CrabNebula provider tests..." if [ "${{ runner.os }}" == "Windows" ]; then powershell -Command "Get-Process -Name 'tauri-e2e-app' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue" || true + powershell -Command "Get-Process -Name 'test-runner-backend' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue" || true + powershell -Command "Get-Process -Name 'tauri-driver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue" || true else pkill -9 -f tauri-e2e-app || true + pkill -9 -f test-runner-backend || true + pkill -9 -f tauri-driver || true fi - name: 🧪 E2E Tests [CrabNebula] @@ -457,8 +461,12 @@ jobs: echo "Cleaning up after CrabNebula provider tests..." if [ "${{ runner.os }}" == "Windows" ]; then powershell -Command "Get-Process -Name 'tauri-e2e-app' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue" || true + powershell -Command "Get-Process -Name 'test-runner-backend' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue" || true + powershell -Command "Get-Process -Name 'tauri-driver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue" || true else pkill -9 -f tauri-e2e-app || true + pkill -9 -f test-runner-backend || true + pkill -9 -f tauri-driver || true fi - name: 🐛 Debug Information [CrabNebula] From a610b009b3a3e33b1aec47900840cd8142d95beb Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 11:05:04 +0100 Subject: [PATCH 084/130] fix(tauri-service): improve function script handling for embedded WebDriver Updated the TauriWorkerService to pass function scripts directly to originalExecute for embedded WebDriver, ensuring correct invocation with arguments. Adjusted related tests to reflect this change, enhancing clarity on how function scripts are processed. --- packages/tauri-service/src/service.ts | 7 ++++--- packages/tauri-service/test/service.spec.ts | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 87378b44c..099dce1e3 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -381,9 +381,10 @@ export default class TauriWorkerService { const scriptString = typeof script === 'function' ? script.toString() : script; if (isEmbedded) { - // For embedded WebDriver: pass the script through untouched so WDIO - // can invoke function scripts correctly. - return originalExecute(scriptString, ...args) as Promise; + return (originalExecute as unknown as (s: typeof script, ...a: typeof args) => Promise)( + script, + ...args, + ); } // For functions: use .toString() - produces valid JS function source diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 57bec5525..cab2354f3 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -208,8 +208,10 @@ describe('TauriWorkerService', () => { const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - // Functions are converted to string via .toString() then passed to original execute - expect(mockExecute).toHaveBeenCalledWith(testFn.toString(), 1, 2); + // For embedded, the original function is passed directly so WDIO can invoke it with args. + // Converting to string would lose the invocation — the WebDriver would get a function + // expression as the script body and return the function object instead of its result. + expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); }); }); @@ -309,15 +311,16 @@ describe('TauriWorkerService', () => { it('should clear stale mocks at session start for embedded driver provider', async () => { const mockBrowser = createMockBrowser(); - // Capture before patchBrowserExecute replaces browser.execute; patchBrowserExecute converts - // functions to strings before delegating to originalExecute, so we match on script content. + // Capture before patchBrowserExecute replaces browser.execute. For embedded, the patch + // passes function scripts directly to originalExecute (not as a string), so we match on + // the function's name which contains the intent. const originalExecute = mockBrowser.execute as ReturnType; const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); await service.before({} as any, [], mockBrowser); const clearCall = originalExecute.mock.calls.find( - ([script]) => typeof script === 'string' && script.includes('__wdio_mocks__'), + ([script]) => typeof script === 'function' && script.name === 'clearStaleMocks', ); expect(clearCall).toBeDefined(); }); From 4008cd7e15a2dca5962ba9593d5ab17b67780598 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 11:05:35 +0100 Subject: [PATCH 085/130] fix(tauri-plugin): improve arrow function detection in script execution Enhanced the `execute` function in guest-js and commands.rs to accurately identify top-level arrow functions. Introduced `hasTopLevelArrow` and `has_arrow_outside_parens` utility functions to prevent false positives in nested expressions. Updated the logic to ensure proper handling of function-like scripts, improving the execution accuracy of user-supplied scripts. Co-Authored-By: Claude Sonnet 4.6 --- packages/tauri-plugin/guest-js/index.ts | 35 ++++++- packages/tauri-plugin/src/commands.rs | 118 +++++++++++++++--------- 2 files changed, 107 insertions(+), 46 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 42d113da5..751389ee9 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -141,6 +141,39 @@ interface ExecuteOptions { * @param argsJson - Serialized user arguments as JSON string (optional) * @returns Result of the script execution */ +// Returns true if s contains '=>' at bracket depth 0 (not nested inside parens/brackets). +// Prevents false positives on expressions like (arr.find(x => x)) where => is nested. +function hasTopLevelArrow(s: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && (inSingle || inDouble || inTemplate)) { + i++; + continue; + } + if (c === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (c === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (c === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (inSingle || inDouble || inTemplate) continue; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === '=' && depth === 0 && i + 1 < s.length && s[i + 1] === '>') return true; + } + return false; +} + export async function execute(script: string, options?: ExecuteOptions, argsJson?: string): Promise { if (!window.__TAURI__) { throw new Error('window.__TAURI__ is not available. Make sure withGlobalTauri is enabled in tauri.conf.json'); @@ -148,7 +181,7 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson const trimmed = script.trim(); const isFunctionLike = - (trimmed.startsWith('(') && trimmed.includes('=>')) || + (trimmed.startsWith('(') && hasTopLevelArrow(trimmed)) || /^function[\s(]/.test(trimmed) || /^async[\s(]/.test(trimmed) || /^(\w+)\s*=>/.test(trimmed); diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index c16d542a5..0a118b15c 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -96,56 +96,90 @@ pub(crate) async fn execute( }; // Check if => appears outside of string literals (to avoid false positives like "foo"=>"bar") - fn contains_arrow_outside_quotes(s: &str) -> bool { - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut in_backtick = false; - - for (i, c) in s.char_indices() { - // Check for escape sequences - count consecutive backslashes before this position - // Odd number of backslashes means the character is escaped - if c != '=' { - let mut backslash_count = 0; - let mut j = i; - while j > 0 { - let prev_char = s.chars().nth(j - 1); - if prev_char == Some('\\') { - backslash_count += 1; - j -= 1; - } else { - break; - } - } - // Skip this character if it's preceded by an odd number of backslashes - if backslash_count % 2 == 1 { + // All characters of interest ('\'', '"', '`', '\\', '=', '>') are ASCII (< 0x80) + // and cannot be continuation bytes in multi-byte UTF-8 sequences, so byte-level + // scanning is correct and avoids the char_indices/chars().nth() index mismatch. + fn contains_arrow_outside_quotes(s: &str) -> bool { + let bytes = s.as_bytes(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut in_backtick = false; + let mut backslash_count: usize = 0; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'\\' { + backslash_count += 1; + i += 1; continue; } + let escaped = backslash_count % 2 == 1; + backslash_count = 0; + if !escaped { + match b { + b'\'' if !in_double_quote && !in_backtick => in_single_quote = !in_single_quote, + b'"' if !in_single_quote && !in_backtick => in_double_quote = !in_double_quote, + b'`' if !in_single_quote && !in_double_quote => in_backtick = !in_backtick, + b'=' if !in_single_quote && !in_double_quote && !in_backtick => { + if i + 1 < bytes.len() && bytes[i + 1] == b'>' { + return true; + } + } + _ => {} + } + } + i += 1; } + false + } - // Track quote state - if c == '\'' && !in_double_quote && !in_backtick { - in_single_quote = !in_single_quote; - } else if c == '"' && !in_single_quote && !in_backtick { - in_double_quote = !in_double_quote; - } else if c == '`' && !in_single_quote && !in_double_quote { - in_backtick = !in_backtick; - } - - // Check for => outside of quotes - if c == '=' && !in_single_quote && !in_double_quote && !in_backtick { - if s.len() > i + 1 && s.chars().nth(i + 1) == Some('>') { - return true; + // Like contains_arrow_outside_quotes but also tracks bracket depth. + // Returns true only if => appears at depth 0 (outside all parens/brackets). + // Prevents false positives on (arr.find(x => x)) where => is nested inside + // the outer parentheses. + fn has_arrow_outside_parens(s: &str) -> bool { + let bytes = s.as_bytes(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut in_backtick = false; + let mut depth: i32 = 0; + let mut backslash_count: usize = 0; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'\\' { + backslash_count += 1; + i += 1; + continue; + } + let escaped = backslash_count % 2 == 1; + backslash_count = 0; + if !escaped { + match b { + b'\'' if !in_double_quote && !in_backtick => in_single_quote = !in_single_quote, + b'"' if !in_single_quote && !in_backtick => in_double_quote = !in_double_quote, + b'`' if !in_single_quote && !in_double_quote => in_backtick = !in_backtick, + _ if !in_single_quote && !in_double_quote && !in_backtick => match b { + b'(' | b'[' => depth += 1, + b')' | b']' => depth -= 1, + b'=' if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b'>' => { + return true; + } + _ => {} + }, + _ => {} + } } + i += 1; } + false } - false - } // Check for arrow functions at START of script: // - "(args) => ..." (parenthesized params) // - "param => ..." (single param, alphanumeric start) // Only detect arrows that are NOT inside string literals - let starts_with_paren_arrow = trimmed.starts_with('(') && contains_arrow_outside_quotes(trimmed); + let starts_with_paren_arrow = trimmed.starts_with('(') && has_arrow_outside_parens(trimmed); let single_param_arrow = trimmed.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') && contains_arrow_outside_quotes(trimmed) && trimmed.find("=>").map(|pos| { @@ -210,13 +244,7 @@ pub(crate) async fn execute( request.script.clone() }; - if !request.args.is_empty() { - let args_json = serde_json::to_string(&request.args) - .map_err(|e| crate::Error::SerializationError(format!("Failed to serialize args: {}", e)))?; - format!("(async () => {{ const __wdio_args = {}; {body} }})()", args_json) - } else { - format!("(async () => {{ {body} }})()") - } + format!("(async () => {{ {body} }})()") }; // Generate unique event ID for this execution From d6f2ae21840f3d730ce467e9c5b48cefaf4ae43e Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 11:28:11 +0100 Subject: [PATCH 086/130] feat(electron-service): add hasTopLevelArrow utility for improved script handling Introduced the `hasTopLevelArrow` function to enhance detection of top-level arrow functions in script execution. Updated the `execute` and `executeCdp` commands to utilize this new utility, ensuring more accurate identification of function-like scripts. This change improves the handling of user-supplied scripts, particularly in differentiating between function-like and plain string scripts. --- .../electron-service/src/commands/execute.ts | 15 +++----- .../src/commands/executeCdp.ts | 6 ++-- packages/electron-service/src/utils.ts | 36 ++++++++++++++++--- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 87fb14e4d..c488194c8 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -1,3 +1,5 @@ +import { hasTopLevelArrow } from '../utils.js'; + export async function execute( browser: WebdriverIO.Browser, script: string | ((...innerArgs: InnerArguments) => ReturnValue), @@ -33,23 +35,14 @@ export async function execute( return (returnValue as ReturnValue) ?? undefined; } -/** - * Wrap string scripts in async IIFE for proper execution in Electron - * - Function-like strings (() => ..., function() {}, async () =>): pass through as-is - * - Pure expressions (e.g., "1 + 2 + 3"): add return and wrap in IIFE - * - Statement scripts (e.g., "return 42", "const x = 1"): wrap in IIFE without adding return - */ function wrapStringScript(script: string): string { const trimmed = script.trim(); - // Check if it's a function-like string (should be passed through as-is) - // Only match single-param arrow at START of script (e.g., "x => x + 1") - // Don't match arrows inside expressions like "return items.filter(x => x > 0)" const isFunctionLike = - (trimmed.startsWith('(') && trimmed.includes('=>')) || + (trimmed.startsWith('(') && hasTopLevelArrow(trimmed)) || /^function[\s(]/.test(trimmed) || /^async[\s(]/.test(trimmed) || - /^(\w+)\s*=>/.test(trimmed); // single-param arrow at START like "x => x + 1" + /^(\w+)\s*=>/.test(trimmed); if (isFunctionLike) { // Function-like string - pass through as-is (CDP can handle it) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 982a64719..4195ac24e 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -7,7 +7,7 @@ import { parse, print } from 'recast'; import type { ElectronCdpBridge } from '../bridge'; import mockStore from '../mockStore.js'; -import { isInternalCommand } from '../utils.js'; +import { hasTopLevelArrow, isInternalCommand } from '../utils.js'; const CACHE_MAX_SIZE = 100; const cache = new Map(); @@ -55,7 +55,7 @@ export async function execute( const trimmed = script.trim(); // Only let recast handle arrow functions starting with ( and containing => // These get transformed to add electron parameter - const isArrowFunction = trimmed.startsWith('(') && trimmed.includes('=>') && !trimmed.includes('function'); + const isArrowFunction = trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.includes('function'); if (isArrowFunction) { // Arrow function - recast handles electron param injection @@ -105,7 +105,7 @@ function wrapStringScriptForCdp(script: string): string { // Check if it's a simple arrow function that can be transformed by recast // These patterns can be safely passed to recast which adds the electron parameter - const canRecastHandle = trimmed.startsWith('(') && trimmed.includes('=>') && !trimmed.includes('function'); + const canRecastHandle = trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.includes('function'); if (canRecastHandle) { // Simple arrow function - pass to recast for transformation diff --git a/packages/electron-service/src/utils.ts b/packages/electron-service/src/utils.ts index 799cb601d..3619303ac 100644 --- a/packages/electron-service/src/utils.ts +++ b/packages/electron-service/src/utils.ts @@ -1,10 +1,36 @@ import type { ExecuteOpts } from '@wdio/native-types'; -/** - * Check if a command is an internal command by examining the last argument. - * Internal commands are marked with `{ internal: true }` and should be - * excluded from certain processing like mock updates and window focus checks. - */ export function isInternalCommand(args: unknown[]): boolean { return Boolean((args[args.length - 1] as ExecuteOpts | undefined)?.internal); } + +export function hasTopLevelArrow(s: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && (inSingle || inDouble || inTemplate)) { + i++; + continue; + } + if (c === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (c === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (c === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (inSingle || inDouble || inTemplate) continue; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === '=' && depth === 0 && i + 1 < s.length && s[i + 1] === '>') return true; + } + return false; +} From 0cffdd33b6a395c067180c6a8512b66619f0f2ba Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 11:28:32 +0100 Subject: [PATCH 087/130] fix(tauri-plugin): enhance return detection for script execution Updated the `execute` function to include an additional check for scripts that start with '('. This change improves the accuracy of return detection in user-supplied scripts, ensuring better handling of various script formats. --- packages/tauri-plugin/src/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 0a118b15c..1b694a053 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -231,7 +231,7 @@ pub(crate) async fn execute( let has_return = { let t = request.script.trim_start(); if let Some(rest) = t.strip_prefix("return") { - rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') + rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') || rest.starts_with('(') } else { false } From 43053ef27f93cc973fb8c439c8b4f34bd94c8fe3 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 11:38:26 +0100 Subject: [PATCH 088/130] fix(tauri-service): enhance backend process management and readiness checks Updated the `startTestRunnerBackend` function to kill orphaned processes holding the specified port, improving resource management. Removed the `settleMs` parameter from `waitTestRunnerBackendReady` calls for cleaner code. Added a status probe for the tauri-driver on macOS to detect dead WebSocket connections, preventing hangs during execution. This change enhances the reliability and responsiveness of the Tauri service. --- .../tauri-service/src/crabnebulaBackend.ts | 30 +++++++++++---- packages/tauri-service/src/launcher.ts | 37 +++++++++++++++++-- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/tauri-service/src/crabnebulaBackend.ts b/packages/tauri-service/src/crabnebulaBackend.ts index 87d347f31..19092903b 100644 --- a/packages/tauri-service/src/crabnebulaBackend.ts +++ b/packages/tauri-service/src/crabnebulaBackend.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from 'node:child_process'; +import { type ChildProcess, execSync, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import { createLogger } from '@wdio/native-utils'; import { findTestRunnerBackend } from './driverManager.js'; @@ -61,6 +61,26 @@ export async function startTestRunnerBackend(options: StartBackendOptions): Prom log.info(`Starting test-runner-backend on port ${port}`); + // Kill any orphaned process still holding the port (e.g. from a previous WDIO run killed by SIGKILL) + try { + if (process.platform === 'win32') { + execSync( + `powershell -NoProfile -NonInteractive -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | Sort-Object -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }"`, + { stdio: 'ignore' }, + ); + } else { + const pidsOutput = execSync(`lsof -ti :${port}`, { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + if (pidsOutput) { + execSync(`kill -9 ${pidsOutput.split('\n').join(' ')}`, { stdio: 'ignore' }); + log.info(`Killed orphaned process(es) on port ${port}: ${pidsOutput.replace(/\n/g, ', ')}`); + } + } + } catch { + // No process on port, or kill failed — port is already free + } + return new Promise((resolve, reject) => { // Use --port flag with fallback to PORT env var const args = ['--port', port.toString()]; @@ -197,7 +217,6 @@ export async function waitTestRunnerBackendReady( host: string = '127.0.0.1', port: number = 3000, timeoutMs: number = 30000, - settleMs: number = 0, ): Promise { const net = await import('node:net'); const started = Date.now(); @@ -218,12 +237,7 @@ export async function waitTestRunnerBackendReady( clearTimeout(timeout); socket.destroy(); log.info(`test-runner-backend ready on ${host}:${port}`); - if (settleMs > 0) { - log.debug(`Waiting ${settleMs}ms for WebSocket handler to initialize...`); - setTimeout(resolve, settleMs); - } else { - resolve(); - } + resolve(); }); socket.on('error', (err) => { diff --git a/packages/tauri-service/src/launcher.ts b/packages/tauri-service/src/launcher.ts index 997d0c3bc..944a787de 100644 --- a/packages/tauri-service/src/launcher.ts +++ b/packages/tauri-service/src/launcher.ts @@ -265,7 +265,7 @@ export default class TauriLaunchService { // Allocate port to prevent collision with worker backends await this.backendPortManager.allocatePortPair(backendPort, backendPort + 1); const { proc } = await startTestRunnerBackend({ port: backendPort, serviceOptions: mergedOptions }); - await waitTestRunnerBackendReady('127.0.0.1', backendPort, 30000, 2000); + await waitTestRunnerBackendReady('127.0.0.1', backendPort, 30000); this.testRunnerBackend = proc; @@ -370,7 +370,7 @@ export default class TauriLaunchService { serviceOptions: instanceOptions, instanceId, }); - await waitTestRunnerBackendReady(hostname, backendPort, 30000, 2000); + await waitTestRunnerBackendReady(hostname, backendPort, 30000); this.workerBackends.set(instanceId, { proc, port: backendPort }); env.REMOTE_WEBDRIVER_URL = `http://${hostname}:${backendPort}`; @@ -537,6 +537,20 @@ export default class TauriLaunchService { throw new SevereServiceError(`Failed to start tauri-driver: ${(error as Error).message}`); } + // On macOS with CrabNebula, probe /status to detect a dead backend WebSocket before WDIO connects. + // The backend can drop its WebSocket to tauri-driver ~68ms after connect; without this check WDIO + // would hang for ~4.7 minutes before discovering the session is broken. + if (process.platform === 'darwin' && isCrabNebula) { + await new Promise((r) => setTimeout(r, 200)); + const statusOk = await this.probeTauriDriverStatus(port); + if (!statusOk) { + throw new SevereServiceError( + 'tauri-driver /status probe failed — CrabNebula backend WebSocket may be broken. ' + + 'Check CN_API_KEY validity and cloud relay connectivity.', + ); + } + } + // Update the capabilities object with hostname and port so WDIO connects to tauri-driver for (const cap of capsList) { (cap as { port?: number; hostname?: string }).port = port; @@ -695,7 +709,7 @@ export default class TauriLaunchService { serviceOptions: workerOptions, instanceId: cid, }); - await waitTestRunnerBackendReady('127.0.0.1', backendPort, 30000, 2000); + await waitTestRunnerBackendReady('127.0.0.1', backendPort, 30000); this.workerBackends.set(cid, { proc, port: backendPort }); workerEnv.REMOTE_WEBDRIVER_URL = `http://127.0.0.1:${backendPort}`; log.info(`Worker ${cid} backend ready on port ${backendPort}`); @@ -912,7 +926,7 @@ export default class TauriLaunchService { serviceOptions: config.options, instanceId: config.instanceId, }); - await waitTestRunnerBackendReady(hostname, config.backendPort, 30000, 2000); + await waitTestRunnerBackendReady(hostname, config.backendPort, 30000); // Store in the same location it was originally stored if (configs.length === 1 && config.instanceId === 'tauri-driver') { @@ -991,6 +1005,21 @@ export default class TauriLaunchService { log.debug('Tauri service completed'); } + private async probeTauriDriverStatus(port: number): Promise { + const http = await import('node:http'); + return new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${port}/status`, { timeout: 5000 }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); + } + /** * Start tauri-driver process */ From 5b5f26a20951a1298ffe5ae29e0c6acf32f7c90f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 11:49:57 +0100 Subject: [PATCH 089/130] fix(tauri-service): improve port validation and process termination in startTestRunnerBackend Enhanced the `startTestRunnerBackend` function by adding validation for the port number to ensure it falls within the valid range. Updated the process termination logic to utilize `execFileSync` for better compatibility and improved handling of orphaned processes, ensuring a more robust backend management experience. --- packages/tauri-service/src/crabnebulaBackend.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/tauri-service/src/crabnebulaBackend.ts b/packages/tauri-service/src/crabnebulaBackend.ts index 19092903b..0b02de64d 100644 --- a/packages/tauri-service/src/crabnebulaBackend.ts +++ b/packages/tauri-service/src/crabnebulaBackend.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, execSync, spawn } from 'node:child_process'; +import { type ChildProcess, execFileSync, execSync, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import { createLogger } from '@wdio/native-utils'; import { findTestRunnerBackend } from './driverManager.js'; @@ -62,6 +62,9 @@ export async function startTestRunnerBackend(options: StartBackendOptions): Prom log.info(`Starting test-runner-backend on port ${port}`); // Kill any orphaned process still holding the port (e.g. from a previous WDIO run killed by SIGKILL) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port: ${port}`); + } try { if (process.platform === 'win32') { execSync( @@ -69,12 +72,18 @@ export async function startTestRunnerBackend(options: StartBackendOptions): Prom { stdio: 'ignore' }, ); } else { - const pidsOutput = execSync(`lsof -ti :${port}`, { stdio: ['ignore', 'pipe', 'ignore'] }) + const pidsOutput = execFileSync('lsof', ['-ti', `:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] }) .toString() .trim(); if (pidsOutput) { - execSync(`kill -9 ${pidsOutput.split('\n').join(' ')}`, { stdio: 'ignore' }); - log.info(`Killed orphaned process(es) on port ${port}: ${pidsOutput.replace(/\n/g, ', ')}`); + const pids = pidsOutput + .split('\n') + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isInteger(n) && n > 0); + for (const pid of pids) { + process.kill(pid, 'SIGKILL'); + } + log.info(`Killed orphaned process(es) on port ${port}: ${pids.join(', ')}`); } } } catch { From 3cacc77acadf720442758970ce1d9e081b567a43 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 12:06:36 +0100 Subject: [PATCH 090/130] fix(tauri-service): refactor process termination command in startTestRunnerBackend Updated the `startTestRunnerBackend` function to use `execFileSync` for executing the PowerShell command that terminates processes occupying the specified port. This change enhances compatibility and improves the clarity of the command execution, contributing to better resource management in the backend. --- packages/tauri-service/src/crabnebulaBackend.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/tauri-service/src/crabnebulaBackend.ts b/packages/tauri-service/src/crabnebulaBackend.ts index 0b02de64d..7384b75fe 100644 --- a/packages/tauri-service/src/crabnebulaBackend.ts +++ b/packages/tauri-service/src/crabnebulaBackend.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, execFileSync, execSync, spawn } from 'node:child_process'; +import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import { createLogger } from '@wdio/native-utils'; import { findTestRunnerBackend } from './driverManager.js'; @@ -67,8 +67,14 @@ export async function startTestRunnerBackend(options: StartBackendOptions): Prom } try { if (process.platform === 'win32') { - execSync( - `powershell -NoProfile -NonInteractive -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | Sort-Object -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }"`, + execFileSync( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | Sort-Object -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }`, + ], { stdio: 'ignore' }, ); } else { From b4c411ecb6c524f336356ff81cf66207bef95cb4 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 12:31:20 +0100 Subject: [PATCH 091/130] fix(tauri-service): update script execution handling for embedded WebDriver Refactored the TauriWorkerService to ensure that both string and function scripts are executed using `executeAsync` for the embedded WebDriver. This change addresses the issue of WebKit not auto-awaiting Promises from synchronous executions, enhancing the reliability of script execution. Updated related tests to reflect the new execution behavior. --- packages/tauri-service/src/service.ts | 16 ++--------- packages/tauri-service/test/service.spec.ts | 31 ++++++++++----------- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 099dce1e3..71ad2a1da 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -370,9 +370,7 @@ export default class TauriWorkerService { return; } - const originalExecute = browser.execute.bind(browser); const originalExecuteAsync = (browser.executeAsync as typeof browser.execute).bind(browser); - const isEmbedded = this.driverProvider === 'embedded'; const patchedExecute = async function patchedExecute( script: string | ((...args: InnerArguments) => ReturnValue), @@ -380,18 +378,8 @@ export default class TauriWorkerService { ): Promise { const scriptString = typeof script === 'function' ? script.toString() : script; - if (isEmbedded) { - return (originalExecute as unknown as (s: typeof script, ...a: typeof args) => Promise)( - script, - ...args, - ); - } - - // For functions: use .toString() - produces valid JS function source - // For strings: pass as-is (let the WebDriver handle it, or use executeAsync for async results) - - // For non-embedded (tauri-driver/official): use executeAsync for both functions and strings - // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute + // Both embedded and non-embedded Tauri WebDrivers use WebKit, which does not auto-await + // Promises returned from execute/sync. Always use executeAsync with an explicit callback. if (typeof script === 'function') { // Function scripts: use executeAsync with .then() callbacks to handle async results // Wrap in Promise.resolve to handle both sync and async function return values diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index cab2354f3..f3c1613b6 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -188,30 +188,31 @@ describe('TauriWorkerService', () => { expect(mockExecute).not.toHaveBeenCalled(); }); - it('should pass string scripts as-is for embedded provider', () => { + it('should use executeAsync for string scripts on embedded provider', () => { + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - expect(mockExecute).toHaveBeenCalledWith('return document.title'); + expect(mockExecuteAsync).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); }); - it('should pass function scripts as-is for embedded provider', () => { + it('should use executeAsync for function scripts on embedded provider', () => { + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute }); + const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - // For embedded, the original function is passed directly so WDIO can invoke it with args. - // Converting to string would lose the invocation — the WebDriver would get a function - // expression as the script body and return the function object instead of its result. - expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); + expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); + expect(mockExecute).not.toHaveBeenCalled(); }); }); @@ -310,17 +311,15 @@ describe('TauriWorkerService', () => { }); it('should clear stale mocks at session start for embedded driver provider', async () => { - const mockBrowser = createMockBrowser(); - // Capture before patchBrowserExecute replaces browser.execute. For embedded, the patch - // passes function scripts directly to originalExecute (not as a string), so we match on - // the function's name which contains the intent. - const originalExecute = mockBrowser.execute as ReturnType; + const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + const mockBrowser = createMockBrowser({ executeAsync: mockExecuteAsync }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); await service.before({} as any, [], mockBrowser); - const clearCall = originalExecute.mock.calls.find( - ([script]) => typeof script === 'function' && script.name === 'clearStaleMocks', + // clearStaleMocks is a function script; patchedExecute routes it through executeAsync + const clearCall = mockExecuteAsync.mock.calls.find( + ([script]) => typeof script === 'string' && script.includes('clearStaleMocks'), ); expect(clearCall).toBeDefined(); }); From 36f89642af7e2e6b289d0fb58ab6c4ba6939cb5c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 12:31:48 +0100 Subject: [PATCH 092/130] refactor(electron-service): simplify IIFE wrapping for script execution Updated the script execution handling in the `execute` command to remove the async IIFE wrapping for both statement and expression-style scripts. This change enhances clarity and consistency in how scripts are executed, while also updating related tests to reflect the new behavior. --- .../electron-service/src/commands/execute.ts | 6 ++-- .../test/commands/execute.spec.ts | 36 ++++++++----------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index c488194c8..fd110827d 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -57,11 +57,11 @@ function wrapStringScript(script: string): string { const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); if (hasRealSemicolon || hasStatementKeyword) { - // Multi-statement or statement-style script - wrap in async IIFE - return `(async () => { ${script} })()`; + // Multi-statement or statement-style script + return `(() => { ${script} })()`; } else { // Pure expression - add return and wrap in async IIFE - return `(async () => { return ${script}; })()`; + return `(() => { return ${script}; })()`; } } diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 91f019efe..4a97aa229 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -78,69 +78,61 @@ describe('execute Command', () => { expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), script); }); - it('should wrap expression-style string scripts in async IIFE with return', async () => { + it('should wrap expression-style string scripts in IIFE with return', async () => { await execute(globalThis.browser, '1 + 2 + 3'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return 1 + 2 + 3; })()'), + expect.stringContaining('(() => { return 1 + 2 + 3; })()'), ); }); - it('should wrap statement-style string scripts in async IIFE without adding return', async () => { + it('should wrap statement-style string scripts in IIFE without adding return', async () => { await execute(globalThis.browser, 'return 42'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return 42 })()'), + expect.stringContaining('(() => { return 42 })()'), ); }); - it('should wrap multi-statement string scripts in async IIFE', async () => { + it('should wrap multi-statement string scripts in IIFE', async () => { await execute(globalThis.browser, 'const x = 10; const y = 20; return x + y;'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(async () => {'), - ); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('(() => {')); }); it('should handle return(expr) pattern without adding extra return', async () => { - // return() pattern should be treated as statement, not expression await execute(globalThis.browser, 'return(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return(document.title) })()'), + expect.stringContaining('(() => { return(document.title) })()'), ); }); it('should not false-positive on semicolons inside string literals', async () => { - // Semicolons inside string literals should not trigger statement detection await execute(globalThis.browser, '"foo;bar"'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return "foo;bar"; })()'), + expect.stringContaining('(() => { return "foo;bar"; })()'), ); }); it('should treat document.title as expression (do prefix false positive)', async () => { - // "document.title" starts with "do" but is NOT a statement - should add return await execute(globalThis.browser, 'document.title'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return document.title; })()'), + expect.stringContaining('(() => { return document.title; })()'), ); }); it('should treat forEach() as expression (for prefix false positive)', async () => { - // "[1,2,3].forEach()" starts with "for" but is NOT a statement - should add return await execute(globalThis.browser, '[1,2,3].forEach(x => x)'); expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('return')); }); it('should treat trySomething() as expression (try prefix false positive)', async () => { - // "trySomething()" starts with "try" but is NOT a statement - should add return await execute(globalThis.browser, 'trySomething()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return trySomething(); })()'), + expect.stringContaining('(() => { return trySomething(); })()'), ); }); @@ -148,7 +140,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'asyncData.fetchAll()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return asyncData.fetchAll(); })()'), + expect.stringContaining('(() => { return asyncData.fetchAll(); })()'), ); }); @@ -156,7 +148,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'functionResult.call()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return functionResult.call(); })()'), + expect.stringContaining('(() => { return functionResult.call(); })()'), ); }); @@ -164,7 +156,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return (document.title); })()'), + expect.stringContaining('(() => { return (document.title); })()'), ); }); @@ -172,7 +164,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(a + b)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return (a + b); })()'), + expect.stringContaining('(() => { return (a + b); })()'), ); }); From b0367f2b7965023b5269e7494dc933327a1a6e3e Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 12:56:21 +0100 Subject: [PATCH 093/130] feat(electron-service): add hasSemicolonOutsideQuotes utility for script validation Introduced the `hasSemicolonOutsideQuotes` function to detect semicolons outside of string literals and template literals in scripts. Updated the `execute` and `executeCdp` commands to utilize this new utility, enhancing the validation of user-supplied scripts and improving overall script handling accuracy. --- .../electron-service/src/commands/execute.ts | 53 +------------------ .../src/commands/executeCdp.ts | 44 +-------------- packages/electron-service/src/utils.ts | 31 +++++++++++ 3 files changed, 33 insertions(+), 95 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index fd110827d..47fb88536 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -1,4 +1,4 @@ -import { hasTopLevelArrow } from '../utils.js'; +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '../utils.js'; export async function execute( browser: WebdriverIO.Browser, @@ -64,54 +64,3 @@ function wrapStringScript(script: string): string { return `(() => { return ${script}; })()`; } } - -/** - * Check for semicolons outside of string literals and template literals - */ -function hasSemicolonOutsideQuotes(str: string): boolean { - let inSingleQuote = false; - let inDoubleQuote = false; - let inTemplateLiteral = false; - let bracketDepth = 0; - - for (let i = 0; i < str.length; i++) { - const char = str[i]; - - // Handle escape sequences - count consecutive backslashes before this char - // Odd number of backslashes means the character is escaped - if (char !== '\\') { - let backslashCount = 0; - let j = i - 1; - while (j >= 0 && str[j] === '\\') { - backslashCount++; - j--; - } - if (backslashCount % 2 === 1) { - // Odd backslashes = escaped character, skip - continue; - } - } - - // Track quote states - if (char === "'" && !inDoubleQuote && !inTemplateLiteral) { - inSingleQuote = !inSingleQuote; - } else if (char === '"' && !inSingleQuote && !inTemplateLiteral) { - inDoubleQuote = !inDoubleQuote; - } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { - inTemplateLiteral = !inTemplateLiteral; - } - - // Track bracket depth (for handling object/array literals inside quotes) - if (!inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { - if (char === '{' || char === '[' || char === '(') bracketDepth++; - if (char === '}' || char === ']' || char === ')') bracketDepth--; - } - - // Check for semicolon outside of quotes/brackets - if (char === ';' && bracketDepth === 0 && !inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { - return true; - } - } - - return false; -} diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 4195ac24e..284c0a21b 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -7,7 +7,7 @@ import { parse, print } from 'recast'; import type { ElectronCdpBridge } from '../bridge'; import mockStore from '../mockStore.js'; -import { hasTopLevelArrow, isInternalCommand } from '../utils.js'; +import { hasSemicolonOutsideQuotes, hasTopLevelArrow, isInternalCommand } from '../utils.js'; const CACHE_MAX_SIZE = 100; const cache = new Map(); @@ -129,48 +129,6 @@ function wrapStringScriptForCdp(script: string): string { } } -function hasSemicolonOutsideQuotes(str: string): boolean { - let inSingleQuote = false; - let inDoubleQuote = false; - let inTemplateLiteral = false; - let bracketDepth = 0; - - for (let i = 0; i < str.length; i++) { - const char = str[i]; - - if (char !== '\\') { - let backslashCount = 0; - let j = i - 1; - while (j >= 0 && str[j] === '\\') { - backslashCount++; - j--; - } - if (backslashCount % 2 === 1) { - continue; - } - } - - if (char === "'" && !inDoubleQuote && !inTemplateLiteral) { - inSingleQuote = !inSingleQuote; - } else if (char === '"' && !inSingleQuote && !inTemplateLiteral) { - inDoubleQuote = !inDoubleQuote; - } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { - inTemplateLiteral = !inTemplateLiteral; - } - - if (!inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { - if (char === '{' || char === '[' || char === '(') bracketDepth++; - if (char === '}' || char === ']' || char === ')') bracketDepth--; - } - - if (char === ';' && bracketDepth === 0 && !inSingleQuote && !inDoubleQuote && !inTemplateLiteral) { - return true; - } - } - - return false; -} - async function syncMockStatus(args: unknown[]) { const mocks = mockStore.getMocks(); if (mocks.length > 0 && !isInternalCommand(args)) { diff --git a/packages/electron-service/src/utils.ts b/packages/electron-service/src/utils.ts index 3619303ac..5c69ce292 100644 --- a/packages/electron-service/src/utils.ts +++ b/packages/electron-service/src/utils.ts @@ -4,6 +4,37 @@ export function isInternalCommand(args: unknown[]): boolean { return Boolean((args[args.length - 1] as ExecuteOpts | undefined)?.internal); } +export function hasSemicolonOutsideQuotes(s: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && (inSingle || inDouble || inTemplate)) { + i++; + continue; + } + if (c === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (c === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (c === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (inSingle || inDouble || inTemplate) continue; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === ';' && depth === 0) return true; + } + return false; +} + export function hasTopLevelArrow(s: string): boolean { let depth = 0; let inSingle = false; From 3ed0a80b0bad5821b50f2c39fe373722a4e0083f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 12:56:32 +0100 Subject: [PATCH 094/130] feat(tauri-plugin): add hasSemicolonOutsideQuotes utility for enhanced script validation Introduced the `hasSemicolonOutsideQuotes` function in both TypeScript and Rust implementations to detect semicolons outside of string literals. Updated the `execute` function to utilize this utility, improving the validation of user-supplied scripts and ensuring more accurate handling of various script formats. --- packages/tauri-plugin/guest-js/index.ts | 39 +++++++++++++- packages/tauri-plugin/src/commands.rs | 72 +++++++++++++++++++------ 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 751389ee9..9bb46d78a 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -141,6 +141,42 @@ interface ExecuteOptions { * @param argsJson - Serialized user arguments as JSON string (optional) * @returns Result of the script execution */ +// These helpers are duplicated from electron-service/src/utils.ts. guest-js compiles to browser +// JavaScript and cannot import from @wdio/native-utils or any Node.js package. + +// Returns true if s contains ';' outside string literals at bracket depth 0. +// Detects multi-statement scripts like "someFn(); anotherFn()" that don't start with a keyword. +function hasSemicolonOutsideQuotes(s: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && (inSingle || inDouble || inTemplate)) { + i++; + continue; + } + if (c === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (c === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (c === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (inSingle || inDouble || inTemplate) continue; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === ';' && depth === 0) return true; + } + return false; +} + // Returns true if s contains '=>' at bracket depth 0 (not nested inside parens/brackets). // Prevents false positives on expressions like (arr.find(x => x)) where => is nested. function hasTopLevelArrow(s: string): boolean { @@ -232,7 +268,8 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson // Statement keywords (return, const, etc.) are passed through as-is; // pure expressions get an explicit return so callers receive the value. const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); - scriptToSend = hasStatementKeyword ? `(async () => { ${script} })()` : `(async () => { return ${script}; })()`; + const hasStatement = hasStatementKeyword || hasSemicolonOutsideQuotes(trimmed); + scriptToSend = hasStatement ? `(async () => { ${script} })()` : `(async () => { return ${script}; })()`; } const invoke = await getInvoke(); diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 1b694a053..08276a72e 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -175,6 +175,43 @@ pub(crate) async fn execute( false } + // Returns true if s contains ';' outside string literals at bracket depth 0. + fn has_semicolon_outside_quotes(s: &str) -> bool { + let bytes = s.as_bytes(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut in_backtick = false; + let mut depth: i32 = 0; + let mut backslash_count: usize = 0; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'\\' { + backslash_count += 1; + i += 1; + continue; + } + let escaped = backslash_count % 2 == 1; + backslash_count = 0; + if !escaped { + match b { + b'\'' if !in_double_quote && !in_backtick => in_single_quote = !in_single_quote, + b'"' if !in_single_quote && !in_backtick => in_double_quote = !in_double_quote, + b'`' if !in_single_quote && !in_double_quote => in_backtick = !in_backtick, + _ if !in_single_quote && !in_double_quote && !in_backtick => match b { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b';' if depth == 0 => return true, + _ => {} + }, + _ => {} + } + } + i += 1; + } + false + } + // Check for arrow functions at START of script: // - "(args) => ..." (parenthesized params) // - "param => ..." (single param, alphanumeric start) @@ -212,24 +249,25 @@ pub(crate) async fn execute( )); } else { // Statement/expression-style script - wrap in block-body IIFE - let has_statement = request.script.trim_start().starts_with("const ") - || request.script.trim_start().starts_with("let ") - || request.script.trim_start().starts_with("var ") - || request.script.trim_start().starts_with("if ") - || request.script.trim_start().starts_with("if(") - || request.script.trim_start().starts_with("for ") - || request.script.trim_start().starts_with("for(") - || request.script.trim_start().starts_with("while ") - || request.script.trim_start().starts_with("while(") - || request.script.trim_start().starts_with("switch ") - || request.script.trim_start().starts_with("switch(") - || request.script.trim_start().starts_with("throw ") - || request.script.trim_start().starts_with("try ") - || request.script.trim_start().starts_with("try{") - || request.script.trim_start().starts_with("do ") - || request.script.trim_start().starts_with("do{"); + let t = request.script.trim_start(); + let has_statement = t.starts_with("const ") + || t.starts_with("let ") + || t.starts_with("var ") + || t.starts_with("if ") + || t.starts_with("if(") + || t.starts_with("for ") + || t.starts_with("for(") + || t.starts_with("while ") + || t.starts_with("while(") + || t.starts_with("switch ") + || t.starts_with("switch(") + || t.starts_with("throw ") + || t.starts_with("try ") + || t.starts_with("try{") + || t.starts_with("do ") + || t.starts_with("do{") + || has_semicolon_outside_quotes(t); let has_return = { - let t = request.script.trim_start(); if let Some(rest) = t.strip_prefix("return") { rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') || rest.starts_with('(') } else { From 57a86918db3b2c4f19c907e7baa66c2944429be5 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 13:28:40 +0100 Subject: [PATCH 095/130] refactor(tauri-service): update script execution handling for embedded WebDriver Enhanced the TauriWorkerService to pass string scripts directly to the synchronous execute method for the embedded WebDriver, avoiding issues with WebKit's Promise handling. Updated tests to reflect the new behavior for both string and function scripts, ensuring accurate execution and improved reliability. --- packages/tauri-service/src/service.ts | 14 ++++++++-- packages/tauri-service/test/service.spec.ts | 29 +++++++++++---------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 71ad2a1da..82bbf293a 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -370,7 +370,9 @@ export default class TauriWorkerService { return; } + const originalExecute = browser.execute.bind(browser); const originalExecuteAsync = (browser.executeAsync as typeof browser.execute).bind(browser); + const isEmbedded = this.driverProvider === 'embedded'; const patchedExecute = async function patchedExecute( script: string | ((...args: InnerArguments) => ReturnValue), @@ -378,8 +380,16 @@ export default class TauriWorkerService { ): Promise { const scriptString = typeof script === 'function' ? script.toString() : script; - // Both embedded and non-embedded Tauri WebDrivers use WebKit, which does not auto-await - // Promises returned from execute/sync. Always use executeAsync with an explicit callback. + if (isEmbedded) { + // Tauri embedded WebDriver's execute/sync wraps the script as a function expression + // and calls it via .apply(null, args) (see tauri-plugin-webdriver executor.rs). Passing + // scriptString (a string, not a function object) avoids WebdriverIO's webdriverioPolyfill + // wrapper, which WebKit cannot parse. + return originalExecute(scriptString, ...args) as Promise; + } + + // Non-embedded (tauri-driver/official): use executeAsync for both functions and strings. + // WebKit (macOS/iOS Tauri) doesn't auto-await Promises from sync execute. if (typeof script === 'function') { // Function scripts: use executeAsync with .then() callbacks to handle async results // Wrap in Promise.resolve to handle both sync and async function return values diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index f3c1613b6..4e0c377e9 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -188,31 +188,30 @@ describe('TauriWorkerService', () => { expect(mockExecute).not.toHaveBeenCalled(); }); - it('should use executeAsync for string scripts on embedded provider', () => { - const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + it('should pass string scripts as-is to sync execute for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); + const mockBrowser = createMockBrowser({ execute: mockExecute }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); mockBrowser.execute('return document.title'); - expect(mockExecuteAsync).toHaveBeenCalled(); - expect(mockExecute).not.toHaveBeenCalled(); + expect(mockExecute).toHaveBeenCalledWith('return document.title'); }); - it('should use executeAsync for function scripts on embedded provider', () => { - const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); + it('should pass function scripts as a string to sync execute for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ execute: mockExecute, executeAsync: mockExecuteAsync }); + const mockBrowser = createMockBrowser({ execute: mockExecute }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); (service as any).patchBrowserExecute(mockBrowser); const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - expect(mockExecuteAsync).toHaveBeenCalledWith(expect.stringContaining('(a, b) => a + b'), 1, 2); - expect(mockExecute).not.toHaveBeenCalled(); + // For embedded, the function is converted to its source string so WebdriverIO skips + // its function-object polyfill wrapper. The embedded WebDriver's execute/sync then + // evaluates the string as a function expression and applies the args. + expect(mockExecute).toHaveBeenCalledWith(testFn.toString(), 1, 2); }); }); @@ -311,14 +310,16 @@ describe('TauriWorkerService', () => { }); it('should clear stale mocks at session start for embedded driver provider', async () => { - const mockExecuteAsync = vi.fn().mockResolvedValue(undefined); - const mockBrowser = createMockBrowser({ executeAsync: mockExecuteAsync }); + const mockBrowser = createMockBrowser(); + const originalExecute = mockBrowser.execute as ReturnType; const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); await service.before({} as any, [], mockBrowser); - // clearStaleMocks is a function script; patchedExecute routes it through executeAsync - const clearCall = mockExecuteAsync.mock.calls.find( + // For embedded, patchedExecute converts function scripts to their source string before + // calling the original (sync) execute. clearStaleMocks appears as the function name in + // the stringified source. + const clearCall = originalExecute.mock.calls.find( ([script]) => typeof script === 'string' && script.includes('clearStaleMocks'), ); expect(clearCall).toBeDefined(); From 35efe7b96d27a60a5700363e5b950f678d103db4 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 14:08:59 +0100 Subject: [PATCH 096/130] refactor(tauri-plugin): clarify script execution handling in PlatformExecutor Updated comments in the PlatformExecutor trait to specify that user scripts are treated as function bodies, aligning with W3C WebDriver specifications. This change enhances clarity regarding the expected format of scripts and improves understanding of the execution process. --- .../tauri-plugin-webdriver/src/platform/executor.rs | 13 +++++++------ packages/tauri-plugin/src/commands.rs | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/tauri-plugin-webdriver/src/platform/executor.rs b/packages/tauri-plugin-webdriver/src/platform/executor.rs index 2f1cedb9e..d7dde6718 100644 --- a/packages/tauri-plugin-webdriver/src/platform/executor.rs +++ b/packages/tauri-plugin-webdriver/src/platform/executor.rs @@ -884,13 +884,14 @@ pub trait PlatformExecutor: Send + Sync { let result_var = format!("__wdio_exec_{}", uuid::Uuid::new_v4()); // Wrapper script that: - // 1. Executes the user's script (handles both function expressions and function bodies) + // 1. Executes the user's script as a function body (per W3C WebDriver spec §13.2.2) // 2. Stores result in a global variable for polling // Note: We use an IIFE that returns `undefined` to avoid Promise serialization issues // - // WDIO sends scripts as function expressions like: "() => { return x; }" - // WebDriver spec expects function bodies like: "return x;" - // We handle both by wrapping the script in a way that works for both cases + // The script is treated as a function body. Clients that want to return a value must + // include an explicit `return` statement — this matches WebdriverIO's function-object + // wrapping (`return (fn).apply(null, arguments)`) and raw string scripts like + // `"return document.title"`. let wrapper = format!( r"(function() {{ var ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf'; @@ -945,8 +946,8 @@ pub trait PlatformExecutor: Send + Sync { (async function() {{ try {{ var args = {args_json}.map(deserializeArg); - // The script from WDIO is a function expression which we call directly - var raw_result = await ({script}).apply(null, args); + // W3C-compliant: wrap as function body, apply with args + var raw_result = await (function() {{ {script} }}).apply(null, args); var serialized = serializeValue(raw_result); window['{result_var}'] = {{ __wd_success: true, __wd_value: serialized }}; }} catch (e) {{ diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 08276a72e..fbc2a59ee 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -5,7 +5,6 @@ use tokio::sync::oneshot; use crate::models::ExecuteRequest; use crate::Result; -use crate::Error; /// Window state information for generic window management /// Mirrors Electron's window tracking - discover active window without app-specific knowledge From a004f59a04dbb57bc6561bf4414e4584d3c4c898 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 14:09:12 +0100 Subject: [PATCH 097/130] refactor(tauri-service): update script handling for embedded WebDriver execution Modified the TauriWorkerService to pass function scripts unchanged to the synchronous execute method for the embedded WebDriver, aligning with W3C compliance. Updated related tests to reflect this change, ensuring accurate function serialization and improved execution reliability. --- packages/tauri-service/src/service.ts | 13 ++++++++----- packages/tauri-service/test/service.spec.ts | 17 ++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 82bbf293a..9e11998c5 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -381,11 +381,14 @@ export default class TauriWorkerService { const scriptString = typeof script === 'function' ? script.toString() : script; if (isEmbedded) { - // Tauri embedded WebDriver's execute/sync wraps the script as a function expression - // and calls it via .apply(null, args) (see tauri-plugin-webdriver executor.rs). Passing - // scriptString (a string, not a function object) avoids WebdriverIO's webdriverioPolyfill - // wrapper, which WebKit cannot parse. - return originalExecute(scriptString, ...args) as Promise; + // Tauri embedded WebDriver's execute/sync is W3C-compliant (wraps the script as a + // function body) and awaits the result internally, so async functions work over the + // sync endpoint. Pass the script through untouched — WebdriverIO will handle function + // serialization via its standard polyfill wrapper. + return (originalExecute as unknown as (s: typeof script, ...a: typeof args) => Promise)( + script, + ...args, + ); } // Non-embedded (tauri-driver/official): use executeAsync for both functions and strings. diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 4e0c377e9..a976d7ba8 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -199,7 +199,7 @@ describe('TauriWorkerService', () => { expect(mockExecute).toHaveBeenCalledWith('return document.title'); }); - it('should pass function scripts as a string to sync execute for embedded provider', () => { + it('should pass function scripts unchanged to sync execute for embedded provider', () => { const mockExecute = vi.fn().mockResolvedValue(undefined); const mockBrowser = createMockBrowser({ execute: mockExecute }); const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); @@ -208,10 +208,10 @@ describe('TauriWorkerService', () => { const testFn = (a: number, b: number) => a + b; mockBrowser.execute(testFn as any, 1, 2); - // For embedded, the function is converted to its source string so WebdriverIO skips - // its function-object polyfill wrapper. The embedded WebDriver's execute/sync then - // evaluates the string as a function expression and applies the args. - expect(mockExecute).toHaveBeenCalledWith(testFn.toString(), 1, 2); + // For embedded, pass the script through untouched — WebdriverIO wraps the function with + // its polyfill and returns via `return (fn).apply(null, arguments)`, which is a valid + // function body for the W3C-compliant embedded WebDriver. + expect(mockExecute).toHaveBeenCalledWith(testFn, 1, 2); }); }); @@ -316,11 +316,10 @@ describe('TauriWorkerService', () => { await service.before({} as any, [], mockBrowser); - // For embedded, patchedExecute converts function scripts to their source string before - // calling the original (sync) execute. clearStaleMocks appears as the function name in - // the stringified source. + // For embedded, patchedExecute passes function scripts through untouched, so + // clearStaleMocks reaches originalExecute as a function with its name preserved. const clearCall = originalExecute.mock.calls.find( - ([script]) => typeof script === 'string' && script.includes('clearStaleMocks'), + ([script]) => typeof script === 'function' && script.name === 'clearStaleMocks', ); expect(clearCall).toBeDefined(); }); From 2cb6d81e72df81667fbd9d6426b7a50a72cc750e Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 14:48:12 +0100 Subject: [PATCH 098/130] refactor(tauri-service): simplify IIFE structure in mock script execution Refactored the inline function structure in the `createMock` function to remove unnecessary parentheses in the script execution strings. This change enhances readability and maintains consistency in the handling of mock implementations for the embedded WebDriver. --- packages/tauri-service/src/mock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri-service/src/mock.ts b/packages/tauri-service/src/mock.ts index bad064075..4e3f28a14 100644 --- a/packages/tauri-service/src/mock.ts +++ b/packages/tauri-service/src/mock.ts @@ -173,7 +173,7 @@ export async function createMock(command: string, browserContext?: WebdriverIO.B const implStr = implFn.toString(); await tauriExecute( browserToUse, - `((_tauri, cmd) => { const mockObj = window.__wdio_mocks__?.[cmd]; if (mockObj) { mockObj.mockImplementation?.(${implStr}); } })`, + `(_tauri, cmd) => { const mockObj = window.__wdio_mocks__?.[cmd]; if (mockObj) { mockObj.mockImplementation?.(${implStr}); } }`, command, ); @@ -186,7 +186,7 @@ export async function createMock(command: string, browserContext?: WebdriverIO.B const implStr = implFn.toString(); await tauriExecute( browserToUse, - `((_tauri, cmd) => { const mockObj = window.__wdio_mocks__?.[cmd]; if (mockObj) { mockObj.mockImplementationOnce?.(${implStr}); } })`, + `(_tauri, cmd) => { const mockObj = window.__wdio_mocks__?.[cmd]; if (mockObj) { mockObj.mockImplementationOnce?.(${implStr}); } }`, command, ); From 329ca84350b463cbcbe016d18bc1b8bc8766198a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 15:07:45 +0100 Subject: [PATCH 099/130] refactor(tauri-service): update script structure for WebDriver compatibility Refactored the script execution in the `triggerDeeplink` function to use plain statements instead of arrow functions. This change ensures compatibility with the embedded WebDriver's function wrapping, enhancing the reliability of deeplink handling. Updated comments for clarity on the purpose of the changes. --- packages/tauri-service/src/commands/triggerDeeplink.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/tauri-service/src/commands/triggerDeeplink.ts b/packages/tauri-service/src/commands/triggerDeeplink.ts index 7912cf44e..fe889e4f1 100644 --- a/packages/tauri-service/src/commands/triggerDeeplink.ts +++ b/packages/tauri-service/src/commands/triggerDeeplink.ts @@ -240,12 +240,13 @@ export async function triggerDeeplink(this: TauriServiceContext, url: string): P } try { - // Build URL using char codes to avoid WebKit parsing the URL string literally + // Build URL using char codes to avoid WebKit parsing the URL string literally. + // Use plain statements (not an arrow function) so the script works correctly when + // the embedded WebDriver wraps it as a function body: (function() { SCRIPT })(). const charCodes = Array.from(validatedUrl) .map((c) => c.charCodeAt(0)) .join(','); - // Use arrow function format - same as working checks in the test - const script = `() => { + const script = ` try { var charCodes = [${charCodes}]; var url = String.fromCharCode.apply(null, charCodes); @@ -260,7 +261,7 @@ export async function triggerDeeplink(this: TauriServiceContext, url: string): P } catch (e) { console.error('[WDIO Deeplink] Error:', e.message); } - }`; + `; await this.browser.execute(script); log.info(`Deeplink injected successfully: ${validatedUrl}`); From 5d0968b4a26c8389ad5057da1d54cac04f55d1f4 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 15:16:53 +0100 Subject: [PATCH 100/130] refactor: move script detection utilities to native-utils Refactored the `hasSemicolonOutsideQuotes` and `hasTopLevelArrow` functions from the electron-service to the newly created scriptDetect module in native-utils. Updated imports in the execute and executeCdp commands to utilize the new location, enhancing modularity and code organization. --- .../electron-service/src/commands/execute.ts | 2 +- .../src/commands/executeCdp.ts | 4 +- packages/electron-service/src/utils.ts | 62 ------------------- packages/native-utils/src/index.ts | 1 + packages/native-utils/src/scriptDetect.ts | 61 ++++++++++++++++++ packages/tauri-service/src/service.ts | 5 +- packages/tauri-service/test/service.spec.ts | 22 ++++--- 7 files changed, 81 insertions(+), 76 deletions(-) create mode 100644 packages/native-utils/src/scriptDetect.ts diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 47fb88536..3ef00303b 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -1,4 +1,4 @@ -import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '../utils.js'; +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils'; export async function execute( browser: WebdriverIO.Browser, diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 284c0a21b..b4b002624 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -3,11 +3,11 @@ import { createLogger } from '@wdio/native-utils'; const log = createLogger('electron-service', 'service'); +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils'; import { parse, print } from 'recast'; import type { ElectronCdpBridge } from '../bridge'; - import mockStore from '../mockStore.js'; -import { hasSemicolonOutsideQuotes, hasTopLevelArrow, isInternalCommand } from '../utils.js'; +import { isInternalCommand } from '../utils.js'; const CACHE_MAX_SIZE = 100; const cache = new Map(); diff --git a/packages/electron-service/src/utils.ts b/packages/electron-service/src/utils.ts index 5c69ce292..75f25a0d5 100644 --- a/packages/electron-service/src/utils.ts +++ b/packages/electron-service/src/utils.ts @@ -3,65 +3,3 @@ import type { ExecuteOpts } from '@wdio/native-types'; export function isInternalCommand(args: unknown[]): boolean { return Boolean((args[args.length - 1] as ExecuteOpts | undefined)?.internal); } - -export function hasSemicolonOutsideQuotes(s: string): boolean { - let depth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === '\\' && (inSingle || inDouble || inTemplate)) { - i++; - continue; - } - if (c === "'" && !inDouble && !inTemplate) { - inSingle = !inSingle; - continue; - } - if (c === '"' && !inSingle && !inTemplate) { - inDouble = !inDouble; - continue; - } - if (c === '`' && !inSingle && !inDouble) { - inTemplate = !inTemplate; - continue; - } - if (inSingle || inDouble || inTemplate) continue; - if (c === '(' || c === '[' || c === '{') depth++; - else if (c === ')' || c === ']' || c === '}') depth--; - else if (c === ';' && depth === 0) return true; - } - return false; -} - -export function hasTopLevelArrow(s: string): boolean { - let depth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === '\\' && (inSingle || inDouble || inTemplate)) { - i++; - continue; - } - if (c === "'" && !inDouble && !inTemplate) { - inSingle = !inSingle; - continue; - } - if (c === '"' && !inSingle && !inTemplate) { - inDouble = !inDouble; - continue; - } - if (c === '`' && !inSingle && !inDouble) { - inTemplate = !inTemplate; - continue; - } - if (inSingle || inDouble || inTemplate) continue; - if (c === '(' || c === '[') depth++; - else if (c === ')' || c === ']') depth--; - else if (c === '=' && depth === 0 && i + 1 < s.length && s[i + 1] === '>') return true; - } - return false; -} diff --git a/packages/native-utils/src/index.ts b/packages/native-utils/src/index.ts index 912930a97..acf038e95 100644 --- a/packages/native-utils/src/index.ts +++ b/packages/native-utils/src/index.ts @@ -25,6 +25,7 @@ export { unwrapOr, wrapAsync, } from './result.js'; +export { hasSemicolonOutsideQuotes, hasTopLevelArrow } from './scriptDetect.js'; export { selectExecutable, validateBinaryPaths } from './selectExecutable.js'; export { waitUntilWindowAvailable } from './window.js'; export { createLogger }; diff --git a/packages/native-utils/src/scriptDetect.ts b/packages/native-utils/src/scriptDetect.ts new file mode 100644 index 000000000..e0bf675d7 --- /dev/null +++ b/packages/native-utils/src/scriptDetect.ts @@ -0,0 +1,61 @@ +export function hasSemicolonOutsideQuotes(s: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && (inSingle || inDouble || inTemplate)) { + i++; + continue; + } + if (c === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (c === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (c === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (inSingle || inDouble || inTemplate) continue; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === ';' && depth === 0) return true; + } + return false; +} + +export function hasTopLevelArrow(s: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && (inSingle || inDouble || inTemplate)) { + i++; + continue; + } + if (c === "'" && !inDouble && !inTemplate) { + inSingle = !inSingle; + continue; + } + if (c === '"' && !inSingle && !inTemplate) { + inDouble = !inDouble; + continue; + } + if (c === '`' && !inSingle && !inDouble) { + inTemplate = !inTemplate; + continue; + } + if (inSingle || inDouble || inTemplate) continue; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === '=' && depth === 0 && i + 1 < s.length && s[i + 1] === '>') return true; + } + return false; +} diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 9e11998c5..1d8c04082 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -1,5 +1,5 @@ import type { TauriAPIs, TauriServiceAPI } from '@wdio/native-types'; -import { createLogger, waitUntilWindowAvailable } from '@wdio/native-utils'; +import { createLogger, hasSemicolonOutsideQuotes, waitUntilWindowAvailable } from '@wdio/native-utils'; import { execute } from './commands/execute.js'; import { clearAllMocks, isMockFunction, mock, resetAllMocks, restoreAllMocks } from './commands/mock.js'; import { triggerDeeplink } from './commands/triggerDeeplink.js'; @@ -418,7 +418,8 @@ export default class TauriWorkerService { const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test( trimmed, ); - const wrappedBody = hasStatementKeyword ? scriptString : `return ${scriptString};`; + const wrappedBody = + hasStatementKeyword || hasSemicolonOutsideQuotes(trimmed) ? scriptString : `return ${scriptString};`; const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} (async function() { ${wrappedBody} }).apply(null, Array.from(arguments).slice(0, arguments.length - 1)).then( diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index a976d7ba8..5b65c9beb 100644 --- a/packages/tauri-service/test/service.spec.ts +++ b/packages/tauri-service/test/service.spec.ts @@ -2,15 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseLogLines } from '../src/logParser.js'; import { closeLogWriter, getLogWriter, isLogWriterInitialized } from '../src/logWriter.js'; -vi.mock('@wdio/native-utils', () => ({ - createLogger: () => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - waitUntilWindowAvailable: vi.fn().mockResolvedValue(undefined), -})); +vi.mock('@wdio/native-utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + waitUntilWindowAvailable: vi.fn().mockResolvedValue(undefined), + }; +}); vi.mock('../src/commands/mock.js', () => ({ clearAllMocks: vi.fn().mockResolvedValue(undefined), From fc4f722e45e15736e357b297c5491555e356e71e Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 15:27:16 +0100 Subject: [PATCH 101/130] test(native-utils): add unit tests for script detection utilities Introduced unit tests for the `hasSemicolonOutsideQuotes` and `hasTopLevelArrow` functions in the new `scriptDetect.spec.ts` file. The tests cover various scenarios to ensure accurate detection of semicolons and arrow functions in different contexts, enhancing the reliability of these utilities. --- .../native-utils/test/scriptDetect.spec.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/native-utils/test/scriptDetect.spec.ts diff --git a/packages/native-utils/test/scriptDetect.spec.ts b/packages/native-utils/test/scriptDetect.spec.ts new file mode 100644 index 000000000..0d6debb87 --- /dev/null +++ b/packages/native-utils/test/scriptDetect.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '../src/scriptDetect.js'; + +describe('hasSemicolonOutsideQuotes', () => { + it('should return false for an empty string', () => { + expect(hasSemicolonOutsideQuotes('')).toBe(false); + }); + + it('should return false for a single expression with no semicolon', () => { + expect(hasSemicolonOutsideQuotes('a + b')).toBe(false); + }); + + it('should return true for two calls separated by a semicolon', () => { + expect(hasSemicolonOutsideQuotes('a(); b()')).toBe(true); + }); + + it('should return true for a trailing semicolon after a call', () => { + expect(hasSemicolonOutsideQuotes('a();')).toBe(true); + }); + + it('should return true when semicolon is at depth 0', () => { + expect(hasSemicolonOutsideQuotes('x = 1; y = 2')).toBe(true); + }); + + it('should return false for a semicolon inside parentheses', () => { + expect(hasSemicolonOutsideQuotes('for (let i = 0; i < 10; i++)')).toBe(false); + }); + + it('should return false for a semicolon inside square brackets', () => { + expect(hasSemicolonOutsideQuotes('arr[a; b]')).toBe(false); + }); + + it('should return false for a semicolon inside curly braces', () => { + expect(hasSemicolonOutsideQuotes('({ a: 1; b: 2 })')).toBe(false); + }); + + it('should return false for a semicolon inside a single-quoted string', () => { + expect(hasSemicolonOutsideQuotes("'a; b'")).toBe(false); + }); + + it('should return false for a semicolon inside a double-quoted string', () => { + expect(hasSemicolonOutsideQuotes('"a; b"')).toBe(false); + }); + + it('should return false for a semicolon inside a template literal', () => { + expect(hasSemicolonOutsideQuotes('`a; b`')).toBe(false); + }); + + it('should return true for a semicolon after a closing string', () => { + expect(hasSemicolonOutsideQuotes('"hello"; doSomething()')).toBe(true); + }); + + it('should handle escaped quotes inside strings', () => { + expect(hasSemicolonOutsideQuotes("'it\\'s alive; nope'")).toBe(false); + expect(hasSemicolonOutsideQuotes('"say \\"hi\\"; nope"')).toBe(false); + }); + + it('should handle escaped backslash before a semicolon inside a string', () => { + expect(hasSemicolonOutsideQuotes("'path\\\\'; real()")).toBe(true); + }); + + it('should return true for a semicolon after nested brackets close', () => { + expect(hasSemicolonOutsideQuotes('fn(a, b); fn2()')).toBe(true); + }); + + it('should handle deeply nested brackets correctly', () => { + expect(hasSemicolonOutsideQuotes('fn(a, [b, {c: d}]); next()')).toBe(true); + }); + + it('should return false for a semicolon inside a template literal with expression', () => { + expect(hasSemicolonOutsideQuotes('`${a}; ${b}`')).toBe(false); + }); +}); + +describe('hasTopLevelArrow', () => { + it('should return false for an empty string', () => { + expect(hasTopLevelArrow('')).toBe(false); + }); + + it('should return true for a simple arrow function', () => { + expect(hasTopLevelArrow('() => {}')).toBe(true); + }); + + it('should return true for an arrow function with parameters', () => { + expect(hasTopLevelArrow('(a, b) => a + b')).toBe(true); + }); + + it('should return true for an arrow function with typed parameters', () => { + expect(hasTopLevelArrow('(_tauri, cmd) => { return cmd; }')).toBe(true); + }); + + it('should return false for an arrow function wrapped in outer parens', () => { + expect(hasTopLevelArrow('((_tauri, cmd) => { return cmd; })')).toBe(false); + }); + + it('should return false for an expression with no arrow', () => { + expect(hasTopLevelArrow('a + b')).toBe(false); + }); + + it('should return false for a greater-than-or-equal operator', () => { + expect(hasTopLevelArrow('a >= b')).toBe(false); + }); + + it('should return false for an arrow inside a callback argument', () => { + expect(hasTopLevelArrow('arr.find(x => x > 0)')).toBe(false); + }); + + it('should return false for an arrow inside nested parens', () => { + expect(hasTopLevelArrow('(fn(x => x))')).toBe(false); + }); + + it('should return true for an async arrow function', () => { + expect(hasTopLevelArrow('async () => {}')).toBe(true); + }); + + it('should return false for an arrow inside square brackets', () => { + expect(hasTopLevelArrow('[() => 1]')).toBe(false); + }); + + it('should return false for an arrow inside a single-quoted string', () => { + expect(hasTopLevelArrow("'() => {}'")).toBe(false); + }); + + it('should return false for an arrow inside a double-quoted string', () => { + expect(hasTopLevelArrow('"() => {}"')).toBe(false); + }); + + it('should return false for an arrow inside a template literal', () => { + expect(hasTopLevelArrow('`() => {}`')).toBe(false); + }); + + it('should return true for an arrow after a top-level closing paren', () => { + expect(hasTopLevelArrow('(a, b) => a')).toBe(true); + }); + + it('should handle escaped quotes in strings correctly', () => { + expect(hasTopLevelArrow("'it\\'s => not'")).toBe(false); + }); +}); From 4ccdd4b5b3a01e6e81380f8424d1b7640b874c69 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 16:38:47 +0100 Subject: [PATCH 102/130] refactor(tauri-service): improve stdout logging and enhance backend readiness detection Updated the logging level for stdout messages in the `startTestRunnerBackend` function to info for better clarity. Enhanced the detection of the backend's ready state to be case-insensitive, ensuring it captures variations in output. Removed redundant logging of stdout and stderr data, streamlining the code. Added unit tests to verify behavior for different backend output scenarios, including capitalized messages and unrelated lines. --- .../tauri-service/src/crabnebulaBackend.ts | 19 ++----- .../test/crabnebulaBackend.spec.ts | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/tauri-service/src/crabnebulaBackend.ts b/packages/tauri-service/src/crabnebulaBackend.ts index 7384b75fe..dd1d40db0 100644 --- a/packages/tauri-service/src/crabnebulaBackend.ts +++ b/packages/tauri-service/src/crabnebulaBackend.ts @@ -156,10 +156,11 @@ export async function startTestRunnerBackend(options: StartBackendOptions): Prom if (proc.stdout) { stdoutRl = createInterface({ input: proc.stdout }); stdoutRl.on('line', (line: string) => { - log.debug(`[test-runner-backend] ${line}`); + log.info(`[test-runner-backend stdout] ${line}`); - // Detect ready state - adjust based on actual backend output - if (line.includes('listening') || line.includes('ready') || line.includes('started')) { + // Detect ready state — case-insensitive to handle "Listening", "listening", etc. + const lowered = line.toLowerCase(); + if (lowered.includes('listening') || lowered.includes('ready') || lowered.includes('started')) { if (!isReady) { isReady = true; cleanup(); @@ -177,18 +178,6 @@ export async function startTestRunnerBackend(options: StartBackendOptions): Prom }); } - // Also log stdout at info level for debugging - if (proc.stdout) { - proc.stdout.on('data', (data: Buffer) => { - log.info(`[test-runner-backend stdout] ${data.toString().trim()}`); - }); - } - if (proc.stderr) { - proc.stderr.on('data', (data: Buffer) => { - log.error(`[test-runner-backend stderr] ${data.toString().trim()}`); - }); - } - proc.on('error', (error: Error) => { if (!isReady) { cleanup(); diff --git a/packages/tauri-service/test/crabnebulaBackend.spec.ts b/packages/tauri-service/test/crabnebulaBackend.spec.ts index ac4388ff0..78bfd4dec 100644 --- a/packages/tauri-service/test/crabnebulaBackend.spec.ts +++ b/packages/tauri-service/test/crabnebulaBackend.spec.ts @@ -129,6 +129,63 @@ describe('CrabNebula Backend', () => { expect(result.port).toBe(3000); }, 10000); + it('should resolve when backend emits capitalised "Listening" (real backend message format)', async () => { + vi.mocked(driverManager.findTestRunnerBackend).mockReturnValue('/mock/backend'); + process.env.CN_API_KEY = 'test-api-key-long-enough'; + vi.mocked(spawn).mockReturnValue(mockProc as ChildProcess); + + const promise = startTestRunnerBackend({ port: 3000 }); + + setImmediate(() => { + mockProc.stdout?.emit( + 'data', + Buffer.from('2026-04-23T15:10:08.434112Z INFO test_runner_backend: Listening on 127.0.0.1:3000\n'), + ); + }); + + const result = await promise; + expect(result.port).toBe(3000); + }, 10000); + + it('should resolve when backend emits capitalised "Ready"', async () => { + vi.mocked(driverManager.findTestRunnerBackend).mockReturnValue('/mock/backend'); + process.env.CN_API_KEY = 'test-api-key-long-enough'; + vi.mocked(spawn).mockReturnValue(mockProc as ChildProcess); + + const promise = startTestRunnerBackend({ port: 3000 }); + + setImmediate(() => { + mockProc.stdout?.emit('data', Buffer.from('Server Ready\n')); + }); + + const result = await promise; + expect(result.port).toBe(3000); + }, 10000); + + it('should not resolve early on an unrelated stdout line', async () => { + vi.mocked(driverManager.findTestRunnerBackend).mockReturnValue('/mock/backend'); + process.env.CN_API_KEY = 'test-api-key-long-enough'; + vi.mocked(spawn).mockReturnValue(mockProc as ChildProcess); + + vi.useFakeTimers(); + + const promise = startTestRunnerBackend({ port: 3000 }); + let resolved = false; + promise.then(() => { + resolved = true; + }); + + mockProc.stdout?.emit('data', Buffer.from('Initializing...\n')); + await vi.advanceTimersByTimeAsync(0); + expect(resolved).toBe(false); + + vi.advanceTimersByTime(15000); + await promise; + expect(resolved).toBe(true); + + vi.useRealTimers(); + }); + it('should resolve on timeout even without ready message', async () => { vi.mocked(driverManager.findTestRunnerBackend).mockReturnValue('/mock/backend'); process.env.CN_API_KEY = 'test-api-key-long-enough'; From 30a5348f1a5da4da7282b6eccc31ed37f1f078e9 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 17:05:51 +0100 Subject: [PATCH 103/130] refactor(electron-service): update script execution to use async IIFE Modified the script execution logic in the `execute` function to wrap scripts in an async IIFE, ensuring proper handling of asynchronous operations. Updated related tests to reflect this change, enhancing the reliability of script execution in various scenarios. --- .../electron-service/src/commands/execute.ts | 17 ++----------- .../test/commands/execute.spec.ts | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 3ef00303b..158ac89c0 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -16,12 +16,6 @@ export async function execute( throw new Error('WDIO browser is not yet initialised'); } - /** - * Wrap string scripts for proper execution - * - Function-like strings (() => ..., function() {}, async () =>): pass through as-is - * - Pure expressions (e.g., "1 + 2 + 3"): add return and wrap in IIFE - * - Statement scripts (e.g., "return 42", "const x = 1"): wrap in IIFE without adding return - */ const scriptString = typeof script === 'function' ? script.toString() : wrapStringScript(script); const returnValue = await browser.execute( @@ -45,22 +39,15 @@ function wrapStringScript(script: string): string { /^(\w+)\s*=>/.test(trimmed); if (isFunctionLike) { - // Function-like string - pass through as-is (CDP can handle it) return script; } - // Check if script has statements - be smarter about semicolons and keywords - // Only count semicolons outside of quotes/brackets const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); - // Match statement keywords at start: const, let, var, if, for, while, switch, throw, try, do - // Use word boundary check to avoid matching expressions like "document.title" (do) or "forEach()" (for) const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); if (hasRealSemicolon || hasStatementKeyword) { - // Multi-statement or statement-style script - return `(() => { ${script} })()`; + return `(async () => { ${script} })()`; } else { - // Pure expression - add return and wrap in async IIFE - return `(() => { return ${script}; })()`; + return `(async () => { return ${script}; })()`; } } diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 4a97aa229..c3935a4d7 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -82,7 +82,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '1 + 2 + 3'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return 1 + 2 + 3; })()'), + expect.stringContaining('(async () => { return 1 + 2 + 3; })()'), ); }); @@ -90,20 +90,23 @@ describe('execute Command', () => { await execute(globalThis.browser, 'return 42'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return 42 })()'), + expect.stringContaining('(async () => { return 42 })()'), ); }); it('should wrap multi-statement string scripts in IIFE', async () => { await execute(globalThis.browser, 'const x = 10; const y = 20; return x + y;'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('(() => {')); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => {'), + ); }); it('should handle return(expr) pattern without adding extra return', async () => { await execute(globalThis.browser, 'return(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return(document.title) })()'), + expect.stringContaining('(async () => { return(document.title) })()'), ); }); @@ -111,7 +114,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '"foo;bar"'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return "foo;bar"; })()'), + expect.stringContaining('(async () => { return "foo;bar"; })()'), ); }); @@ -119,7 +122,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'document.title'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return document.title; })()'), + expect.stringContaining('(async () => { return document.title; })()'), ); }); @@ -132,7 +135,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'trySomething()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return trySomething(); })()'), + expect.stringContaining('(async () => { return trySomething(); })()'), ); }); @@ -140,7 +143,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'asyncData.fetchAll()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return asyncData.fetchAll(); })()'), + expect.stringContaining('(async () => { return asyncData.fetchAll(); })()'), ); }); @@ -148,7 +151,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'functionResult.call()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return functionResult.call(); })()'), + expect.stringContaining('(async () => { return functionResult.call(); })()'), ); }); @@ -156,7 +159,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return (document.title); })()'), + expect.stringContaining('(async () => { return (document.title); })()'), ); }); @@ -164,7 +167,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(a + b)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return (a + b); })()'), + expect.stringContaining('(async () => { return (a + b); })()'), ); }); From d18c8f378ce6f3d8be6c294ae73e3d18b5a8052a Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 17:09:42 +0100 Subject: [PATCH 104/130] refactor(electron-service): enhance script execution to conditionally use async IIFE Updated the `execute` function to conditionally wrap scripts in an async IIFE based on the presence of `await`. Adjusted related tests to verify the correct handling of both async and synchronous scripts, improving the robustness of script execution. --- .../electron-service/src/commands/execute.ts | 6 ++- .../test/commands/execute.spec.ts | 43 ++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 158ac89c0..34b714ae5 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -44,10 +44,12 @@ function wrapStringScript(script: string): string { const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); + const needsAsync = /\bawait\b/.test(trimmed); + const wrap = needsAsync ? 'async ' : ''; if (hasRealSemicolon || hasStatementKeyword) { - return `(async () => { ${script} })()`; + return `(${wrap}() => { ${script} })()`; } else { - return `(async () => { return ${script}; })()`; + return `(${wrap}() => { return ${script}; })()`; } } diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index c3935a4d7..550db4f3e 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { execute } from '../../src/commands/execute.js'; describe('execute Command', () => { - beforeEach(async () => { + beforeEach(() => { globalThis.browser = { electron: {}, execute: vi.fn((fn: (script: string, ...args: unknown[]) => unknown, script: string, ...args: unknown[]) => @@ -82,7 +82,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '1 + 2 + 3'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return 1 + 2 + 3; })()'), + expect.stringContaining('(() => { return 1 + 2 + 3; })()'), ); }); @@ -90,23 +90,20 @@ describe('execute Command', () => { await execute(globalThis.browser, 'return 42'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return 42 })()'), + expect.stringContaining('(() => { return 42 })()'), ); }); it('should wrap multi-statement string scripts in IIFE', async () => { await execute(globalThis.browser, 'const x = 10; const y = 20; return x + y;'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(async () => {'), - ); + expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('(() => {')); }); it('should handle return(expr) pattern without adding extra return', async () => { await execute(globalThis.browser, 'return(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return(document.title) })()'), + expect.stringContaining('(() => { return(document.title) })()'), ); }); @@ -114,7 +111,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '"foo;bar"'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return "foo;bar"; })()'), + expect.stringContaining('(() => { return "foo;bar"; })()'), ); }); @@ -122,7 +119,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'document.title'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return document.title; })()'), + expect.stringContaining('(() => { return document.title; })()'), ); }); @@ -135,7 +132,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'trySomething()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return trySomething(); })()'), + expect.stringContaining('(() => { return trySomething(); })()'), ); }); @@ -143,7 +140,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'asyncData.fetchAll()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return asyncData.fetchAll(); })()'), + expect.stringContaining('(() => { return asyncData.fetchAll(); })()'), ); }); @@ -151,7 +148,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'functionResult.call()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return functionResult.call(); })()'), + expect.stringContaining('(() => { return functionResult.call(); })()'), ); }); @@ -159,7 +156,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return (document.title); })()'), + expect.stringContaining('(() => { return (document.title); })()'), ); }); @@ -167,7 +164,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(a + b)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return (a + b); })()'), + expect.stringContaining('(() => { return (a + b); })()'), ); }); @@ -175,4 +172,20 @@ describe('execute Command', () => { await execute(globalThis.browser, '(x, y) => x + y'); expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '(x, y) => x + y'); }); + + it('should use async IIFE when script contains await', async () => { + await execute(globalThis.browser, 'return await someAsyncFn()'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(async () => { return await someAsyncFn() })()'), + ); + }); + + it('should use sync IIFE when script has no await', async () => { + await execute(globalThis.browser, 'return syncFn()'); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(() => { return syncFn() })()'), + ); + }); }); From 294579af0b4af78a84cc74c08b3ccf20cf1014ce Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 17:56:59 +0100 Subject: [PATCH 105/130] refactor(tauri-service): enhance backend WebSocket readiness detection on macOS Improved the detection logic for the tauri-driver's WebSocket readiness on macOS with CrabNebula. The new implementation polls the /status endpoint until the WebSocket is established, reducing the risk of WDIO hanging for an extended period. This change includes a retry mechanism with increasing delays to ensure reliable connection detection. --- packages/tauri-service/src/launcher.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/tauri-service/src/launcher.ts b/packages/tauri-service/src/launcher.ts index 944a787de..cb6b1e384 100644 --- a/packages/tauri-service/src/launcher.ts +++ b/packages/tauri-service/src/launcher.ts @@ -537,12 +537,24 @@ export default class TauriLaunchService { throw new SevereServiceError(`Failed to start tauri-driver: ${(error as Error).message}`); } - // On macOS with CrabNebula, probe /status to detect a dead backend WebSocket before WDIO connects. - // The backend can drop its WebSocket to tauri-driver ~68ms after connect; without this check WDIO - // would hang for ~4.7 minutes before discovering the session is broken. + // On macOS with CrabNebula, poll /status until the cloud relay WebSocket is established. + // The relay is set up asynchronously after the backend starts listening, and tauri-driver's + // own startup probe can reach it before it's ready. Without this check, WDIO would hang + // for ~4.7 minutes before discovering the session is broken. if (process.platform === 'darwin' && isCrabNebula) { - await new Promise((r) => setTimeout(r, 200)); - const statusOk = await this.probeTauriDriverStatus(port); + const probeDeadline = Date.now() + 30000; + let statusOk = false; + let delay = 200; + while (Date.now() < probeDeadline) { + await new Promise((r) => setTimeout(r, delay)); + delay = 1000; + statusOk = await this.probeTauriDriverStatus(port); + if (statusOk) { + log.info('tauri-driver /status relay check passed'); + break; + } + log.debug('tauri-driver /status relay not yet ready, retrying...'); + } if (!statusOk) { throw new SevereServiceError( 'tauri-driver /status probe failed — CrabNebula backend WebSocket may be broken. ' + From bda3ee6719700039e46f2f3ec94249fc64b4972f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 17:57:11 +0100 Subject: [PATCH 106/130] test(tauri-service): add integration tests for CrabNebula status probe Introduced a new integration test suite for the CrabNebula service, focusing on the /status probe functionality on macOS. The tests cover scenarios for successful probes, retries after initial failures, and handling of probe deadline expirations, ensuring robust validation of the TauriLaunchService's readiness detection. --- .../launcher.crabnebula.integration.spec.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts diff --git a/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts b/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts new file mode 100644 index 000000000..9ce67cc4a --- /dev/null +++ b/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import getPort from 'get-port'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SevereServiceError } from 'webdriverio'; +import { mockSuccessPath } from '../mockPaths.js'; + +vi.mock('../../src/crabnebulaBackend.js', () => ({ + startTestRunnerBackend: vi.fn().mockResolvedValue({ + proc: { kill: vi.fn(), killed: false }, + port: 3000, + }), + waitTestRunnerBackendReady: vi.fn().mockResolvedValue(undefined), + stopTestRunnerBackend: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/driverManager.js', () => ({ + ensureTauriDriver: vi.fn(), + ensureWebKitWebDriver: vi.fn(), + findTestRunnerBackend: vi.fn().mockReturnValue('/mock/test-runner-backend'), +})); + +vi.mock('../../src/pathResolver.js', () => ({ + getTauriAppInfo: vi.fn().mockResolvedValue({ version: '1.0.0' }), + getTauriBinaryPath: vi.fn().mockResolvedValue('/app/my-app'), + getWebKitWebDriverPath: vi.fn().mockReturnValue('/usr/bin/WebKitWebDriver'), +})); + +vi.mock('@wdio/native-utils', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + })), + isErr: vi.fn(() => false), + isOk: vi.fn(() => true), + Ok: vi.fn((v: unknown) => ({ ok: true, value: v })), + Err: vi.fn((e: unknown) => ({ ok: false, error: e })), +})); + +vi.mock('../../src/edgeDriverManager.js', () => ({ + ensureMsEdgeDriver: vi.fn().mockResolvedValue({ ok: true, value: { method: 'found', driverVersion: '120.0.0' } }), +})); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { ...actual, execSync: vi.fn() }; +}); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +import { ensureTauriDriver } from '../../src/driverManager.js'; +import TauriLaunchService from '../../src/launcher.js'; + +const crabnebulaCapabilities = [ + { + browserName: 'tauri', + 'tauri:options': { application: '/app/tauri-app' }, + 'wdio:tauriServiceOptions': { driverProvider: 'crabnebula', crabnebulaManageBackend: false }, + }, +]; + +describe.skipIf(process.platform !== 'darwin')('CrabNebula /status probe retry (macOS)', () => { + let launcher: TauriLaunchService; + + beforeEach(async () => { + vi.clearAllMocks(); + process.env.CN_API_KEY = 'test-key-long-enough-to-pass-validation'; + vi.mocked(ensureTauriDriver).mockResolvedValue({ + ok: true, + value: { path: mockSuccessPath, method: 'found' }, + }); + }); + + afterEach(async () => { + delete process.env.CN_API_KEY; + if (launcher) { + try { + await Promise.race([ + (launcher as any).onComplete?.(), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)), + ]); + } catch { + // ignore + } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + it('should pass when /status probe succeeds on the first attempt', async () => { + const port = await getPort({ port: 14444 }); + launcher = new TauriLaunchService( + { driverProvider: 'crabnebula', tauriDriverPort: port }, + crabnebulaCapabilities[0] as any, + { maxInstances: 1 }, + ); + + const spy = vi.spyOn(launcher as any, 'probeTauriDriverStatus').mockResolvedValueOnce(true); + + await expect((launcher as any).onPrepare({ maxInstances: 1 }, crabnebulaCapabilities)).resolves.not.toThrow(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should retry /status probe and succeed after initial failures', async () => { + const port = await getPort({ port: 14446 }); + launcher = new TauriLaunchService( + { driverProvider: 'crabnebula', tauriDriverPort: port }, + crabnebulaCapabilities[0] as any, + { maxInstances: 1 }, + ); + + const spy = vi + .spyOn(launcher as any, 'probeTauriDriverStatus') + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + await expect((launcher as any).onPrepare({ maxInstances: 1 }, crabnebulaCapabilities)).resolves.not.toThrow(); + expect(spy).toHaveBeenCalledTimes(3); + }); + + it('should throw SevereServiceError when probe deadline expires', async () => { + const port = await getPort({ port: 14448 }); + launcher = new TauriLaunchService( + { driverProvider: 'crabnebula', tauriDriverPort: port }, + crabnebulaCapabilities[0] as any, + { maxInstances: 1 }, + ); + + vi.spyOn(launcher as any, 'probeTauriDriverStatus').mockResolvedValue(false); + + // Expire the deadline after the first probe: first Date.now() call sets probeDeadline, + // second (in while condition after probe) returns a value past it. + const realNow = Date.now(); + let callCount = 0; + vi.spyOn(Date, 'now').mockImplementation(() => { + callCount++; + return callCount <= 2 ? realNow : realNow + 31000; + }); + + try { + await expect((launcher as any).onPrepare({ maxInstances: 1 }, crabnebulaCapabilities)).rejects.toThrow( + SevereServiceError, + ); + } finally { + vi.restoreAllMocks(); + } + }); +}); From c96c2220985114f5afbb667f06a25b1f26e0288f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 18:40:36 +0100 Subject: [PATCH 107/130] refactor(tauri-service): remove CrabNebula /status probe logic and related tests Eliminated the /status probe logic for CrabNebula from the TauriLaunchService, simplifying the startup process. Updated integration tests to reflect this change, renaming tests for clarity and ensuring they focus on the onPrepare functionality without the probe mechanism. --- packages/tauri-service/src/launcher.ts | 41 -------------- .../launcher.crabnebula.integration.spec.ts | 54 +------------------ 2 files changed, 2 insertions(+), 93 deletions(-) diff --git a/packages/tauri-service/src/launcher.ts b/packages/tauri-service/src/launcher.ts index cb6b1e384..2adb8f814 100644 --- a/packages/tauri-service/src/launcher.ts +++ b/packages/tauri-service/src/launcher.ts @@ -537,32 +537,6 @@ export default class TauriLaunchService { throw new SevereServiceError(`Failed to start tauri-driver: ${(error as Error).message}`); } - // On macOS with CrabNebula, poll /status until the cloud relay WebSocket is established. - // The relay is set up asynchronously after the backend starts listening, and tauri-driver's - // own startup probe can reach it before it's ready. Without this check, WDIO would hang - // for ~4.7 minutes before discovering the session is broken. - if (process.platform === 'darwin' && isCrabNebula) { - const probeDeadline = Date.now() + 30000; - let statusOk = false; - let delay = 200; - while (Date.now() < probeDeadline) { - await new Promise((r) => setTimeout(r, delay)); - delay = 1000; - statusOk = await this.probeTauriDriverStatus(port); - if (statusOk) { - log.info('tauri-driver /status relay check passed'); - break; - } - log.debug('tauri-driver /status relay not yet ready, retrying...'); - } - if (!statusOk) { - throw new SevereServiceError( - 'tauri-driver /status probe failed — CrabNebula backend WebSocket may be broken. ' + - 'Check CN_API_KEY validity and cloud relay connectivity.', - ); - } - } - // Update the capabilities object with hostname and port so WDIO connects to tauri-driver for (const cap of capsList) { (cap as { port?: number; hostname?: string }).port = port; @@ -1017,21 +991,6 @@ export default class TauriLaunchService { log.debug('Tauri service completed'); } - private async probeTauriDriverStatus(port: number): Promise { - const http = await import('node:http'); - return new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${port}/status`, { timeout: 5000 }, (res) => { - res.resume(); - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.on('timeout', () => { - req.destroy(); - resolve(false); - }); - }); - } - /** * Start tauri-driver process */ diff --git a/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts b/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts index 9ce67cc4a..567bc2dae 100644 --- a/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts +++ b/packages/tauri-service/test/integration/launcher.crabnebula.integration.spec.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import getPort from 'get-port'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SevereServiceError } from 'webdriverio'; import { mockSuccessPath } from '../mockPaths.js'; vi.mock('../../src/crabnebulaBackend.js', () => ({ @@ -63,7 +62,7 @@ const crabnebulaCapabilities = [ }, ]; -describe.skipIf(process.platform !== 'darwin')('CrabNebula /status probe retry (macOS)', () => { +describe.skipIf(process.platform !== 'darwin')('CrabNebula onPrepare (macOS)', () => { let launcher: TauriLaunchService; beforeEach(async () => { @@ -90,7 +89,7 @@ describe.skipIf(process.platform !== 'darwin')('CrabNebula /status probe retry ( await new Promise((resolve) => setTimeout(resolve, 100)); }); - it('should pass when /status probe succeeds on the first attempt', async () => { + it('should complete onPrepare without throwing when backend and driver start successfully', async () => { const port = await getPort({ port: 14444 }); launcher = new TauriLaunchService( { driverProvider: 'crabnebula', tauriDriverPort: port }, @@ -98,55 +97,6 @@ describe.skipIf(process.platform !== 'darwin')('CrabNebula /status probe retry ( { maxInstances: 1 }, ); - const spy = vi.spyOn(launcher as any, 'probeTauriDriverStatus').mockResolvedValueOnce(true); - - await expect((launcher as any).onPrepare({ maxInstances: 1 }, crabnebulaCapabilities)).resolves.not.toThrow(); - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('should retry /status probe and succeed after initial failures', async () => { - const port = await getPort({ port: 14446 }); - launcher = new TauriLaunchService( - { driverProvider: 'crabnebula', tauriDriverPort: port }, - crabnebulaCapabilities[0] as any, - { maxInstances: 1 }, - ); - - const spy = vi - .spyOn(launcher as any, 'probeTauriDriverStatus') - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); - await expect((launcher as any).onPrepare({ maxInstances: 1 }, crabnebulaCapabilities)).resolves.not.toThrow(); - expect(spy).toHaveBeenCalledTimes(3); - }); - - it('should throw SevereServiceError when probe deadline expires', async () => { - const port = await getPort({ port: 14448 }); - launcher = new TauriLaunchService( - { driverProvider: 'crabnebula', tauriDriverPort: port }, - crabnebulaCapabilities[0] as any, - { maxInstances: 1 }, - ); - - vi.spyOn(launcher as any, 'probeTauriDriverStatus').mockResolvedValue(false); - - // Expire the deadline after the first probe: first Date.now() call sets probeDeadline, - // second (in while condition after probe) returns a value past it. - const realNow = Date.now(); - let callCount = 0; - vi.spyOn(Date, 'now').mockImplementation(() => { - callCount++; - return callCount <= 2 ? realNow : realNow + 31000; - }); - - try { - await expect((launcher as any).onPrepare({ maxInstances: 1 }, crabnebulaCapabilities)).rejects.toThrow( - SevereServiceError, - ); - } finally { - vi.restoreAllMocks(); - } }); }); From 84fdd2dac2bcdd537b9ffacc86db735602cea8fe Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 18:43:40 +0100 Subject: [PATCH 108/130] fix(electron-service): improve arrow function detection in script execution Updated the logic in the `execute` function to correctly identify arrow functions that may contain the substring "function" in their method calls, ensuring they are not incorrectly excluded. Added tests to verify the handling of various arrow function scenarios, including those with "function" in property access and actual function declarations, enhancing the robustness of script execution. --- .../src/commands/executeCdp.ts | 6 ++- .../test/commands/executeCdp.spec.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index b4b002624..008f94ce1 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -55,7 +55,8 @@ export async function execute( const trimmed = script.trim(); // Only let recast handle arrow functions starting with ( and containing => // These get transformed to add electron parameter - const isArrowFunction = trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.includes('function'); + const isArrowFunction = + trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.match(/^\s*(async\s+)?function\b/); // exclude function declarations if (isArrowFunction) { // Arrow function - recast handles electron param injection @@ -105,7 +106,8 @@ function wrapStringScriptForCdp(script: string): string { // Check if it's a simple arrow function that can be transformed by recast // These patterns can be safely passed to recast which adds the electron parameter - const canRecastHandle = trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.includes('function'); + const canRecastHandle = + trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.match(/^\s*(async\s+)?function\b/); if (canRecastHandle) { // Simple arrow function - pass to recast for transformation diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 265bb73a9..3985d253f 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -132,6 +132,43 @@ describe('execute Command', () => { ); }); + it('should handle arrow functions calling methods with "function" in the name', async () => { + // Arrow function that calls a helper method — should pass through to recast + // (not be falsely excluded by old guard that checked !includes('function')) + await execute(globalThis.browser, client, '(electron) => electron.getFunction().call()'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + functionDeclaration: '() => electron.getFunction().call()', + }), + ); + }); + + it('should handle arrow functions with "function" in property/method names', async () => { + // Arrow function with methods named containing 'function' — should NOT be wrapped + await execute(globalThis.browser, client, '(electron) => helpers.functionHelper(electron)'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + // Should pass through recast (parenthesized arrow with =>), electron param injected + functionDeclaration: expect.stringMatching(/^\(.*\)\s*=>/), + }), + ); + }); + + it('should exclude actual function keyword declarations', async () => { + // Real function declaration (not arrow) should be wrapped + // The guard checks !match(/^\s*(async\s+)?function\b/) to exclude function declarations + await execute(globalThis.browser, client, 'async function test(electron) { return 42; }'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + // Should be wrapped (since it starts with function keyword) + functionDeclaration: expect.stringContaining('async () =>'), + }), + ); + }); + it('should call `mock.update()` when mockStore has some mocks', async () => { const updateMock = vi.fn(); vi.mocked(mockStore.getMocks).mockReturnValue([['dummy', { update: updateMock } as unknown as ElectronMock]]); From a92ee8a7f927f0244c4fc77fe9f8234c560ea8b5 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Thu, 23 Apr 2026 19:08:32 +0100 Subject: [PATCH 109/130] fix(tauri-plugin-webdriver): ensure async IIFE is used in script execution Updated the script execution logic to wrap the function in an async IIFE, improving compatibility with asynchronous operations. This change enhances the handling of script execution within the WebDriver context, ensuring proper execution flow and result serialization. --- packages/tauri-plugin-webdriver/src/platform/executor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-plugin-webdriver/src/platform/executor.rs b/packages/tauri-plugin-webdriver/src/platform/executor.rs index d7dde6718..fcb5609d7 100644 --- a/packages/tauri-plugin-webdriver/src/platform/executor.rs +++ b/packages/tauri-plugin-webdriver/src/platform/executor.rs @@ -947,7 +947,7 @@ pub trait PlatformExecutor: Send + Sync { try {{ var args = {args_json}.map(deserializeArg); // W3C-compliant: wrap as function body, apply with args - var raw_result = await (function() {{ {script} }}).apply(null, args); + var raw_result = await (async function() {{ {script} }}).apply(null, args); var serialized = serializeValue(raw_result); window['{result_var}'] = {{ __wd_success: true, __wd_value: serialized }}; }} catch (e) {{ From 5e9353db29f3a6bfdcf711559ff33db3a2036c44 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 00:25:06 +0100 Subject: [PATCH 110/130] refactor(electron-service): simplify script wrapping logic in executeCdp Removed unnecessary checks for arrow functions in the script wrapping logic, streamlining the handling of string scripts to avoid parsing errors. This change enhances code clarity and maintains robust script execution. --- .../electron-service/src/commands/executeCdp.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 008f94ce1..bac8ece3c 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -104,18 +104,7 @@ export async function execute( function wrapStringScriptForCdp(script: string): string { const trimmed = script.trim(); - // Check if it's a simple arrow function that can be transformed by recast - // These patterns can be safely passed to recast which adds the electron parameter - const canRecastHandle = - trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.match(/^\s*(async\s+)?function\b/); - - if (canRecastHandle) { - // Simple arrow function - pass to recast for transformation - return script; - } - - // For all other strings, wrap them to avoid parsing errors - // This includes: + // For string scripts, wrap them to avoid parsing errors: // - "function() {}" (recast handles these differently) // - "1 + 2 + 3" (expression - would be called as function) // - "return 42" (statement - parsing error) From 3458f7ff094593f4cef36eda713a9cde0dbbc5d0 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 00:49:55 +0100 Subject: [PATCH 111/130] refactor(electron-service): update script wrapping to use function declarations Replaced arrow function syntax with traditional function declarations in the script wrapping logic of `executeCdp`. This change improves consistency and clarity in the generated scripts, ensuring better compatibility with various execution contexts. Updated related tests to reflect the new function declaration format. --- packages/electron-service/src/commands/executeCdp.ts | 4 ++-- packages/electron-service/test/commands/executeCdp.spec.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index bac8ece3c..cb77885ce 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -114,9 +114,9 @@ function wrapStringScriptForCdp(script: string): string { const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); if (hasRealSemicolon || hasStatementKeyword) { - return `async () => { ${script} }`; + return `async function() { ${script} }`; } else { - return `async () => (${script})`; + return `async function() { return (${script}) }`; } } diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 3985d253f..650d92e37 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -115,7 +115,7 @@ describe('execute Command', () => { expect(client.send).toHaveBeenCalledWith( 'Runtime.callFunctionOn', expect.objectContaining({ - functionDeclaration: expect.stringContaining('async () => { const a = 1 }'), + functionDeclaration: expect.stringContaining('async function() { const a = 1 }'), }), ); }); @@ -127,7 +127,7 @@ describe('execute Command', () => { expect(client.send).toHaveBeenCalledWith( 'Runtime.callFunctionOn', expect.objectContaining({ - functionDeclaration: expect.stringContaining('async () => {'), + functionDeclaration: expect.stringContaining('async function() {'), }), ); }); @@ -164,7 +164,7 @@ describe('execute Command', () => { 'Runtime.callFunctionOn', expect.objectContaining({ // Should be wrapped (since it starts with function keyword) - functionDeclaration: expect.stringContaining('async () =>'), + functionDeclaration: expect.stringContaining('async function()'), }), ); }); From 2f01aa5646d919c0578fc54f37a31c9505d67b0f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 02:18:16 +0100 Subject: [PATCH 112/130] refactor(electron-service): replace arrow functions with traditional function declarations in script wrapping Updated the script wrapping logic in the `execute` function to utilize traditional function declarations instead of arrow functions. This change enhances consistency and compatibility across different execution contexts. Adjusted related tests to ensure they reflect the new function declaration format. --- .../electron-service/src/commands/execute.ts | 4 +-- .../test/commands/execute.spec.ts | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index 34b714ae5..1c65c3e03 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -48,8 +48,8 @@ function wrapStringScript(script: string): string { const wrap = needsAsync ? 'async ' : ''; if (hasRealSemicolon || hasStatementKeyword) { - return `(${wrap}() => { ${script} })()`; + return `(${wrap}function() { ${script} })()`; } else { - return `(${wrap}() => { return ${script}; })()`; + return `(${wrap}function() { return ${script}; })()`; } } diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index 550db4f3e..8516de571 100644 --- a/packages/electron-service/test/commands/execute.spec.ts +++ b/packages/electron-service/test/commands/execute.spec.ts @@ -82,7 +82,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '1 + 2 + 3'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return 1 + 2 + 3; })()'), + expect.stringContaining('(function() { return 1 + 2 + 3; })()'), ); }); @@ -90,20 +90,23 @@ describe('execute Command', () => { await execute(globalThis.browser, 'return 42'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return 42 })()'), + expect.stringContaining('(function() { return 42 })()'), ); }); it('should wrap multi-statement string scripts in IIFE', async () => { await execute(globalThis.browser, 'const x = 10; const y = 20; return x + y;'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('(() => {')); + expect(globalThis.browser.execute).toHaveBeenCalledWith( + expect.any(Function), + expect.stringContaining('(function() {'), + ); }); it('should handle return(expr) pattern without adding extra return', async () => { await execute(globalThis.browser, 'return(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return(document.title) })()'), + expect.stringContaining('(function() { return(document.title) })()'), ); }); @@ -111,7 +114,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '"foo;bar"'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return "foo;bar"; })()'), + expect.stringContaining('(function() { return "foo;bar"; })()'), ); }); @@ -119,7 +122,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'document.title'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return document.title; })()'), + expect.stringContaining('(function() { return document.title; })()'), ); }); @@ -132,7 +135,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'trySomething()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return trySomething(); })()'), + expect.stringContaining('(function() { return trySomething(); })()'), ); }); @@ -140,7 +143,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'asyncData.fetchAll()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return asyncData.fetchAll(); })()'), + expect.stringContaining('(function() { return asyncData.fetchAll(); })()'), ); }); @@ -148,7 +151,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'functionResult.call()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return functionResult.call(); })()'), + expect.stringContaining('(function() { return functionResult.call(); })()'), ); }); @@ -156,7 +159,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(document.title)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return (document.title); })()'), + expect.stringContaining('(function() { return (document.title); })()'), ); }); @@ -164,7 +167,7 @@ describe('execute Command', () => { await execute(globalThis.browser, '(a + b)'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return (a + b); })()'), + expect.stringContaining('(function() { return (a + b); })()'), ); }); @@ -177,7 +180,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'return await someAsyncFn()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(async () => { return await someAsyncFn() })()'), + expect.stringContaining('(async function() { return await someAsyncFn() })()'), ); }); @@ -185,7 +188,7 @@ describe('execute Command', () => { await execute(globalThis.browser, 'return syncFn()'); expect(globalThis.browser.execute).toHaveBeenCalledWith( expect.any(Function), - expect.stringContaining('(() => { return syncFn() })()'), + expect.stringContaining('(function() { return syncFn() })()'), ); }); }); From c93b83f9242d116895771080a11367a7a7fa0d8c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 09:59:01 +0100 Subject: [PATCH 113/130] refactor(tauri-plugin): enhance script execution by embedding arguments in function body Updated the `execute` function to embed user arguments into the script body, allowing access via the `arguments` object. This change improves the flexibility of script execution and ensures that additional arguments are correctly serialized. Adjusted related tests to verify the new behavior and ensure compatibility with the updated script wrapping logic. --- .../guest-js/__tests__/index.spec.ts | 17 ++++++++++------- packages/tauri-plugin/guest-js/index.ts | 12 ++++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts index 057894f8d..e046a97c1 100644 --- a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts +++ b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts @@ -221,7 +221,7 @@ describe('execute', () => { }); it('should serialize additional arguments into the wrapped script', async () => { - await execute('(tauri, a, b) => a + b', 'hello', 42); + await execute('(tauri, a, b) => a + b', {}, '["hello",42]'); const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); expect(pluginCalls[0][1].request.script).toContain('["hello",42]'); @@ -266,19 +266,22 @@ describe('execute', () => { it('should route statement-style string scripts to statement path', async () => { await execute('return 42'); - // Should be routed to statement path (wrapped with async IIFE, not function wrapper) const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); - // Statement path wraps as: `(async () => { return 42 })()` - not the function-like wrapper - expect(pluginCalls[0][1].request.script).toContain('(async () => { return 42 })()'); + expect(pluginCalls[0][1].request.script).toContain('(async function() { return 42 }).apply(null, [])'); }); it('should route expression-style string scripts to expression path', async () => { await execute('1 + 2 + 3'); - // Should be routed to expression path (wrapped with return - includes semicolon inside braces) const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); - // Expression path wraps as: `(async () => { return 1 + 2 + 3; })()` (semicolon inside) - expect(pluginCalls[0][1].request.script).toContain('(async () => { return 1 + 2 + 3; })()'); + expect(pluginCalls[0][1].request.script).toContain('(async function() { return 1 + 2 + 3; }).apply(null, [])'); + }); + + it('should embed args into string script body so arguments[0] etc. are accessible', async () => { + await execute('return arguments[0] + arguments[1]', {}, '[10,32]'); + + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + expect(pluginCalls[0][1].request.script).toContain('.apply(null, [10,32])'); }); it('should handle statement-style string scripts', async () => { diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 9bb46d78a..e68e4bc9f 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -264,12 +264,16 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson })() `.trim(); } else { - // Plain string script — not callable. Wrap as an async IIFE body. - // Statement keywords (return, const, etc.) are passed through as-is; - // pure expressions get an explicit return so callers receive the value. + // Plain string script — not callable. Wrap as an async function body and apply the + // user args so they are accessible via arguments[0], arguments[1], etc. (W3C §13.2.2). + // Using a named function (not an arrow) is required: arrow functions have no arguments object. + // No conditional async needed — the Tauri IPC always awaits the result. const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); const hasStatement = hasStatementKeyword || hasSemicolonOutsideQuotes(trimmed); - scriptToSend = hasStatement ? `(async () => { ${script} })()` : `(async () => { return ${script}; })()`; + const argsArray = argsJson ?? '[]'; + scriptToSend = hasStatement + ? `(async function() { ${script} }).apply(null, ${argsArray})` + : `(async function() { return ${script}; }).apply(null, ${argsArray})`; } const invoke = await getInvoke(); From af0ffaf654be0dfedc741d74aeaa0ecea3726952 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 09:59:12 +0100 Subject: [PATCH 114/130] chore(dependencies): update devDependencies and format package.json Updated the devDependencies in the tauri-plugin package.json to include @vitest/coverage-v8, jsdom, and vitest at specified versions. Reformatted the files and keywords sections for improved readability in package.json. Updated pnpm-lock.yaml to reflect these changes. --- packages/tauri-plugin/package.json | 5 ++++- pnpm-lock.yaml | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/package.json b/packages/tauri-plugin/package.json index e3046823c..710c7b144 100644 --- a/packages/tauri-plugin/package.json +++ b/packages/tauri-plugin/package.json @@ -32,9 +32,12 @@ "@wdio/native-spy": "workspace:*" }, "devDependencies": { + "@vitest/coverage-v8": "4.1.4", "esbuild": "^0.27.4", + "jsdom": "^28.1.0", "tsx": "^4.19.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "4.1.4" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec871fa18..c406416bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -943,15 +943,24 @@ importers: specifier: workspace:* version: link:../native-spy devDependencies: + '@vitest/coverage-v8': + specifier: 4.1.4 + version: 4.1.4(vitest@4.1.4) esbuild: specifier: ^0.27.4 version: 0.27.7 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 tsx: specifier: ^4.19.0 version: 4.21.0 typescript: specifier: ^5.0.0 version: 5.9.3 + vitest: + specifier: 4.1.4 + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/tauri-plugin-webdriver: {} From 3fa62e939aa98bbd8aa0b0deefe10b68b66a2321 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 10:16:55 +0100 Subject: [PATCH 115/130] refactor(tauri-plugin): improve function detection in script execution Enhanced the `execute` function to include detection for function-like patterns, specifically adding support for `throw(` in the script execution logic. This change improves the accuracy of function detection, ensuring better handling of various script formats. --- packages/tauri-plugin/src/commands.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index fbc2a59ee..d6ab960ea 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -227,8 +227,8 @@ pub(crate) async fn execute( }).unwrap_or(false); // Only detect function-like patterns: function, async, arrow functions // Don't use starts_with('(') as it catches any parenthesized expression like (document.title) - let is_function = has_keyword_prefix(trimmed, "function") - || has_keyword_prefix(trimmed, "function*") + let is_function = has_keyword_prefix(trimmed, "function") + || has_keyword_prefix(trimmed, "function*") || has_keyword_prefix(trimmed, "async") || starts_with_paren_arrow || single_param_arrow; @@ -261,6 +261,7 @@ pub(crate) async fn execute( || t.starts_with("switch ") || t.starts_with("switch(") || t.starts_with("throw ") + || t.starts_with("throw(") || t.starts_with("try ") || t.starts_with("try{") || t.starts_with("do ") From 2129c94fc08692e32d58889c039aca14b1dc6b5b Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 10:50:26 +0100 Subject: [PATCH 116/130] refactor(tauri-plugin): streamline script execution and event handling Refactored the `execute` function to simplify the handling of script execution and event listening. Removed unnecessary references to the invoking window and consolidated event listener logic to improve clarity and maintainability. Enhanced function detection to support async IIFEs, ensuring better compatibility with various script formats. --- packages/tauri-plugin/src/commands.rs | 56 ++++++++++----------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index d6ab960ea..07ed9b3b6 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -51,10 +51,6 @@ pub(crate) async fn execute( log::debug!("Execute command called"); log::trace!("Script length: {} chars", request.script.len()); - // Retain a reference to the invoking window for listener registration. - // We clone before the conditional because the else branch moves `window` into target_window. - let invoking_window = window.clone(); - // Determine which window to use for execution let target_window = if let Some(ref label) = request.window_label { log::debug!("Target window label specified: {}", label); @@ -225,21 +221,20 @@ pub(crate) async fn execute( // e.g. "x => x + 1" is a param arrow; "obj.fn(x => x)" is not !before.is_empty() && !before.contains(' ') && !before.contains('(') }).unwrap_or(false); - // Only detect function-like patterns: function, async, arrow functions - // Don't use starts_with('(') as it catches any parenthesized expression like (document.title) + // Only detect function-like patterns: function, async, arrow functions, or pre-packaged + // async IIFEs emitted by guest-js (both branches produce "(async ...)" patterns). + // Don't use starts_with('(') alone as it catches any parenthesized expression like (document.title). let is_function = has_keyword_prefix(trimmed, "function") || has_keyword_prefix(trimmed, "function*") || has_keyword_prefix(trimmed, "async") || starts_with_paren_arrow - || single_param_arrow; + || single_param_arrow + || trimmed.starts_with("(async"); - let script = if !request.args.is_empty() && is_function { - // With args + callable function - pass through as-is - // Guest-js already handles wrapping with Tauri API injection - request.script.clone() - } else if is_function { - // Function script with no args - pass through as-is - // Guest-js already wraps it with proper Tauri API injection + let script = if is_function { + // Callable/pre-packaged script — pass through as-is. + // guest-js wraps both function-like and plain-string cases into async IIFEs before + // invoking this command, so no further wrapping is needed here. request.script.clone() } else if !request.args.is_empty() { // String script with args (not a callable function) - return error @@ -323,19 +318,13 @@ pub(crate) async fn execute( } } - // Listen for the result event on both app and window targets for compatibility - // Different Tauri providers may emit to different targets - let tx_clone_app: Arc>>>> = Arc::clone(&tx); - let tx_clone_window: Arc>>>> = Arc::clone(&tx); - - let listener_id_app = app.listen(&event_id.clone(), move |event| { - log::trace!("Received result event on app target: {}", event.payload()); - handle_event(event, tx_clone_app.clone()); - }); + // Listen for the result event on the app target. + // guest-js uses emit() from @tauri-apps/api/event which targets the app scope. + let tx_clone: Arc>>>> = Arc::clone(&tx); - let listener_id_window = invoking_window.listen(&event_id, move |event| { - log::trace!("Received result event on window target: {}", event.payload()); - handle_event(event, tx_clone_window.clone()); + let listener_id = app.listen(&event_id, move |event| { + log::trace!("Received result event: {}", event.payload()); + handle_event(event, tx_clone.clone()); }); // Wrap the script to: @@ -400,8 +389,7 @@ pub(crate) async fn execute( // Evaluate the script in the target window if let Err(e) = target_window.eval(&script_with_result) { log::error!("Failed to eval script: {}", e); - app.unlisten(listener_id_app); - invoking_window.unlisten(listener_id_window); + app.unlisten(listener_id); return Err(crate::Error::ExecuteError(format!("Failed to eval script: {}", e))); } @@ -417,21 +405,18 @@ pub(crate) async fn execute( Ok(Ok(Ok(result))) => { log::debug!("Execute completed successfully"); log::trace!("Result: {:?}", result); - app.unlisten(listener_id_app); - invoking_window.unlisten(listener_id_window); + app.unlisten(listener_id); Ok(result) } Ok(Ok(Err(e))) => { log::error!("JS error during execution: {}", e); - app.unlisten(listener_id_app); - invoking_window.unlisten(listener_id_window); + app.unlisten(listener_id); Err(e) } Ok(Err(_)) => { // Channel closed without sending (shouldn't happen) log::error!("Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label); - app.unlisten(listener_id_app); - invoking_window.unlisten(listener_id_window); + app.unlisten(listener_id); Err(crate::Error::ExecuteError(format!( "Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label @@ -440,8 +425,7 @@ pub(crate) async fn execute( Err(_) => { log::error!("Timeout waiting for execute result after 30s. Event ID: {}. Window: {}", event_id, window_label); - app.unlisten(listener_id_app); - invoking_window.unlisten(listener_id_window); + app.unlisten(listener_id); Err(crate::Error::ExecuteError(format!( "Script execution timed out after 30s. Event ID: {}. Window: {}", event_id, window_label From fdcdd1afb34804945c4103694ee97187eff27f43 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 11:19:45 +0100 Subject: [PATCH 117/130] refactor(electron-service): remove execute command and related tests Deleted the `execute` command implementation and its associated tests to streamline the codebase. This change simplifies the electron-service package by eliminating unused functionality, enhancing maintainability and clarity. --- .../electron-service/src/commands/execute.ts | 55 ----- .../test/commands/execute.spec.ts | 194 ------------------ packages/electron-service/test/mock.spec.ts | 5 - .../electron-service/test/service.spec.ts | 6 - 4 files changed, 260 deletions(-) delete mode 100644 packages/electron-service/src/commands/execute.ts delete mode 100644 packages/electron-service/test/commands/execute.spec.ts diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts deleted file mode 100644 index 1c65c3e03..000000000 --- a/packages/electron-service/src/commands/execute.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils'; - -export async function execute( - browser: WebdriverIO.Browser, - script: string | ((...innerArgs: InnerArguments) => ReturnValue), - ...args: InnerArguments -): Promise { - /** - * parameter check - */ - if (typeof script !== 'string' && typeof script !== 'function') { - throw new Error('Expecting script to be type of "string" or "function"'); - } - - if (!browser) { - throw new Error('WDIO browser is not yet initialised'); - } - - const scriptString = typeof script === 'function' ? script.toString() : wrapStringScript(script); - - const returnValue = await browser.execute( - function executeWithinElectron(script: string, ...args) { - return window.wdioElectron.execute(script, args); - }, - scriptString, - ...args, - ); - - return (returnValue as ReturnValue) ?? undefined; -} - -function wrapStringScript(script: string): string { - const trimmed = script.trim(); - - const isFunctionLike = - (trimmed.startsWith('(') && hasTopLevelArrow(trimmed)) || - /^function[\s(]/.test(trimmed) || - /^async[\s(]/.test(trimmed) || - /^(\w+)\s*=>/.test(trimmed); - - if (isFunctionLike) { - return script; - } - - const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); - const needsAsync = /\bawait\b/.test(trimmed); - const wrap = needsAsync ? 'async ' : ''; - - if (hasRealSemicolon || hasStatementKeyword) { - return `(${wrap}function() { ${script} })()`; - } else { - return `(${wrap}function() { return ${script}; })()`; - } -} diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts deleted file mode 100644 index 8516de571..000000000 --- a/packages/electron-service/test/commands/execute.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { execute } from '../../src/commands/execute.js'; - -describe('execute Command', () => { - beforeEach(() => { - globalThis.browser = { - electron: {}, - execute: vi.fn((fn: (script: string, ...args: unknown[]) => unknown, script: string, ...args: unknown[]) => - typeof fn === 'string' ? new Function(`return (${fn}).apply(this, arguments)`)() : fn(script, ...args), - ), - } as unknown as WebdriverIO.Browser; - - globalThis.wdioElectron = { - execute: vi.fn(), - }; - }); - - it('should throw an error when called with a script argument of the wrong type', async () => { - await expect(() => execute(globalThis.browser, {} as string)).rejects.toThrowError( - new Error('Expecting script to be type of "string" or "function"'), - ); - }); - - it('should throw an error when called without a script argument', async () => { - // @ts-expect-error no script argument - await expect(() => execute(globalThis.browser)).rejects.toThrowError( - new Error('Expecting script to be type of "string" or "function"'), - ); - }); - - it('should throw an error when the browser is not initialised', async () => { - // @ts-expect-error no browser argument - await expect(() => execute(undefined, '() => 1 + 2 + 3')).rejects.toThrowError( - new Error('WDIO browser is not yet initialised'), - ); - }); - - it('should execute a function', async () => { - await execute(globalThis.browser, (a, b, c) => a + b + c, 1, 2, 3); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '(a, b, c) => a + b + c', 1, 2, 3); - expect(globalThis.wdioElectron.execute).toHaveBeenCalledWith('(a, b, c) => a + b + c', [1, 2, 3]); - }); - - it('should execute a stringified function', async () => { - await execute(globalThis.browser, '() => 1 + 2 + 3'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '() => 1 + 2 + 3'); - expect(globalThis.wdioElectron.execute).toHaveBeenCalledWith('() => 1 + 2 + 3', []); - }); - - it('should handle scripts with quotes', async () => { - const scriptWithQuotes = '() => "He said \\"hello\\""'; - await execute(globalThis.browser, scriptWithQuotes); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithQuotes); - }); - - it('should handle scripts with newlines', async () => { - const scriptWithNewlines = '() => "line1\\nline2"'; - await execute(globalThis.browser, scriptWithNewlines); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithNewlines); - }); - - it('should handle scripts with unicode', async () => { - const scriptWithUnicode = '() => "Hello 世界"'; - await execute(globalThis.browser, scriptWithUnicode); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithUnicode); - }); - - it('should handle scripts with backslashes', async () => { - const scriptWithBackslashes = '() => "C:\\\\path\\\\file"'; - await execute(globalThis.browser, scriptWithBackslashes); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), scriptWithBackslashes); - }); - - it('should handle mixed special characters', async () => { - const script = '() => "Test \\n \\t \\u001b and \\\\ backslash"'; - await execute(globalThis.browser, script); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), script); - }); - - it('should wrap expression-style string scripts in IIFE with return', async () => { - await execute(globalThis.browser, '1 + 2 + 3'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return 1 + 2 + 3; })()'), - ); - }); - - it('should wrap statement-style string scripts in IIFE without adding return', async () => { - await execute(globalThis.browser, 'return 42'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return 42 })()'), - ); - }); - - it('should wrap multi-statement string scripts in IIFE', async () => { - await execute(globalThis.browser, 'const x = 10; const y = 20; return x + y;'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() {'), - ); - }); - - it('should handle return(expr) pattern without adding extra return', async () => { - await execute(globalThis.browser, 'return(document.title)'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return(document.title) })()'), - ); - }); - - it('should not false-positive on semicolons inside string literals', async () => { - await execute(globalThis.browser, '"foo;bar"'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return "foo;bar"; })()'), - ); - }); - - it('should treat document.title as expression (do prefix false positive)', async () => { - await execute(globalThis.browser, 'document.title'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return document.title; })()'), - ); - }); - - it('should treat forEach() as expression (for prefix false positive)', async () => { - await execute(globalThis.browser, '[1,2,3].forEach(x => x)'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), expect.stringContaining('return')); - }); - - it('should treat trySomething() as expression (try prefix false positive)', async () => { - await execute(globalThis.browser, 'trySomething()'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return trySomething(); })()'), - ); - }); - - it('should treat asyncData.fetchAll() as expression (async prefix false positive)', async () => { - await execute(globalThis.browser, 'asyncData.fetchAll()'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return asyncData.fetchAll(); })()'), - ); - }); - - it('should treat functionResult.call() as expression (function prefix false positive)', async () => { - await execute(globalThis.browser, 'functionResult.call()'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return functionResult.call(); })()'), - ); - }); - - it('should treat (document.title) as expression (paren without arrow)', async () => { - await execute(globalThis.browser, '(document.title)'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return (document.title); })()'), - ); - }); - - it('should treat (a + b) as expression (paren without arrow)', async () => { - await execute(globalThis.browser, '(a + b)'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return (a + b); })()'), - ); - }); - - it('should treat (x, y) => x + y as function-like (paren arrow)', async () => { - await execute(globalThis.browser, '(x, y) => x + y'); - expect(globalThis.browser.execute).toHaveBeenCalledWith(expect.any(Function), '(x, y) => x + y'); - }); - - it('should use async IIFE when script contains await', async () => { - await execute(globalThis.browser, 'return await someAsyncFn()'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(async function() { return await someAsyncFn() })()'), - ); - }); - - it('should use sync IIFE when script has no await', async () => { - await execute(globalThis.browser, 'return syncFn()'); - expect(globalThis.browser.execute).toHaveBeenCalledWith( - expect.any(Function), - expect.stringContaining('(function() { return syncFn() })()'), - ); - }); -}); diff --git a/packages/electron-service/test/mock.spec.ts b/packages/electron-service/test/mock.spec.ts index fd3225df3..a9a04a637 100644 --- a/packages/electron-service/test/mock.spec.ts +++ b/packages/electron-service/test/mock.spec.ts @@ -20,11 +20,6 @@ vi.doMock('@wdio/native-spy', () => ({ return mockFn; }, })); -vi.mock('../src/commands/execute', () => { - return { - execute: vi.fn(), - }; -}); type ElectronMockExecuteFn = ( electron: Partial, diff --git a/packages/electron-service/test/service.spec.ts b/packages/electron-service/test/service.spec.ts index 235e47908..2571056b8 100644 --- a/packages/electron-service/test/service.spec.ts +++ b/packages/electron-service/test/service.spec.ts @@ -39,12 +39,6 @@ vi.mock('../src/commands/clearAllMocks.js', () => ({ clearAllMocks: vi.fn() })); vi.mock('../src/commands/resetAllMocks.js', () => ({ resetAllMocks: vi.fn() })); vi.mock('../src/commands/restoreAllMocks.js', () => ({ restoreAllMocks: vi.fn() })); -vi.mock('../src/commands/execute', () => { - return { - execute: vi.fn(), - }; -}); - vi.mock('../src/commands/executeCdp', () => { return { execute: vi.fn(), From 21ec49e67ba618552e0955efde8e2f0a496200a5 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 11:34:46 +0100 Subject: [PATCH 118/130] refactor(tauri-service): enhance statement keyword detection in script execution Updated the regex used for detecting statement keywords in the script execution logic to include support for parentheses and curly braces. This change improves the accuracy of function detection, ensuring better handling of various script formats. --- packages/tauri-service/src/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 1d8c04082..91387b637 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -415,7 +415,7 @@ export default class TauriWorkerService { // For strings: use executeAsync with explicit done callback // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly const trimmed = scriptString.trim(); - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test( + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[({]|$)/.test( trimmed, ); const wrappedBody = From bd30316c2c11b88c59a3414d91e7c9723d5f4c4c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 11:34:54 +0100 Subject: [PATCH 119/130] refactor(electron-service): enhance statement keyword detection in script wrapping Updated the regex for detecting statement keywords in the `wrapStringScriptForCdp` function to include support for parentheses and curly braces. This change improves the accuracy of script parsing, ensuring better handling of various script formats. --- packages/electron-service/src/commands/executeCdp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index cb77885ce..c45931178 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -110,7 +110,7 @@ function wrapStringScriptForCdp(script: string): string { // - "return 42" (statement - parsing error) // - "const x = 1" (statement - parsing error) - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[({]|$)/.test(trimmed); const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); if (hasRealSemicolon || hasStatementKeyword) { From 5bb50fd5087f4c077c7df28af789995b0fd6072f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 11:35:03 +0100 Subject: [PATCH 120/130] refactor(tauri-plugin): improve statement detection in script execution Updated the regex for detecting statement keywords in the `execute` function to include support for parentheses and curly braces. Added tests to ensure that `try{` and `do{` are correctly treated as statements, enhancing the accuracy of script parsing and execution. --- .../guest-js/__tests__/index.spec.ts | 18 ++++++++++++++++++ packages/tauri-plugin/guest-js/index.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts index e046a97c1..70384bdee 100644 --- a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts +++ b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts @@ -303,6 +303,24 @@ describe('execute', () => { expect(pluginCalls[0][1].request.script).toContain('return 1 + 2 + 3'); expect(result).toBe(6); }); + + it('should treat try{ (no space) as a statement, not an expression', async () => { + await execute('try{x=1}catch(e){e}'); + + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + const sent = pluginCalls[0][1].request.script as string; + expect(sent).toContain('try{x=1}catch(e){e}'); + expect(sent).not.toContain('return try{'); + }); + + it('should treat do{ (no space) as a statement, not an expression', async () => { + await execute('do{x++}while(x<3)'); + + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + const sent = pluginCalls[0][1].request.script as string; + expect(sent).toContain('do{x++}while(x<3)'); + expect(sent).not.toContain('return do{'); + }); }); describe('setupConsoleForwarding', () => { diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index e68e4bc9f..cf10586d6 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -268,7 +268,7 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson // user args so they are accessible via arguments[0], arguments[1], etc. (W3C §13.2.2). // Using a named function (not an arrow) is required: arrow functions have no arguments object. // No conditional async needed — the Tauri IPC always awaits the result. - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test(trimmed); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[({]|$)/.test(trimmed); const hasStatement = hasStatementKeyword || hasSemicolonOutsideQuotes(trimmed); const argsArray = argsJson ?? '[]'; scriptToSend = hasStatement From 78cd2a4a710e4ab0d11305b3d13651942b0fbafa Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 11:48:20 +0100 Subject: [PATCH 121/130] feat(native-utils): add script detection utilities and tests Introduced two new functions, `hasSemicolonOutsideQuotes` and `hasTopLevelArrow`, in `script-detect.ts` to enhance script analysis capabilities. Updated `index.ts` to export these functions and added comprehensive tests in `script-detect.spec.ts` to validate their behavior. Updated package.json to include new module paths for the script detection utilities. --- packages/native-utils/package.json | 12 +++++++++++- packages/native-utils/src/index.ts | 2 +- .../src/{scriptDetect.ts => script-detect.ts} | 0 .../{scriptDetect.spec.ts => script-detect.spec.ts} | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) rename packages/native-utils/src/{scriptDetect.ts => script-detect.ts} (100%) rename packages/native-utils/test/{scriptDetect.spec.ts => script-detect.spec.ts} (99%) diff --git a/packages/native-utils/package.json b/packages/native-utils/package.json index cadd02703..78ff8e4ac 100644 --- a/packages/native-utils/package.json +++ b/packages/native-utils/package.json @@ -21,7 +21,17 @@ } }, "./dist/cjs/index.js" - ] + ], + "./script-detect": { + "import": { + "types": "./dist/esm/script-detect.d.ts", + "default": "./dist/esm/script-detect.js" + }, + "require": { + "types": "./dist/cjs/script-detect.d.ts", + "default": "./dist/cjs/script-detect.js" + } + } }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.11.0" diff --git a/packages/native-utils/src/index.ts b/packages/native-utils/src/index.ts index acf038e95..a37dbd5f4 100644 --- a/packages/native-utils/src/index.ts +++ b/packages/native-utils/src/index.ts @@ -25,7 +25,7 @@ export { unwrapOr, wrapAsync, } from './result.js'; -export { hasSemicolonOutsideQuotes, hasTopLevelArrow } from './scriptDetect.js'; +export { hasSemicolonOutsideQuotes, hasTopLevelArrow } from './script-detect.js'; export { selectExecutable, validateBinaryPaths } from './selectExecutable.js'; export { waitUntilWindowAvailable } from './window.js'; export { createLogger }; diff --git a/packages/native-utils/src/scriptDetect.ts b/packages/native-utils/src/script-detect.ts similarity index 100% rename from packages/native-utils/src/scriptDetect.ts rename to packages/native-utils/src/script-detect.ts diff --git a/packages/native-utils/test/scriptDetect.spec.ts b/packages/native-utils/test/script-detect.spec.ts similarity index 99% rename from packages/native-utils/test/scriptDetect.spec.ts rename to packages/native-utils/test/script-detect.spec.ts index 0d6debb87..2ac3320dc 100644 --- a/packages/native-utils/test/scriptDetect.spec.ts +++ b/packages/native-utils/test/script-detect.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '../src/scriptDetect.js'; +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '../src/script-detect.js'; describe('hasSemicolonOutsideQuotes', () => { it('should return false for an empty string', () => { From e57477ff4c49196000deaa3c746fa8734998317d Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 11:48:43 +0100 Subject: [PATCH 122/130] feat(tauri-plugin): integrate @wdio/native-utils for script detection Added @wdio/native-utils as a dependency in the tauri-plugin package to utilize script detection utilities. Updated the index.ts to import and use `hasSemicolonOutsideQuotes` and `hasTopLevelArrow` functions for improved script analysis. Modified tsconfig.json to exclude test files from compilation and streamlined TypeScript declaration generation in the build script. --- packages/tauri-plugin/guest-js/index.ts | 69 +------------------ packages/tauri-plugin/package.json | 3 +- .../tauri-plugin/scripts/build-guest-js.ts | 7 +- packages/tauri-plugin/tsconfig.json | 2 +- pnpm-lock.yaml | 3 + 5 files changed, 9 insertions(+), 75 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index cf10586d6..2bfc10c59 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -141,74 +141,7 @@ interface ExecuteOptions { * @param argsJson - Serialized user arguments as JSON string (optional) * @returns Result of the script execution */ -// These helpers are duplicated from electron-service/src/utils.ts. guest-js compiles to browser -// JavaScript and cannot import from @wdio/native-utils or any Node.js package. - -// Returns true if s contains ';' outside string literals at bracket depth 0. -// Detects multi-statement scripts like "someFn(); anotherFn()" that don't start with a keyword. -function hasSemicolonOutsideQuotes(s: string): boolean { - let depth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === '\\' && (inSingle || inDouble || inTemplate)) { - i++; - continue; - } - if (c === "'" && !inDouble && !inTemplate) { - inSingle = !inSingle; - continue; - } - if (c === '"' && !inSingle && !inTemplate) { - inDouble = !inDouble; - continue; - } - if (c === '`' && !inSingle && !inDouble) { - inTemplate = !inTemplate; - continue; - } - if (inSingle || inDouble || inTemplate) continue; - if (c === '(' || c === '[' || c === '{') depth++; - else if (c === ')' || c === ']' || c === '}') depth--; - else if (c === ';' && depth === 0) return true; - } - return false; -} - -// Returns true if s contains '=>' at bracket depth 0 (not nested inside parens/brackets). -// Prevents false positives on expressions like (arr.find(x => x)) where => is nested. -function hasTopLevelArrow(s: string): boolean { - let depth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === '\\' && (inSingle || inDouble || inTemplate)) { - i++; - continue; - } - if (c === "'" && !inDouble && !inTemplate) { - inSingle = !inSingle; - continue; - } - if (c === '"' && !inSingle && !inTemplate) { - inDouble = !inDouble; - continue; - } - if (c === '`' && !inSingle && !inDouble) { - inTemplate = !inTemplate; - continue; - } - if (inSingle || inDouble || inTemplate) continue; - if (c === '(' || c === '[') depth++; - else if (c === ')' || c === ']') depth--; - else if (c === '=' && depth === 0 && i + 1 < s.length && s[i + 1] === '>') return true; - } - return false; -} +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils/script-detect'; export async function execute(script: string, options?: ExecuteOptions, argsJson?: string): Promise { if (!window.__TAURI__) { diff --git a/packages/tauri-plugin/package.json b/packages/tauri-plugin/package.json index 710c7b144..87710dd05 100644 --- a/packages/tauri-plugin/package.json +++ b/packages/tauri-plugin/package.json @@ -29,7 +29,8 @@ "dependencies": { "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-log": "2.8.0", - "@wdio/native-spy": "workspace:*" + "@wdio/native-spy": "workspace:*", + "@wdio/native-utils": "workspace:*" }, "devDependencies": { "@vitest/coverage-v8": "4.1.4", diff --git a/packages/tauri-plugin/scripts/build-guest-js.ts b/packages/tauri-plugin/scripts/build-guest-js.ts index dcc12866d..57b76aaa3 100644 --- a/packages/tauri-plugin/scripts/build-guest-js.ts +++ b/packages/tauri-plugin/scripts/build-guest-js.ts @@ -52,11 +52,8 @@ async function main() { console.log('✅ JavaScript bundle created'); - // Generate TypeScript declarations with tsc - execSync( - 'tsc guest-js/index.ts --outDir dist-js --declaration --emitDeclarationOnly --esModuleInterop --skipLibCheck', - { cwd: packageRoot, stdio: 'inherit' }, - ); + // Generate TypeScript declarations with tsc, using tsconfig.json for moduleResolution + execSync('tsc --project tsconfig.json --emitDeclarationOnly', { cwd: packageRoot, stdio: 'inherit' }); console.log('✅ Type declarations generated'); console.log('🎉 Build complete!'); diff --git a/packages/tauri-plugin/tsconfig.json b/packages/tauri-plugin/tsconfig.json index 72e987fe0..a379ed542 100644 --- a/packages/tauri-plugin/tsconfig.json +++ b/packages/tauri-plugin/tsconfig.json @@ -12,5 +12,5 @@ "strict": true }, "include": ["guest-js/**/*"], - "exclude": ["node_modules", "dist-js"] + "exclude": ["node_modules", "dist-js", "guest-js/__tests__"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c406416bc..d3b92fac2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -942,6 +942,9 @@ importers: '@wdio/native-spy': specifier: workspace:* version: link:../native-spy + '@wdio/native-utils': + specifier: workspace:* + version: link:../native-utils devDependencies: '@vitest/coverage-v8': specifier: 4.1.4 From d44e1a4febdc791efb785a10797b4762d4a1e446 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 13:16:31 +0100 Subject: [PATCH 123/130] fix(turborepo): add @wdio/native-utils dependency and specify inputs for js build Updated the @wdio/tauri-plugin build configuration in turbo.json to include @wdio/native-utils as a dependency and defined inputs for the JavaScript build process. This change enhances the build setup by ensuring necessary scripts are included for processing. --- turbo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index c1b0406d4..5dee3370d 100644 --- a/turbo.json +++ b/turbo.json @@ -143,7 +143,8 @@ "outputs": ["dist/**"] }, "@wdio/tauri-plugin#build:js": { - "dependsOn": ["@wdio/native-spy#build"], + "dependsOn": ["@wdio/native-spy#build", "@wdio/native-utils#build"], + "inputs": ["guest-js/**", "scripts/build-guest-js.ts"], "outputs": ["dist-js/**"] }, "@wdio/tauri-service#build:console-wrapper": { From 99da983cc5e0b741a142d782b4ef61c375d3ed4c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 13:21:18 +0100 Subject: [PATCH 124/130] feat(electron-service): enhance arrow function handling in executeCdp Updated the executeCdp function to route both regular and async arrow function strings through recast, ensuring the electron parameter is stripped correctly. Added a test case to verify this behavior for async arrow functions, improving the accuracy of script execution. --- packages/electron-service/src/commands/executeCdp.ts | 8 +++++--- .../electron-service/test/commands/executeCdp.spec.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index c45931178..e2e3e3e73 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -53,10 +53,12 @@ export async function execute( let functionDeclaration: string; if (typeof script === 'string') { const trimmed = script.trim(); - // Only let recast handle arrow functions starting with ( and containing => - // These get transformed to add electron parameter + // Route arrow functions through recast so the electron param is stripped. + // Covers: (electron, ...args) => ... and async (electron, ...args) => ... const isArrowFunction = - trimmed.startsWith('(') && hasTopLevelArrow(trimmed) && !trimmed.match(/^\s*(async\s+)?function\b/); // exclude function declarations + (trimmed.startsWith('(') || /^async\s*\(/.test(trimmed)) && + hasTopLevelArrow(trimmed) && + !/^(async\s+)?function\b/.test(trimmed); if (isArrowFunction) { // Arrow function - recast handles electron param injection diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 650d92e37..28faf4216 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -156,6 +156,16 @@ describe('execute Command', () => { ); }); + it('should route async arrow function strings through recast and strip the electron param', async () => { + await execute(globalThis.browser, client, 'async (electron, x) => x * 2'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + functionDeclaration: expect.stringMatching(/^async\s+\(?x\)?\s*=>/), + }), + ); + }); + it('should exclude actual function keyword declarations', async () => { // Real function declaration (not arrow) should be wrapped // The guard checks !match(/^\s*(async\s+)?function\b/) to exclude function declarations From af2d5a9fda15faf0e6e1d2b560d5b0a59cf38222 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 14:30:56 +0100 Subject: [PATCH 125/130] refactor(electron-service): improve function handling in executeCdp Updated the executeCdp function to route both named and anonymous function declaration strings through recast, ensuring the electron parameter is stripped correctly. Adjusted test cases to verify the handling of various function formats, enhancing the accuracy of script execution. --- .../src/commands/executeCdp.ts | 23 ++++++++++--------- .../test/commands/executeCdp.spec.ts | 18 +++++++++++---- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index e2e3e3e73..9b9d36ce2 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -53,18 +53,19 @@ export async function execute( let functionDeclaration: string; if (typeof script === 'string') { const trimmed = script.trim(); - // Route arrow functions through recast so the electron param is stripped. - // Covers: (electron, ...args) => ... and async (electron, ...args) => ... - const isArrowFunction = - (trimmed.startsWith('(') || /^async\s*\(/.test(trimmed)) && - hasTopLevelArrow(trimmed) && - !/^(async\s+)?function\b/.test(trimmed); - - if (isArrowFunction) { - // Arrow function - recast handles electron param injection - functionDeclaration = getCachedOrParse(script); + // Route function-like strings through recast so the electron param is stripped. + // Covers: (e, ...args) => ..., async (e, ...args) => ..., function(e) {...}, async function(e) {...} + const isFunctionLike = + /^(async\s+)?function\b/.test(trimmed) || + ((trimmed.startsWith('(') || /^async\s*\(/.test(trimmed)) && hasTopLevelArrow(trimmed)); + + if (isFunctionLike) { + // Anonymous function expressions (function(...){}) are only valid in expression context. + // Babel rejects them at statement level ("Unexpected token"), so wrap in parens first. + // Named declarations (function foo(...){}) and arrow functions parse fine as-is. + const needsParens = /^(async\s+)?function\s*\(/.test(trimmed); + functionDeclaration = getCachedOrParse(needsParens ? `(${script.trim()})` : script); } else { - // Not a simple arrow function - wrap it ourselves functionDeclaration = wrapStringScriptForCdp(script); } } else { diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 28faf4216..e9be174a1 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -166,15 +166,23 @@ describe('execute Command', () => { ); }); - it('should exclude actual function keyword declarations', async () => { - // Real function declaration (not arrow) should be wrapped - // The guard checks !match(/^\s*(async\s+)?function\b/) to exclude function declarations + it('should route named function declaration strings through recast and strip the electron param', async () => { await execute(globalThis.browser, client, 'async function test(electron) { return 42; }'); expect(client.send).toHaveBeenCalledWith( 'Runtime.callFunctionOn', expect.objectContaining({ - // Should be wrapped (since it starts with function keyword) - functionDeclaration: expect.stringContaining('async function()'), + functionDeclaration: expect.stringContaining('async function test()'), + }), + ); + }); + + it('should route anonymous function expression strings through recast and strip the electron param', async () => { + await execute(globalThis.browser, client, 'function(electron) { return 42; }'); + expect(client.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + // Wrapped in parens for expression-context parsing; electron param stripped + functionDeclaration: expect.stringContaining('(function() {'), }), ); }); From 861bf3257e9597af56ea88417c0e8c82dceed702 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 14:31:28 +0100 Subject: [PATCH 126/130] refactor(tauri-plugin): move import to correct place --- packages/tauri-plugin/guest-js/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 2bfc10c59..9fb3e3d37 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -5,6 +5,7 @@ import type { InvokeArgs } from '@tauri-apps/api/core'; import * as nativeSpy from '@wdio/native-spy'; +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils/script-detect'; // Lazy-load invoke function to support both global Tauri API and dynamic imports // This allows the plugin to work both with bundlers (Vite) and without (plain ES modules) @@ -141,8 +142,6 @@ interface ExecuteOptions { * @param argsJson - Serialized user arguments as JSON string (optional) * @returns Result of the script execution */ -import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils/script-detect'; - export async function execute(script: string, options?: ExecuteOptions, argsJson?: string): Promise { if (!window.__TAURI__) { throw new Error('window.__TAURI__ is not available. Make sure withGlobalTauri is enabled in tauri.conf.json'); From ed553fe00368ac881c3f8a2c8be0b8c0c75dab85 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 15:10:54 +0100 Subject: [PATCH 127/130] refactor(native-utils): enhance script detection logic for semicolons and arrow functions Updated the `hasSemicolonOutsideQuotes` and `hasTopLevelArrow` functions in `script-detect.ts` to improve handling of template literals and nested expressions. Added new test cases to cover various scenarios, including nested template literals and top-level arrow functions following template literals, ensuring accurate detection of semicolons and arrow functions in different contexts. --- packages/native-utils/src/script-detect.ts | 92 +++++++++++++++---- .../native-utils/test/script-detect.spec.ts | 24 +++++ 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/packages/native-utils/src/script-detect.ts b/packages/native-utils/src/script-detect.ts index e0bf675d7..7997784bf 100644 --- a/packages/native-utils/src/script-detect.ts +++ b/packages/native-utils/src/script-detect.ts @@ -2,29 +2,56 @@ export function hasSemicolonOutsideQuotes(s: string): boolean { let depth = 0; let inSingle = false; let inDouble = false; - let inTemplate = false; + // Each frame: { inStr: true } = reading template string chars, + // { inStr: false, exprDepth: n } = inside ${} expression that opened at depth n. + const tmpl: Array<{ inStr: boolean; exprDepth: number }> = []; + for (let i = 0; i < s.length; i++) { const c = s[i]; - if (c === '\\' && (inSingle || inDouble || inTemplate)) { + const topInStr = tmpl.length > 0 && tmpl[tmpl.length - 1].inStr; + + if (c === '\\' && (inSingle || inDouble || topInStr)) { i++; continue; } - if (c === "'" && !inDouble && !inTemplate) { + + if (topInStr) { + if (c === '`') { + tmpl.pop(); + } else if (c === '$' && i + 1 < s.length && s[i + 1] === '{') { + i++; + depth++; + tmpl[tmpl.length - 1].inStr = false; + tmpl[tmpl.length - 1].exprDepth = depth; + } + continue; + } + + if (c === "'" && !inDouble) { inSingle = !inSingle; continue; } - if (c === '"' && !inSingle && !inTemplate) { + if (c === '"' && !inSingle) { inDouble = !inDouble; continue; } - if (c === '`' && !inSingle && !inDouble) { - inTemplate = !inTemplate; + if (inSingle || inDouble) continue; + + if (c === '`') { + tmpl.push({ inStr: true, exprDepth: 0 }); continue; } - if (inSingle || inDouble || inTemplate) continue; - if (c === '(' || c === '[' || c === '{') depth++; - else if (c === ')' || c === ']' || c === '}') depth--; - else if (c === ';' && depth === 0) return true; + + if (c === '(' || c === '[' || c === '{') { + depth++; + } else if (c === ')' || c === ']' || c === '}') { + depth--; + if (tmpl.length > 0 && !tmpl[tmpl.length - 1].inStr && depth === tmpl[tmpl.length - 1].exprDepth - 1) { + tmpl[tmpl.length - 1].inStr = true; + } + } else if (c === ';' && depth === 0) { + return true; + } } return false; } @@ -33,29 +60,54 @@ export function hasTopLevelArrow(s: string): boolean { let depth = 0; let inSingle = false; let inDouble = false; - let inTemplate = false; + const tmpl: Array<{ inStr: boolean; exprDepth: number }> = []; + for (let i = 0; i < s.length; i++) { const c = s[i]; - if (c === '\\' && (inSingle || inDouble || inTemplate)) { + const topInStr = tmpl.length > 0 && tmpl[tmpl.length - 1].inStr; + + if (c === '\\' && (inSingle || inDouble || topInStr)) { i++; continue; } - if (c === "'" && !inDouble && !inTemplate) { + + if (topInStr) { + if (c === '`') { + tmpl.pop(); + } else if (c === '$' && i + 1 < s.length && s[i + 1] === '{') { + i++; + depth++; + tmpl[tmpl.length - 1].inStr = false; + tmpl[tmpl.length - 1].exprDepth = depth; + } + continue; + } + + if (c === "'" && !inDouble) { inSingle = !inSingle; continue; } - if (c === '"' && !inSingle && !inTemplate) { + if (c === '"' && !inSingle) { inDouble = !inDouble; continue; } - if (c === '`' && !inSingle && !inDouble) { - inTemplate = !inTemplate; + if (inSingle || inDouble) continue; + + if (c === '`') { + tmpl.push({ inStr: true, exprDepth: 0 }); continue; } - if (inSingle || inDouble || inTemplate) continue; - if (c === '(' || c === '[') depth++; - else if (c === ')' || c === ']') depth--; - else if (c === '=' && depth === 0 && i + 1 < s.length && s[i + 1] === '>') return true; + + if (c === '(' || c === '[' || c === '{') { + depth++; + } else if (c === ')' || c === ']' || c === '}') { + depth--; + if (tmpl.length > 0 && !tmpl[tmpl.length - 1].inStr && depth === tmpl[tmpl.length - 1].exprDepth - 1) { + tmpl[tmpl.length - 1].inStr = true; + } + } else if (c === '=' && depth === 0 && tmpl.length === 0 && i + 1 < s.length && s[i + 1] === '>') { + return true; + } } return false; } diff --git a/packages/native-utils/test/script-detect.spec.ts b/packages/native-utils/test/script-detect.spec.ts index 2ac3320dc..a36517d4c 100644 --- a/packages/native-utils/test/script-detect.spec.ts +++ b/packages/native-utils/test/script-detect.spec.ts @@ -70,6 +70,18 @@ describe('hasSemicolonOutsideQuotes', () => { it('should return false for a semicolon inside a template literal with expression', () => { expect(hasSemicolonOutsideQuotes('`${a}; ${b}`')).toBe(false); }); + + it('should return false for a semicolon inside a nested template literal', () => { + expect(hasSemicolonOutsideQuotes('`${`inner; value`}`')).toBe(false); + }); + + it('should return false for a semicolon in a doubly-nested template literal', () => { + expect(hasSemicolonOutsideQuotes('`${`${`deep; value`}`}`')).toBe(false); + }); + + it('should return true for a semicolon after a nested template literal closes', () => { + expect(hasSemicolonOutsideQuotes('`${`inner`}`; done()')).toBe(true); + }); }); describe('hasTopLevelArrow', () => { @@ -136,4 +148,16 @@ describe('hasTopLevelArrow', () => { it('should handle escaped quotes in strings correctly', () => { expect(hasTopLevelArrow("'it\\'s => not'")).toBe(false); }); + + it('should return false for an arrow inside a nested template expression', () => { + expect(hasTopLevelArrow('`${(x) => x}`')).toBe(false); + }); + + it('should return false for a nested template expression not containing an arrow at top level', () => { + expect(hasTopLevelArrow('`${`inner`}`')).toBe(false); + }); + + it('should return true for a top-level arrow after a template literal', () => { + expect(hasTopLevelArrow('(`template`) => 1')).toBe(true); + }); }); From 5b0799b3bc30d1a1efaa5659d7ab7fcd1665ac1f Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 15:11:28 +0100 Subject: [PATCH 128/130] fix(tauri-plugin): refine async function detection in script execution Updated the logic in the `execute` function to accurately differentiate between async function declarations and async expressions. Added a test case to ensure that `async(42)` is treated as an expression, improving the handling of async scripts in the plugin. --- packages/tauri-plugin/guest-js/__tests__/index.spec.ts | 9 +++++++++ packages/tauri-plugin/guest-js/index.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts index 70384bdee..137fccd17 100644 --- a/packages/tauri-plugin/guest-js/__tests__/index.spec.ts +++ b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts @@ -321,6 +321,15 @@ describe('execute', () => { expect(sent).toContain('do{x++}while(x<3)'); expect(sent).not.toContain('return do{'); }); + + it('should treat async(42) as an expression, not a function-like script', async () => { + await execute('async(42)'); + + const pluginCalls = originalInvoke.mock.calls.filter((call: unknown[]) => call[0] === 'plugin:wdio|execute'); + const sent = pluginCalls[0][1].request.script as string; + expect(sent).not.toContain('__wdio_tauri'); + expect(sent).toContain('async(42)'); + }); }); describe('setupConsoleForwarding', () => { diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index 9fb3e3d37..ada23344c 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -151,7 +151,7 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson const isFunctionLike = (trimmed.startsWith('(') && hasTopLevelArrow(trimmed)) || /^function[\s(]/.test(trimmed) || - /^async[\s(]/.test(trimmed) || + (/^async[\s(]/.test(trimmed) && (/^async\s+function\b/.test(trimmed) || hasTopLevelArrow(trimmed))) || /^(\w+)\s*=>/.test(trimmed); let scriptToSend: string; From 12ef14d8b08b2777c96beaea757394af53dab571 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 15:28:27 +0100 Subject: [PATCH 129/130] refactor(tauri-plugin): improve semicolon detection in template literals Enhanced the `has_semicolon_outside_quotes` function to accurately track semicolon usage in template literals, including nested expressions. Introduced a stack-based approach to manage template frames, improving detection logic and ensuring correct handling of various string contexts. --- packages/tauri-plugin/src/commands.rs | 79 ++++++++++++++++++++------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index 07ed9b3b6..bbad7cc46 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -171,36 +171,77 @@ pub(crate) async fn execute( } // Returns true if s contains ';' outside string literals at bracket depth 0. + // Uses a stack-based tracker for template literals so that nested template + // expressions like `${`inner; value`}` do not produce false positives. fn has_semicolon_outside_quotes(s: &str) -> bool { + struct TmplFrame { in_str: bool, expr_depth: i32 } let bytes = s.as_bytes(); + let mut depth: i32 = 0; let mut in_single_quote = false; let mut in_double_quote = false; - let mut in_backtick = false; - let mut depth: i32 = 0; - let mut backslash_count: usize = 0; + let mut tmpl: Vec = Vec::new(); let mut i = 0; while i < bytes.len() { let b = bytes[i]; - if b == b'\\' { - backslash_count += 1; + let top_in_str = tmpl.last().map_or(false, |f| f.in_str); + + // Escape: skip next byte when inside a string or template string chars. + if b == b'\\' && (in_single_quote || in_double_quote || top_in_str) { + i += 2; + continue; + } + + // Inside template string chars: only backtick and ${ are significant. + if top_in_str { + if b == b'`' { + tmpl.pop(); + } else if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + i += 1; // consume the { + depth += 1; + if let Some(frame) = tmpl.last_mut() { + frame.in_str = false; + frame.expr_depth = depth; + } + } i += 1; continue; } - let escaped = backslash_count % 2 == 1; - backslash_count = 0; - if !escaped { - match b { - b'\'' if !in_double_quote && !in_backtick => in_single_quote = !in_single_quote, - b'"' if !in_single_quote && !in_backtick => in_double_quote = !in_double_quote, - b'`' if !in_single_quote && !in_double_quote => in_backtick = !in_backtick, - _ if !in_single_quote && !in_double_quote && !in_backtick => match b { - b'(' | b'[' | b'{' => depth += 1, - b')' | b']' | b'}' => depth -= 1, - b';' if depth == 0 => return true, - _ => {} - }, - _ => {} + + if b == b'\'' && !in_double_quote { + in_single_quote = !in_single_quote; + i += 1; + continue; + } + if b == b'"' && !in_single_quote { + in_double_quote = !in_double_quote; + i += 1; + continue; + } + if in_single_quote || in_double_quote { + i += 1; + continue; + } + + // Start a new template literal. + if b == b'`' { + tmpl.push(TmplFrame { in_str: true, expr_depth: 0 }); + i += 1; + continue; + } + + match b { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + depth -= 1; + // Check if this } closes a template expression. + if let Some(frame) = tmpl.last_mut() { + if !frame.in_str && depth == frame.expr_depth - 1 { + frame.in_str = true; + } + } } + b';' if depth == 0 => return true, + _ => {} } i += 1; } From f00962f0501584ea4d0d01bef2a337b449041daf Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 24 Apr 2026 16:53:54 +0100 Subject: [PATCH 130/130] refactor: enhance statement keyword detection in script execution Updated the regex for detecting statement keywords in the `execute` function across multiple files to improve accuracy. The new regex ensures that statement keywords are correctly identified, enhancing the handling of various script formats and improving overall script execution reliability. --- packages/electron-service/src/commands/executeCdp.ts | 2 +- packages/tauri-plugin/guest-js/index.ts | 2 +- packages/tauri-plugin/src/commands.rs | 2 +- packages/tauri-service/src/service.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 9b9d36ce2..d56fb4add 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -113,7 +113,7 @@ function wrapStringScriptForCdp(script: string): string { // - "return 42" (statement - parsing error) // - "const x = 1" (statement - parsing error) - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[({]|$)/.test(trimmed); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=[^\w$]|$)/.test(trimmed); const hasRealSemicolon = hasSemicolonOutsideQuotes(trimmed); if (hasRealSemicolon || hasStatementKeyword) { diff --git a/packages/tauri-plugin/guest-js/index.ts b/packages/tauri-plugin/guest-js/index.ts index ada23344c..9e217b625 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -200,7 +200,7 @@ export async function execute(script: string, options?: ExecuteOptions, argsJson // user args so they are accessible via arguments[0], arguments[1], etc. (W3C §13.2.2). // Using a named function (not an arrow) is required: arrow functions have no arguments object. // No conditional async needed — the Tauri IPC always awaits the result. - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[({]|$)/.test(trimmed); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=[^\w$]|$)/.test(trimmed); const hasStatement = hasStatementKeyword || hasSemicolonOutsideQuotes(trimmed); const argsArray = argsJson ?? '[]'; scriptToSend = hasStatement diff --git a/packages/tauri-plugin/src/commands.rs b/packages/tauri-plugin/src/commands.rs index bbad7cc46..89d814dfa 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -305,7 +305,7 @@ pub(crate) async fn execute( || has_semicolon_outside_quotes(t); let has_return = { if let Some(rest) = t.strip_prefix("return") { - rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') || rest.starts_with('(') + rest.is_empty() || !rest.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$') } else { false } diff --git a/packages/tauri-service/src/service.ts b/packages/tauri-service/src/service.ts index 91387b637..54d3e6db3 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -415,7 +415,7 @@ export default class TauriWorkerService { // For strings: use executeAsync with explicit done callback // WebKit (macOS/iOS Tauri) doesn't auto-await returned Promises - must call callback explicitly const trimmed = scriptString.trim(); - const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[({]|$)/.test( + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=[^\w$]|$)/.test( trimmed, ); const wrappedBody =