diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index ab1192e..e8422a0 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { byTestId, byText, getPlatform, getTestExternalId } from './selectors.js'; +import { byTestId, byText, getPlatform, getSdkType, getTestExternalId } from './selectors.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const tooltipContent = JSON.parse( @@ -10,11 +10,23 @@ const tooltipContent = JSON.parse( ); async function stopScrolling() { + const platform = getPlatform(); + + if (platform === 'android') { + // Android's scrollGesture already completes the gesture. A follow-up tap in + // the center of the screen can hit interactive elements like LOGIN USER. + await driver.pause(150); + return; + } + + let x: number; + let y: number; + const mainScroll = await byTestId('main_scroll_view'); const loc = await mainScroll.getLocation(); const size = await mainScroll.getSize(); - const x = Math.round(loc.x + 6); - const y = Math.round(loc.y + size.height / 2); + x = Math.round(loc.x + 6); + y = Math.round(loc.y + size.height / 2); await driver.performActions([ { @@ -41,16 +53,19 @@ async function swipeMainContent( distance: 'small' | 'normal' | 'large' = 'normal', ) { const distances = { small: 0.2, normal: 0.5, large: 1.0 }; - const mainScroll = await byTestId('main_scroll_view'); const platform = getPlatform(); const invertedDirection = direction === 'up' ? 'down' : 'up'; if (platform === 'ios') { await driver.execute('mobile: swipe', { direction: invertedDirection }); } else { + const { width, height } = await driver.getWindowSize(); await driver.execute('mobile: scrollGesture', { - elementId: mainScroll.elementId, - direction: direction === 'up' ? 'down' : 'up', + left: 0, + top: Math.round(height * 0.1), + width, + height: Math.round(height * 0.8), + direction, percent: distances[distance], }); } @@ -80,22 +95,30 @@ export async function scrollToEl( maxScrolls?: number; } = {}, ) { - const { by = 'testId', partial = false, direction = 'down', maxScrolls = 10 } = opts; + const { by = 'testId', partial = false, direction = 'down', maxScrolls = 20 } = opts; const finder = (id: string) => (by === 'text' ? byText(id, partial) : byTestId(id)); for (let i = 0; i < maxScrolls; i++) { - const el = await finder(identifier); + let el = await finder(identifier); if (await el.isDisplayed()) { + const { y } = await el.getLocation(); + const { height } = await driver.getWindowSize(); + if (y > height * 0.9) { + await swipeMainContent(direction, 'small'); + el = await finder(identifier); + } return el; } - await swipeMainContent(direction, 'small'); + await swipeMainContent(direction); } throw new Error(`Element "${identifier}" not found after ${maxScrolls} scrolls`); } /** - * Wait for a native system alert to appear and return its text. - * Returns null if no alert appears within the timeout. + * Wait for an iOS system alert to appear and return its text without + * dismissing it. Returns null if no alert appears within the timeout. + * iOS-only — used by the location spec which needs to accept with a + * specific button label. */ export async function waitForAlert(timeoutMs = 10_000): Promise { try { @@ -116,21 +139,68 @@ export async function waitForAlert(timeoutMs = 10_000): Promise { } } +/** + * Wait for a native system alert/permission dialog, accept it, and return + * its text. Returns null if no dialog appears within the timeout. + * + * iOS: uses XCUITest `mobile: alert` API. + * Android: looks for the standard permission dialog "Allow" button via + * UiAutomator (works for POST_NOTIFICATIONS, location, etc.). + */ +export async function acceptSystemAlert(timeoutMs = 10_000): Promise { + const platform = getPlatform(); + + try { + if (platform === 'ios') { + const text = await waitForAlert(timeoutMs); + if (text) await driver.acceptAlert(); + return text; + } + + const allowBtn = await $('android=new UiSelector().text("Allow")'); + await allowBtn.waitForDisplayed({ timeout: timeoutMs }); + let text = 'Permission dialog'; + try { + const msgEl = await $( + 'android=new UiSelector().resourceId("com.android.permissioncontroller:id/permission_message")', + ); + text = await msgEl.getText(); + } catch { + /* best-effort */ + } + await allowBtn.click(); + return text; + } catch { + return null; + } +} + +async function acceptSystemAlerts(timeoutMs: number): Promise { + await browser.waitUntil( + async () => { + const alertText = await acceptSystemAlert(500); + return !alertText; + }, + { timeout: timeoutMs, interval: 500 }, + ); +} + /** * Wait for the app to fully launch and the home screen to be visible. */ export async function waitForAppReady(opts: { skipLogin?: boolean } = {}) { const { skipLogin = false } = opts; - const mainScroll = await byTestId('main_scroll_view'); - await mainScroll.waitForDisplayed({ timeout: 5_000 }); const alertHandled = await browser.sharedStore.get('alertHandled'); if (!alertHandled) { - const alert = await waitForAlert(); - if (alert) await driver.acceptAlert(); + // Accept permission dialogs until the app UI is visible. + await acceptSystemAlerts(5_000); await browser.sharedStore.set('alertHandled', true); } + const mainScroll = await byTestId('main_scroll_view'); + await mainScroll.waitForDisplayed({ timeout: 5_000 }); + if (skipLogin) return; // want to login user so we can't clean up/delete user data for the next rerun @@ -150,12 +220,12 @@ export async function waitForAppReady(opts: { skipLogin?: boolean } = {}) { * Tap the login button, enter an external user ID, and confirm. */ export async function loginUser(externalUserId: string) { - const loginButton = await byTestId('login_user_button'); + const loginButton = await byText('LOGIN USER'); await loginButton.click(); const userIdInput = await byTestId('login_user_id_input'); await userIdInput.waitForDisplayed({ timeout: 5_000 }); - await userIdInput.setValue(externalUserId); + await typeInto(userIdInput, externalUserId); const confirmButton = await byTestId('login_confirm_button'); await confirmButton.click(); @@ -177,6 +247,18 @@ export async function togglePushEnabled() { await toggle.click(); } +/** + * Type text into an input field. On Flutter Android, setValue is unreliable + * so we tap the field and use the native `mobile: type` command instead. + */ +export async function typeInto( + el: { click(): Promise; setValue(value: string): Promise }, + text: string, +) { + await el.click(); + await el.setValue(text); +} + /** * Add a single tag via the UI. */ @@ -186,10 +268,10 @@ export async function addTag(key: string, value: string) { const keyInput = await byTestId('tag_key_input'); await keyInput.waitForDisplayed({ timeout: 5_000 }); - await keyInput.setValue(key); + await typeInto(keyInput, key); const valueInput = await byTestId('tag_value_input'); - await valueInput.setValue(value); + await typeInto(valueInput, value); const confirmButton = await byTestId('tag_confirm_button'); await confirmButton.click(); @@ -229,24 +311,21 @@ export async function lockScreen() { */ export async function returnToApp() { const caps = driver.capabilities as Record; - const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; - await driver.updateSettings({ defaultActiveApplication: bundleId }); - await driver.execute('mobile: activateApp', { bundleId }); - await driver.pause(1_000); -} + const platform = getPlatform(); -/** - * Clear all notifications. - * Android: uses the native clearAllNotifications command. - * iOS: taps the app's "CLEAR ALL" button since XCUITest has no equivalent. - */ -export async function clearAllNotifications() { - if (getPlatform() === 'android') { - await driver.execute('mobile: clearAllNotifications', {}); + if (platform === 'android') { + await driver.pressKeyCode(4); + const appId = (caps['appPackage'] ?? caps['appium:appPackage']) as string; + if (appId) { + await driver.execute('mobile: activateApp', { appId }); + } } else { - const clearButton = await scrollToEl('clear_all_button'); - await clearButton.click(); + const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; + await driver.updateSettings({ defaultActiveApplication: bundleId }); + await driver.execute('mobile: activateApp', { bundleId }); } + + await driver.pause(1_000); } /** @@ -279,40 +358,11 @@ export async function waitForNotification(opts: { } if (expectImage) { - const location = await titleEl.getLocation(); - const size = await titleEl.getSize(); - const centerX = Math.round(location.x + size.width / 2); - const startY = Math.round(location.y + size.height / 2); - const endY = startY + 300; - - await driver.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: centerX, y: startY }, - { type: 'pointerDown', button: 0 }, - { type: 'pause', duration: 100 }, - { type: 'pointerMove', duration: 300, x: centerX, y: endY }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]); - await driver.releaseActions(); - await driver.pause(500); - const image = await $('//android.widget.ImageView'); await image.waitForDisplayed({ timeout: 5_000 }); } - await driver.pressKeyCode(4); - - const caps = driver.capabilities as Record; - const appId = (caps['appPackage'] ?? caps['appium:appPackage']) as string; - if (appId) { - await driver.execute('mobile: activateApp', { appId }); - } + await returnToApp(); return; } @@ -394,7 +444,9 @@ export async function checkNotification(opts: { body?: string; expectImage?: boolean; }) { - await clearAllNotifications(); + const clearButton = await scrollToEl('clear_all_button'); + await clearButton.click(); + await driver.pause(1_000); const button = await scrollToEl(opts.buttonId); await button.click(); @@ -407,12 +459,54 @@ export async function checkNotification(opts: { } export async function isWebViewVisible() { - const platform = getPlatform(); - const webview = - platform === 'ios' - ? await $('-ios predicate string:type == "XCUIElementTypeWebView"') - : await $('android=new UiSelector().className("android.webkit.WebView")'); - return webview.isExisting(); + if (getPlatform() === 'ios') { + const webview = await $('-ios predicate string:type == "XCUIElementTypeWebView"'); + return await webview.isExisting(); + } + + const contexts = await driver.getContexts(); + return contexts.some((c) => String(c).includes('WEBVIEW')); +} + +/** + * On Android, Appium pools all IAM webviews under a single WEBVIEW_* context, + * so closing one IAM doesn't remove the context -- old window handles linger. + * We iterate window handles to find the one whose

matches the expected title. + */ +async function switchToWebViewContext() { + const contexts = await driver.getContexts(); + const webviewContext = contexts.find((c) => String(c) !== 'NATIVE_APP'); + if (!webviewContext) return false; + await driver.switchContext(String(webviewContext)); + return true; +} + +async function switchToIAMWebView(expectedTitle: string, timeoutMs: number) { + if (getPlatform() === 'ios') { + expect(await switchToWebViewContext()).toBe(true); + return; + } + + await driver.waitUntil( + async () => { + try { + if (!(await switchToWebViewContext())) return false; + + for (const handle of await driver.getWindowHandles()) { + await driver.switchToWindow(handle); + const h1 = await $('h1'); + if ((await h1.isExisting()) && (await h1.getText()) === expectedTitle) { + return true; + } + } + await driver.switchContext('NATIVE_APP'); + return false; + } catch { + return false; + } + }, + { timeout: timeoutMs, timeoutMsg: `Could not find IAM with title "${expectedTitle}"` }, + ); } export async function checkInAppMessage(opts: { @@ -433,24 +527,26 @@ export async function checkInAppMessage(opts: { }); await driver.pause(1_000); - const contexts = await driver.getContexts(); - const webviewContext = contexts.find((c) => String(c) !== 'NATIVE_APP'); - expect(webviewContext).toBeDefined(); - await driver.switchContext(String(webviewContext)); + await switchToIAMWebView(expectedTitle, timeoutMs); const title = await $('h1'); await title.waitForExist({ timeout: timeoutMs }); - const text = await title.getText(); - expect(text).toBe(expectedTitle); + expect(await title.getText()).toBe(expectedTitle); const closeButton = await $('.close-button'); await closeButton.click(); await driver.switchContext('NATIVE_APP'); - await driver.waitUntil(async () => !(await isWebViewVisible()), { - timeout: timeoutMs, - timeoutMsg: 'IAM webview still visible after closing', - }); + + if (getPlatform() === 'ios') { + await driver.waitUntil(async () => !(await isWebViewVisible()), { + timeout: timeoutMs, + timeoutMsg: 'IAM webview still visible after closing', + }); + await driver.pause(1_000); + } else { + await driver.pause(3_000); + } } export async function checkTooltip(buttonId: string, key: string) { diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 78e73e8..f145964 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -112,7 +112,7 @@ export function getTestData() { } export async function deleteUser(externalId: string) { - console.log(`Deleting user: ${externalId}`); + console.info(`Deleting user: ${externalId}`); try { const response = await fetch( `https://api.onesignal.com/apps/${process.env.ONESIGNAL_APP_ID}/users/by/external_id/${externalId}`, @@ -127,12 +127,21 @@ export async function deleteUser(externalId: string) { if (!response.ok) { throw new Error(`Failed to delete user: ${response.statusText}`); } - console.log(`User deleted successfully`); + console.info(`User deleted successfully`); } catch (error) { console.error(`Failed to delete user: ${error}`); } } +export async function getToggleState(el: { + getAttribute(name: string): Promise; +}): Promise { + if (getPlatform() === 'ios') { + return (await el.getAttribute('value')) === '1'; + } + return (await el.getAttribute('checked')) === 'true'; +} + export function getSdkType(): SdkType { const sdkType = process.env.SDK_TYPE; if (sdkType && VALID_SDK_TYPES.has(sdkType)) { @@ -143,28 +152,70 @@ export function getSdkType(): SdkType { ); } +/** + * On Flutter Android, the standard WebDriver getText() often returns empty + * because Flutter writes text into content-desc / text attributes rather than + * the property that UiAutomator2's getText maps to. This proxy intercepts + * getText() and falls back to those attributes. + */ +function withFlutterAndroidFixes }>(el: T): T { + if (!(getPlatform() === 'android' && getSdkType() === 'flutter')) { + return el; + } + + return new Proxy(el, { + get(target, prop, receiver) { + if (prop === 'getText') { + return async () => { + const text = (await target.getText()).trim(); + if (text) return text; + + const attrs = ['content-desc', 'contentDescription', 'text', 'name']; + for (const attr of attrs) { + try { + const val = ( + await ( + target as unknown as { getAttribute(n: string): Promise } + ).getAttribute(attr) + )?.trim(); + if (val) return val; + } catch { + /* best-effort */ + } + } + + return ''; + }; + } + + const value = Reflect.get(target, prop, receiver); + if (typeof value === 'function') { + return value.bind(target); + } + + return value; + }, + }); +} + /** * Select an element by its cross-platform test ID. * * Native iOS uses `accessibilityIdentifier`, native Android Compose uses - * `testTag`, RN uses `testID`, and Flutter uses `Semantics(label:)` — all - * map to Appium accessibility id. Capacitor uses `data-testid` as a CSS - * attribute inside a WebView. + * `testTag`, RN uses `testID` — all map to Appium accessibility id (`~`). + * Flutter uses `Semantics(identifier:)` which maps to `accessibilityIdentifier` + * on iOS (`~`) but to `resource-id` on Android (UiAutomator selector). + * Capacitor uses `data-testid` as a CSS attribute inside a WebView. */ export async function byTestId(id: string) { const sdkType = getSdkType(); - switch (sdkType) { - case 'react-native': - case 'flutter': - case 'unity': - case 'cordova': - case 'dotnet': - case 'ios': - case 'android': - return $(`~${id}`); - case 'capacitor': - return $(`[data-testid="${id}"]`); - } + const platform = getPlatform(); + + if (sdkType === 'capacitor') return $(`[data-testid="${id}"]`); + if (sdkType === 'flutter' && platform === 'android') + return withFlutterAndroidFixes(await $(`id=${id}`)); + + return $(`~${id}`); } /** @@ -173,16 +224,17 @@ export async function byTestId(id: string) { */ export async function byText(text: string, partial = false) { const platform = getPlatform(); - const sdkType = getSdkType(); - - if (sdkType === 'capacitor') { - return $(`//*[contains(text(), "${text}")]`); - } if (platform === 'ios') { const op = partial ? 'CONTAINS' : '=='; return $(`-ios predicate string:label ${op} "${text}"`); } - const method = partial ? 'textContains' : 'text'; - return $(`android=new UiSelector().${method}("${text}")`); + + if (partial) { + return withFlutterAndroidFixes( + await $(`//*[contains(@content-desc, "${text}") or contains(@text, "${text}")]`), + ); + } + + return withFlutterAndroidFixes(await $(`//*[@content-desc="${text}" or @text="${text}"]`)); } diff --git a/appium/tests/specs/01_user.spec.ts b/appium/tests/specs/01_user.spec.ts index 08c4d00..726c4f9 100644 --- a/appium/tests/specs/01_user.spec.ts +++ b/appium/tests/specs/01_user.spec.ts @@ -7,6 +7,12 @@ describe('User', () => { await scrollToEl('user_section'); }); + after(async () => { + // login user back so we can clean up the user data for the next run + await driver.pause(3_000); + await waitForAppReady(); + }); + it('should start as anonymous', async () => { const statusEl = await scrollToEl('user_status_value'); const status = await statusEl.getText(); @@ -29,6 +35,7 @@ describe('User', () => { const externalId = await externalIdEl.getText(); expect(externalId).toBe(getTestExternalId()); + await driver.pause(3_000); await logoutUser(); statusEl = await scrollToEl('user_status_value'); diff --git a/appium/tests/specs/03_iam.spec.ts b/appium/tests/specs/03_iam.spec.ts index 498e54e..57f8249 100644 --- a/appium/tests/specs/03_iam.spec.ts +++ b/appium/tests/specs/03_iam.spec.ts @@ -5,6 +5,7 @@ import { scrollToEl, waitForAppReady, } from '../helpers/app'; +import { getToggleState } from '../helpers/selectors'; describe('In-App Messaging', () => { before(async () => { @@ -33,18 +34,21 @@ describe('In-App Messaging', () => { it('can pause iam', async () => { const toggle = await scrollToEl('Pause In-App', { by: 'text', partial: true, direction: 'up' }); - expect(await toggle.getAttribute('value')).toBe('0'); - await toggle.click({ x: 0, y: 0 }); - expect(await toggle.getAttribute('value')).toBe('1'); + expect(await getToggleState(toggle)).toBe(false); + await toggle.click(); + expect(await getToggleState(toggle)).toBe(true); // try to show top banner, should fail since IAM is paused const button = await scrollToEl('TOP BANNER', { by: 'text' }); await button.click(); await driver.pause(3_000); - expect(await isWebViewVisible()).toBe(false); + + if (driver.isIOS) { + expect(await isWebViewVisible()).toBe(false); + } // reset back - await toggle.click({ x: 0, y: 0 }); + await toggle.click(); await checkInAppMessage({ buttonLabel: 'TOP BANNER', expectedTitle: 'Top Banner', diff --git a/appium/tests/specs/04_alias.spec.ts b/appium/tests/specs/04_alias.spec.ts index aa656c8..aca310a 100644 --- a/appium/tests/specs/04_alias.spec.ts +++ b/appium/tests/specs/04_alias.spec.ts @@ -1,4 +1,4 @@ -import { checkTooltip, expectPairInSection, scrollToEl, waitForAppReady } from '../helpers/app'; +import { checkTooltip, expectPairInSection, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; import { byTestId, byText } from '../helpers/selectors.js'; describe('Aliases', () => { @@ -17,10 +17,10 @@ describe('Aliases', () => { const labelInput = await byTestId('alias_label_input'); await labelInput.waitForDisplayed({ timeout: 5_000 }); - await labelInput.setValue('test_label'); + await typeInto(labelInput, 'test_label'); const idInput = await byTestId('alias_id_input'); - await idInput.setValue('test_id'); + await typeInto(idInput, 'test_id'); const confirmButton = await byText('Add'); await confirmButton.click(); @@ -37,19 +37,19 @@ describe('Aliases', () => { const label0 = await byTestId('Label_input_0'); await label0.waitForDisplayed({ timeout: 5_000 }); - await label0.setValue('test_label_2'); + await typeInto(label0, 'test_label_2'); const id0 = await byTestId('ID_input_0'); await id0.waitForDisplayed({ timeout: 5_000 }); - await id0.setValue('test_id_2'); + await typeInto(id0, 'test_id_2'); const label1 = await byTestId('Label_input_1'); await label1.waitForDisplayed({ timeout: 5_000 }); - await label1.setValue('test_label_3'); + await typeInto(label1, 'test_label_3'); const id1 = await byTestId('ID_input_1'); await id1.waitForDisplayed({ timeout: 5_000 }); - await id1.setValue('test_id_3'); + await typeInto(id1, 'test_id_3'); const confirmButton = await byText('Add All'); await confirmButton.click(); diff --git a/appium/tests/specs/05_email.spec.ts b/appium/tests/specs/05_email.spec.ts index 75499ea..1455776 100644 --- a/appium/tests/specs/05_email.spec.ts +++ b/appium/tests/specs/05_email.spec.ts @@ -1,4 +1,4 @@ -import { checkTooltip, scrollToEl, waitForAppReady } from '../helpers/app'; +import { checkTooltip, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; import { byTestId, byText, getTestData } from '../helpers/selectors.js'; describe('Emails', () => { @@ -20,7 +20,7 @@ describe('Emails', () => { const emailInput = await byTestId('Email_input'); await emailInput.waitForDisplayed({ timeout: 5_000 }); - await emailInput.setValue(email); + await typeInto(emailInput, email); const confirmButton = await byText('Add'); await confirmButton.click(); diff --git a/appium/tests/specs/06_sms.spec.ts b/appium/tests/specs/06_sms.spec.ts index ac305b0..e8a0314 100644 --- a/appium/tests/specs/06_sms.spec.ts +++ b/appium/tests/specs/06_sms.spec.ts @@ -1,4 +1,4 @@ -import { checkTooltip, scrollToEl, waitForAppReady } from '../helpers/app'; +import { checkTooltip, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; import { byTestId, byText, getTestData } from '../helpers/selectors.js'; describe('SMS', () => { @@ -19,7 +19,7 @@ describe('SMS', () => { const smsInput = await byTestId('SMS Number_input'); await smsInput.waitForDisplayed({ timeout: 5_000 }); - await smsInput.setValue(sms); + await typeInto(smsInput, sms); const confirmButton = await byText('Add'); await confirmButton.click(); diff --git a/appium/tests/specs/07_tag.spec.ts b/appium/tests/specs/07_tag.spec.ts index e129060..cadf5ff 100644 --- a/appium/tests/specs/07_tag.spec.ts +++ b/appium/tests/specs/07_tag.spec.ts @@ -1,4 +1,10 @@ -import { checkTooltip, expectPairInSection, scrollToEl, waitForAppReady } from '../helpers/app'; +import { + checkTooltip, + expectPairInSection, + scrollToEl, + typeInto, + waitForAppReady, +} from '../helpers/app'; import { byTestId, byText } from '../helpers/selectors.js'; describe('Tags', () => { @@ -18,10 +24,10 @@ describe('Tags', () => { // add tag const keyInput = await byTestId('tag_key_input'); await keyInput.waitForDisplayed({ timeout: 5_000 }); - await keyInput.setValue('test_tag'); + await typeInto(keyInput, 'test_tag'); const valueInput = await byTestId('tag_value_input'); - await valueInput.setValue('test_tag_value'); + await typeInto(valueInput, 'test_tag_value'); const confirmButton = await byTestId('tag_confirm_button'); await confirmButton.click(); @@ -43,20 +49,20 @@ describe('Tags', () => { // add tags const key0 = await byTestId('Key_input_0'); await key0.waitForDisplayed({ timeout: 5_000 }); - await key0.setValue('test_tag_2'); + await typeInto(key0, 'test_tag_2'); const value0 = await byTestId('Value_input_0'); - await value0.setValue('test_tag_value_2'); + await typeInto(value0, 'test_tag_value_2'); const addRowButton = await byText('Add Row'); await addRowButton.click(); const key1 = await byTestId('Key_input_1'); await key1.waitForDisplayed({ timeout: 5_000 }); - await key1.setValue('test_tag_3'); + await typeInto(key1, 'test_tag_3'); const value1 = await byTestId('Value_input_1'); - await value1.setValue('test_tag_value_3'); + await typeInto(value1, 'test_tag_value_3'); let confirmButton = await byText('Add All'); await confirmButton.click(); @@ -65,7 +71,7 @@ describe('Tags', () => { await expectPairInSection('tags', 'test_tag_3', 'test_tag_value_3'); // remove tags - const removeButton = await scrollToEl('REMOVE TAGS'); + const removeButton = await scrollToEl('REMOVE TAGS', { by: 'text' }); await removeButton.click(); const tag2Checkbox = await byTestId('remove_checkbox_test_tag_2'); diff --git a/appium/tests/specs/08_outcome.spec.ts b/appium/tests/specs/08_outcome.spec.ts index cfdcf18..57b5f7f 100644 --- a/appium/tests/specs/08_outcome.spec.ts +++ b/appium/tests/specs/08_outcome.spec.ts @@ -1,4 +1,4 @@ -import { checkTooltip, scrollToEl, waitForAppReady } from '../helpers/app'; +import { checkTooltip, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; import { byTestId, byText } from '../helpers/selectors.js'; describe('Outcomes', () => { @@ -17,7 +17,7 @@ describe('Outcomes', () => { const nameInput = await byTestId('outcome_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await nameInput.setValue('test_normal'); + await typeInto(nameInput, 'test_normal'); const normalRadio = await byText('Normal Outcome'); await normalRadio.click(); @@ -35,7 +35,7 @@ describe('Outcomes', () => { const nameInput = await byTestId('outcome_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await nameInput.setValue('test_unique'); + await typeInto(nameInput, 'test_unique'); const uniqueRadio = await byText('Unique Outcome'); await uniqueRadio.click(); @@ -57,11 +57,11 @@ describe('Outcomes', () => { const withValueRadio = await byText('Outcome with Value'); await withValueRadio.click(); - await nameInput.setValue('test_valued'); + await typeInto(nameInput, 'test_valued'); const valueInput = await byTestId('outcome_value_input'); await valueInput.waitForDisplayed({ timeout: 5_000 }); - await valueInput.setValue('3.14'); + await typeInto(valueInput, '3.14'); const sendBtn = await byTestId('outcome_send_button'); await sendBtn.click(); diff --git a/appium/tests/specs/09_trigger.spec.ts b/appium/tests/specs/09_trigger.spec.ts index f369d48..44ae0dd 100644 --- a/appium/tests/specs/09_trigger.spec.ts +++ b/appium/tests/specs/09_trigger.spec.ts @@ -1,4 +1,10 @@ -import { checkTooltip, expectPairInSection, scrollToEl, waitForAppReady } from '../helpers/app'; +import { + checkTooltip, + expectPairInSection, + scrollToEl, + typeInto, + waitForAppReady, +} from '../helpers/app'; import { byTestId, byText } from '../helpers/selectors.js'; async function addMultipleTriggers() { @@ -10,16 +16,16 @@ async function addMultipleTriggers() { const key0 = await byTestId('Key_input_0'); await key0.waitForDisplayed({ timeout: 5_000 }); - await key0.setValue('test_trigger_key_2'); + await typeInto(key0, 'test_trigger_key_2'); const value0 = await byTestId('Value_input_0'); - await value0.setValue('test_trigger_value_2'); + await typeInto(value0, 'test_trigger_value_2'); const key1 = await byTestId('Key_input_1'); - await key1.setValue('test_trigger_key_3'); + await typeInto(key1, 'test_trigger_key_3'); const value1 = await byTestId('Value_input_1'); - await value1.setValue('test_trigger_value_3'); + await typeInto(value1, 'test_trigger_value_3'); let confirmButton = await byText('Add All'); await confirmButton.click(); @@ -45,10 +51,10 @@ describe('Triggers', () => { // add trigger const keyInput = await byTestId('trigger_key_input'); await keyInput.waitForDisplayed({ timeout: 5_000 }); - await keyInput.setValue('test_trigger_key'); + await typeInto(keyInput, 'test_trigger_key'); const valueInput = await byTestId('trigger_value_input'); - await valueInput.setValue('test_trigger_value'); + await typeInto(valueInput, 'test_trigger_value'); const confirmButton = await byTestId('trigger_confirm_button'); await confirmButton.click(); @@ -67,7 +73,7 @@ describe('Triggers', () => { await addMultipleTriggers(); // remove triggers - const removeButton = await scrollToEl('REMOVE TRIGGERS'); + const removeButton = await scrollToEl('REMOVE TRIGGERS', { by: 'text' }); await removeButton.click(); const trigger2Checkbox = await byTestId('remove_checkbox_test_trigger_key_2'); @@ -93,7 +99,7 @@ describe('Triggers', () => { await addMultipleTriggers(); // clear all triggers - const clearButton = await scrollToEl('CLEAR ALL TRIGGERS'); + const clearButton = await scrollToEl('CLEAR ALL TRIGGERS', { by: 'text' }); await clearButton.click(); await scrollToEl('triggers_section', { direction: 'up' }); diff --git a/appium/tests/specs/10_event.spec.ts b/appium/tests/specs/10_event.spec.ts index 816d169..7cf216d 100644 --- a/appium/tests/specs/10_event.spec.ts +++ b/appium/tests/specs/10_event.spec.ts @@ -1,4 +1,4 @@ -import { scrollToEl, waitForAppReady } from '../helpers/app'; +import { checkTooltip, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; import { byTestId, byText, getTestData } from '../helpers/selectors.js'; const TEST_JSON = { @@ -25,9 +25,9 @@ describe('Custom Events', () => { }); // wait for rename when merged to main - // it('should show correct tooltip info', async () => { - // await checkTooltip('custom_events_info_icon', 'trackEvent'); - // }); + it('should show correct tooltip info', async () => { + await checkTooltip('custom_events_info_icon', 'customEvents'); + }); it('can send a custom event with no properties', async () => { const { customEvent } = getTestData(); @@ -36,9 +36,9 @@ describe('Custom Events', () => { const nameInput = await byTestId('event_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await nameInput.setValue(`${customEvent}_no_props`); + await typeInto(nameInput, `${customEvent}_no_props`); - const trackBtn = await byTestId('event_track_button'); + const trackBtn = await byText('Track'); await trackBtn.click(); const snackbar = await byText(`Event tracked: ${customEvent}_no_props`); @@ -52,13 +52,13 @@ describe('Custom Events', () => { const nameInput = await byTestId('event_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await nameInput.setValue(`${customEvent}_with_props`); + await typeInto(nameInput, `${customEvent}_with_props`); const propertiesInput = await byTestId('event_properties_input'); - await propertiesInput.click(); - await propertiesInput.setValue(JSON.stringify(TEST_JSON)); + const json = JSON.stringify(TEST_JSON); + await typeInto(propertiesInput, json); - const trackBtn = await byTestId('event_track_button'); + const trackBtn = await byText('Track'); await trackBtn.click(); const snackbar = await byText(`Event tracked: ${customEvent}_with_props`); diff --git a/appium/tests/specs/11_location.spec.ts b/appium/tests/specs/11_location.spec.ts index 9b00a98..adbca4b 100644 --- a/appium/tests/specs/11_location.spec.ts +++ b/appium/tests/specs/11_location.spec.ts @@ -1,5 +1,5 @@ import { waitForAppReady, waitForAlert, scrollToEl, checkTooltip } from '../helpers/app.js'; -import { byText } from '../helpers/selectors.js'; +import { byText, getPlatform } from '../helpers/selectors.js'; describe('Location', () => { before(async () => { @@ -16,13 +16,19 @@ describe('Location', () => { await promptButton.click(); await driver.pause(3_000); - const alert = await waitForAlert(); - expect(alert).toContain('location'); - await driver.execute('mobile: alert', { - action: 'accept', - buttonLabel: 'Allow While Using App', - }); + if (getPlatform() === 'ios') { + const alert = await waitForAlert(); + expect(alert).toContain('location'); + await driver.execute('mobile: alert', { + action: 'accept', + buttonLabel: 'Allow While Using App', + }); + } else { + const allowBtn = await byText('While using the app'); + await allowBtn.waitForDisplayed({ timeout: 10_000 }); + await allowBtn.click(); + } }); it('can share location', async () => { diff --git a/appium/tests/specs/12_activity.spec.ts b/appium/tests/specs/12_activity.spec.ts index 7fe161d..125d7ca 100644 --- a/appium/tests/specs/12_activity.spec.ts +++ b/appium/tests/specs/12_activity.spec.ts @@ -26,10 +26,10 @@ async function checkActivity(options: { orderId?: string; status: string; messag describe('Live Activities', () => { before(async function () { + await waitForAppReady({ skipLogin: true }); if (getPlatform() !== 'ios') { return this.skip(); } - await waitForAppReady({ skipLogin: true }); await scrollToEl('live_activities_section'); }); diff --git a/appium/tests/specs/debug.spec.ts b/appium/tests/specs/debug.spec.ts deleted file mode 100644 index 7dd3702..0000000 --- a/appium/tests/specs/debug.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -describe('Debug', () => { - it('log ui hierarchy', async () => { - const html = await driver.getPageSource(); - console.log(html); - }); - // it('can debug', async () => { - // await scrollToTop(); - // }); -}); diff --git a/appium/wdio.android.conf.ts b/appium/wdio.android.conf.ts index 06db0a9..5a5a0d4 100644 --- a/appium/wdio.android.conf.ts +++ b/appium/wdio.android.conf.ts @@ -15,6 +15,13 @@ export const config: WebdriverIO.Config = { 'appium:autoGrantPermissions': false, 'appium:noReset': true, ...(isLocal ? {} : { 'bstack:options': bstackOptions }), + + // Disable ID locator autocompletion to avoid Flutter's Semantics(container:true) wrapping inputs in a View. + // @ts-expect-error - Appium types are not fully compatible with WebdriverIO types + 'appium:settings[disableIdLocatorAutocompletion]': true, + + // Hide keyboard during session + 'appium:hideKeyboard': true, }, ], }; diff --git a/appium/wdio.ios.conf.ts b/appium/wdio.ios.conf.ts index 7056ebc..125e416 100644 --- a/appium/wdio.ios.conf.ts +++ b/appium/wdio.ios.conf.ts @@ -15,6 +15,9 @@ export const config: WebdriverIO.Config = { 'appium:autoAcceptAlerts': false, 'appium:noReset': true, ...(isLocal ? {} : { 'bstack:options': bstackOptions }), + + // Hide keyboard during session + 'appium:hideKeyboard': true, }, ], };