Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ This module extends the `appium-flutter-driver` with custom visibility-related c
| `assertVisible` | ✅ | `driver.execute('flutter:assertVisible', { key: 'myKey' })`<br>`driver.execute('flutter:assertVisible', { text: 'Login' })` | Widget |
| `assertNotVisible` | ✅ | `driver.execute('flutter:assertNotVisible', { key: 'hiddenWidget' })` | Widget |
| `assertTappable` | ✅ | `driver.execute('flutter:assertTappable', { label: 'Submit' })` | Widget |
| `isVisible` | ✅ | `driver.execute('flutter:isVisible', { key: 'myKey' }, 3000)` | Widget |
| `isNotVisible` | ✅ | `driver.execute('flutter:isNotVisible', { key: 'hiddenWidget' }, 3000)` | Widget |
| `isAbsent` | ✅ | `driver.execute('flutter:isAbsent', { key: 'hiddenWidget' }, 3000)` | Widget |
| `isTappable` | ✅ | `driver.execute('flutter:isTappable', { label: 'Submit' }, 3000)` | Widget |

## 🔍 Input Formats

Expand Down
56 changes: 55 additions & 1 deletion driver/lib/commands/assertions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {util} from '@appium/support';
import {byValueKey, byText, byTooltip, type SerializableFinder} from 'appium-flutter-finder';

import type {FlutterDriver} from '../driver.js';
Expand All @@ -8,7 +9,9 @@ export type FinderInput =
| {label: string}
| SerializableFinder
| string
| {getRawFinder: () => SerializableFinder}; // FlutterElement-like input
| {getRawFinder: () => SerializableFinder} // FlutterElement-like input
| {[util.W3C_WEB_ELEMENT_IDENTIFIER]: string}
| {ELEMENT: string};

// Serialize a finder to base64
const serializeFinder = (finder: SerializableFinder): string => Buffer.from(JSON.stringify(finder)).toString('base64');
Expand All @@ -20,16 +23,34 @@ const isRawFinder = (input: any): input is SerializableFinder =>
const isFlutterElementLike = (input: any): input is {getRawFinder: () => SerializableFinder} =>
input && typeof input === 'object' && typeof input.getRawFinder === 'function';

const isWebElementObject = (input: any): input is {[util.W3C_WEB_ELEMENT_IDENTIFIER]: string} | {ELEMENT: string} =>
Boolean(
input &&
typeof input === 'object' &&
(typeof input[util.W3C_WEB_ELEMENT_IDENTIFIER] === 'string' || typeof input.ELEMENT === 'string'),
);

// Convert FinderInput to base64 string
function getFinderBase64(input: FinderInput): string {
if (typeof input === 'string') {
return input; // already base64
}

if (!input || typeof input !== 'object') {
throw new Error('Invalid finder input: must provide key, text, label, raw finder, or FlutterElement');
}

if (isFlutterElementLike(input)) {
return serializeFinder(input.getRawFinder());
}

if (isWebElementObject(input)) {
return (
(input as {[util.W3C_WEB_ELEMENT_IDENTIFIER]?: string})[util.W3C_WEB_ELEMENT_IDENTIFIER] ??
(input as {ELEMENT: string}).ELEMENT
);
}

if (isRawFinder(input)) {
return serializeFinder(input);
}
Expand Down Expand Up @@ -70,6 +91,26 @@ async function executeAssertion(
}
}

// Generic helper to check element state and return boolean without throwing on timeout/not-found
async function checkCondition(
driver: FlutterDriver,
command: string,
input: FinderInput,
timeout = 5000,
extraArgs: object = {},
): Promise<boolean> {
const base64 = getFinderBase64(input);
try {
await driver.executeElementCommand(command, base64, {
timeout,
...extraArgs,
});
return true;
} catch {
return false;
}
}

// Exported assertion commands
export const assertVisible = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise<void> =>
await executeAssertion(driver, 'waitFor', input, timeout, {visible: true});
Expand All @@ -79,3 +120,16 @@ export const assertNotVisible = async (driver: FlutterDriver, input: FinderInput

export const assertTappable = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise<void> =>
await executeAssertion(driver, 'waitForTappable', input, timeout);

// Exported boolean check commands
export const isVisible = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise<boolean> =>
await checkCondition(driver, 'waitFor', input, timeout, {visible: true});

export const isNotVisible = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise<boolean> =>
await checkCondition(driver, 'waitForAbsent', input, timeout);

export const isAbsent = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise<boolean> =>
await isNotVisible(driver, input, timeout);

export const isTappable = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise<boolean> =>
await checkCondition(driver, 'waitForTappable', input, timeout);
15 changes: 14 additions & 1 deletion driver/lib/commands/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import B from 'bluebird';
import type {FlutterDriver} from '../driver.js';
import {reConnectFlutterDriver} from '../sessions/session.js';
import {launchApp} from './../ios/app.js';
import {assertVisible, assertNotVisible, assertTappable, type FinderInput} from './assertions.js';
import {
assertVisible,
assertNotVisible,
assertTappable,
isVisible,
isNotVisible,
isAbsent,
isTappable,
type FinderInput,
} from './assertions.js';
import {longTap, scroll, scrollIntoView, scrollUntilVisible, scrollUntilTapable} from './execute/scroll.js';
import {waitFor, waitForAbsent, waitForTappable} from './execute/wait.js';

Expand Down Expand Up @@ -141,6 +150,10 @@ const commandHandlers: CommandMap = {
assertNotVisible: async (driver, input: FinderInput, timeout = 5000) =>
await assertNotVisible(driver, input, timeout),
assertTappable: async (driver, input: FinderInput, timeout = 5000) => await assertTappable(driver, input, timeout),
isVisible: async (driver, input: FinderInput, timeout = 5000) => await isVisible(driver, input, timeout),
isNotVisible: async (driver, input: FinderInput, timeout = 5000) => await isNotVisible(driver, input, timeout),
isAbsent: async (driver, input: FinderInput, timeout = 5000) => await isAbsent(driver, input, timeout),
isTappable: async (driver, input: FinderInput, timeout = 5000) => await isTappable(driver, input, timeout),
getTextWithCommandExtension: async (driver, params: {findBy: string}) =>
await driver.socket!.executeSocketCommand({
command: 'getTextWithCommandExtension',
Expand Down
77 changes: 76 additions & 1 deletion driver/test/assertions.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import {it} from 'node:test';

import {assertVisible} from '../build/lib/commands/assertions.js';
import {assertVisible, isAbsent, isNotVisible, isTappable, isVisible} from '../build/lib/commands/assertions.js';
import {execute} from '../build/lib/commands/execute.js';

it('loads the ESM finder from the ESM driver', async () => {
let command;
Expand All @@ -24,6 +25,80 @@ it('loads the ESM finder from the ESM driver', async () => {
assert.deepEqual(options, {timeout: 5000, visible: true});
});

it('returns true from isVisible, isNotVisible, isAbsent, and isTappable when executeElementCommand succeeds', async () => {
const calls = [];
const driver = {
executeElementCommand: async (command, finder, options) => {
calls.push({command, finder, options});
},
};

assert.equal(await isVisible(driver, {key: 'enabled-button'}, 3000), true);
assert.equal(await isNotVisible(driver, {text: 'Hidden'}, 1500), true);
assert.equal(await isAbsent(driver, {label: 'Gone'}, 1000), true);
assert.equal(await isTappable(driver, {key: 'submit'}, 2000), true);

assert.equal(calls.length, 4);
assert.equal(calls[0].command, 'waitFor');
assert.deepEqual(calls[0].options, {timeout: 3000, visible: true});
assert.equal(calls[1].command, 'waitForAbsent');
assert.deepEqual(calls[1].options, {timeout: 1500});
assert.equal(calls[2].command, 'waitForAbsent');
assert.deepEqual(calls[2].options, {timeout: 1000});
assert.equal(calls[3].command, 'waitForTappable');
assert.deepEqual(calls[3].options, {timeout: 2000});
});

it('returns false from isVisible, isNotVisible, isAbsent, and isTappable when element does not exist or times out', async () => {
const driver = {
executeElementCommand: async () => {
throw new Error('Cannot execute command waitFor, server response {"isError":true}');
},
};

assert.equal(await isVisible(driver, {key: 'disabled-button'}, 1000), false);
assert.equal(await isNotVisible(driver, {key: 'present-button'}, 1000), false);
assert.equal(await isAbsent(driver, {key: 'present-button'}, 1000), false);
assert.equal(await isTappable(driver, {key: 'disabled-button'}, 1000), false);
});

it('dispatches flutter:isVisible, flutter:isNotVisible, flutter:isAbsent, and flutter:isTappable via execute', async () => {
let shouldFail = false;
const driver = {
executeElementCommand: async () => {
if (shouldFail) {
throw new Error('Timed out');
}
},
};

assert.equal(await execute.call(driver, 'flutter:isVisible', [{key: 'btn'}, 1000]), true);
assert.equal(await execute.call(driver, 'flutter:isNotVisible', [{key: 'btn'}, 1000]), true);
assert.equal(await execute.call(driver, 'flutter:isAbsent', [{key: 'btn'}, 1000]), true);
assert.equal(await execute.call(driver, 'flutter:isTappable', [{key: 'btn'}, 1000]), true);

shouldFail = true;
assert.equal(await execute.call(driver, 'flutter:isVisible', [{key: 'btn'}, 1000]), false);
assert.equal(await execute.call(driver, 'flutter:isNotVisible', [{key: 'btn'}, 1000]), false);
assert.equal(await execute.call(driver, 'flutter:isAbsent', [{key: 'btn'}, 1000]), false);
assert.equal(await execute.call(driver, 'flutter:isTappable', [{key: 'btn'}, 1000]), false);
});

it('accepts W3C WebElement objects and throws on invalid finder input', async () => {
let receivedFinder;
const driver = {
executeElementCommand: async (_cmd, finder) => {
receivedFinder = finder;
},
};

const encoded = Buffer.from(JSON.stringify({finderType: 'ByText', text: 'Hello'})).toString('base64');
assert.equal(await isVisible(driver, {'element-6066-11e4-a52e-4f735466cecf': encoded}, 500), true);
assert.equal(receivedFinder, encoded);

await assert.rejects(() => isVisible(driver, {}), /Invalid finder input/);
});

it('loads the driver entry point as ESM', async () => {
const {FlutterDriver} = await import('../build/lib/driver.js');

Expand Down
2 changes: 2 additions & 0 deletions example/ruby/example_sample2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def test_run_example_android
text_finder = by_text 'Tap me!'

@driver.execute_script 'flutter:assertVisible', {text: 'Tap me!'}, 10000
assert_equal true, (@driver.execute_script 'flutter:isVisible', {text: 'Tap me!'}, 10000)
assert_equal false, (@driver.execute_script 'flutter:isVisible', {text: 'Non-existent'}, 500)

element = ::Appium::Flutter::Element.new(@driver, finder: text_finder)
# @driver.execute_script('flutter:waitForTappable', text_finder, 10000)
Expand Down
Loading