Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions packages/dioxus-service/src/commands/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@

import type { DioxusAPIs } from '@wdio/native-types';

// Tracks which browser instances are using the embedded driver so that
// unwrapEmbeddedResult is only applied on that path. External WebDriver
// results are raw JS values and must not be treated as envelopes.
const embeddedBrowsers = new WeakSet<WebdriverIO.Browser>();

/**
* Mark a browser session as using the embedded driver. Called by the worker
* service during `before()` when `driverProvider === 'embedded'`. Once
* marked, execute() and runInterceptorScript() unwrap the __wdio_value__
* envelope that the embedded polling loop adds to every result.
*/
export function markAsEmbedded(browser: WebdriverIO.Browser): void {
embeddedBrowsers.add(browser);
}

export async function execute<ReturnValue, InnerArguments extends unknown[] = unknown[]>(
browser: WebdriverIO.Browser,
script: string | ((dx: DioxusAPIs, ...args: InnerArguments) => ReturnValue),
Expand Down Expand Up @@ -52,12 +67,12 @@ export async function execute<ReturnValue, InnerArguments extends unknown[] = un
}
})
.join(', ');
// The user's function is wrapped in an explicit Promise so any
// synchronous throw is converted to a controlled rejection — protects
// against WKWebView edge cases where an AsyncFunction body throwing
// through an IIFE can leave the eval pipeline in an inconsistent state.
// Promise.resolve(...).then(resolve, reject) handles both sync values
// and returned promises (incl. those that reject after an await).
// For the embedded driver the guest-js AsyncFunction loop awaits the
// returned Promise. We resolve with {__wdio_value__: r} so undefined
// (key absent in JSON.stringify) is distinguishable from null (key
// present). External WebDriver resolves Promises natively; no envelope.
const isEmbedded = embeddedBrowsers.has(browser);
const thenResolve = isEmbedded ? 'function(__r){resolve({__wdio_value__:__r});}' : 'resolve';
const wrapped = `
const userFn = (${fnSource});
const dx = window.__WDIO_DIOXUS__;
Expand All @@ -69,16 +84,22 @@ export async function execute<ReturnValue, InnerArguments extends unknown[] = un
}
return new Promise(function (resolve, reject) {
try {
Promise.resolve(userFn(dx, ${argsLiteral})).then(resolve, reject);
Promise.resolve(userFn(dx, ${argsLiteral})).then(${thenResolve}, reject);
} catch (e) {
reject(e);
}
});
`;
return (await browser.execute(wrapped)) as ReturnValue;
const raw = await browser.execute(wrapped);
return isEmbedded ? unwrapEmbeddedResult<ReturnValue>(raw) : (raw as ReturnValue);
}

return (await browser.execute(script, ...args)) as ReturnValue;
// String-form scripts are passed through without an envelope. The embedded
// driver runs them as plain IIFEs; unwrapping here would corrupt any plain
// object result. String-form cannot distinguish undefined from null on the
// embedded driver — use function-form if that matters.
const raw = await browser.execute(script, ...args);
return raw as ReturnValue;
}

/**
Expand All @@ -90,5 +111,27 @@ export async function execute<ReturnValue, InnerArguments extends unknown[] = un
* Not exported from the package's public surface — only consumed by mock.ts.
*/
export async function runInterceptorScript<T>(browser: WebdriverIO.Browser, script: string): Promise<T> {
return browser.execute(`return (${script})()`) as Promise<T>;
const isEmbedded = embeddedBrowsers.has(browser);
// Interceptor scripts are synchronous; the envelope is safe without a Promise wrapper.
const wrapped = isEmbedded ? `return { __wdio_value__: (${script})() }` : `return (${script})()`;
const raw = await browser.execute(wrapped);
return isEmbedded ? unwrapEmbeddedResult<T>(raw) : (raw as T);
}

// The embedded polling loop wraps results in { __wdio_value__: result } so
// undefined can be distinguished from null across the JSON boundary.
// JSON.stringify omits undefined object properties, so:
// undefined result → { __wdio_value__: undefined } → "{}" (key absent)
// null result → { __wdio_value__: null } → '{"__wdio_value__":null}' (key present)
// This mirrors the pattern in @wdio/tauri-service.
function unwrapEmbeddedResult<T>(raw: unknown): T {
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
const envelope = raw as Record<string, unknown>;
if ('__wdio_value__' in envelope) {
return envelope['__wdio_value__'] as T;
}
// Key absent: JSON.stringify omitted it because the JS result was undefined
return undefined as unknown as T;
}
return raw as T;
}
Comment thread
goosewobbler marked this conversation as resolved.
7 changes: 6 additions & 1 deletion packages/dioxus-service/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DioxusServiceAPI } from '@wdio/native-types';
import { createLogger, DEFAULT_TEARDOWN_TIMEOUT_MS, isBenignTeardownError, runBounded } from '@wdio/native-utils';

import { clearAllMocks, isMockFunction, resetAllMocks, restoreAllMocks } from './commands/allMocks.js';
import { execute } from './commands/execute.js';
import { execute, markAsEmbedded } from './commands/execute.js';
import { mock } from './commands/mock.js';
import { triggerDeeplink } from './commands/triggerDeeplink.js';
import mockStore from './mockStore.js';
Expand Down Expand Up @@ -44,12 +44,14 @@ async function safeDeleteSession(browser: WebdriverIO.Browser, label: string): P
export default class DioxusWorkerService {
private browser?: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser;
private devServerUrl?: string;
private isEmbedded: boolean;

constructor(_options: DioxusServiceOptions, _capabilities: unknown) {
const capOptions = (_capabilities as { 'wdio:dioxusServiceOptions'?: DioxusServiceOptions })[
'wdio:dioxusServiceOptions'
];
this.devServerUrl = capOptions?.devServerUrl ?? _options.devServerUrl;
this.isEmbedded = (capOptions?.driverProvider ?? _options.driverProvider ?? 'embedded') === 'embedded';
log.debug('DioxusWorkerService initialised');
}

Expand Down Expand Up @@ -169,6 +171,9 @@ export default class DioxusWorkerService {
}

private addDioxusApi(browser: WebdriverIO.Browser): void {
if (this.isEmbedded) {
markAsEmbedded(browser);
}
const dioxus: DioxusServiceAPI = {
execute: <R, A extends unknown[]>(script: Parameters<typeof execute<R, A>>[1], ...args: A): Promise<R> =>
execute<R, A>(browser, script, ...args),
Expand Down
51 changes: 46 additions & 5 deletions packages/dioxus-service/test/commands/execute.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { describe, expect, it, vi } from 'vitest';

import { execute, runInterceptorScript } from '../../src/commands/execute.js';
import { execute, markAsEmbedded, runInterceptorScript } from '../../src/commands/execute.js';

function browserStub(returnValue: unknown = undefined): WebdriverIO.Browser {
const execute = vi.fn().mockResolvedValue(returnValue);
return { execute } as unknown as WebdriverIO.Browser;
}

function embeddedBrowserStub(returnValue: unknown = undefined): WebdriverIO.Browser {
const browser = browserStub(returnValue);
markAsEmbedded(browser);
return browser;
}

describe('execute command', () => {
it('should pass through a string script unmodified', async () => {
const browser = browserStub('result');
Expand Down Expand Up @@ -49,11 +55,31 @@ describe('execute command', () => {
expect(wrappedScript).toContain('wdio_dioxus_bridge::install');
});

it('should return the value produced by browser.execute', async () => {
it('should unwrap the embedded envelope for function-form scripts', async () => {
const browser = embeddedBrowserStub({ __wdio_value__: { hello: 'world' } });
await expect(execute(browser, (_dx) => ({ hello: 'world' }))).resolves.toEqual({ hello: 'world' });
});

it('should return a plain object as-is on the external driver path (string-form)', async () => {
const browser = browserStub({ hello: 'world' });
await expect(execute(browser, 'return {}')).resolves.toEqual({ hello: 'world' });
});

it('should return a plain object as-is on the external driver path (function-form)', async () => {
const browser = browserStub({ hello: 'world' });
await expect(execute(browser, (_dx) => ({ hello: 'world' }))).resolves.toEqual({ hello: 'world' });
});

it('should return undefined when embedded function-form returns undefined', async () => {
const browser = embeddedBrowserStub({});
await expect(execute(browser, (_dx) => undefined)).resolves.toBeUndefined();
});

it('should return null when embedded function-form returns null', async () => {
const browser = embeddedBrowserStub({ __wdio_value__: null });
await expect(execute(browser, (_dx) => null)).resolves.toBeNull();
});

it('should inline multiple positional args as JSON into the wrapper', async () => {
const browser = browserStub();
await execute(browser, (_dx, _a: number, _b: string, _c: boolean) => null, 1, 'two', true);
Expand Down Expand Up @@ -85,7 +111,7 @@ describe('execute command', () => {
});

describe('runInterceptorScript', () => {
it('should wrap the script as `return (script)()` so arrow functions actually invoke', async () => {
it('should wrap the script as `return (script)()` on the external driver path', async () => {
const browser = browserStub('ok');
const adapterScript = '(_dx) => { window.__wdio_mocks__.greet = 42; }';

Expand All @@ -95,9 +121,24 @@ describe('runInterceptorScript', () => {
expect(actualScript).toBe(`return (${adapterScript})()`);
});

it('should return the value produced by browser.execute', async () => {
const browser = browserStub({ calls: [['a']], results: [], invocationCallOrder: [1] });
it('should wrap the script with the __wdio_value__ envelope on the embedded driver path', async () => {
const browser = embeddedBrowserStub(undefined);
const adapterScript = '(_dx) => {}';

await runInterceptorScript(browser, adapterScript);

const [actualScript] = vi.mocked(browser.execute).mock.calls[0] as [string];
expect(actualScript).toBe(`return { __wdio_value__: (${adapterScript})() }`);
});

it('should return the value produced by browser.execute (embedded driver)', async () => {
const browser = embeddedBrowserStub({ __wdio_value__: { calls: [['a']], results: [], invocationCallOrder: [1] } });
const result = await runInterceptorScript(browser, '(_dx) => ({ calls: [["a"]] })');
expect(result).toEqual({ calls: [['a']], results: [], invocationCallOrder: [1] });
});

it('should return undefined when the embedded driver returns the empty envelope', async () => {
const browser = embeddedBrowserStub({});
await expect(runInterceptorScript(browser, '() => undefined')).resolves.toBeUndefined();
});
});
35 changes: 22 additions & 13 deletions packages/dioxus-service/test/mock.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { markAsEmbedded } from '../src/commands/execute.js';
import { createMock } from '../src/mock.js';
import mockStore from '../src/mockStore.js';

function makeBrowser(): WebdriverIO.Browser {
return { execute: vi.fn().mockResolvedValue(undefined) } as unknown as WebdriverIO.Browser;
const browser = { execute: vi.fn().mockResolvedValue(undefined) } as unknown as WebdriverIO.Browser;
markAsEmbedded(browser);
return browser;
}

describe('createMock', () => {
Expand Down Expand Up @@ -118,9 +121,11 @@ describe('createMock', () => {
vi.mocked(browser.execute)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
calls: [['arg1']],
results: [{ type: 'return', value: 42 }],
invocationCallOrder: [1],
__wdio_value__: {
calls: [['arg1']],
results: [{ type: 'return', value: 42 }],
invocationCallOrder: [1],
},
});

const m = await createMock('greet', browser);
Expand All @@ -136,18 +141,22 @@ describe('createMock', () => {
.mockResolvedValueOnce(undefined)
// First update: two calls.
.mockResolvedValueOnce({
calls: [['a'], ['b']],
results: [
{ type: 'return', value: 1 },
{ type: 'return', value: 2 },
],
invocationCallOrder: [1, 2],
__wdio_value__: {
calls: [['a'], ['b']],
results: [
{ type: 'return', value: 1 },
{ type: 'return', value: 2 },
],
invocationCallOrder: [1, 2],
},
})
// Second update: inner cleared, only one call now.
.mockResolvedValueOnce({
calls: [['c']],
results: [{ type: 'return', value: 3 }],
invocationCallOrder: [3],
__wdio_value__: {
calls: [['c']],
results: [{ type: 'return', value: 3 }],
invocationCallOrder: [3],
},
});

const m = await createMock('greet', browser);
Expand Down
Loading