Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
This is an Appium driver for automating macOS applications using Apple's XCTest framework.

> [!IMPORTANT]
> Since major version *3.0.0*, this driver is only compatible with Appium 3.
> Since major version *5.0.0*, this driver is only compatible with Appium 4.


## Documentation
Expand Down
3 changes: 2 additions & 1 deletion docs/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ and prerequisites of Appium itself can be found in [the Appium documentation](ht

| Mac2 driver version | Supported Appium server version |
| --- | --- |
| >= 3.0.0 | Appium 3 |
| >= 5.0.0 | Appium 4 |
| 3.0.0 - 4.x | Appium 3 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| 3.0.0 - 4.x | Appium 3 |
| 3.0.0 - 4.6.1 | Appium 3 |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

| 1.0.0 - 2.2.2 | Appium 2 |

## Installation
Expand Down
51 changes: 47 additions & 4 deletions lib/commands/find.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import {errors, isErrorType} from 'appium/driver.js';
import {util} from 'appium/support.js';
import {ConditionUnmetError} from 'asyncbox';

import type {Mac2Driver} from '../driver.js';

// Older asyncbox (possibly bundled with the base driver) throws a plain Error
function isConditionUnmet(e: unknown): boolean {
return e instanceof ConditionUnmetError || /Condition unmet/.test((e as Error)?.message ?? '');
}

/**
* This is needed to make lookup by image working
*
Expand Down Expand Up @@ -32,8 +39,44 @@ export async function findElOrEls(
normalizedStrategy = 'class chain';
}

return await this.wda.proxy.command(endpoint, 'POST', {
using: normalizedStrategy,
value: selector,
});
const doFind = async (): Promise<any> =>
await this.wda.proxy.command(endpoint, 'POST', {
using: normalizedStrategy,
value: selector,
});
if (!this.implicitWaitMs) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use this.implicitWaitMs === 0 here? Appium 4 accepts implicit: null to disable the timeout, and its implicitWaitForCondition maps that value to Infinity. This falsy check instead bypasses the helper, so a lookup fails immediately on the first NoSuchElementError even if the element appears on the next attempt. I reproduced this with implicit: null and a stub that fails once and then succeeds: only one lookup is made. XCUITest's native lookup delegates to the helper for an active session, so it preserves the unlimited wait. Checking explicitly for zero would retain the default behavior agreed in #436 while honoring the new nullable timeout semantics. A regression test for null would cover this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. The check is now this.implicitWaitMs === 0, so null goes through implicitWaitForCondition and waits without a deadline. Added a regression test (fail once, then succeed with implicit: null).

return await doFind();
}

// The WDA server ignores the /timeouts API, so the implicit wait is applied here
let result: any;
let lastError: Error | undefined;
// A lookup error other than NoSuchElementError stops the wait and must reach the caller as is
let lookupError: unknown;
try {
await this.implicitWaitForCondition(async () => {
try {
result = await doFind();
} catch (e) {
if (!isErrorType(e, errors.NoSuchElementError)) {
lookupError = e;
throw e;
}
lastError = e as Error;
return false;
}
return !mult || (Array.isArray(result) && result.length > 0);
});
} catch (e) {
if (lookupError !== undefined) {
throw lookupError;
}
if (!isConditionUnmet(e)) {
throw e;
}
if (!mult && lastError) {
throw lastError;
}
}
return result;
}
60 changes: 35 additions & 25 deletions lib/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import type {
HTTPMethod,
HTTPBody,
DefaultCreateSessionResult,
DriverData,
InitialOpts,
StringRecord,
ExternalDriver,
DriverCaps,
DriverOpts,
W3CDriverCaps,
TimeoutValue,
} from '@appium/types';
import type {Chromedriver} from 'appium-chromedriver';
import {BaseDriver, DeviceSettings, errors} from 'appium/driver.js';
Expand Down Expand Up @@ -217,27 +217,40 @@ export class Mac2Driver
}

// Timeouts belong to the session: keep them here and mirror to Chromedriver in a web view
override async implicitWaitW3C(ms: number): Promise<void> {
await super.implicitWaitW3C(ms);
await this.chromedriver?.jwproxy.command('/timeouts', 'POST', {implicit: this.implicitWaitMs});
}

override async pageLoadTimeoutW3C(ms: number): Promise<void> {
if (!this.chromedriver) {
return await super.pageLoadTimeoutW3C(ms);
override async timeouts(
type?: string,
ms?: number | string,
script?: TimeoutValue,
pageLoad?: TimeoutValue,
implicit?: TimeoutValue,
command?: number,
): Promise<void> {
if (type && ms !== undefined) {
// Deprecated 'type'/'ms' form: normalize to the W3C one
const legacyMs = this.parseTimeoutArgument(ms);
({script, pageLoad, implicit} = {
script: type === 'script' ? legacyMs : undefined,
pageLoad: type === 'page load' ? legacyMs : undefined,
implicit: type === 'implicit' ? legacyMs : undefined,
});
command = type === 'command' ? legacyMs : undefined;
type = ms = undefined;
Comment thread
mykola-mokhnach marked this conversation as resolved.
Resolved
}
const pageLoad = this.parseTimeoutArgument(ms);
await this.chromedriver.jwproxy.command('/timeouts', 'POST', {pageLoad});
this._pageLoadTimeoutMs = pageLoad;
}

override async scriptTimeoutW3C(ms: number): Promise<void> {
if (!this.chromedriver) {
return await super.scriptTimeoutW3C(ms);
if (pageLoad !== undefined && !this.chromedriver) {
throw new errors.NotImplementedError('Not implemented: page load timeout is only supported in web view contexts');
}
await super.timeouts(type, ms, script, pageLoad, implicit, command);
if (script !== undefined) {
this._scriptTimeoutMs = script;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we distinguish an unset script timeout from an explicitly supplied null? This now accepts and stores script: null (no timeout), but setContext only restores _scriptTimeoutMs when it is not null. After switching to another web view, or returning to a web view via NATIVE_APP, the new Chromedriver session therefore keeps its default script timeout instead of preserving the user's unlimited setting. I reproduced this with a mocked Chromedriver: the initial {script: null} is forwarded, but no script timeout is sent when the replacement session starts. Using a separate unset sentinel such as undefined, and restoring explicit null values, would preserve the setting across context changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a separate unset sentinel, I dropped the driver's own _scriptTimeoutMs/_pageLoadTimeoutMs mirrors and now read the base driver's scriptTimeoutMs/pageLoadTimeoutMs/implicitWaitMs (which always hold a value, null included) when starting a Chromedriver session. This restores an explicit null across web view switches and also picks up timeouts set via capabilities. Added a test for getSessionTimeouts.

}
if (pageLoad !== undefined) {
this._pageLoadTimeoutMs = pageLoad;
}
for (const [name, value] of Object.entries({script, pageLoad, implicit})) {
if (value !== undefined) {
await this.chromedriver?.jwproxy.command('/timeouts', 'POST', {[name]: value});
}
}
const script = this.parseTimeoutArgument(ms);
await this.chromedriver.jwproxy.command('/timeouts', 'POST', {script});
this._scriptTimeoutMs = script;
}

isWebviewEnabled(): boolean {
Expand Down Expand Up @@ -281,12 +294,9 @@ export class Mac2Driver
}

override async createSession(
w3cCaps1: W3CMac2DriverCaps,
w3cCaps2?: W3CMac2DriverCaps,
w3cCaps3?: W3CMac2DriverCaps,
driverData?: DriverData[],
w3cCapabilities: W3CMac2DriverCaps,
): Promise<DefaultCreateSessionResult<Mac2Constraints>> {
const [sessionId, caps] = await super.createSession(w3cCaps1, w3cCaps2, w3cCaps3, driverData);
const [sessionId, caps] = await super.createSession(w3cCapabilities);
this._wda = WDA_MAC_SERVER;
this.caps = caps as Mac2DriverCaps;
// oxlint-disable-next-line no-self-assign -- narrows this.opts' type for the rest of the method
Expand Down
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,20 @@
"teen_process": "^4.2.0"
},
"devDependencies": {
"@appium/docutils": "^3.0.0",
"@appium/oxc-config": "^1.1.0",
"@appium/semantic-release-config": "^1.1.0",
"@appium/tsconfig": "^1.2.0",
"@appium/types": "^1.0.0-rc.1",
"@appium/docutils": "^4.0.0-beta.5",
"@appium/oxc-config": "^2.0.0-beta.2",
"@appium/semantic-release-config": "^2.0.0-beta.1",
"@appium/tsconfig": "^2.0.0-beta.1",
"@appium/types": "^2.0.0-beta.5",
"@types/node": "^26.0.0",
"@types/portscanner": "^2.1.1",
"webdriverio": "^9.0.0"
"webdriverio": "^10.1.0"
},
"peerDependencies": {
"appium": "^3.0.0-rc.2"
"appium": "^4.0.0-beta.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
"npm": ">=10"
},
"appium": {
Expand Down
87 changes: 87 additions & 0 deletions test/unit/find.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import {describe, it} from 'node:test';

import {errors} from 'appium/driver.js';

import {Mac2Driver} from '../../lib/driver.js';

describe('findElOrEls', () => {
function makeDriver(responses: (() => unknown)[], implicitWaitMs: number) {
const driver = new Mac2Driver({} as any);
const calls: string[] = [];
(driver as any)._wda = {
proxy: {
command: async (url: string) => {
calls.push(url);
const respond = responses[Math.min(calls.length, responses.length) - 1];
return respond();
},
},
};
driver.setImplicitWait(implicitWaitMs);
return {driver, calls};
}

const notFound = () => {
throw new errors.NoSuchElementError();
};

it('sends a single request if the implicit wait is not set', async () => {
const {driver, calls} = makeDriver([notFound], 0);
await assert.rejects(driver.findElOrEls('accessibility id', 'foo', false), errors.NoSuchElementError);
assert.equal(calls.length, 1);
});

it('retries a single element lookup within the implicit wait', async () => {
const element = {ELEMENT: '1'};
const {driver, calls} = makeDriver([notFound, () => element], 2000);
assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', false), element);
assert.equal(calls.length, 2);
});

it('retries a multiple elements lookup within the implicit wait', async () => {
const elements = [{ELEMENT: '1'}];
const {driver, calls} = makeDriver([() => [], () => elements], 2000);
assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', true), elements);
assert.equal(calls.length, 2);
});

it('throws the lookup error once the implicit wait expires', async () => {
const {driver, calls} = makeDriver([notFound], 600);
await assert.rejects(driver.findElOrEls('accessibility id', 'foo', false), errors.NoSuchElementError);
assert.ok(calls.length > 1);
});

it('returns an empty list once the implicit wait expires', async () => {
const {driver, calls} = makeDriver([() => []], 600);
assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', true), []);
assert.ok(calls.length > 1);
});

it('does not retry on other errors', async () => {
const {driver, calls} = makeDriver(
[
() => {
throw new errors.InvalidSelectorError();
},
],
2000,
);
await assert.rejects(driver.findElOrEls('xpath', '//', false), errors.InvalidSelectorError);
assert.equal(calls.length, 1);
});

it('throws a lookup error whose message looks like the wait timeout', async () => {
const {driver, calls} = makeDriver(
[
() => {
throw new errors.InvalidSelectorError('Invalid selector: Condition unmet');
},
],
2000,
);
await assert.rejects(driver.findElOrEls('xpath', '//', false), errors.InvalidSelectorError);
await assert.rejects(driver.findElOrEls('xpath', '//', true), errors.InvalidSelectorError);
assert.equal(calls.length, 2);
});
});
Loading