diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index a6a3360..8427826 100755 --- a/appium/scripts/run-all.sh +++ b/appium/scripts/run-all.sh @@ -46,7 +46,7 @@ USAGE done PLATFORMS=(ios android) -SDKS=(cordova react-native flutter) +SDKS=(cordova react-native flutter dotnet) declare -a RESULTS FAILED=0 @@ -56,7 +56,9 @@ for platform in "${PLATFORMS[@]}"; do label="${sdk} / ${platform}" echo "" echo -e "${BOLD}━━━ Running: ${label} ━━━${NC}" - if "$SCRIPT_DIR/run-local.sh" --platform="$platform" --sdk="$sdk" "${EXTRA_ARGS[@]}"; then + # `${arr[@]+"${arr[@]}"}` expands the array only when it has elements; + # under `set -u`, a bare `"${EXTRA_ARGS[@]}"` errors out on an empty array. + if "$SCRIPT_DIR/run-local.sh" --platform="$platform" --sdk="$sdk" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}; then RESULTS+=("PASS ${label}") else RESULTS+=("FAIL ${label}") diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index 52a7e89..1b65a15 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -24,6 +24,7 @@ error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } # ── Defaults ────────────────────────────────────────────────────────────────── APPIUM_PORT="${APPIUM_PORT:-4723}" +SYSTEM_PORT="${SYSTEM_PORT:-}" SKIP_BUILD=false SKIP_DEVICE=false SKIP_RESET=false @@ -32,14 +33,16 @@ SPEC="tests/specs/**/*.spec.ts" # ── Parse args ──────────────────────────────────────────────────────────────── for arg in "$@"; do case "$arg" in - --platform=*) PLATFORM="${arg#--platform=}" ;; - --sdk=*) SDK_TYPE="${arg#--sdk=}" ;; - --device=*) DEVICE="${arg#--device=}" ;; - --skip) SKIP_BUILD=true; SKIP_DEVICE=true; SKIP_RESET=true ;; - --skip-build) SKIP_BUILD=true ;; - --skip-device) SKIP_DEVICE=true ;; - --skip-reset) SKIP_RESET=true ;; - --spec=*) SPEC="${arg#--spec=}" ;; + --platform=*) PLATFORM="${arg#--platform=}" ;; + --sdk=*) SDK_TYPE="${arg#--sdk=}" ;; + --device=*) DEVICE="${arg#--device=}" ;; + --appium-port=*) APPIUM_PORT="${arg#--appium-port=}" ;; + --system-port=*) SYSTEM_PORT="${arg#--system-port=}" ;; + --skip) SKIP_BUILD=true; SKIP_DEVICE=true; SKIP_RESET=true ;; + --skip-build) SKIP_BUILD=true ;; + --skip-device) SKIP_DEVICE=true ;; + --skip-reset) SKIP_RESET=true ;; + --spec=*) SPEC="${arg#--spec=}" ;; --help|-h) cat < "$DEMO_DIR/.env" </dev/null \ + | sort \ + | xargs shasum 2>/dev/null \ + | shasum \ + | awk '{print $1}' +} + +dotnet_sdk_inputs_hash() { + local platform="$1" + local roots=() + while IFS= read -r p; do roots+=("$p"); done < <(dotnet_sdk_paths "$platform") + dotnet_hash_paths "${roots[@]}" +} + +# Demo hash folds in the SDK hash so an SDK edit busts the demo cache too. +dotnet_demo_inputs_hash() { + local platform="$1" + local sdk_hash="$2" + local demo_hash + demo_hash=$(dotnet_hash_paths "$DEMO_DIR") + printf '%s\n%s\n' "$sdk_hash" "$demo_hash" | shasum | awk '{print $1}' +} + +# Returns 0 if a previous build's stamp matches the current input hash AND the +# expected output artifact still exists, in which case the caller should skip. +dotnet_build_is_cached() { + local stamp="$1" artifact="$2" hash="$3" + [[ -e "$artifact" ]] || return 1 + [[ -f "$stamp" ]] || return 1 + [[ "$(cat "$stamp")" == "$hash" ]] || return 1 + return 0 +} + +# Build only the SDK + binding projects for the given platform. The demo's +# csproj has a ProjectReference to `OneSignalSDK.DotNet`, so building that one +# project transitively builds every binding it pulls in for the target TFM. +# Cached separately so demo-only edits don't pay the SDK build cost. +build_dotnet_sdk() { + local platform="$1" # ios | android + local tfm="${DOTNET_TFM}-${platform}" + local sdk_proj="$DOTNET_DIR/OneSignalSDK.DotNet/OneSignalSDK.DotNet.csproj" + local sdk_dll="$DOTNET_DIR/OneSignalSDK.DotNet/bin/Debug/${tfm}/OneSignalSDK.DotNet.dll" + local stamp="$DOTNET_DIR/OneSignalSDK.DotNet/bin/Debug/.sdk-build-${platform}.stamp" + local hash="$2" + + if dotnet_build_is_cached "$stamp" "$sdk_dll" "$hash"; then + info ".NET SDK unchanged, skipping SDK rebuild" + return + fi + + info "Building .NET SDK + bindings for ${tfm}..." + dotnet build "$sdk_proj" -c Debug -f "$tfm" + + [[ -f "$sdk_dll" ]] || error "SDK build did not produce $sdk_dll" + mkdir -p "$(dirname "$stamp")" + echo "$hash" > "$stamp" +} + +build_dotnet_ios() { + write_dotnet_demo_env + + command -v dotnet >/dev/null 2>&1 || error "dotnet CLI not found in PATH — install the .NET SDK" + + local sdk_hash demo_hash + sdk_hash=$(dotnet_sdk_inputs_hash ios) + demo_hash=$(dotnet_demo_inputs_hash ios "$sdk_hash") + + local stamp="$DEMO_DIR/bin/Debug/.dotnet-build-ios-${DOTNET_IOS_RID}.stamp" + if dotnet_build_is_cached "$stamp" "$APP_PATH" "$demo_hash"; then + info ".NET SDK + demo source unchanged, skipping rebuild" + info "App: $APP_PATH" + return + fi + + build_dotnet_sdk ios "$sdk_hash" + + # --no-dependencies: SDK is already built (and cached) by build_dotnet_sdk, + # so MSBuild can skip even checking referenced projects for up-to-date. + info "Building Debug .app for iOS simulator (${DOTNET_IOS_RID})..." + (cd "$DEMO_DIR" && dotnet build demo.csproj \ + -c Debug \ + -f "${DOTNET_TFM}-ios" \ + -p:RuntimeIdentifier="${DOTNET_IOS_RID}" \ + --no-dependencies) + + [[ -d "$APP_PATH" ]] || error ".app not found after build at $APP_PATH" + mkdir -p "$(dirname "$stamp")" + echo "$demo_hash" > "$stamp" + info "App built: $APP_PATH" +} + +build_dotnet_android() { + write_dotnet_demo_env + + command -v dotnet >/dev/null 2>&1 || error "dotnet CLI not found in PATH — install the .NET SDK" + + local sdk_hash demo_hash + sdk_hash=$(dotnet_sdk_inputs_hash android) + demo_hash=$(dotnet_demo_inputs_hash android "$sdk_hash") + + local stamp="$DEMO_DIR/bin/Debug/.dotnet-build-android.stamp" + if dotnet_build_is_cached "$stamp" "$APP_PATH" "$demo_hash"; then + info ".NET SDK + demo source unchanged, skipping rebuild" + info "App: $APP_PATH" + return + fi + + build_dotnet_sdk android "$sdk_hash" + + # EmbedAssembliesIntoApk=true: by default `dotnet build -c Debug` for Android + # uses Fast Deployment, which leaves the managed assemblies *out* of the APK + # and pushes them live to /data/.../files/.__override__// via + # `-t:Run`. Appium just installs the APK, so without this flag monodroid + # aborts at startup with "No assemblies found in ... Fast Deployment. Exiting". + # + # AndroidLinkMode=None: skips the IL linker/trimmer pass on every demo edit. + # The linker normally trims unused IL across SDK + bindings + demo, which is + # ~15-25s of fixed cost per build. Debug builds don't need it (slightly larger + # APK is fine on the dev loop) and turning it off keeps incremental rebuilds + # of demo-only changes well under a minute. + # + # --no-dependencies: SDK already built above, so we skip MSBuild's + # up-to-date check on every referenced project. + info "Building Debug signed APK with embedded assemblies..." + (cd "$DEMO_DIR" && dotnet build demo.csproj \ + -c Debug \ + -f "${DOTNET_TFM}-android" \ + -p:EmbedAssembliesIntoApk=true \ + -p:AndroidUseFastDeployment=false \ + -p:AndroidLinkMode=None \ + --no-dependencies) + + [[ -f "$APP_PATH" ]] || error ".apk not found after build at $APP_PATH" + mkdir -p "$(dirname "$stamp")" + echo "$demo_hash" > "$stamp" + info "App built: $APP_PATH" +} + build_app() { if [[ "$SKIP_BUILD" == true ]]; then if [[ "$PLATFORM" == "ios" && ! -d "$APP_PATH" ]] || [[ "$PLATFORM" == "android" && ! -f "$APP_PATH" ]]; then @@ -491,6 +706,12 @@ build_app() { else build_cordova_android fi + elif [[ "$SDK_TYPE" == "dotnet" ]]; then + if [[ "$PLATFORM" == "ios" ]]; then + build_dotnet_ios + else + build_dotnet_android + fi fi } @@ -636,6 +857,8 @@ run_tests() { BUNDLE_ID="${BUNDLE_ID:-}" \ ONESIGNAL_APP_ID="${ONESIGNAL_APP_ID:-}" \ ONESIGNAL_API_KEY="${ONESIGNAL_API_KEY:-}" \ + APPIUM_PORT="$APPIUM_PORT" \ + SYSTEM_PORT="${SYSTEM_PORT:-}" \ bunx wdio run "$conf" --spec "$SPEC" } diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 4acbe3e..d4c4e6e 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -15,9 +15,6 @@ 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; } @@ -151,12 +148,15 @@ export async function scrollToEl( async function scrollExtraIfNeeded }>( el: T, refetch: () => Promise, - threshold = 0.9, ): Promise { try { const { y } = await el.getLocation(); const { height } = await driver.getWindowSize(); - if (y > height * threshold) { + if (y < 50) { + await swipeMainContent('up', 'small'); + return await refetch(); + } + if (y > height * 0.9) { await swipeMainContent('down', 'small'); return await refetch(); } @@ -186,7 +186,7 @@ const ANDROID_PERMISSION_PACKAGES = new Set([ async function clickAndroidPermissionButton( selectors: string[], - timeoutMs = 2000, + timeoutMs = 10_000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -213,7 +213,7 @@ async function clickAndroidPermissionButton( * System permission dialogs live under SpringBoard on iOS, so treat them like * regular UI and click the expected button if it is visible. */ -async function clickIosPermissionButton(buttonLabel: string, timeoutMs = 1500) { +async function clickIosPermissionButton(buttonLabel: string, timeoutMs = 10_000) { await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); try { const button = await $( @@ -386,7 +386,7 @@ export async function loginUser(externalUserId: string) { const userIdInput = await byTestId('login_user_id_input'); await userIdInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(userIdInput, externalUserId); + await userIdInput.setValue(externalUserId); await confirmModal('singleinput_confirm_button'); } @@ -407,18 +407,6 @@ 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); -} - /** * Tap a modal's confirm button and wait for the modal to dismiss. * @@ -446,10 +434,10 @@ export async function addTag(key: string, value: string) { const keyInput = await byTestId('tag_key_input'); await keyInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(keyInput, key); + await keyInput.setValue(key); const valueInput = await byTestId('tag_value_input'); - await typeInto(valueInput, value); + await valueInput.setValue(value); await confirmModal('tag_confirm_button'); } @@ -484,10 +472,8 @@ export async function lockScreen() { await switchToNativeContext(); await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); await driver.lock(); - await driver.pause(500); await driver.execute('mobile: pressButton', { name: 'home' }); - await driver.pause(500); } /** @@ -509,7 +495,6 @@ export async function returnToApp() { await driver.execute('mobile: activateApp', { bundleId }); } - await driver.pause(1_000); await ensureMainWebViewContext(); } @@ -519,8 +504,11 @@ export async function returnToApp() { * Android: opens the notification shade, verifies the title (and optionally * body) are visible, then closes the shade. * - * iOS: swipes down from the top-left to open the notification center, - * verifies the notification, then returns to the app. + * iOS: asserts against the foreground notification banner that SpringBoard + * overlays on the app while it's in foreground. No home press, no + * Notification Center swipe, no lock-screen path. Requires the SDK demo's + * `notificationWillDisplay` handler to allow display (the OneSignal demos + * default to this). */ export async function waitForNotification(opts: { title: string; @@ -560,83 +548,42 @@ export async function waitForNotification(opts: { return; } - // iOS: swipe down from the top-left to open notification center - // (top-right opens Control Center on iOS 16+). + // iOS: query the foreground banner SpringBoard renders over the app. // Native predicate selectors (`-ios predicate string:...`) only resolve in - // NATIVE_APP. WebView SDKs (Capacitor/Cordova) are parked in a `WEBVIEW_*` - // context by `waitForAppReady`, so the predicate query hangs until the - // command timeout. Swap to native for the shade work and restore on exit. + // NATIVE_APP, and the banner lives in SpringBoard's UI tree, so we point + // `defaultActiveApplication` at SpringBoard for the query and restore the + // app on the way out. + const caps = driver.capabilities as Record; + const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; await switchToNativeContext(); try { await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); - await driver.execute('mobile: pressButton', { name: 'home' }); - await driver.pause(1_000); - - const { width, height } = await driver.getWindowSize(); - await driver.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: Math.round(width * 0.1), y: 5 }, - { type: 'pointerDown', button: 0 }, - { type: 'pause', duration: 100 }, - { - type: 'pointerMove', - duration: 500, - x: Math.round(width * 0.1), - y: Math.round(height * 0.6), - }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]); - await driver.releaseActions(); - await driver.pause(2_000); - const predicate = body ? `label CONTAINS "${title}" AND label CONTAINS "${body}"` : `label CONTAINS "${title}"`; - const notification = await $(`-ios predicate string:${predicate}`); - await notification.waitForDisplayed({ timeout: timeoutMs }); + const banner = await $(`-ios predicate string:${predicate}`); + await banner.waitForDisplayed({ timeout: timeoutMs }); if (expectImage) { - const location = await notification.getLocation(); - const size = await notification.getSize(); - const startX = Math.round(location.x + size.width * 0.8); - const endX = Math.round(location.x + size.width * 0.2); - const centerY = Math.round(location.y + size.height / 2); - - await driver.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: startX, y: centerY }, - { type: 'pointerDown', button: 0 }, - { type: 'pause', duration: 100 }, - { type: 'pointerMove', duration: 300, x: endX, y: centerY }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]); - await driver.releaseActions(); - await driver.pause(300); - - const viewButton = await $(`-ios predicate string:label == "View"`); - await viewButton.waitForDisplayed({ timeout: 5_000 }); - await viewButton.click(); - await driver.pause(500); - - const image = await $('-ios class chain:**/XCUIElementTypeImage'); - await image.waitForDisplayed({ timeout: 5_000 }); + // Long-press to expand the banner; the attachment renders as a new + // XCUIElementTypeImage on top of the existing app icon. + const before = await driver.findElements('-ios class chain', '**/XCUIElementTypeImage'); + await driver.execute('mobile: touchAndHold', { + elementId: banner.elementId, + duration: 1.0, + }); + + const after = await driver.findElements('-ios class chain', '**/XCUIElementTypeImage'); + expect(after.length).toBeGreaterThan(before.length); } - await returnToApp(); + // dismiss the banner + await banner.click(); } finally { + if (bundleId) { + await driver.updateSettings({ defaultActiveApplication: bundleId }); + } await ensureMainWebViewContext(); } } @@ -647,13 +594,8 @@ export async function checkNotification(opts: { body?: string; expectImage?: boolean; }) { - const clearButton = await scrollToEl('clear_all_button'); - await clearButton.click(); - - await driver.pause(1_000); - const button = await scrollToEl(opts.buttonId, { direction: 'up' }); + const button = await scrollToEl(opts.buttonId); await button.click(); - await driver.pause(3_000); await waitForNotification({ title: opts.title, body: opts.body, @@ -695,6 +637,13 @@ async function switchToWebViewContext() { return true; } +// Handles that we've already matched as IAM webviews on Android. After the IAM +// is closed, chromedriver does not detach the handle from getWindowHandles(), +// so we must skip it on subsequent iterations to avoid `switchToWindow` -> +// "no such window" noise (and chromedriver instability when many stale handles +// pile up). +const knownStaleIAMHandles = new Set(); + async function switchToIAMWebView(expectedTitle: string, timeoutMs: number) { if (getPlatform() === 'ios') { // On hybrid iOS the app itself is a WebView, so getContexts() returns @@ -728,10 +677,24 @@ async function switchToIAMWebView(expectedTitle: string, timeoutMs: number) { try { if (!(await switchToWebViewContext())) return false; - for (const handle of await driver.getWindowHandles()) { - await driver.switchToWindow(handle); + // chromedriver appends new IAM windows to the end and does NOT detach + // closed-IAM handles from getWindowHandles(). Iterate newest-first and + // skip handles previously matched/failed -- otherwise switchToWindow + // on a stale handle emits `no such window` warnings and, when several + // stale handles pile up, can crash the chromedriver session. + const candidates = [...(await driver.getWindowHandles())] + .reverse() + .filter((h) => !knownStaleIAMHandles.has(h)); + for (const handle of candidates) { + try { + await driver.switchToWindow(handle); + } catch { + knownStaleIAMHandles.add(handle); + continue; + } const h1 = await $('h1'); if ((await h1.isExisting()) && (await h1.getText()) === expectedTitle) { + knownStaleIAMHandles.add(handle); return true; } } @@ -761,7 +724,6 @@ export async function checkInAppMessage(opts: { timeout: timeoutMs, timeoutMsg: `IAM webview not shown after clicking "${buttonId}"`, }); - await driver.pause(1_000); await switchToIAMWebView(expectedTitle, timeoutMs); @@ -779,20 +741,12 @@ export async function checkInAppMessage(opts: { timeout: timeoutMs, timeoutMsg: 'IAM webview still visible after closing', }); - await driver.pause(1_000); - } else { - await driver.pause(3_000); } - await ensureMainWebViewContext(); } /** - * Asserts a transient snackbar/toast appears with the expected text, then waits - * for it to fully disappear. This prevents the next test from racing against a - * lingering toast that can intercept taps or block hit-testing on freshly - * opened modals (e.g. `react-native-toast-message` keeps toasts visible for - * ~4s by default). + * Asserts a transient snackbar/toast appears with the expected text * * Cordova/Capacitor render the toast as `` (Ionic), whose visible * text lives inside the component's shadow root and is not reachable via diff --git a/appium/tests/helpers/logger.ts b/appium/tests/helpers/logger.ts deleted file mode 100644 index cda300c..0000000 --- a/appium/tests/helpers/logger.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getPlatform } from './selectors.js'; - -const BUNDLE_ID = process.env.BUNDLE_ID || 'com.onesignal.example'; -const collectedLogs: string[] = []; - -function drainLogs() { - const logType = getPlatform() === 'ios' ? 'syslog' : 'logcat'; - return driver.getLogs(logType); -} - -async function collectNewLogs(): Promise { - const entries = await drainLogs(); - for (const entry of entries) { - const msg = String((entry as Record).message ?? entry); - if (msg.includes(BUNDLE_ID)) { - collectedLogs.push(msg); - } - } -} - -export function hasLogContaining(substring: string): boolean { - return collectedLogs.some((msg) => msg.includes(substring)); -} - -// Avoid using this function and rely on snackbars instead -export async function waitForLog( - substring: string, - timeoutMs = 30_000, - pollMs = 1_000, -): Promise { - collectedLogs.length = 0; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await collectNewLogs(); - if (hasLogContaining(substring)) { - return; - } - await driver.pause(pollMs); - } - throw new Error(`Timed out waiting for log containing "${substring}" after ${timeoutMs}ms`); -} diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 75527b0..8024adb 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -183,12 +183,25 @@ 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. + * On Flutter Android, two interactions need shimming: + * + * - `getText()` often returns empty because Flutter writes its text into the + * `content-desc` / `text` attributes rather than the property UiAutomator2's + * getText maps to. We fall back to those attributes. + * - `setValue()` doesn't focus the field first because Flutter renders inputs + * to a Skia canvas with Semantics shims (no native EditText), so W3C + * "send keys" lands without an IME binding and the keystrokes get dropped. + * We tap the element first to bind the IME, then forward to setValue. + * + * iOS XCUITest doesn't hit either problem in practice. */ -function withFlutterAndroidFixes }>(el: T): T { +function withFlutterAndroidFixes< + T extends { + getText(): Promise; + setValue(value: string): Promise; + click(): Promise; + }, +>(el: T): T { if (!(getPlatform() === 'android' && getSdkType() === 'flutter')) { return el; } @@ -218,6 +231,13 @@ function withFlutterAndroidFixes }>(el: T }; } + if (prop === 'setValue') { + return async (value: string) => { + await target.click(); + await target.setValue(value); + }; + } + const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return value.bind(target); @@ -238,6 +258,10 @@ function withFlutterAndroidFixes }>(el: T * bridge surfaced it as content-desc but new arch sets it as the view tag, * which UiAutomator2 exposes via resource-id. * - Native Android Compose testTag → accessibility id (`~`) + * - .NET MAUI AutomationId → resource-id (`id=`), but namespaced as + * `:id/`. The wdio Android config disables locator + * autocompletion to dodge a Flutter quirk, so for dotnet we re-enable + * it (see wdio.android.conf.ts) and short ids match transparently. * Capacitor uses `data-testid` as a CSS attribute inside a WebView. */ export async function byTestId(id: string) { diff --git a/appium/tests/specs/01_user.spec.ts b/appium/tests/specs/01_user.spec.ts index 7386efc..6ddc9ab 100644 --- a/appium/tests/specs/01_user.spec.ts +++ b/appium/tests/specs/01_user.spec.ts @@ -9,7 +9,6 @@ describe('User', () => { after(async () => { // login user back so we can clean up the user data for the next run - await driver.pause(1_000); await waitForAppReady(); }); @@ -35,7 +34,6 @@ describe('User', () => { const externalId = await externalIdEl.getText(); expect(externalId).toBe(getTestExternalId()); - await driver.pause(1_500); 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 30d9c83..adb1484 100644 --- a/appium/tests/specs/03_iam.spec.ts +++ b/appium/tests/specs/03_iam.spec.ts @@ -46,7 +46,6 @@ describe('In-App Messaging', () => { // try to show top banner, should fail since IAM is paused const button = await scrollToEl('send_iam_top_banner_button'); await button.click(); - await driver.pause(3_000); if (driver.isIOS) { expect(await isWebViewVisible()).toBe(false); diff --git a/appium/tests/specs/04_alias.spec.ts b/appium/tests/specs/04_alias.spec.ts index 037e4e9..82005f0 100644 --- a/appium/tests/specs/04_alias.spec.ts +++ b/appium/tests/specs/04_alias.spec.ts @@ -3,7 +3,6 @@ import { confirmModal, expectPairInSection, scrollToEl, - typeInto, waitForAppReady, } from '../helpers/app'; import { byTestId } from '../helpers/selectors.js'; @@ -28,10 +27,10 @@ describe('Aliases', () => { const labelInput = await byTestId('alias_label_input'); await labelInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(labelInput, 'test_label'); + await labelInput.setValue('test_label'); const idInput = await byTestId('alias_id_input'); - await typeInto(idInput, 'test_id'); + await idInput.setValue('test_id'); await confirmModal('singlepair_confirm_button'); @@ -47,19 +46,19 @@ describe('Aliases', () => { const label0 = await byTestId('multipair_key_0'); await label0.waitForDisplayed({ timeout: 5_000 }); - await typeInto(label0, 'test_label_2'); + await label0.setValue('test_label_2'); const id0 = await byTestId('multipair_value_0'); await id0.waitForDisplayed({ timeout: 5_000 }); - await typeInto(id0, 'test_id_2'); + await id0.setValue('test_id_2'); const label1 = await byTestId('multipair_key_1'); await label1.waitForDisplayed({ timeout: 5_000 }); - await typeInto(label1, 'test_label_3'); + await label1.setValue('test_label_3'); const id1 = await byTestId('multipair_value_1'); await id1.waitForDisplayed({ timeout: 5_000 }); - await typeInto(id1, 'test_id_3'); + await id1.setValue('test_id_3'); await confirmModal('multipair_confirm_button'); diff --git a/appium/tests/specs/05_email.spec.ts b/appium/tests/specs/05_email.spec.ts index 0d48b86..40ec6fb 100644 --- a/appium/tests/specs/05_email.spec.ts +++ b/appium/tests/specs/05_email.spec.ts @@ -1,4 +1,4 @@ -import { checkTooltip, confirmModal, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; +import { checkTooltip, confirmModal, scrollToEl, waitForAppReady } from '../helpers/app'; import { byTestId, getTestData } from '../helpers/selectors.js'; describe('Emails', () => { @@ -20,14 +20,13 @@ describe('Emails', () => { const emailInput = await byTestId('email_input'); await emailInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(emailInput, email); + await emailInput.setValue(email); await confirmModal('singleinput_confirm_button'); let el = await byTestId(`emails_value_${email}`); await el.waitForDisplayed({ timeout: 5_000 }); // remove email - await driver.pause(2_000); const removeButton = await byTestId(`emails_remove_${email}`); await removeButton.click(); diff --git a/appium/tests/specs/06_sms.spec.ts b/appium/tests/specs/06_sms.spec.ts index 07641b8..d51c61b 100644 --- a/appium/tests/specs/06_sms.spec.ts +++ b/appium/tests/specs/06_sms.spec.ts @@ -1,4 +1,4 @@ -import { checkTooltip, confirmModal, scrollToEl, typeInto, waitForAppReady } from '../helpers/app'; +import { checkTooltip, confirmModal, scrollToEl, waitForAppReady } from '../helpers/app'; import { byTestId, getTestData } from '../helpers/selectors.js'; describe('SMS', () => { @@ -19,7 +19,7 @@ describe('SMS', () => { const smsInput = await byTestId('sms_input'); await smsInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(smsInput, sms); + await smsInput.setValue(sms); await confirmModal('singleinput_confirm_button'); @@ -28,7 +28,6 @@ describe('SMS', () => { await el.waitForDisplayed({ timeout: 5_000 }); // remove sms - await driver.pause(2_000); const removeButton = await byTestId(`sms_remove_${sms}`); await removeButton.click(); diff --git a/appium/tests/specs/07_tag.spec.ts b/appium/tests/specs/07_tag.spec.ts index fbe6e20..7b31341 100644 --- a/appium/tests/specs/07_tag.spec.ts +++ b/appium/tests/specs/07_tag.spec.ts @@ -3,7 +3,6 @@ import { confirmModal, expectPairInSection, scrollToEl, - typeInto, waitForAppReady, } from '../helpers/app'; import { byTestId } from '../helpers/selectors.js'; @@ -29,17 +28,16 @@ describe('Tags', () => { // add tag const keyInput = await byTestId('tag_key_input'); await keyInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(keyInput, 'test_tag'); + await keyInput.setValue('test_tag'); const valueInput = await byTestId('tag_value_input'); - await typeInto(valueInput, 'test_tag_value'); + await valueInput.setValue('test_tag_value'); await confirmModal('singlepair_confirm_button'); await expectPairInSection('tags', 'test_tag', 'test_tag_value'); // remove tag - await driver.pause(2_000); const removeButton = await byTestId(`tags_remove_test_tag`); await removeButton.click(); @@ -54,20 +52,20 @@ describe('Tags', () => { // add tags const key0 = await byTestId('multipair_key_0'); await key0.waitForDisplayed({ timeout: 5_000 }); - await typeInto(key0, 'test_tag_2'); + await key0.setValue('test_tag_2'); const value0 = await byTestId('multipair_value_0'); - await typeInto(value0, 'test_tag_value_2'); + await value0.setValue('test_tag_value_2'); const addRowButton = await byTestId('multipair_add_row_button'); await addRowButton.click(); const key1 = await byTestId('multipair_key_1'); await key1.waitForDisplayed({ timeout: 5_000 }); - await typeInto(key1, 'test_tag_3'); + await key1.setValue('test_tag_3'); const value1 = await byTestId('multipair_value_1'); - await typeInto(value1, 'test_tag_value_3'); + await value1.setValue('test_tag_value_3'); await confirmModal('multipair_confirm_button'); diff --git a/appium/tests/specs/08_outcome.spec.ts b/appium/tests/specs/08_outcome.spec.ts index 28eeb1b..594a326 100644 --- a/appium/tests/specs/08_outcome.spec.ts +++ b/appium/tests/specs/08_outcome.spec.ts @@ -2,7 +2,6 @@ import { checkTooltip, expectSnackbar, scrollToEl, - typeInto, waitForAppReady, } from '../helpers/app'; import { byTestId } from '../helpers/selectors.js'; @@ -23,7 +22,7 @@ describe('Outcomes', () => { const nameInput = await byTestId('outcome_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(nameInput, 'test_normal'); + await nameInput.setValue('test_normal'); const normalRadio = await byTestId('outcome_type_normal_radio'); await normalRadio.click(); @@ -40,7 +39,7 @@ describe('Outcomes', () => { const nameInput = await byTestId('outcome_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(nameInput, 'test_unique'); + await nameInput.setValue('test_unique'); const uniqueRadio = await byTestId('outcome_type_unique_radio'); await uniqueRadio.click(); @@ -61,11 +60,11 @@ describe('Outcomes', () => { const withValueRadio = await byTestId('outcome_type_value_radio'); await withValueRadio.click(); - await typeInto(nameInput, 'test_valued'); + await nameInput.setValue('test_valued'); const valueInput = await byTestId('outcome_value_input'); await valueInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(valueInput, '3.14'); + await valueInput.setValue('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 f6ceb08..8649913 100644 --- a/appium/tests/specs/09_trigger.spec.ts +++ b/appium/tests/specs/09_trigger.spec.ts @@ -3,7 +3,6 @@ import { confirmModal, expectPairInSection, scrollToEl, - typeInto, waitForAppReady, } from '../helpers/app'; import { byTestId } from '../helpers/selectors.js'; @@ -17,16 +16,16 @@ async function addMultipleTriggers() { const key0 = await byTestId('multipair_key_0'); await key0.waitForDisplayed({ timeout: 5_000 }); - await typeInto(key0, 'test_trigger_key_2'); + await key0.setValue('test_trigger_key_2'); const value0 = await byTestId('multipair_value_0'); - await typeInto(value0, 'test_trigger_value_2'); + await value0.setValue('test_trigger_value_2'); const key1 = await byTestId('multipair_key_1'); - await typeInto(key1, 'test_trigger_key_3'); + await key1.setValue('test_trigger_key_3'); const value1 = await byTestId('multipair_value_1'); - await typeInto(value1, 'test_trigger_value_3'); + await value1.setValue('test_trigger_value_3'); await confirmModal('multipair_confirm_button'); @@ -55,17 +54,16 @@ describe('Triggers', () => { // add trigger const keyInput = await byTestId('trigger_key_input'); await keyInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(keyInput, 'test_trigger_key'); + await keyInput.setValue('test_trigger_key'); const valueInput = await byTestId('trigger_value_input'); - await typeInto(valueInput, 'test_trigger_value'); + await valueInput.setValue('test_trigger_value'); await confirmModal('singlepair_confirm_button'); await expectPairInSection('triggers', 'test_trigger_key', 'test_trigger_value'); // remove trigger - await driver.pause(1_000); const removeButton = await byTestId(`triggers_remove_test_trigger_key`); await removeButton.click(); @@ -101,15 +99,15 @@ describe('Triggers', () => { it('can clear all triggers', async () => { await addMultipleTriggers(); - await driver.pause(1_000); - // clear all triggers const clearButton = await scrollToEl('clear_triggers_button'); await clearButton.click(); await scrollToEl('triggers_section', { direction: 'up' }); const el = await byTestId('triggers_empty'); - await el.waitForDisplayed({ timeout: 5_000 }); - expect(await el.getText()).toContain('No triggers added'); + await el.waitUntil(async () => (await el.getText()).includes('No triggers added'), { + timeout: 5_000, + timeoutMsg: 'Expected triggers_empty to contain "No triggers added"', + }); }); }); diff --git a/appium/tests/specs/10_event.spec.ts b/appium/tests/specs/10_event.spec.ts index f4fbf3a..9ae5a09 100644 --- a/appium/tests/specs/10_event.spec.ts +++ b/appium/tests/specs/10_event.spec.ts @@ -2,7 +2,6 @@ import { checkTooltip, expectSnackbar, scrollToEl, - typeInto, waitForAppReady, } from '../helpers/app'; import { byTestId, getTestData } from '../helpers/selectors.js'; @@ -42,7 +41,7 @@ describe('Custom Events', () => { const nameInput = await byTestId('event_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(nameInput, `${customEvent}_no_props`); + await nameInput.setValue(`${customEvent}_no_props`); const trackBtn = await byTestId('event_track_button'); await trackBtn.click(); @@ -57,11 +56,11 @@ describe('Custom Events', () => { const nameInput = await byTestId('event_name_input'); await nameInput.waitForDisplayed({ timeout: 5_000 }); - await typeInto(nameInput, `${customEvent}_with_props`); + await nameInput.setValue(`${customEvent}_with_props`); const propertiesInput = await byTestId('event_properties_input'); const json = JSON.stringify(TEST_JSON); - await typeInto(propertiesInput, json); + await propertiesInput.setValue(json); const trackBtn = await byTestId('event_track_button'); await trackBtn.click(); diff --git a/appium/tests/specs/11_location.spec.ts b/appium/tests/specs/11_location.spec.ts index e4da50d..2d118d3 100644 --- a/appium/tests/specs/11_location.spec.ts +++ b/appium/tests/specs/11_location.spec.ts @@ -22,7 +22,6 @@ describe('Location', () => { it('can prompt for location', async () => { const promptButton = await scrollToEl('prompt_location_button'); await promptButton.click(); - await driver.pause(3_000); await switchToNativeContext(); await allowLocation(); diff --git a/appium/tests/specs/12_activity.spec.ts b/appium/tests/specs/12_activity.spec.ts index 8848cf9..a303e75 100644 --- a/appium/tests/specs/12_activity.spec.ts +++ b/appium/tests/specs/12_activity.spec.ts @@ -43,8 +43,14 @@ describe('Live Activities', () => { const clickUpdateButton = async () => { const updateButton = await scrollToEl('update_live_activity_button'); + await updateButton.waitForEnabled({ timeout: 15_000 }); await updateButton.click(); - await driver.pause(3_000); + + // Wait for the disable->re-enable cycle so we know the update HTTP + // call finished. Observing the disable first is required - otherwise + // the re-enable wait can return before isLaUpdating flips. + await updateButton.waitForEnabled({ reverse: true, timeout: 15_000 }); + await updateButton.waitForEnabled({ timeout: 15_000 }); }; await checkActivity({ @@ -63,7 +69,6 @@ describe('Live Activities', () => { // end live activity const endButton = await scrollToEl('end_live_activity_button'); await endButton.click(); - await driver.pause(3_000); await lockScreen(); const activityEl = await $(`-ios predicate string:label CONTAINS "ORD-1234"`); diff --git a/appium/wdio.android.conf.ts b/appium/wdio.android.conf.ts index cc34099..07dee86 100644 --- a/appium/wdio.android.conf.ts +++ b/appium/wdio.android.conf.ts @@ -1,6 +1,34 @@ import { sharedConfig, bstackOptions } from './wdio.shared.conf.js'; const isLocal = !process.env.BROWSERSTACK_USERNAME; +const isDotNet = process.env.SDK_TYPE === 'dotnet'; + +// .NET MAUI compiles Android activities with CRC-hashed Java class names (e.g. +// `crc64126b3a41c71c5f27.MainActivity`) instead of the C# namespace path, so +// Appium's launchable-activity wait check can't validate the launch and times +// out. Skip that check (`appWaitForLaunch: false`) and rely on the per-test +// `waitForAppReady` element wait to confirm the app is up. MAUI's cold install +// also takes longer than the defaults, so widen the install/launch budgets. +const dotnetAndroidCaps = isDotNet + ? { + 'appium:appWaitForLaunch': false, + 'appium:appWaitDuration': 120_000, + 'appium:androidInstallTimeout': 180_000, + } + : {}; + +// Per-session UiAutomator2 ports. Required when running 2+ Android sessions +// in parallel on one host so the instrumentation/chromedriver sockets don't +// collide on the defaults (8200 / random). Single-session runs can leave both +// unset and let Appium pick the defaults. +const parallelPortCaps = { + ...(process.env.SYSTEM_PORT + ? { 'appium:systemPort': Number(process.env.SYSTEM_PORT) } + : {}), + ...(process.env.CHROMEDRIVER_PORT + ? { 'appium:chromedriverPort': Number(process.env.CHROMEDRIVER_PORT) } + : {}), +}; export const config: WebdriverIO.Config = { ...sharedConfig, @@ -14,12 +42,20 @@ export const config: WebdriverIO.Config = { ...(process.env.BUNDLE_ID ? { 'appium:appPackage': process.env.BUNDLE_ID } : {}), 'appium:autoGrantPermissions': false, 'appium:noReset': true, + 'appium:disableWindowAnimation': true, + + ...dotnetAndroidCaps, + ...parallelPortCaps, ...(isLocal ? {} : { 'bstack:options': bstackOptions }), // Disable ID locator autocompletion to avoid Flutter's Semantics(container:true) wrapping inputs in a View. + // .NET MAUI exposes AutomationId as the Android resource-id but namespaced + // (e.g. `com.onesignal.example:id/main_scroll_view`); the test suite + // queries by short id, so leave autocompletion ON for dotnet so Appium + // prepends the package automatically. // @ts-expect-error - Appium types are not fully compatible with WebdriverIO types - 'appium:settings[disableIdLocatorAutocompletion]': true, + 'appium:settings[disableIdLocatorAutocompletion]': !isDotNet, // Hide keyboard during session 'appium:hideKeyboard': true, diff --git a/appium/wdio.ios.conf.ts b/appium/wdio.ios.conf.ts index 337981c..3776b88 100644 --- a/appium/wdio.ios.conf.ts +++ b/appium/wdio.ios.conf.ts @@ -8,6 +8,7 @@ export const config: WebdriverIO.Config = { { platformName: 'iOS', 'appium:app': isLocal ? process.env.APP_PATH : process.env.BROWSERSTACK_APP_URL, + 'appium:reduceMotion': true, 'appium:deviceName': process.env.DEVICE || 'iPhone 17', 'appium:platformVersion': process.env.OS_VERSION || '26', 'appium:automationName': 'XCUITest', @@ -16,9 +17,6 @@ export const config: WebdriverIO.Config = { 'appium:noReset': true, ...(isLocal ? {} : { 'bstack:options': bstackOptions }), - - // Hide keyboard during session - 'appium:hideKeyboard': true, }, ], }; diff --git a/appium/wdio.shared.conf.ts b/appium/wdio.shared.conf.ts index 5ec0959..fb09875 100644 --- a/appium/wdio.shared.conf.ts +++ b/appium/wdio.shared.conf.ts @@ -11,7 +11,7 @@ const browserstackConnection = { const localConnection = { hostname: 'localhost', - port: 4723, + port: Number(process.env.APPIUM_PORT) || 4723, services: ['shared-store'] as string[], }; diff --git a/demo/build.md b/demo/build.md index 0e89f00..0ac5129 100644 --- a/demo/build.md +++ b/demo/build.md @@ -10,7 +10,7 @@ Prompts and requirements to build the OneSignal {{PLATFORM}} Sample App from scr Create a new {{PLATFORM}} project at `examples/demo/` (relative to the SDK repo root). -- Clean architecture: repository pattern with platform-idiomatic state management +- Clean architecture: platform-idiomatic state container that calls the OneSignal SDK directly — a `useOneSignal` hook for React (react-native, react), Cordova, and Capacitor; an `AppViewModel` for .NET MAUI (C#) and Flutter. No repository wrapper layer. - App name: "OneSignal Demo" - Top app bar: centered title with OneSignal logo SVG + "{{PLATFORM}}" text - Android package name / iOS bundle identifier: `com.onesignal.example` @@ -35,9 +35,9 @@ Reference the OneSignal SDK from the parent repo using a local path/file depende - Navigation (if needed) - Icon library (Material icons) -### Prompt 1.3 - OneSignal Repository +### Prompt 1.3 - OneSignal SDK Operations -Plain class (not tied to UI framework) injected into the state management layer. Centralizes all OneSignal SDK calls: +Call the OneSignal SDK directly from the state container — a `useOneSignal` hook for React (react-native, react), Cordova, and Capacitor; an `AppViewModel` for .NET MAUI (C#) and Flutter. Do not introduce a repository/wrapper layer. The state container should expose the operations below as actions/methods: - **User**: loginUser(externalUserId) -> async, logoutUser() -> async - **Aliases**: addAlias(label, id), addAliases(map) @@ -332,6 +332,13 @@ Single boolean `isLoading` on the state container drives a per-section inline sp `fetchUserDataFromApi` is the single source of truth for refreshing user data and owns the `isLoading` toggle. On entry it bumps a monotonically-increasing `requestSequence` counter, sets `isLoading=true`, and notifies. In a try/finally it reads `getOnesignalId()` (returns early if null), calls `fetchUser`, and then writes the lists/externalId — but only if `requestSequence` still matches its captured value, so a stale fetch can't overwrite a newer one. The finally clears `isLoading` only when `requestSequence` still matches, so an in-flight call doesn't prematurely clear the spinner for a newer one. +Merge, don't replace. When the fetch returns, write each remote list into local state via merge helpers rather than wholesale assignment: + +- `mergePairs(prev, next)` for key/value lists (aliases, tags): upsert each remote key into the existing list. Existing keys keep their position; remote values overwrite local ones for the same key; keys present locally but missing remotely are kept (so an optimistic add issued during the in-flight fetch is not dropped before the SDK has flushed it). +- `mergeUnique(prev, next)` for flat string lists (emails, smsNumbers): union of `prev` and `next` preserving order and de-duplicating. + +This keeps the UI stable: rows don't flicker/re-order when the fetch completes, and an item added locally a moment before the response arrives stays visible. The same `mergePairs`/`mergeUnique` helpers are reused by the per-action add handlers (addAlias, addTag, addEmail, ...) so optimistic updates and remote refreshes share one code path. + State transitions: - **Cold start**: if `onesignalId` exists, await `fetchUserDataFromApi()` (which manages its own `isLoading`). If `onesignalId` is null, leave `isLoading=false`; sections show their normal empty state. The `OneSignal.login(storedExternalUserId)` call inside cold start may also trigger the user-state observer, which fires its own fetch; the request-sequence guard collapses the race. @@ -423,7 +430,7 @@ Add Multiple dialogs use the same values for the first row and support multiple ### Prompt 6.2 - Accessibility Identifiers (Appium) -Use the platform's accessibility/test ID mechanism (e.g. `Semantics(identifier:)` in Flutter, `accessibilityIdentifier` in iOS, `testID` in React Native). These identifiers allow Appium to locate elements reliably. +Use the platform's accessibility/test ID mechanism (e.g. `Semantics(identifier:)` in Flutter, `accessibilityIdentifier` in iOS, `testID` in React Native, `data-testid` in Cordova/Capacitor web, `AutomationId` in .NET MAUI). These identifiers allow Appium to locate elements reliably and MUST match exactly across platforms — the shared Appium suite under `sdk-shared/appium/tests/` selects elements by these ids. **Scroll view**: `main_scroll_view` @@ -433,65 +440,97 @@ Section keys: `app`, `user`, `push`, `send_push`, `iam`, `send_iam`, `aliases`, **Value displays**: -| Identifier | Element | -| ---------------------- | --------------------------------- | -| `app_id_value` | App ID text | -| `push_id_value` | Push Subscription ID text | -| `user_status_value` | User status (Anonymous/Logged In) | -| `user_external_id_value` | External ID text | +| Identifier | Element | +| ------------------------ | --------------------------------- | +| `app_id_value` | App ID text | +| `push_id_value` | Push Subscription ID text | +| `user_status_value` | User status (Anonymous/Logged In) | +| `user_external_id_value` | External ID text | **Buttons**: -| Identifier | Button | -| ------------------------ | --------------------------------------- | -| `login_user_button` | Login / Switch User | -| `logout_user_button` | Logout User | -| `send_simple_button` | Simple notification | -| `send_image_button` | Image notification | -| `send_sound_button` | Sound notification | -| `send_custom_button` | Custom notification | -| `clear_all_button` | Clear all notifications | -| `add_tag_button` | Add Tag | +| Identifier | Button | +| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `login_user_button` | Login / Switch User | +| `logout_user_button` | Logout User | +| `prompt_push_button` | Prompt Push (only when permission not granted) | +| `send_simple_button` | Send Simple notification | +| `send_image_button` | Send Image notification | +| `send_sound_button` | Send Sound notification | +| `send_custom_button` | Send Custom notification (opens dialog) | +| `clear_all_button` | Clear all notifications | +| `send_iam_top_banner_button`, `send_iam_bottom_banner_button`, `send_iam_center_modal_button`, `send_iam_full_screen_button` | Send In-App Message (one per IAM type — pattern: `send_iam_{type}_button`) | +| `add_alias_button`, `add_multiple_aliases_button` | Aliases section actions | +| `add_email_button` | Add Email (opens dialog) | +| `add_sms_button` | Add SMS (opens dialog) | +| `add_tag_button`, `add_multiple_tags_button`, `remove_tags_button` | Tags section actions | +| `add_trigger_button`, `add_multiple_triggers_button`, `remove_triggers_button`, `clear_triggers_button` | Triggers section actions | +| `send_outcome_button` | Send Outcome (opens dialog) | +| `track_event_button` | Track Event (opens dialog) | +| `prompt_location_button`, `check_location_button` | Location section actions | +| `start_live_activity_button`, `update_live_activity_button`, `end_live_activity_button` | Live Activities section actions (iOS only) | +| `next_screen_button` | Bottom NEXT SCREEN navigation button | **Toggles**: -| Identifier | Toggle | -| ------------------------ | --------------------------------------- | -| `push_enabled_toggle` | Push Enabled | -| `pause_iam_toggle` | Pause In-App Messages | - -**Dialog inputs** (passed as parameters to reusable dialog components): - -Confirm buttons on the shared single-input and single-pair dialog components are generic; descriptive ids name only what's *inside* the dialog (the input field). - -| Identifier | Dialog field | -| ----------------------------- | -------------------------------------- | -| `singleinput_confirm_button` | Confirm on any SingleInput dialog (login, email, sms, ...) | -| `singlepair_confirm_button` | Confirm on any SinglePair dialog (alias, tag, trigger, ...) | -| `multipair_confirm_button` | Confirm on the MultiPair dialog | -| `multipair_add_row_button` | Add row inside the MultiPair dialog | -| `login_user_id_input` | Login External User Id | -| `alias_label_input` | Add Alias label field | -| `alias_id_input` | Add Alias ID field | -| `tag_key_input` | Add Tag key field | -| `tag_value_input` | Add Tag value field | -| `trigger_key_input` | Add Trigger key field | -| `trigger_value_input` | Add Trigger value field | -| `outcome_name_input` | Outcome name field | -| `outcome_value_input` | Outcome value field | -| `outcome_send_button` | Outcome send button | -| `event_name_input` | Custom Event name field | -| `event_properties_input` | Custom Event properties field | -| `event_track_button` | Custom Event track button | -| `tooltip_title` | Tooltip dialog title | -| `tooltip_description` | Tooltip dialog description | -| `tooltip_ok_button` | Tooltip dialog OK/confirm button | - -**List items**: Generated from `sectionKey` parameter: -- Key-value pairs: `{sectionKey}_pair_key_{keyText}`, `{sectionKey}_pair_value_{keyText}` -- Remove buttons: `{sectionKey}_remove_{keyText}` or `{sectionKey}_remove_{text}` -- Multi-select checkboxes: `remove_checkbox_{key}` -- Multi-pair rows: `{keyLabel}_input_{index}`, `{valueLabel}_input_{index}` +| Identifier | Toggle | +| ------------------------- | ------------------------------------------------------- | +| `consent_required_toggle` | Consent Required (App section) | +| `privacy_consent_toggle` | Privacy Consent (only visible when consent required on) | +| `push_enabled_toggle` | Push Enabled (Push section) | +| `pause_iam_toggle` | Pause In-App Messages | +| `location_shared_toggle` | Location Shared | + +**Dialog inputs and confirm buttons** (passed as parameters to reusable dialog components): + +Confirm buttons on the shared SingleInput, SinglePair, MultiPair and MultiSelectRemove dialog components are generic; descriptive ids name only what's _inside_ the dialog (the input fields). + +| Identifier | Dialog field / control | +| ---------------------------------- | ----------------------------------------------------------- | +| `singleinput_confirm_button` | Confirm on any SingleInput dialog (login, email, sms, ...) | +| `singlepair_confirm_button` | Confirm on any SinglePair dialog (alias, tag, trigger, ...) | +| `multipair_confirm_button` | Confirm on any MultiPair dialog | +| `multipair_add_row_button` | Add row inside any MultiPair dialog | +| `multipair_key_{idx}` | Key field of MultiPair row N (0-indexed) | +| `multipair_value_{idx}` | Value field of MultiPair row N (0-indexed) | +| `multiselect_confirm_button` | Confirm on the MultiSelectRemove dialog | +| `remove_checkbox_{key}` | Checkbox in MultiSelectRemove dialog (one per item) | +| `login_user_id_input` | Login External User Id field | +| `alias_label_input` | Add Alias label field | +| `alias_id_input` | Add Alias ID field | +| `email_input` | Add Email field | +| `sms_input` | Add SMS field | +| `tag_key_input` | Add Tag key field | +| `tag_value_input` | Add Tag value field | +| `trigger_key_input` | Add Trigger key field | +| `trigger_value_input` | Add Trigger value field | +| `outcome_name_input` | Outcome name field | +| `outcome_value_input` | Outcome value field | +| `outcome_type_normal_radio` | Outcome dialog: Normal radio option | +| `outcome_type_unique_radio` | Outcome dialog: Unique radio option | +| `outcome_type_value_radio` | Outcome dialog: With Value radio option | +| `outcome_send_button` | Outcome send/confirm button | +| `event_name_input` | Custom Event name field | +| `event_properties_input` | Custom Event properties field | +| `event_track_button` | Custom Event track/confirm button | +| `custom_notification_title_input` | Custom Notification title field | +| `custom_notification_body_input` | Custom Notification body field | +| `live_activity_id_input` | Live Activity ID input (iOS only) | +| `live_activity_order_number_input` | Live Activity Order # input (iOS only) | +| `tooltip_title` | Tooltip dialog title | +| `tooltip_description` | Tooltip dialog description | +| `tooltip_ok_button` | Tooltip dialog OK/confirm button | + +**List items and per-section list state**: Generated from each section's `sectionKey`. The four list sections that depend on the API fetch (Aliases, Tags, Emails, SMS) render either a loading spinner or an empty state in the same slot, so both must be addressable. + +| Identifier pattern | Element | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `{sectionKey}_loading` | Inline loading spinner in the list slot (Aliases, Tags, Emails, SMS) | +| `{sectionKey}_empty` | Empty-state text in the list slot (e.g. `aliases_empty`, `tags_empty`) | +| `{sectionKey}_pair_key_{keyText}` | Key cell of a key-value list row (Aliases, Tags, Triggers) | +| `{sectionKey}_pair_value_{keyText}` | Value cell of a key-value list row | +| `{sectionKey}_value_{value}` | Single-value list row (Emails -> `emails_value_{email}`, SMS -> `sms_value_{number}`) | +| `{sectionKey}_remove_{keyText}` / `{sectionKey}_remove_{value}` | Per-row remove (X) button | --- @@ -511,7 +550,7 @@ Auto-request in home screen's init/mount lifecycle. PROMPT PUSH button as fallba ### Prompt 8.1 - State Management -Single state container at app root. Holds all UI state with public getters. Exposes action methods that update state and notify UI. Receives OneSignalRepository + PreferencesService via injection. Initialize SDK before rendering. Fetch tooltips in background. +Single state container at app root. Holds all UI state with public getters. Exposes action methods that update state and notify UI. Implementation is platform-idiomatic: a `useOneSignal` hook for React (react-native, react), Cordova, and Capacitor; an `AppViewModel` for .NET MAUI (C#) and Flutter. The state container calls the OneSignal SDK directly (no repository wrapper) and depends only on `PreferencesService` and `OneSignalApiService`. Initialize SDK before rendering. Fetch tooltips in background. ### Prompt 8.2 - Reusable Components @@ -558,7 +597,11 @@ Only the following actions show snackbar feedback from the UI: - Custom Events: "Event tracked: {name}" - Location check: "Location shared: {bool}" -All other actions (add/remove items, notifications, IAM, live activities, etc.) use `debugPrint()` / console logging only -- no snackbar. The state management layer should NOT hold snackbar state or expose snackbar messages. Use `debugPrint()` for all internal logging instead of a custom LogManager. +All other actions (add/remove items, notifications, IAM, live activities, etc.) use the platform's standard logging primitive only -- no snackbar. The state management layer should NOT hold snackbar state or expose snackbar messages. + +Logging: + +- Use the platform's built-in logging primitive directly (`console.log`/`console.error` for JS/TS, `debugPrint` for Dart, `System.Diagnostics.Debug.WriteLine` for C#, `print`/`NSLog` for Swift, `Log.d`/`Log.e` for Kotlin/Java). --- diff --git a/demo/styles.md b/demo/styles.md index a0a0376..a60e83f 100644 --- a/demo/styles.md +++ b/demo/styles.md @@ -141,15 +141,15 @@ Standalone bordered inputs used in dialogs. Borderless label + input pairs displayed inside a card (e.g. Live Activity fields). The card provides the outer border; individual inputs have no border. -| Property | Value | -| --------------- | ------------------------------------ | +| Property | Value | +| --------------- | ---------------------------------------- | | Layout | Horizontal row (label left, input right) | -| Row spacing | 4 vertical between rows | -| Label style | bodyMedium (14), color osGrey600 | -| Label min-width | 80 | -| Input style | bodyMedium (14), default text color | -| Input alignment | Right-aligned, flex fill | -| Input border | None | +| Row spacing | 4 vertical between rows | +| Label style | bodyMedium (14), color osGrey600 | +| Label min-width | 80 | +| Input style | bodyMedium (14), default text color | +| Input alignment | Right-aligned, flex fill | +| Input border | None | ## Warning Banner