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] 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 c9f616c36..3934bbe43 100644 --- a/docs/tauri-playwright-improvements-plan.md +++ b/docs/tauri-playwright-improvements-plan.md @@ -13,7 +13,7 @@ | 2 | Direct WebView eval channel (supplement WebDriver) | Tauri (embedded) | Large | High | Pending | | 3 | Multi-window label configuration | Tauri | Small | Medium | **Completed** | | 4 | Native screenshot capture | Tauri + Electron | Medium | Low | Pending | -| 5 | Audit JS string interpolation / escaping | Tauri + Electron | Small | Medium | Pending | +| 5 | Audit JS string interpolation / escaping | Tauri + Electron | Small | Medium | **Done** | | 6 | IPC mock serialization pattern | Tauri + Electron | Medium | Medium | Pending | --- 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); + }); + }); }); diff --git a/package.json b/package.json index 7d364efea..78f94d329 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,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", diff --git a/packages/electron-cdp-bridge/package.json b/packages/electron-cdp-bridge/package.json index ffe5ef065..fba343b56 100644 --- a/packages/electron-cdp-bridge/package.json +++ b/packages/electron-cdp-bridge/package.json @@ -56,11 +56,7 @@ "typescript": "^5.9.3", "vitest": "^4.0.18" }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "files": ["dist", "README.md", "LICENSE"], "publishConfig": { "access": "public", "provenance": true diff --git a/packages/electron-service/package.json b/packages/electron-service/package.json index 50a8d4327..770706ea3 100644 --- a/packages/electron-service/package.json +++ b/packages/electron-service/package.json @@ -37,14 +37,7 @@ "test:dev": "vitest --coverage", "lint": "biome check ." }, - "keywords": [ - "webdriverio", - "wdio", - "wdio-service", - "electron", - "chromedriver", - "tests" - ], + "keywords": ["webdriverio", "wdio", "wdio-service", "electron", "chromedriver", "tests"], "peerDependencies": { "electron": "*", "webdriverio": ">9.0.0" @@ -97,12 +90,7 @@ "typescript": "^5.9.3", "vitest": "^4.0.18" }, - "files": [ - "dist", - "docs", - "README.md", - "LICENSE" - ], + "files": ["dist", "docs", "README.md", "LICENSE"], "publishConfig": { "access": "public", "provenance": true diff --git a/packages/electron-service/src/commands/execute.ts b/packages/electron-service/src/commands/execute.ts index ead3d046b..34b714ae5 100644 --- a/packages/electron-service/src/commands/execute.ts +++ b/packages/electron-service/src/commands/execute.ts @@ -1,3 +1,5 @@ +import { hasSemicolonOutsideQuotes, hasTopLevelArrow } from '@wdio/native-utils'; + export async function execute( browser: WebdriverIO.Browser, script: string | ((...innerArgs: InnerArguments) => ReturnValue), @@ -14,13 +16,40 @@ export async function execute( 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); }, - `${script}`, + 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}() => { ${script} })()`; + } else { + return `(${wrap}() => { return ${script}; })()`; + } +} diff --git a/packages/electron-service/src/commands/executeCdp.ts b/packages/electron-service/src/commands/executeCdp.ts index 8c47bf165..b4b002624 100644 --- a/packages/electron-service/src/commands/executeCdp.ts +++ b/packages/electron-service/src/commands/executeCdp.ts @@ -3,9 +3,9 @@ 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 { isInternalCommand } from '../utils.js'; @@ -47,7 +47,27 @@ export async function execute( return undefined; } - const functionDeclaration = getCachedOrParse(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('(') && hasTopLevelArrow(trimmed) && !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')); @@ -76,6 +96,39 @@ export async function execute( return result.result.value as ReturnValue; } +/** + * 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('(') && hasTopLevelArrow(trimmed) && !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 () => (${script})`; + } +} + 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 799cb601d..75f25a0d5 100644 --- a/packages/electron-service/src/utils.ts +++ b/packages/electron-service/src/utils.ts @@ -1,10 +1,5 @@ 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); } diff --git a/packages/electron-service/test/commands/execute.spec.ts b/packages/electron-service/test/commands/execute.spec.ts index b7feaa30b..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[]) => @@ -47,4 +47,145 @@ describe('execute Command', () => { 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('(() => { 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('(() => { 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('(() => {')); + }); + + 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) })()'), + ); + }); + + 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('(() => { 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('(() => { 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('(() => { 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('(() => { 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('(() => { 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('(() => { 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('(() => { 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 () => { 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() })()'), + ); + }); }); diff --git a/packages/electron-service/test/commands/executeCdp.spec.ts b/packages/electron-service/test/commands/executeCdp.spec.ts index 9be10b3b0..265bb73a9 100644 --- a/packages/electron-service/test/commands/executeCdp.spec.ts +++ b/packages/electron-service/test/commands/executeCdp.spec.ts @@ -109,8 +109,27 @@ 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 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 () => { diff --git a/packages/native-spy/package.json b/packages/native-spy/package.json index d69e456c7..1a5548745 100644 --- a/packages/native-spy/package.json +++ b/packages/native-spy/package.json @@ -47,11 +47,7 @@ "typescript": "^5.9.3", "vitest": "^4.0.18" }, - "files": [ - "dist", - "LICENSE", - "README.md" - ], + "files": ["dist", "LICENSE", "README.md"], "repository": { "type": "git", "url": "https://github.com/webdriverio/desktop-mobile.git", diff --git a/packages/native-types/package.json b/packages/native-types/package.json index 5471a40ec..c1900ff23 100644 --- a/packages/native-types/package.json +++ b/packages/native-types/package.json @@ -45,11 +45,7 @@ "typescript": "^5.9.3", "webdriverio": "catalog:default" }, - "files": [ - "dist", - "LICENSE", - "README.md" - ], + "files": ["dist", "LICENSE", "README.md"], "publishConfig": { "access": "public", "provenance": true diff --git a/packages/native-utils/package.json b/packages/native-utils/package.json index ce1e7bfb3..cadd02703 100644 --- a/packages/native-utils/package.json +++ b/packages/native-utils/package.json @@ -56,11 +56,7 @@ "typescript": "^5.9.3", "vitest": "^4.0.18" }, - "files": [ - "dist", - "LICENSE", - "README.md" - ], + "files": ["dist", "LICENSE", "README.md"], "publishConfig": { "access": "public", "provenance": true 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/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); + }); +}); 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/guest-js/__tests__/index.spec.ts b/packages/tauri-plugin/guest-js/__tests__/index.spec.ts index 347edf730..057894f8d 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 01eb05b97..9bb46d78a 100644 --- a/packages/tauri-plugin/guest-js/index.ts +++ b/packages/tauri-plugin/guest-js/index.ts @@ -141,17 +141,96 @@ 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; +} + 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'); } - // 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('(') && hasTopLevelArrow(trimmed)) || + /^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 +263,20 @@ 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); + const hasStatement = hasStatementKeyword || hasSemicolonOutsideQuotes(trimmed); + scriptToSend = hasStatement ? `(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 4551b0742..fbc2a59ee 100644 --- a/packages/tauri-plugin/src/commands.rs +++ b/packages/tauri-plugin/src/commands.rs @@ -1,10 +1,10 @@ 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; -use crate::Error; /// Window state information for generic window management /// Mirrors Electron's window tracking - discover active window without app-specific knowledge @@ -51,6 +51,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); @@ -75,31 +79,221 @@ 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 - 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) - } else { + // 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) + }; + + // Check if => appears outside of string literals (to avoid false positives like "foo"=>"bar") + // 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 + } + + // 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 + } + + // 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) + // Only detect arrows that are NOT inside string literals + 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| { + let before = trimmed[..pos].trim(); + // 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) + 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; + + 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 + request.script.clone() + } 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 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 = { + if let Some(rest) = t.strip_prefix("return") { + rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') || 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) + } else { + // Has statements or already has return - pass through as-is + request.script.clone() + }; + + format!("(async () => {{ {body} }})()") }; // Generate unique event ID for this execution 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 - let tx_clone = Arc::clone(&tx); - let listener_id = app.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"); @@ -126,6 +320,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 = invoking_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: @@ -163,8 +372,10 @@ pub(crate) async fn execute( throw new Error('Tauri core.invoke not available after timeout'); }} - // Execute the user's script - const result = await ({}); + // 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 = await __wdio_script; if (result === undefined) {{ await __wdio_emit('{}', {{ success: true, __wdio_undefined__: true }}); @@ -188,7 +399,8 @@ 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.unlisten(listener_id_app); + invoking_window.unlisten(listener_id_window); return Err(crate::Error::ExecuteError(format!("Failed to eval script: {}", e))); } @@ -199,23 +411,26 @@ pub(crate) async fn execute( // This matches the WebDriver default script timeout let window_label = target_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"); log::trace!("Result: {:?}", result); - app.unlisten(listener_id); + app.unlisten(listener_id_app); + invoking_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); + 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.unlisten(listener_id_app); + invoking_window.unlisten(listener_id_window); Err(crate::Error::ExecuteError(format!( "Channel closed unexpectedly. Event ID: {}. Window: {}", event_id, window_label @@ -224,7 +439,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); + invoking_window.unlisten(listener_id_window); Err(crate::Error::ExecuteError(format!( "Script execution timed out after 30s. Event ID: {}. Window: {}", event_id, window_label @@ -255,7 +471,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(), @@ -263,10 +479,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/commands/execute.ts b/packages/tauri-service/src/commands/execute.ts index f5829b5ea..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( 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 }, invokeCommand: string, invokeArgs: unknown[]) => core.invoke(invokeCommand, ...invokeArgs), + command, + args, + ); return { ok: true, 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}`); diff --git a/packages/tauri-service/src/crabnebulaBackend.ts b/packages/tauri-service/src/crabnebulaBackend.ts index c8a052348..dd1d40db0 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, execFileSync, spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import { createLogger } from '@wdio/native-utils'; import { findTestRunnerBackend } from './driverManager.js'; @@ -61,6 +61,41 @@ 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') { + 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 { + const pidsOutput = execFileSync('lsof', ['-ti', `:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + if (pidsOutput) { + 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 { + // 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()]; @@ -121,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(); @@ -142,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/src/launcher.ts b/packages/tauri-service/src/launcher.ts index 7f9e49040..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); + 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); + 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); + 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); + 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 */ 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, ); diff --git a/packages/tauri-service/src/pluginCache.ts b/packages/tauri-service/src/pluginCache.ts new file mode 100644 index 000000000..621339f9e --- /dev/null +++ b/packages/tauri-service/src/pluginCache.ts @@ -0,0 +1,13 @@ +const pluginAvailabilityCache = new WeakMap(); + +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/service.ts b/packages/tauri-service/src/service.ts index 6ce17c127..1d8c04082 100644 --- a/packages/tauri-service/src/service.ts +++ b/packages/tauri-service/src/service.ts @@ -1,10 +1,11 @@ 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'; import mockStore from './mockStore.js'; import { CONSOLE_WRAPPER_SCRIPT } from './scripts/console-wrapper.js'; + import type { TauriCapabilities, TauriServiceGlobalOptions, TauriServiceOptions } from './types.js'; import { clearWindowState, @@ -370,6 +371,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( @@ -379,18 +381,61 @@ export default class TauriWorkerService { const scriptString = typeof script === 'function' ? script.toString() : script; if (isEmbedded) { - // For embedded WebDriver: skip console wrapper as console forwarding - // is handled by tauri-plugin-webdriver. - 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, + ); } - // For tauri-driver: use sync execute with console wrapper - const wrappedScript = ` + // 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 + const wrappedScript = ` ${CONSOLE_WRAPPER_SCRIPT} - return (${scriptString}).apply(null, arguments); - `; - - return originalExecute(wrappedScript, ...args) as Promise; + 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) { + 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 + const trimmed = scriptString.trim(); + const hasStatementKeyword = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=\s|[(]|$)/.test( + trimmed, + ); + 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( + (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; + } }; Object.defineProperty(browser, 'execute', { 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'); 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'; diff --git a/packages/tauri-service/test/service.spec.ts b/packages/tauri-service/test/service.spec.ts index 3c18fc39e..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), @@ -52,6 +56,7 @@ import { clearWindowState, ensureActiveWindowFocus, setCurrentWindowLabel } from function createMockBrowser(overrides: Record = {}): WebdriverIO.Browser { return { execute: vi.fn().mockResolvedValue(undefined), + executeAsync: vi.fn().mockResolvedValue(undefined), isMultiremote: false, sessionId: 'test-session-123', instances: [], @@ -102,6 +107,116 @@ describe('TauriWorkerService', () => { expect(firstExecute).toBe(secondExecute); }); + + 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 }); + const service = new TauriWorkerService({ driverProvider: 'official' }, { 'wdio:tauriServiceOptions': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + mockBrowser.execute('return document.title'); + + // String scripts should use executeAsync with explicit done 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__'); + // execute should NOT be called for string scripts + 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); + 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); + + // 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 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 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': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + mockBrowser.execute('return document.title'); + + expect(mockExecute).toHaveBeenCalledWith('return document.title'); + }); + + 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': {} }); + + (service as any).patchBrowserExecute(mockBrowser); + const testFn = (a: number, b: number) => a + b; + mockBrowser.execute(testFn as any, 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); + }); }); describe('before()', () => { @@ -155,27 +270,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(); @@ -195,15 +315,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; patchBrowserExecute converts - // functions to strings before delegating to originalExecute, so we match on script content. const originalExecute = mockBrowser.execute as ReturnType; const service = new TauriWorkerService({ driverProvider: 'embedded' }, { 'wdio:tauriServiceOptions': {} }); await service.before({} as any, [], mockBrowser); + // 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('__wdio_mocks__'), + ([script]) => typeof script === 'function' && script.name === 'clearStaleMocks', ); expect(clearCall).toBeDefined(); }); 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: {