From ae297889806630f2e107837f3222e70f45a9f394 Mon Sep 17 00:00:00 2001 From: hoanle0126 Date: Mon, 26 Oct 2026 03:32:35 +0700 Subject: [PATCH] feat: add boolean visibility and tappability check commands --- README.md | 4 ++ driver/lib/commands/assertions.ts | 56 +++++++++++++++++++++- driver/lib/commands/execute.ts | 15 +++++- driver/test/assertions.test.mjs | 77 ++++++++++++++++++++++++++++++- example/ruby/example_sample2.rb | 2 + 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cd925e07..d81af555 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,10 @@ This module extends the `appium-flutter-driver` with custom visibility-related c | `assertVisible` | ✅ | `driver.execute('flutter:assertVisible', { key: 'myKey' })`
`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 diff --git a/driver/lib/commands/assertions.ts b/driver/lib/commands/assertions.ts index 1177af97..babe3878 100644 --- a/driver/lib/commands/assertions.ts +++ b/driver/lib/commands/assertions.ts @@ -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'; @@ -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'); @@ -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); } @@ -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 { + 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 => await executeAssertion(driver, 'waitFor', input, timeout, {visible: true}); @@ -79,3 +120,16 @@ export const assertNotVisible = async (driver: FlutterDriver, input: FinderInput export const assertTappable = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise => await executeAssertion(driver, 'waitForTappable', input, timeout); + +// Exported boolean check commands +export const isVisible = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise => + await checkCondition(driver, 'waitFor', input, timeout, {visible: true}); + +export const isNotVisible = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise => + await checkCondition(driver, 'waitForAbsent', input, timeout); + +export const isAbsent = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise => + await isNotVisible(driver, input, timeout); + +export const isTappable = async (driver: FlutterDriver, input: FinderInput, timeout = 5000): Promise => + await checkCondition(driver, 'waitForTappable', input, timeout); diff --git a/driver/lib/commands/execute.ts b/driver/lib/commands/execute.ts index 47a2373f..26df3713 100644 --- a/driver/lib/commands/execute.ts +++ b/driver/lib/commands/execute.ts @@ -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'; @@ -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', diff --git a/driver/test/assertions.test.mjs b/driver/test/assertions.test.mjs index 58fc945e..39856a9b 100644 --- a/driver/test/assertions.test.mjs +++ b/driver/test/assertions.test.mjs @@ -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; @@ -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'); diff --git a/example/ruby/example_sample2.rb b/example/ruby/example_sample2.rb index e722f35a..c26e831a 100644 --- a/example/ruby/example_sample2.rb +++ b/example/ruby/example_sample2.rb @@ -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)