From 623ce070a7f387adcd2c8ba44e2d510b06944e90 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 11:47:25 +0300 Subject: [PATCH 01/24] Phase 1. Scripts and test plans. --- .af-e2e/test-plan.json | 427 +++++++++++++++ .af-smoke/rc-test-plan.json | 209 ++++++++ .gitignore | 9 + scripts/TOOLING_PIN.txt | 8 + scripts/af-scenario-runner.sh | 975 ++++++++++++++++++++++++++++++++++ scripts/dump-android-logs.sh | 34 ++ 6 files changed, 1662 insertions(+) create mode 100644 .af-e2e/test-plan.json create mode 100644 .af-smoke/rc-test-plan.json create mode 100644 scripts/TOOLING_PIN.txt create mode 100755 scripts/af-scenario-runner.sh create mode 100755 scripts/dump-android-logs.sh diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json new file mode 100644 index 00000000..43194bf3 --- /dev/null +++ b/.af-e2e/test-plan.json @@ -0,0 +1,427 @@ +{ + "_meta": { + "plan_id": "cordova-e2e", + "plugin": "cordova", + "version": "1.0.0", + "description": "End-to-end test plan for the AppsFlyer Cordova plugin. Runs against plugin source (path/file dependency) in examples/cordovatestapp/. Covers E2E-001..E2E-006 per appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.", + "platforms": [ + "android", + "ios" + ], + "schema_version": "1.0.0", + "tooling_contract_ref": "E2E-001, E2E-002, E2E-003, E2E-004, E2E-005, E2E-006" + }, + "config": { + "android": { + "package_name": "com.appsflyer.qa.cordova", + "activity": ".MainActivity", + "apk_path": "examples/cordovatestapp/platforms/android/app/build/outputs/apk/debug/app-debug.apk", + "build_cmd": "cd examples/cordovatestapp && npm ci && cordova build android --debug" + }, + "ios": { + "bundle_id": "com.appsflyer.qa.cordova", + "app_path": "examples/cordovatestapp/platforms/ios/build/Debug-iphonesimulator/cordovaTestApp.app", + "build_cmd": "cd examples/cordovatestapp && npm ci && cordova build ios --debug" + } + }, + "phases": [ + { + "id": "phase_1", + "name": "Cold launch coverage", + "scenario_ref": "E2E-001", + "description": "Fresh install. Validate SDK startup, pre/post-start APIs, three auto-launched events with HTTP 200, and all standard callbacks.", + "requires_fresh_install": true, + "wait_after_launch_sec": 240, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns SUCCESS", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result: SUCCESS", + "fail_action": "abort" + }, + { + "id": "is_first_launch_true", + "description": "onInstallConversionData fires with is_first_launch=true", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": { + "field": "is_first_launch", + "expected": "true" + }, + "fail_action": "abort" + }, + { + "id": "pre_start_apis_complete", + "description": "Pre-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "post_start_apis_complete", + "description": "Post-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "get_sdk_version", + "description": "getSDKVersion returns a value", + "type": "log_contains", + "pattern": "[AF_QA][getSDKVersion] result:", + "fail_action": "fail" + }, + { + "id": "get_appsflyer_uid", + "description": "getAppsFlyerUID returns a value", + "type": "log_contains", + "pattern": "[AF_QA][getAppsFlyerUID] result:", + "fail_action": "fail" + }, + { + "id": "event_af_demo_launch", + "description": "af_demo_launch event fires", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_demo_launch)] result:", + "fail_action": "fail" + }, + { + "id": "event_af_purchase", + "description": "af_purchase event fires", + "type": "log_contains", + "pattern": "[AF_QA][logEvent: af_purchase sent] result:", + "fail_action": "fail" + }, + { + "id": "event_af_content_view", + "description": "af_content_view event fires", + "type": "log_contains", + "pattern": "[AF_QA][logEvent: af_content_view sent] result:", + "fail_action": "fail" + }, + { + "id": "http_200_count", + "description": "At least 3 HTTP 200 responses from AppsFlyer servers", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 3, + "fail_action": "fail" + }, + { + "id": "on_deep_linking_callback", + "description": "onDeepLinking callback fires (NOT_FOUND expected on clean launch)", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions or SDK start errors in logs", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL", + "[AF_QA][startSDK] error:", + "response code:4", + "response code:5" + ], + "fail_action": "fail" + } + ] + }, + { + "id": "phase_2", + "name": "Background deep link", + "scenario_ref": "E2E-002", + "description": "App is brought down after Phase 1 SDK start; the deep-link URL re-launches it. On Android the app is HOME-keyed to background and re-entered via VIEW intent. On iOS the app is terminated and re-launched with `-deepLinkURL `, which the Cordova app's URL handler / AppDelegate hook (same deep link pipeline as production) \u2014 same SDK pipeline as a real custom-scheme open, but without iOS 17/18-simulator's 'Open in ?' confirmation prompt that `simctl openurl` triggers and that nothing in a non-interactive CI run can dismiss. Both paths fire onDeepLinking with Status.FOUND.", + "requires_fresh_install": false, + "wait_after_trigger_sec": 90, + "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": [ + "adb shell input keyevent KEYCODE_HOME", + "sleep 2" + ], + "ios": [ + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", + "sleep 1" + ] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "deeplink_found", + "description": "onDeepLinking fires with Status.FOUND", + "type": "log_contains", + "pattern": "status=Status.FOUND", + "fail_action": "fail" + }, + { + "id": "deeplink_value_bg", + "description": "deepLinkValue matches qa_deeplink_bg", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_bg", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions after deep link", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL" + ], + "fail_action": "fail" + } + ] + }, + { + "id": "phase_3", + "name": "Foreground deep link", + "scenario_ref": "E2E-003", + "description": "Fresh install. App is in foreground after SDK start. On Android a brief HOME switch triggers onPause and the VIEW intent brings the app back. On iOS the app is terminated and re-launched with `-deepLinkURL ` (same launch-arg path as phase_2), since `simctl openurl` against a running iOS-simulator app still triggers the 'Open in ?' confirmation prompt; the foreground-vs-killed distinction is Android-only. Both paths fire onDeepLinking with Status.FOUND.", + "requires_fresh_install": true, + "wait_after_launch_sec": 240, + "wait_after_trigger_sec": 90, + "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": [ + "adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", + "sleep 1" + ], + "ios": [ + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", + "sleep 1" + ] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns SUCCESS on fresh install", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result: SUCCESS", + "fail_action": "abort" + }, + { + "id": "is_first_launch_true", + "description": "onInstallConversionData fires with is_first_launch=true before deep link", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": { + "field": "is_first_launch", + "expected": "true" + }, + "fail_action": "abort" + }, + { + "id": "deeplink_found_fg", + "description": "onDeepLinking fires with Status.FOUND after foreground deep link", + "type": "log_contains", + "pattern": "status=Status.FOUND", + "fail_action": "fail" + }, + { + "id": "deeplink_value_fg", + "description": "deepLinkValue matches qa_deeplink_fg", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_fg", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL" + ], + "fail_action": "fail" + } + ] + }, + { + "id": "phase_4", + "name": "Custom in-app event with parameters", + "scenario_ref": "E2E-004", + "description": "Verifies the custom logEvent with multi-type parameters (string, number, nested map) fired by the example app's auto-run after cold launch. Covers E2E-004.", + "requires_fresh_install": false, + "wait_after_trigger_sec": 5, + "checks": [ + { + "id": "custom_event_logged", + "description": "logEvent invocation with name and full param map is logged", + "type": "log_contains", + "pattern": "[AF_QA][logEvent] name=af_qa_custom_purchase", + "fail_action": "fail" + }, + { + "id": "param_revenue_present", + "description": "Revenue parameter present in serialized payload", + "type": "log_contains", + "pattern": "af_revenue", + "fail_action": "fail" + }, + { + "id": "param_currency_present", + "description": "Currency parameter present", + "type": "log_contains", + "pattern": "af_currency", + "fail_action": "fail" + }, + { + "id": "param_nested_metadata", + "description": "Nested metadata map preserved in payload", + "type": "log_contains", + "pattern": "metadata", + "fail_action": "fail" + }, + { + "id": "custom_event_http_200", + "description": "Custom event request returns HTTP 200", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 1, + "fail_action": "fail" + }, + { + "id": "no_log_event_error", + "description": "No logEvent error reported", + "type": "absent", + "patterns": [ + "[AF_QA][logEvent] error:" + ], + "fail_action": "fail" + } + ] + }, + { + "id": "phase_5", + "name": "Identity APIs round-trip", + "scenario_ref": "E2E-005", + "description": "Sets customer user id, currency code, and additional data before start. Verifies readback and propagation into a post-start event payload. Covers E2E-005.", + "requires_fresh_install": true, + "wait_after_launch_sec": 240, + "wait_after_trigger_sec": 5, + "checks": [ + { + "id": "set_customer_user_id_logged", + "description": "setCustomerUserId readback present", + "type": "log_contains", + "pattern": "[AF_QA][setCustomerUserId] result: e2e_user_42", + "fail_action": "fail" + }, + { + "id": "set_currency_code_logged", + "description": "setCurrencyCode readback present", + "type": "log_contains", + "pattern": "[AF_QA][setCurrencyCode] result: EUR", + "fail_action": "fail" + }, + { + "id": "set_additional_data_logged", + "description": "setAdditionalData echoes its keys", + "type": "log_contains", + "pattern": "[AF_QA][setAdditionalData]", + "fail_action": "fail" + }, + { + "id": "user_id_in_payload", + "description": "Post-start event payload carries customer_user_id", + "type": "regex_match", + "pattern": "customer_user_id[ =:]+e2e_user_42", + "fail_action": "fail" + }, + { + "id": "additional_data_in_payload", + "description": "Post-start event payload contains additional data tenant key", + "type": "log_contains", + "pattern": "tenant", + "fail_action": "fail" + }, + { + "id": "is_first_launch_true", + "description": "Install conversion still fires with is_first_launch=true", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": { + "field": "is_first_launch", + "expected": "true" + }, + "fail_action": "fail" + }, + { + "id": "identity_event_http_200", + "description": "Identity check event receives HTTP 200", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 1, + "fail_action": "fail" + } + ] + }, + { + "id": "phase_6", + "name": "Consent / SDK stop toggle", + "scenario_ref": "E2E-006", + "description": "Auto-run dispatches stop(true), an event that must be suppressed, then stop(false), and an event that must succeed. Verifies the privacy kill switch. Covers E2E-006.", + "requires_fresh_install": false, + "wait_after_trigger_sec": 5, + "checks": [ + { + "id": "stop_true_logged", + "description": "stop(true) readback present", + "type": "log_contains", + "pattern": "[AF_QA][stop] result: true", + "fail_action": "fail" + }, + { + "id": "stop_false_logged", + "description": "stop(false) readback present", + "type": "log_contains", + "pattern": "[AF_QA][stop] result: false", + "fail_action": "fail" + }, + { + "id": "suppressed_event_no_http", + "description": "While stopped, af_qa_suppressed must not produce an HTTP 200 response", + "type": "absent", + "patterns": [ + "af_qa_suppressed.*response code:200", + "af_qa_suppressed.*response_status=200" + ], + "fail_action": "fail" + }, + { + "id": "resumed_event_http_200", + "description": "After stop(false), af_qa_resumed produces HTTP 200", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 1, + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions across the stop/start cycle", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL" + ], + "fail_action": "fail" + } + ] + } + ], + "report": { + "output_dir": ".af-e2e/reports/", + "format": "json" + } +} diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json new file mode 100644 index 00000000..d5e691bc --- /dev/null +++ b/.af-smoke/rc-test-plan.json @@ -0,0 +1,209 @@ +{ + "_meta": { + "plan_id": "cordova-rc-smoke", + "plugin": "cordova", + "version": "1.0.0", + "description": "Post-publish smoke plan for the AppsFlyer Cordova plugin. SMOKE-001..003 against examples/cordovatestapp_rc_smoke/ (CI-synthesized, npm-pinned cordova-plugin-appsflyer-sdk)", + "platforms": [ + "android", + "ios" + ], + "schema_version": "1.0.0", + "tooling_contract_ref": "SMOKE-001, SMOKE-002, SMOKE-003" + }, + "config": { + "android": { + "package_name": "com.appsflyer.qa.cordova", + "activity": ".MainActivity", + "apk_path": "examples/cordovatestapp_rc_smoke/platforms/android/app/build/outputs/apk/debug/app-debug.apk", + "build_cmd": "cd examples/cordovatestapp_rc_smoke && npm ci && cordova build android --debug" + }, + "ios": { + "bundle_id": "com.appsflyer.qa.cordova", + "app_path": "examples/cordovatestapp_rc_smoke/platforms/ios/build/Debug-iphonesimulator/cordovaTestApp.app", + "build_cmd": "cd examples/cordovatestapp_rc_smoke && npm ci && cordova build ios --debug" + } + }, + "phases": [ + { + "id": "phase_1", + "name": "Cold launch smoke (RC artifact)", + "scenario_ref": "SMOKE-001", + "description": "Fresh install using the npm-pinned RC build. Validates SDK startup, install conversion data, pre/post-start API markers, three standard events, and HTTP 200 responses.", + "requires_fresh_install": true, + "wait_after_launch_sec": 240, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns SUCCESS", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result: SUCCESS", + "fail_action": "abort" + }, + { + "id": "is_first_launch_true", + "description": "onInstallConversionData fires with is_first_launch=true", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": { + "field": "is_first_launch", + "expected": "true" + }, + "fail_action": "abort" + }, + { + "id": "pre_start_apis_complete", + "description": "Pre-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "post_start_apis_complete", + "description": "Post-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "event_af_demo_launch", + "description": "af_demo_launch event fires", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_demo_launch)] result:", + "fail_action": "fail" + }, + { + "id": "http_200_count", + "description": "At least 3 HTTP 200 responses from AppsFlyer servers", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 3, + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions or SDK start errors", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL", + "[AF_QA][startSDK] error:", + "response code:4", + "response code:5" + ], + "fail_action": "fail" + } + ] + }, + { + "id": "phase_2", + "name": "Background deep link (RC artifact)", + "scenario_ref": "SMOKE-002", + "description": "Backgrounds the RC build after Phase 1. Deep link returns app to foreground. onDeepLinking fires with Status.FOUND. iOS terminates the app and re-launches with `-deepLinkURL `, which the example app's AppDelegate replays through `application:openURL:options:` -- bypasses iOS 17/18-simulator's 'Open in ?' confirmation prompt that `simctl openurl` triggers and that nothing in CI can dismiss. Mirrors the mechanic in .af-e2e/test-plan.json.", + "requires_fresh_install": false, + "wait_after_trigger_sec": 90, + "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": [ + "adb shell input keyevent KEYCODE_HOME", + "sleep 2" + ], + "ios": [ + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", + "sleep 1" + ] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "deeplink_found", + "description": "onDeepLinking fires with Status.FOUND", + "type": "log_contains", + "pattern": "status=Status.FOUND", + "fail_action": "fail" + }, + { + "id": "deeplink_value_bg", + "description": "deepLinkValue matches qa_deeplink_bg", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_bg", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions after deep link", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL" + ], + "fail_action": "fail" + } + ] + }, + { + "id": "phase_3", + "name": "Foreground deep link (RC artifact)", + "scenario_ref": "SMOKE-003", + "description": "Fresh install of the RC build, then deep link. onDeepLinking fires with Status.FOUND and deepLinkValue=qa_deeplink_fg. iOS uses `simctl launch -deepLinkURL` + AppDelegate replay; same rationale as phase_2.", + "requires_fresh_install": true, + "wait_after_launch_sec": 240, + "wait_after_trigger_sec": 90, + "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": [ + "adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", + "sleep 1" + ], + "ios": [ + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", + "sleep 1" + ] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns SUCCESS on fresh install", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result: SUCCESS", + "fail_action": "abort" + }, + { + "id": "deeplink_found_fg", + "description": "onDeepLinking fires with Status.FOUND after foreground deep link", + "type": "log_contains", + "pattern": "status=Status.FOUND", + "fail_action": "fail" + }, + { + "id": "deeplink_value_fg", + "description": "deepLinkValue matches qa_deeplink_fg", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_fg", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions", + "type": "absent", + "patterns": [ + "Fatal Exception", + "FATAL" + ], + "fail_action": "fail" + } + ] + } + ], + "report": { + "output_dir": ".af-smoke/reports/", + "format": "json" + } +} diff --git a/.gitignore b/.gitignore index e7e353dd..71fe611e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,12 @@ examples/demoA/node_modules/ examples/cordova-unit-tests-app/node_modules/ examples/cordova-unit-tests-app/platforms/ #/examples/demoA/www/lib/ + +# Scenario runner + contract test app (secrets & machine-local reports) +.env +.env.local +.af-e2e/reports/ +.af-smoke/reports/ + +# CI-synthesized RC smoke copy of the example app (not committed) +examples/cordovatestapp_rc_smoke/ diff --git a/scripts/TOOLING_PIN.txt b/scripts/TOOLING_PIN.txt new file mode 100644 index 00000000..09321f2e --- /dev/null +++ b/scripts/TOOLING_PIN.txt @@ -0,0 +1,8 @@ +# Source of vendored af-scenario-runner.sh (appsflyer-mobile-plugin-tooling) +# Update this file whenever scripts/af-scenario-runner.sh is refreshed. +REPO=https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling.git +COMMIT=c99620093d4f9e8eac385936217cab422575b2ff + +# This repo may carry Cordova-specific comment + log-capture tweaks on top of +# the same upstream file. When refreshing from tooling, merge or re-apply +# those tweaks after copying — do not overwrite blindly. diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh new file mode 100755 index 00000000..d26984b4 --- /dev/null +++ b/scripts/af-scenario-runner.sh @@ -0,0 +1,975 @@ +#!/usr/bin/env bash +# +# af-scenario-runner.sh — Unified AppsFlyer plugin scenario runner +# +# Drives a JSON-driven scenario cycle for any AppsFlyer plugin using ADB +# (Android) and xcrun simctl (iOS). Reads a test plan, executes each phase, +# validates log output against expected patterns, and produces a structured +# JSON report. Used for both pre-publish E2E (.af-e2e/test-plan.json) and +# post-publish smoke (.af-smoke/rc-test-plan.json) — the runner is the same; +# the plan is what differs. +# +# **This copy** is vendored in `appsflyer-cordova-plugin` from +# `AppsFlyerSDK/appsflyer-mobile-plugin-tooling` (see `scripts/TOOLING_PIN.txt`). +# Behaviour matches upstream; comments here are Cordova-first where log +# sources differ (WebView / `cordova build` vs Flutter / `flutter build`). +# +# Usage: +# ./af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json +# ./af-scenario-runner.sh --platform ios --plan .af-smoke/rc-test-plan.json +# ./af-scenario-runner.sh --platform android --plan --phase phase_1 +# ./af-scenario-runner.sh --platform android --plan --dry-run +# ./af-scenario-runner.sh --platform android --plan --build +# +# Requirements: +# - bash 4+, jq +# - Android: ADB in PATH, emulator booted +# - iOS: Xcode CLI tools, simulator booted +# +# The script is agent-agnostic: any AI coding assistant (Cursor, Claude Code, +# GitHub Copilot, Windsurf) or a human can invoke it from a terminal. + +set -euo pipefail + +# Bash 5.2+ enables patsub_replacement by default, which makes `&` in the +# replacement string of ${var//pat/repl} expand to the matched pattern (sed +# style). That mangles deep link URLs like `?a=1&b=2` into `?a=1{{DEEP_LINK_URL}}b=2` +# when we substitute the URL into trigger templates below. Disable it so +# replacements are taken literally on every supported runner (macOS bash 3.2, +# Ubuntu bash 5.2+, etc.). +shopt -u patsub_replacement 2>/dev/null || true + +# ─── Defaults ──────────────────────────────────────────────────────────────── + +PLATFORM="" +PLAN_FILE="" +PHASE_FILTER="" +DRY_RUN=false +BUILD_FIRST=false +VERBOSE=false +REPORT_DIR="" +LOG_TAG="AF_QA" + +# Timestamps +RUN_ID="" +RUN_START="" + +# Counters +TOTAL_CHECKS=0 +PASSED_CHECKS=0 +FAILED_CHECKS=0 +WARNED_CHECKS=0 +ABORTED=false + +# ─── Colors ────────────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# ─── Usage ─────────────────────────────────────────────────────────────────── + +usage() { + cat < Target platform (required) + --plan Path to test-plan.json (required) + --phase Run only this phase (optional; runs all if omitted) + --build Build the app before running (optional) + --dry-run Show what would run without executing (optional) + --verbose Print extra debug output (optional) + --report-dir Override report output directory (optional) + -h, --help Show this help + +Examples: + $(basename "$0") --platform android --plan .af-e2e/test-plan.json + $(basename "$0") --platform ios --plan .af-smoke/rc-test-plan.json --phase phase_1 + $(basename "$0") --platform android --plan .af-e2e/test-plan.json --build --verbose +EOF + exit 0 +} + +# ─── Logging helpers ───────────────────────────────────────────────────────── + +log_info() { echo -e "${CYAN}[INFO]${NC} $*" >&2; } +log_ok() { echo -e "${GREEN}[PASS]${NC} $*" >&2; } +log_fail() { echo -e "${RED}[FAIL]${NC} $*" >&2; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } +log_step() { echo -e "${BOLD}────── $* ──────${NC}" >&2; } +log_debug() { if $VERBOSE; then echo -e "[DEBUG] $*" >&2; fi; } + +# ─── Argument parsing ──────────────────────────────────────────────────────── + +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) PLATFORM="$2"; shift 2 ;; + --plan) PLAN_FILE="$2"; shift 2 ;; + --phase) PHASE_FILTER="$2"; shift 2 ;; + --build) BUILD_FIRST=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --report-dir) REPORT_DIR="$2"; shift 2 ;; + -h|--help) usage ;; + *) echo "Unknown option: $1"; usage ;; + esac +done + +[[ -z "$PLATFORM" ]] && { echo "Error: --platform is required"; usage; } +[[ -z "$PLAN_FILE" ]] && { echo "Error: --plan is required"; usage; } +[[ ! -f "$PLAN_FILE" ]] && { echo "Error: Plan file not found: $PLAN_FILE"; exit 1; } + +# Validate platform +case "$PLATFORM" in + android|ios) ;; + *) echo "Error: --platform must be 'android' or 'ios'"; exit 1 ;; +esac + +# Check dependencies +command -v jq >/dev/null 2>&1 || { echo "Error: jq is required but not installed. Install with: brew install jq"; exit 1; } + +if [[ "$PLATFORM" == "android" ]]; then + command -v adb >/dev/null 2>&1 || { echo "Error: adb not found in PATH"; exit 1; } +elif [[ "$PLATFORM" == "ios" ]]; then + command -v xcrun >/dev/null 2>&1 || { echo "Error: xcrun not found (install Xcode CLI tools)"; exit 1; } +fi + +# ─── Read plan ─────────────────────────────────────────────────────────────── + +PLAN=$(cat "$PLAN_FILE") +PLAN_ID=$(echo "$PLAN" | jq -r '._meta.plan_id // "unknown"') +PLUGIN_NAME=$(echo "$PLAN" | jq -r '._meta.plugin // "unknown"') + +# Platform-specific config +PACKAGE_NAME=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.package_name // .config.${PLATFORM}.bundle_id // \"\"") +APP_PATH=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.apk_path // .config.${PLATFORM}.app_path // \"\"") +BUILD_CMD=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.build_cmd // \"\"") +ACTIVITY=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.activity // \"\"") + +# Report directory +if [[ -z "$REPORT_DIR" ]]; then + REPORT_DIR=$(echo "$PLAN" | jq -r '.report.output_dir // ".af-smoke/reports/"') +fi + +# Generate run ID +RUN_START=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +RUN_ID="${PLAN_ID}-${PLATFORM}-$(date +%Y%m%d_%H%M%S)" + +log_info "Plan: ${PLAN_ID} | Plugin: ${PLUGIN_NAME} | Platform: ${PLATFORM}" +log_info "Package: ${PACKAGE_NAME}" +log_info "Run ID: ${RUN_ID}" + +if $DRY_RUN; then + log_warn "DRY RUN MODE — no commands will be executed" +fi + +# ─── Setup report directory ────────────────────────────────────────────────── + +mkdir -p "$REPORT_DIR" +REPORT_FILE="${REPORT_DIR}/${RUN_ID}.json" +PHASE_RESULTS="[]" + +# ─── Platform helpers ──────────────────────────────────────────────────────── + +# --- Android --- + +android_get_device() { + adb devices | grep -w "device" | head -1 | awk '{print $1}' +} + +android_is_installed() { + adb shell pm list packages 2>/dev/null | grep -q "$PACKAGE_NAME" +} + +android_uninstall() { + log_info "Uninstalling $PACKAGE_NAME..." + if android_is_installed; then + adb uninstall "$PACKAGE_NAME" 2>/dev/null || true + else + log_info "App not installed, skipping uninstall" + fi +} + +android_install() { + log_info "Installing $APP_PATH..." + if [[ ! -f "$APP_PATH" ]]; then + log_fail "APK not found at $APP_PATH" + if [[ -n "$BUILD_CMD" ]]; then + log_info "Hint: run with --build to build first, or manually: $BUILD_CMD" + fi + return 1 + fi + adb install -r "$APP_PATH" +} + +android_launch() { + log_info "Launching $PACKAGE_NAME..." + adb logcat -c + adb shell am start -n "${PACKAGE_NAME}/${ACTIVITY}" 2>/dev/null || \ + adb shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 2>/dev/null +} + +android_get_pid() { + adb shell pidof "$PACKAGE_NAME" 2>/dev/null | tr -d '[:space:]' +} + +android_collect_logs() { + local log_file="$1" + local tail_lines="${ANDROID_LOGCAT_TAIL_LINES:-2000}" + + # Always start from an empty file so each phase capture is self-contained. + : > "$log_file" + + # Strategy 1: Read the app's af_qa_logs.txt from internal storage via + # `run-as` (debuggable APK). Cordova: `[AF_QA]` lines often also appear in + # logcat via Chromium/WebView (`console.log`), but a **file** mirror is still + # the contract when you need the same markers as iOS `Documents/af_qa_logs.txt`. + # Try `files/` first (typical for `cordova-plugin-file` / native helpers), then + # `app_flutter/` (kept for Flutter-style test apps that share this runner). + # `run-as` requires a **debug** build (e.g. `cordova build android --debug`). + local found=0 + for path in files/af_qa_logs.txt app_flutter/af_qa_logs.txt; do + if adb shell "run-as $PACKAGE_NAME cat $path 2>/dev/null" >> "$log_file" 2>/dev/null; then + if [[ -s "$log_file" ]]; then + log_debug "Pulled Android QA log from $path" + found=1 + break + fi + fi + done + if [[ "$found" -eq 0 ]]; then + log_debug "No af_qa_logs.txt found via run-as; relying on logcat only" + fi + + # Strategy 2: Always also append logcat. Picks up AppsFlyer native SDK lines + # (HTTP response codes, etc.), Cordova/Chromium **`[AF_QA]`** `console.log` + # output, and other markers used by `count_matches`. Limit to the recent tail + # so CI does not dump the whole emulator buffer every phase. + adb logcat -d -t "$tail_lines" 2>&1 | grep -E "${LOG_TAG}|AppsFlyer|chromium|Console|Cordova|WebView|response code:|preparing data:" >> "$log_file" || true +} + +android_background_app() { + log_info "Backgrounding app (HOME key)..." + adb shell input keyevent KEYCODE_HOME +} + +android_trigger_deeplink() { + local url="$1" + log_info "Triggering deep link: $url" + adb shell am start -W -a android.intent.action.VIEW -d "$url" +} + +android_is_alive() { + local pid + pid=$(android_get_pid) + [[ -n "$pid" ]] +} + +# --- iOS --- + +IOS_UDID="" +IOS_LAST_PID="" + +ios_get_booted_udid() { + xcrun simctl list devices booted -j 2>/dev/null | \ + jq -r '[.devices[][] | select(.state == "Booted")] | first | .udid // empty' +} + +ios_ensure_udid() { + if [[ -z "$IOS_UDID" ]]; then + IOS_UDID=$(ios_get_booted_udid) + if [[ -z "$IOS_UDID" ]]; then + log_fail "No booted iOS simulator found. Boot one with: xcrun simctl boot " + exit 1 + fi + log_info "Using simulator: $IOS_UDID" + fi +} + +ios_is_installed() { + xcrun simctl listapps "$IOS_UDID" 2>/dev/null | grep -q "$PACKAGE_NAME" 2>/dev/null +} + +ios_uninstall() { + ios_ensure_udid + log_info "Uninstalling $PACKAGE_NAME..." + if ios_is_installed; then + xcrun simctl uninstall "$IOS_UDID" "$PACKAGE_NAME" 2>/dev/null || true + else + log_info "App not installed, skipping uninstall" + fi +} + +ios_install() { + ios_ensure_udid + log_info "Installing $APP_PATH..." + if [[ ! -d "$APP_PATH" ]]; then + log_fail "App bundle not found at $APP_PATH" + if [[ -n "$BUILD_CMD" ]]; then + log_info "Hint: run with --build to build first, or manually: $BUILD_CMD" + fi + return 1 + fi + xcrun simctl install "$IOS_UDID" "$APP_PATH" +} + +ios_launch() { + ios_ensure_udid + log_info "Launching $PACKAGE_NAME..." + # Capture launch output so we can pin log filtering to this PID. simctl + # prints ": " on success; anything else (already running, + # error) leaves IOS_LAST_PID empty and the collector falls back to the + # unfiltered window. + local out + out=$(xcrun simctl launch "$IOS_UDID" "$PACKAGE_NAME" 2>&1 || true) + echo "$out" + IOS_LAST_PID=$(echo "$out" | awk -F': ' '/^'"$PACKAGE_NAME"': [0-9]+$/ {print $2}' | tail -1) + [[ -n "$IOS_LAST_PID" ]] && log_debug "Launched PID: $IOS_LAST_PID" +} + +ios_get_pid() { + xcrun simctl spawn "$IOS_UDID" launchctl list 2>/dev/null | \ + grep "$PACKAGE_NAME" | awk '{print $1}' | head -1 +} + +ios_collect_logs() { + local log_file="$1" + + ios_ensure_udid + + # Always start from an empty file so each phase capture is self-contained. + : > "$log_file" + + # Strategy 1: Read the app's af_qa_logs.txt from the simulator filesystem + # (test-app contract: `Documents/af_qa_logs.txt`). Same path for Cordova + # (native/file bridge) and Flutter (`af_qa_logger.dart`); the file is the + # reliable source of `[AF_QA]` because `simctl log show` alone misses many lines. + local sim_data_dir + sim_data_dir="$HOME/Library/Developer/CoreSimulator/Devices/${IOS_UDID}/data" + if [[ -d "$sim_data_dir" ]]; then + local qa_log + qa_log=$(find "$sim_data_dir/Containers/Data/Application" -name "af_qa_logs.txt" -maxdepth 4 2>/dev/null | head -1) + if [[ -n "$qa_log" && -f "$qa_log" ]]; then + log_debug "Found iOS QA log file: $qa_log" + cat "$qa_log" >> "$log_file" + fi + fi + + # Strategy 2: Always also append simctl log show output. The file logger + # only carries [AF_QA] lines; SDK HTTP traffic (response code:200, etc.) + # only shows up via os_log and is required by count_matches checks. + # Window is 240s so back-to-back phases (cold launch -> 60s settle -> deep + # link -> 12s wait) still fit. The grep filter also captures URL-open + # events from CoreSimulatorBridge / launchservices so deep-link triage has + # something to look at when onDeepLinking doesn't fire. + # + # Pin the predicate to the current app process PID when known so prior-run + # entries that still sit inside the rolling window can't poison absent- + # pattern checks (e.g. phase_1 no_fatal_errors). Falls back to unfiltered + # when PID is unknown (first phase before launch, or `simctl launch` + # failed to print one). + log_debug "Appending simctl log show output" + local predicate_args=() + if [[ -n "$IOS_LAST_PID" ]]; then + predicate_args=(--predicate "processIdentifier == $IOS_LAST_PID") + fi + xcrun simctl spawn "$IOS_UDID" log show \ + --last 240s --style compact "${predicate_args[@]}" 2>&1 | \ + grep -E "${LOG_TAG}|appsflyer|CFNetwork:Summary|response_status|response code|Opening URL|launchservices|openURL|continueUserActivity" >> "$log_file" || true + + # Best-effort screenshot for failure triage (no-op if nothing booted). + local shot_dir="${log_file%/*}" + local shot_file="${shot_dir}/${log_file##*/}.png" + shot_file="${shot_file%_logs.txt.png}_screen.png" + xcrun simctl io "$IOS_UDID" screenshot "$shot_file" 2>/dev/null || true +} + +ios_background_app() { + ios_ensure_udid + log_info "Backgrounding app (launching Safari)..." + xcrun simctl launch "$IOS_UDID" com.apple.mobilesafari 2>/dev/null || true +} + +ios_trigger_deeplink() { + local url="$1" + ios_ensure_udid + log_info "Triggering deep link: $url" + xcrun simctl openurl "$IOS_UDID" "$url" 2>/dev/null || true +} + +# ─── Platform dispatcher ──────────────────────────────────────────────────── + +platform_uninstall() { + if [[ "$PLATFORM" == "android" ]]; then android_uninstall; else ios_uninstall; fi +} + +platform_install() { + if [[ "$PLATFORM" == "android" ]]; then android_install; else ios_install; fi +} + +platform_launch() { + if [[ "$PLATFORM" == "android" ]]; then android_launch; else ios_launch; fi +} + +platform_collect_logs() { + if [[ "$PLATFORM" == "android" ]]; then android_collect_logs "$1"; else ios_collect_logs "$1"; fi +} + +platform_background() { + if [[ "$PLATFORM" == "android" ]]; then android_background_app; else ios_background_app; fi +} + +platform_trigger_deeplink() { + if [[ "$PLATFORM" == "android" ]]; then android_trigger_deeplink "$1"; else ios_trigger_deeplink "$1"; fi +} + +# Print the device-side af_qa_logs.txt to stdout (best effort, empty on miss). +# Used by `wait_for_qa_marker` to poll mid-phase without reshuffling the full +# log-collection pipeline. +platform_peek_qa_log() { + if [[ "$PLATFORM" == "android" ]]; then + for path in files/af_qa_logs.txt app_flutter/af_qa_logs.txt; do + adb shell "run-as $PACKAGE_NAME cat $path 2>/dev/null" 2>/dev/null && return 0 + done + return 0 + fi + ios_ensure_udid + local sim_data_dir + sim_data_dir="$HOME/Library/Developer/CoreSimulator/Devices/${IOS_UDID}/data" + [[ -d "$sim_data_dir" ]] || return 0 + local qa_log + qa_log=$(find "$sim_data_dir/Containers/Data/Application" \ + -name "af_qa_logs.txt" -maxdepth 4 2>/dev/null | head -1) + [[ -n "$qa_log" && -f "$qa_log" ]] || return 0 + cat "$qa_log" 2>/dev/null || true +} + +# wait_for_qa_marker [interval_sec] +# Polls the device-side QA log and returns 0 as soon as the marker +# appears, or after the timeout (also 0 — caller still runs validation against +# whatever logs exist). Lets local runs finish quickly while CI runs use the +# full ceiling for slow no-KVM emulators / cold macOS sims. +wait_for_qa_marker() { + local marker="$1" + local timeout_sec="$2" + local interval="${3:-3}" + local start_ts now elapsed remaining sleep_for + + start_ts=$(date +%s) + log_info "Waiting up to ${timeout_sec}s for marker: ${marker} (poll every ${interval}s)" + while true; do + now=$(date +%s) + elapsed=$(( now - start_ts )) + if (( elapsed >= timeout_sec )); then + break + fi + + if platform_peek_qa_log | grep -qF -- "$marker" 2>/dev/null; then + log_info "Marker observed after ${elapsed}s" + return 0 + fi + + remaining=$(( timeout_sec - elapsed )) + sleep_for="$interval" + if (( sleep_for > remaining )); then + sleep_for="$remaining" + fi + sleep "$sleep_for" + done + log_warn "Marker not observed within ${timeout_sec}s; proceeding to log collection anyway" + return 0 +} + +run_phase_command() { + local label="$1" + local command="$2" + local allow_failure="$3" + local output status + + log_info "${label}: ${command}" + set +e + output=$(eval "$command" 2>&1) + status=$? + set -e + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" >&2 + fi + + if [[ "$status" -ne 0 ]]; then + if [[ "$allow_failure" == "true" ]]; then + log_warn "${label} failed with exit code ${status}; continuing" + return 0 + fi + log_fail "${label} failed with exit code ${status}" + return "$status" + fi +} + +deep_link_wait_marker() { + local phase_json="$1" + echo "$phase_json" | jq -r ' + [ + .checks[]? + | select(.type == "log_contains") + | .pattern + | select(startswith("deepLinkValue=")) + ][0] // empty + ' +} + +# ─── Build ─────────────────────────────────────────────────────────────────── + +build_app() { + if [[ -z "$BUILD_CMD" ]]; then + log_warn "No build_cmd configured in test plan for $PLATFORM" + return 1 + fi + log_step "Building app" + log_info "Running: $BUILD_CMD" + if ! $DRY_RUN; then + (eval "$BUILD_CMD") + fi +} + +# ─── Log validation engine ─────────────────────────────────────────────────── + +# validate_check +# Returns a JSON object: {"status": "PASS|FAIL|WARN", "evidence": "..."} +validate_check() { + local log_file="$1" + local check_json="$2" + + local check_id check_type pattern description fail_action + check_id=$(echo "$check_json" | jq -r '.id') + check_type=$(echo "$check_json" | jq -r '.type') + description=$(echo "$check_json" | jq -r '.description') + fail_action=$(echo "$check_json" | jq -r '.fail_action // "fail"') + + log_debug "Validating check: $check_id ($check_type)" + + case "$check_type" in + + log_contains) + pattern=$(echo "$check_json" | jq -r '.pattern') + local match + match=$(grep -F "$pattern" "$log_file" 2>/dev/null | head -1 || true) + if [[ -n "$match" ]]; then + # Optional payload_check + local payload_field payload_expected + payload_field=$(echo "$check_json" | jq -r '.payload_check.field // empty') + if [[ -n "$payload_field" ]]; then + payload_expected=$(echo "$check_json" | jq -r '.payload_check.expected') + if echo "$match" | grep -q "${payload_field}.*${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "\"${payload_field}\":.*${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "${payload_field}=${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "${payload_field}: ${payload_expected}" 2>/dev/null; then + jq -n --arg evidence "$(echo "$match" | head -c 500)" \ + '{status: "PASS", evidence: $evidence}' + else + jq -n \ + --arg field "$payload_field" \ + --arg expected "$payload_expected" \ + --arg line "$(echo "$match" | head -c 300)" \ + '{status: "FAIL", evidence: "Pattern found but payload check failed: \($field) != \($expected). Line: \($line)"}' + fi + else + echo "{\"status\":\"PASS\",\"evidence\":$(echo "$match" | head -c 500 | jq -Rs .)}" + fi + else + echo "{\"status\":\"FAIL\",\"evidence\":\"Pattern not found in logs: ${pattern}\"}" + fi + ;; + + count_matches) + pattern=$(echo "$check_json" | jq -r '.pattern') + local minimum + minimum=$(echo "$check_json" | jq -r '.minimum // 1') + local count + count=$(grep -cE "$pattern" "$log_file" 2>/dev/null || echo "0") + if [[ "$count" -ge "$minimum" ]]; then + echo "{\"status\":\"PASS\",\"evidence\":\"Found ${count} matches (minimum: ${minimum})\"}" + else + echo "{\"status\":\"FAIL\",\"evidence\":\"Found only ${count} matches (minimum: ${minimum})\"}" + fi + ;; + + absent) + local patterns_json patterns_arr status evidence + patterns_json=$(echo "$check_json" | jq -r '.patterns // []') + status="PASS" + evidence="No forbidden patterns found" + while IFS= read -r forbidden_pattern; do + forbidden_pattern=$(echo "$forbidden_pattern" | jq -r '.') + local found + found=$(grep -F "$forbidden_pattern" "$log_file" 2>/dev/null | head -1 || true) + if [[ -n "$found" ]]; then + status="FAIL" + evidence="Forbidden pattern found: ${forbidden_pattern} -> $(echo "$found" | head -c 200)" + break + fi + done < <(echo "$patterns_json" | jq -c '.[]') + echo "{\"status\":\"${status}\",\"evidence\":$(echo "$evidence" | jq -Rs .)}" + ;; + + regex_match) + pattern=$(echo "$check_json" | jq -r '.pattern') + local match + match=$(grep -E "$pattern" "$log_file" 2>/dev/null | head -1 || true) + if [[ -n "$match" ]]; then + echo "{\"status\":\"PASS\",\"evidence\":$(echo "$match" | head -c 500 | jq -Rs .)}" + else + echo "{\"status\":\"FAIL\",\"evidence\":\"Regex not matched in logs: ${pattern}\"}" + fi + ;; + + *) + echo "{\"status\":\"WARN\",\"evidence\":\"Unknown check type: ${check_type}\"}" + ;; + esac +} + +# ─── Phase execution ───────────────────────────────────────────────────────── + +# run_phase +run_phase() { + local phase_json="$1" + + local phase_id phase_name requires_fresh scenario_ref wait_sec + phase_id=$(echo "$phase_json" | jq -r '.id') + phase_name=$(echo "$phase_json" | jq -r '.name') + requires_fresh=$(echo "$phase_json" | jq -r '.requires_fresh_install // false') + scenario_ref=$(echo "$phase_json" | jq -r '.scenario_ref // "N/A"') + wait_sec=$(echo "$phase_json" | jq -r '.wait_after_launch_sec // 25') + local wait_trigger_sec + wait_trigger_sec=$(echo "$phase_json" | jq -r '.wait_after_trigger_sec // 5') + + log_step "Phase: ${phase_name} [${phase_id}] (Scenario: ${scenario_ref})" + + local phase_log_file="${REPORT_DIR}/${RUN_ID}_${phase_id}_logs.txt" + local phase_status="PASS" + local checks_json="{}" + + if $DRY_RUN; then + log_info "[DRY RUN] Would execute phase: $phase_name" + if [[ "$requires_fresh" == "true" ]]; then + log_info "[DRY RUN] Would uninstall + reinstall $PACKAGE_NAME" + fi + log_info "[DRY RUN] Would launch app and wait ${wait_sec}s" + log_info "[DRY RUN] Would collect logs and validate $(echo "$phase_json" | jq '.checks | length') checks" + + # Produce a dry-run result + local dry_result + dry_result=$(jq -n \ + --arg pid "$phase_id" \ + --arg ps "DRY_RUN" \ + '{phase_id: $pid, status: $ps, checks: {}, log_file: "N/A"}') + PHASE_RESULTS=$(echo "$PHASE_RESULTS" | jq --argjson r "$dry_result" '. + [$r]') + return + fi + + # Fresh install if required + if [[ "$requires_fresh" == "true" ]]; then + platform_uninstall + sleep 1 + if ! platform_install; then + log_fail "Installation failed — aborting phase" + phase_status="BLOCKED" + local blocked_result + blocked_result=$(jq -n \ + --arg pid "$phase_id" \ + --arg ps "$phase_status" \ + '{phase_id: $pid, status: $ps, checks: {}, log_file: "N/A", note: "Installation failed"}') + PHASE_RESULTS=$(echo "$PHASE_RESULTS" | jq --argjson r "$blocked_result" '. + [$r]') + return + fi + sleep 1 + + platform_launch + # Poll the QA log for the auto-run-complete marker rather than always + # sleeping the full ceiling. Use a slower interval here because each ADB + # `run-as cat` is costly on GitHub's emulator. + wait_for_qa_marker "[AF_QA][AUTO_APIS] --- Auto run complete ---" "$wait_sec" 10 + fi + + # Pre-actions (deep link phases: background the app, etc.) + local pre_actions + pre_actions=$(echo "$phase_json" | jq -r ".pre_actions.${PLATFORM} // empty") + if [[ -n "$pre_actions" && "$pre_actions" != "null" ]]; then + log_info "Executing pre-actions..." + while IFS= read -r action; do + action=$(echo "$action" | jq -r '.') + action="${action//\{\{BUNDLE_ID\}\}/$PACKAGE_NAME}" + action="${action//\{\{PACKAGE_NAME\}\}/$PACKAGE_NAME}" + if [[ "$PLATFORM" == "ios" ]]; then + ios_ensure_udid + action="${action//\{\{UDID\}\}/$IOS_UDID}" + fi + run_phase_command "Pre-action" "$action" true + done < <(echo "$phase_json" | jq -c ".pre_actions.${PLATFORM}[]") + fi + + # Trigger deep link if present + local deep_link_url + deep_link_url=$(echo "$phase_json" | jq -r '.deep_link_url // empty') + if [[ -n "$deep_link_url" ]]; then + # Use platform-specific trigger command or generic + local trigger_cmd + trigger_cmd=$(echo "$phase_json" | jq -r ".trigger.${PLATFORM} // empty") + if [[ -n "$trigger_cmd" && "$trigger_cmd" != "null" ]]; then + trigger_cmd="${trigger_cmd//\{\{DEEP_LINK_URL\}\}/$deep_link_url}" + trigger_cmd="${trigger_cmd//\{\{BUNDLE_ID\}\}/$PACKAGE_NAME}" + trigger_cmd="${trigger_cmd//\{\{PACKAGE_NAME\}\}/$PACKAGE_NAME}" + if [[ "$PLATFORM" == "ios" ]]; then + ios_ensure_udid + trigger_cmd="${trigger_cmd//\{\{UDID\}\}/$IOS_UDID}" + fi + if ! run_phase_command "Deep link trigger" "$trigger_cmd" false; then + log_warn "Deep link trigger failed; continuing to collect logs and run checks" + fi + else + if ! platform_trigger_deeplink "$deep_link_url"; then + log_warn "Deep link trigger failed; continuing to collect logs and run checks" + fi + fi + + local deep_link_marker + deep_link_marker=$(deep_link_wait_marker "$phase_json") + if [[ -n "$deep_link_marker" ]]; then + wait_for_qa_marker "$deep_link_marker" "$wait_trigger_sec" 3 + else + log_info "Waiting ${wait_trigger_sec}s for deep link to propagate..." + sleep "$wait_trigger_sec" + fi + fi + + # Collect logs + log_info "Collecting logs..." + platform_collect_logs "$phase_log_file" + + local log_lines + log_lines=$(wc -l < "$phase_log_file" 2>/dev/null | tr -d ' ') + log_info "Collected ${log_lines} log lines" + + if [[ "$log_lines" -eq 0 ]]; then + log_warn "No logs collected — check that the app is running and logging with [AF_QA]" + fi + + # Validate each check + local num_checks + num_checks=$(echo "$phase_json" | jq '.checks | length') + log_info "Running ${num_checks} checks..." + + local i=0 + while [[ $i -lt $num_checks ]]; do + local check + check=$(echo "$phase_json" | jq -c ".checks[$i]") + local check_id + check_id=$(echo "$check" | jq -r '.id') + local check_desc + check_desc=$(echo "$check" | jq -r '.description') + local fail_action + fail_action=$(echo "$check" | jq -r '.fail_action // "fail"') + + local result + result=$(validate_check "$phase_log_file" "$check") + local check_status + check_status=$(echo "$result" | jq -r '.status') + local evidence + evidence=$(echo "$result" | jq -r '.evidence') + + TOTAL_CHECKS=$((TOTAL_CHECKS + 1)) + + if [[ "$check_status" == "PASS" ]]; then + log_ok "$check_id: $check_desc" + PASSED_CHECKS=$((PASSED_CHECKS + 1)) + elif [[ "$check_status" == "WARN" ]]; then + log_warn "$check_id: $check_desc — $evidence" + WARNED_CHECKS=$((WARNED_CHECKS + 1)) + else + log_fail "$check_id: $check_desc — $evidence" + FAILED_CHECKS=$((FAILED_CHECKS + 1)) + phase_status="FAIL" + + if [[ "$fail_action" == "abort" ]]; then + log_fail "Abort triggered by $check_id — skipping remaining checks in this phase" + ABORTED=true + break + fi + fi + + checks_json=$(echo "$checks_json" | jq --arg k "$check_id" --argjson v "$result" '. + {($k): $v}') + i=$((i + 1)) + done + + # Produce phase result + local phase_result + phase_result=$(jq -n \ + --arg pid "$phase_id" \ + --arg ps "$phase_status" \ + --argjson ch "$checks_json" \ + --arg lf "$phase_log_file" \ + '{phase_id: $pid, status: $ps, checks: $ch, log_file: $lf}') + PHASE_RESULTS=$(echo "$PHASE_RESULTS" | jq --argjson r "$phase_result" '. + [$r]') + + echo "" +} + +# ─── Main ──────────────────────────────────────────────────────────────────── + +main() { + log_step "AppsFlyer scenario runner" + log_info "Started at $RUN_START" + + # Verify device/simulator is available (skip in dry-run) + if $DRY_RUN; then + if [[ "$PLATFORM" == "android" ]]; then + local device + device=$(android_get_device 2>/dev/null || true) + log_info "Android device: ${device:-}" + else + IOS_UDID=$(ios_get_booted_udid 2>/dev/null || true) + log_info "iOS simulator: ${IOS_UDID:-}" + fi + else + if [[ "$PLATFORM" == "android" ]]; then + local device + device=$(android_get_device) + if [[ -z "$device" ]]; then + log_fail "No Android device/emulator found. Start one with: emulator -avd " + exit 1 + fi + log_info "Android device: $device" + elif [[ "$PLATFORM" == "ios" ]]; then + ios_ensure_udid + fi + fi + + # Build if requested + if $BUILD_FIRST; then + build_app + fi + + # Get phases from the plan + local num_phases + num_phases=$(echo "$PLAN" | jq '.phases | length') + log_info "Test plan has ${num_phases} phases" + + local p=0 + while [[ $p -lt $num_phases ]]; do + local phase + phase=$(echo "$PLAN" | jq -c ".phases[$p]") + local pid + pid=$(echo "$phase" | jq -r '.id') + + # Apply phase filter if set + if [[ -n "$PHASE_FILTER" && "$pid" != "$PHASE_FILTER" ]]; then + log_debug "Skipping phase $pid (filter: $PHASE_FILTER)" + p=$((p + 1)) + continue + fi + + run_phase "$phase" + + if $ABORTED; then + log_warn "Run aborted after phase $pid" + break + fi + + p=$((p + 1)) + done + + # ── Final report ────────────────────────────────────────────────────────── + + local overall_status="PASS" + if [[ $FAILED_CHECKS -gt 0 ]]; then + overall_status="FAIL" + fi + if $ABORTED; then + overall_status="ABORTED" + fi + + local run_end + run_end=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local start_epoch end_epoch duration_sec + start_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$RUN_START" +%s 2>/dev/null || date -d "$RUN_START" +%s 2>/dev/null || echo "0") + end_epoch=$(date +%s) + duration_sec=$(( end_epoch - start_epoch )) + + local device_name="" + if [[ "$PLATFORM" == "android" ]]; then + device_name=$(android_get_device 2>/dev/null || echo "N/A") + else + device_name="${IOS_UDID:-N/A}" + fi + + local report + report=$(jq -n \ + --arg rid "$RUN_ID" \ + --arg plat "$PLATFORM" \ + --arg dev "$device_name" \ + --arg plan "$PLAN_ID" \ + --arg plugin "$PLUGIN_NAME" \ + --arg status "$overall_status" \ + --arg start "$RUN_START" \ + --arg end "$run_end" \ + --argjson dur "$duration_sec" \ + --argjson total "$TOTAL_CHECKS" \ + --argjson passed "$PASSED_CHECKS" \ + --argjson failed "$FAILED_CHECKS" \ + --argjson warned "$WARNED_CHECKS" \ + --argjson phases "$PHASE_RESULTS" \ + '{ + run_id: $rid, + platform: $plat, + device: $dev, + plan_id: $plan, + plugin: $plugin, + overall_status: $status, + started_at: $start, + finished_at: $end, + duration_sec: $dur, + total_checks: $total, + passed: $passed, + failed: $failed, + warned: $warned, + phases: $phases + }') + + # Write report + if ! $DRY_RUN; then + echo "$report" | jq '.' > "$REPORT_FILE" + # Also write a latest.json symlink + ln -sf "$(basename "$REPORT_FILE")" "${REPORT_DIR}/latest.json" + log_info "Report saved to: $REPORT_FILE" + fi + + # Print summary + echo "" + log_step "Summary" + echo -e " Plan: ${PLAN_ID}" + echo -e " Plugin: ${PLUGIN_NAME}" + echo -e " Platform: ${PLATFORM}" + echo -e " Device: ${device_name}" + echo -e " Checks: ${PASSED_CHECKS}/${TOTAL_CHECKS} passed, ${FAILED_CHECKS} failed, ${WARNED_CHECKS} warned" + + if [[ "$overall_status" == "PASS" ]]; then + echo -e " Status: ${GREEN}${BOLD}PASS${NC}" + elif [[ "$overall_status" == "ABORTED" ]]; then + echo -e " Status: ${RED}${BOLD}ABORTED${NC}" + else + echo -e " Status: ${RED}${BOLD}FAIL${NC}" + fi + echo "" + + # Exit with appropriate code + if [[ "$overall_status" != "PASS" ]]; then + exit 1 + fi +} + +main diff --git a/scripts/dump-android-logs.sh b/scripts/dump-android-logs.sh new file mode 100755 index 00000000..c83c525e --- /dev/null +++ b/scripts/dump-android-logs.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Dump Android diagnostics when the Cordova E2E/smoke runner step fails. +# Intended for use as: ./scripts/af-scenario-runner.sh ... || ./scripts/dump-android-logs.sh + +# +# Default package matches D5 (.af-e2e / .af-smoke plans): com.appsflyer.qa.cordova +# Override per job: PACKAGE_NAME=com.example.app ./scripts/dump-android-logs.sh +# +# Always exits 1 so the CI step stays failed after dumping. + +set +e + +PKG="${PACKAGE_NAME:-com.appsflyer.qa.cordova}" + +echo "::group::Package / process (${PKG})" +adb shell "pm path ${PKG}" 2>&1 +adb shell "ps -A | grep -E '${PKG}|chromium|Cordova' || true" 2>&1 +echo "::endgroup::" + +echo "::group::run-as sandbox (debuggable builds only)" +adb shell "run-as ${PKG} ls -la" 2>&1 +adb shell "run-as ${PKG} ls -la files" 2>&1 || true +echo "::endgroup::" + +echo "::group::adb logcat dump (last 500 lines, unfiltered)" +adb logcat -d -t 500 +echo "::endgroup::" + +echo "::group::AndroidRuntime + chromium (tail)" +adb logcat -d -b crash 2>/dev/null | tail -200 +adb logcat -d AndroidRuntime:E chromium:I '*:S' 2>/dev/null | tail -150 +echo "::endgroup::" + +exit 1 From 151f179f9bad7339f6e94408077db830336a2a70 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 14:43:31 +0300 Subject: [PATCH 02/24] Phase 2. Minimalistic test app, e2e workflows + scripts. --- .af-e2e/test-plan.json | 10 +- .af-smoke/rc-test-plan.json | 10 +- .github/workflows/android-e2e.yml | 139 +++++++++++++++++++++++++++ .github/workflows/ios-e2e.yml | 121 +++++++++++++++++++++++ .gitignore | 7 +- .npmignore | 8 +- scripts/e2e-cordova-build.sh | 144 ++++++++++++++++++++++++++++ scripts/smoke-cordova-build.sh | 135 ++++++++++++++++++++++++++ scripts/sync-test-app-e2e-copy.sh | 63 ++++++++++++ scripts/sync-test-app-rc-smoke.sh | 55 +++++++++++ test-app/.env.example | 3 + test-app/.gitignore | 5 + test-app/README.md | 153 ++++++++++++++++++++++++++++++ test-app/config.xml | 20 ++++ test-app/package.json | 26 +++++ test-app/www/index.html | 16 ++++ test-app/www/js/index.js | 3 + 17 files changed, 905 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/android-e2e.yml create mode 100644 .github/workflows/ios-e2e.yml create mode 100755 scripts/e2e-cordova-build.sh create mode 100755 scripts/smoke-cordova-build.sh create mode 100755 scripts/sync-test-app-e2e-copy.sh create mode 100755 scripts/sync-test-app-rc-smoke.sh create mode 100644 test-app/.env.example create mode 100644 test-app/.gitignore create mode 100644 test-app/README.md create mode 100644 test-app/config.xml create mode 100644 test-app/package.json create mode 100644 test-app/www/index.html create mode 100644 test-app/www/js/index.js diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index 43194bf3..ee038aa7 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -3,7 +3,7 @@ "plan_id": "cordova-e2e", "plugin": "cordova", "version": "1.0.0", - "description": "End-to-end test plan for the AppsFlyer Cordova plugin. Runs against plugin source (path/file dependency) in examples/cordovatestapp/. Covers E2E-001..E2E-006 per appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.", + "description": "End-to-end test plan for the AppsFlyer Cordova plugin. build_cmd syncs test-app/ to a sibling directory (../appsflyer-cordova-plugin-e2e by default) then builds with file: plugin path. Covers E2E-001..E2E-006 per appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.", "platforms": [ "android", "ios" @@ -15,13 +15,13 @@ "android": { "package_name": "com.appsflyer.qa.cordova", "activity": ".MainActivity", - "apk_path": "examples/cordovatestapp/platforms/android/app/build/outputs/apk/debug/app-debug.apk", - "build_cmd": "cd examples/cordovatestapp && npm ci && cordova build android --debug" + "apk_path": "../appsflyer-cordova-plugin-e2e/platforms/android/app/build/outputs/apk/debug/app-debug.apk", + "build_cmd": "./scripts/e2e-cordova-build.sh android" }, "ios": { "bundle_id": "com.appsflyer.qa.cordova", - "app_path": "examples/cordovatestapp/platforms/ios/build/Debug-iphonesimulator/cordovaTestApp.app", - "build_cmd": "cd examples/cordovatestapp && npm ci && cordova build ios --debug" + "app_path": "../appsflyer-cordova-plugin-e2e/platforms/ios/build/Debug-iphonesimulator/AFQACordova.app", + "build_cmd": "./scripts/e2e-cordova-build.sh ios" } }, "phases": [ diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json index d5e691bc..e14bded4 100644 --- a/.af-smoke/rc-test-plan.json +++ b/.af-smoke/rc-test-plan.json @@ -3,7 +3,7 @@ "plan_id": "cordova-rc-smoke", "plugin": "cordova", "version": "1.0.0", - "description": "Post-publish smoke plan for the AppsFlyer Cordova plugin. SMOKE-001..003 against examples/cordovatestapp_rc_smoke/ (CI-synthesized, npm-pinned cordova-plugin-appsflyer-sdk)", + "description": "Post-publish smoke plan for the AppsFlyer Cordova plugin. SMOKE-001..003 against test-app_rc_smoke/ (CI-synthesized; build_cmd runs scripts/smoke-cordova-build.sh which rsyncs test-app/ then pins cordova-plugin-appsflyer-sdk to the npm version from root package.json or SMOKE_PLUGIN_VERSION).", "platforms": [ "android", "ios" @@ -15,13 +15,13 @@ "android": { "package_name": "com.appsflyer.qa.cordova", "activity": ".MainActivity", - "apk_path": "examples/cordovatestapp_rc_smoke/platforms/android/app/build/outputs/apk/debug/app-debug.apk", - "build_cmd": "cd examples/cordovatestapp_rc_smoke && npm ci && cordova build android --debug" + "apk_path": "test-app_rc_smoke/platforms/android/app/build/outputs/apk/debug/app-debug.apk", + "build_cmd": "./scripts/smoke-cordova-build.sh android" }, "ios": { "bundle_id": "com.appsflyer.qa.cordova", - "app_path": "examples/cordovatestapp_rc_smoke/platforms/ios/build/Debug-iphonesimulator/cordovaTestApp.app", - "build_cmd": "cd examples/cordovatestapp_rc_smoke && npm ci && cordova build ios --debug" + "app_path": "test-app_rc_smoke/platforms/ios/build/Debug-iphonesimulator/AFQACordova.app", + "build_cmd": "./scripts/smoke-cordova-build.sh ios" } }, "phases": [ diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml new file mode 100644 index 00000000..1cc322df --- /dev/null +++ b/.github/workflows/android-e2e.yml @@ -0,0 +1,139 @@ +# Cordova plugin: build E2E sibling app + optional af-scenario-runner on an Android emulator. +# Mirrors Flutter appsflyer-flutter-plugin android-e2e layout (KVM, DNS, nslookup, ENV_FILE → sibling .env). +# Trigger: workflow_dispatch (manual) or workflow_call (e.g. from rc-release in Phase 6). + +name: Android E2E (scenario runner) + +on: + workflow_dispatch: + inputs: + run_scenarios: + description: After a successful Cordova build, run af-scenario-runner on the emulator + type: boolean + default: true + scenario_phase: + description: Optional --phase id (e.g. phase_1). Leave empty to run all phases. + type: string + default: '' + workflow_call: + inputs: + run_scenarios: + type: boolean + default: true + required: false + scenario_phase: + type: string + required: false + default: '' + secrets: + ENV_FILE: + required: false + +permissions: + contents: read + +concurrency: + group: android-e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-e2e: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v4 + with: + packages: >- + platforms;android-34 + platform-tools + build-tools;34.0.0 + + - name: Install Cordova CLI + run: npm install -g cordova + + - name: Write E2E sibling .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + set -euo pipefail + PLUGIN_ROOT="${GITHUB_WORKSPACE}" + E2E_DIR="$(dirname "${PLUGIN_ROOT}")/$(basename "${PLUGIN_ROOT}")-e2e" + mkdir -p "${E2E_DIR}" + if [[ -z "${ENV_FILE:-}" ]]; then + echo "::notice::ENV_FILE secret is unset; scenario phases that need DEV_KEY/APP_ID will fail until the repository (or caller) secret is configured." + else + printf '%s\n' "${ENV_FILE}" > "${E2E_DIR}/.env" + fi + + - name: Build Cordova E2E (Android) + env: + CORDOVA_E2E_ANDROID_JAVA_HOME: ${{ env.JAVA_HOME }} + run: | + set -euo pipefail + cd "${GITHUB_WORKSPACE}" + chmod +x scripts/*.sh + ./scripts/e2e-cordova-build.sh android + + # Some `ubuntu-latest` runner images ship `/dev/kvm` owned by group + # `kvm` without adding the runner user to that group. When this + # happens, reactivecircus/android-emulator-runner falls back to + # `-accel off` (software TCG), which boots in ~9 minutes instead of + # ~30s and brings up slirp networking unreliably -- leading to + # "Emulator has no IP connectivity" even with `-dns-server` set. + # The udev rule below grants 0666 perms on /dev/kvm so the action's + # ProbeKVM check finds it accessible and uses `-accel kvm`. This is + # the fix recommended by GitHub's actions/runner-images team and + # the one printed by the action's own diagnostic message. + - name: Enable KVM group perms (Linux emulator) + if: ${{ inputs.run_scenarios }} + run: | + set -euo pipefail + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm-perms.rules + sudo udevadm control --reload-rules && sudo udevadm trigger || true + sudo chgrp kvm /dev/kvm 2>/dev/null || true + sudo chmod g+rwx /dev/kvm 2>/dev/null || true + + - name: Run af-scenario-runner (Android) + if: ${{ inputs.run_scenarios }} + uses: reactivecircus/android-emulator-runner@v2 + env: + SCENARIO_PHASE: ${{ inputs.scenario_phase }} + with: + api-level: 34 + arch: x86_64 + target: google_apis + avd-name: cordova-e2e + force-avd-creation: true + emulator-options: -no-window -no-snapshot -no-audio -dns-server 8.8.8.8,1.1.1.1 + script: | + set -euo pipefail + cd "${GITHUB_WORKSPACE}" + chmod +x scripts/*.sh + nslookup appsflyersdk.com || true + adb devices + PHASE_ARGS=() + if [[ -n "${SCENARIO_PHASE:-}" ]]; then + PHASE_ARGS=(--phase "${SCENARIO_PHASE}") + fi + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose "${PHASE_ARGS[@]:-}" \ + || ./scripts/dump-android-logs.sh + + - name: Upload af-scenario-runner reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: af-e2e-reports-android-${{ github.run_id }} + path: .af-e2e/reports/ + if-no-files-found: warn diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml new file mode 100644 index 00000000..e9b7e757 --- /dev/null +++ b/.github/workflows/ios-e2e.yml @@ -0,0 +1,121 @@ +# Cordova plugin: build E2E sibling app + optional af-scenario-runner on a booted iOS Simulator. +# Trigger: workflow_dispatch (manual) or workflow_call (e.g. from rc-release in Phase 6). + +name: iOS E2E (scenario runner) + +on: + workflow_dispatch: + inputs: + run_scenarios: + description: After a successful Cordova build, run af-scenario-runner on the simulator + type: boolean + default: true + scenario_phase: + description: Optional --phase id (e.g. phase_1). Leave empty to run all phases. + type: string + default: '' + workflow_call: + inputs: + run_scenarios: + type: boolean + default: true + required: false + scenario_phase: + type: string + required: false + default: '' + secrets: + ENV_FILE: + required: false + +permissions: + contents: read + +concurrency: + group: ios-e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ios-e2e: + runs-on: macos-14 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Ensure CocoaPods + run: | + set -euo pipefail + if ! command -v pod >/dev/null 2>&1; then + sudo gem install cocoapods -N + fi + pod --version + + - name: Install Cordova CLI + run: npm install -g cordova + + - name: Write E2E sibling .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + set -euo pipefail + PLUGIN_ROOT="${GITHUB_WORKSPACE}" + E2E_DIR="$(dirname "${PLUGIN_ROOT}")/$(basename "${PLUGIN_ROOT}")-e2e" + mkdir -p "${E2E_DIR}" + if [[ -z "${ENV_FILE:-}" ]]; then + echo "::notice::ENV_FILE secret is unset; scenario phases that need DEV_KEY/APP_ID will fail until the repository (or caller) secret is configured." + else + printf '%s\n' "${ENV_FILE}" > "${E2E_DIR}/.env" + fi + + - name: Build Cordova E2E (iOS) + run: | + set -euo pipefail + cd "${GITHUB_WORKSPACE}" + chmod +x scripts/*.sh + ./scripts/e2e-cordova-build.sh ios + + - name: Shutdown simulators and boot iPhone + if: ${{ inputs.run_scenarios }} + run: | + set -euo pipefail + xcrun simctl shutdown all || true + UDID="$( + xcrun simctl list devices available -j | jq -r ' + [(.devices // {}) | to_entries[] | .value[] + | select(.isAvailable == true and ((.name // "") | contains("iPhone")))] + | if length == 0 then "" else .[0].udid end' + )" + if [[ -z "${UDID}" ]]; then + echo "::error::No available iPhone simulator" + xcrun simctl list devices available + exit 1 + fi + echo "Booting ${UDID}" + xcrun simctl boot "${UDID}" + xcrun simctl bootstatus "${UDID}" -b + + - name: Run af-scenario-runner (iOS) + if: ${{ inputs.run_scenarios }} + env: + SCENARIO_PHASE: ${{ inputs.scenario_phase }} + run: | + set -euo pipefail + cd "${GITHUB_WORKSPACE}" + chmod +x scripts/*.sh + PHASE_ARGS=() + if [[ -n "${SCENARIO_PHASE:-}" ]]; then + PHASE_ARGS=(--phase "${SCENARIO_PHASE}") + fi + ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose "${PHASE_ARGS[@]:-}" + + - name: Upload af-scenario-runner reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: af-e2e-reports-ios-${{ github.run_id }} + path: .af-e2e/reports/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 71fe611e..d780c9c3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,8 @@ examples/cordova-unit-tests-app/platforms/ .af-e2e/reports/ .af-smoke/reports/ -# CI-synthesized RC smoke copy of the example app (not committed) -examples/cordovatestapp_rc_smoke/ +# CI-synthesized RC smoke copy of test-app/ (not committed) +test-app_rc_smoke/ + +# E2E sibling copy path (written by scripts/sync-test-app-e2e-copy.sh) +.af-e2e/e2e_copy_dest.txt diff --git a/.npmignore b/.npmignore index b7265688..7f0fbb38 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,7 @@ -examples \ No newline at end of file +examples +# Automation shell lives inside the plugin repo; excluding it from npm pack prevents +# Cordova from copying test-app/ into plugins/.../test-app/... (infinite path / ENAMETOOLONG). +test-app/ +test-app_rc_smoke/ +.cursor/ +.idea/ diff --git a/scripts/e2e-cordova-build.sh b/scripts/e2e-cordova-build.sh new file mode 100755 index 00000000..5d6eba18 --- /dev/null +++ b/scripts/e2e-cordova-build.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Sync reference test-app/ to the E2E sibling copy, then npm install + cordova build (or run). +# Used by .af-e2e/test-plan.json build_cmd (CI + local) — default is **build** only, same as before. +# +# Usage (from plugin repo root): +# ./scripts/e2e-cordova-build.sh android # build APK only +# ./scripts/e2e-cordova-build.sh android run # build + install/launch on emulator +# ./scripts/e2e-cordova-build.sh ios # build .app for simulator only +# ./scripts/e2e-cordova-build.sh ios run # build + install/launch on simulator +# +# Env: +# TEST_APP_E2E_COPY_DEST Same as sync-test-app-e2e-copy.sh (must match .af-e2e paths if non-default) +# CORDOVA_E2E_ANDROID_JAVA_HOME If set, used as JAVA_HOME for Android builds only (overrides auto-pick). +# CORDOVA_E2E_RESPECT_JAVA_HOME If set to 1, do not change JAVA_HOME for Android (Gradle may still use +# ~/.gradle/gradle.properties org.gradle.java.home unless you set GRADLE_OPTS). +# CORDOVA_E2E_IOS_BUILDCONFIG Optional path to Cordova build.json for iOS (default: build.json in E2E app root if present). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLATFORM="${1:?usage: $0 android|ios [build|run]}" +ACTION="${2:-build}" +if [[ "$ACTION" != build && "$ACTION" != run ]]; then + echo "usage: $0 android|ios [build|run] (default: build)" >&2 + exit 1 +fi + +# Cordova Android + default Gradle often break on the newest JDK (e.g. Java 25 → "Unsupported class file major version 69"). +java_major() { + local java_bin="${1:-java}" + local line + line="$("$java_bin" -version 2>&1 | head -n1)" || return 1 + if [[ "$line" =~ version\ \"1\. ]]; then + echo 8 + return + fi + if [[ "$line" =~ version\ \"([0-9]+) ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + return 1 +} + +pick_java_home_for_android() { + if [[ -n "${CORDOVA_E2E_ANDROID_JAVA_HOME:-}" ]]; then + export JAVA_HOME="${CORDOVA_E2E_ANDROID_JAVA_HOME}" + echo "[e2e-cordova-build] JAVA_HOME from CORDOVA_E2E_ANDROID_JAVA_HOME=$JAVA_HOME" + return + fi + if [[ "${CORDOVA_E2E_RESPECT_JAVA_HOME:-0}" == "1" ]]; then + echo "[e2e-cordova-build] CORDOVA_E2E_RESPECT_JAVA_HOME=1 — leaving JAVA_HOME unchanged" + return + fi + # macOS: always prefer a stable JDK for Cordova/AGP. Shell `java` can be 17 while Gradle still uses + # org.gradle.java.home from ~/.gradle/gradle.properties (e.g. JDK 25) — we override that via GRADLE_OPTS below. + if [[ "$(uname -s)" == "Darwin" ]] && [[ -x /usr/libexec/java_home ]]; then + local v new_home + for v in 17 21; do + if new_home="$(/usr/libexec/java_home -v "$v" 2>/dev/null)"; then + export JAVA_HOME="$new_home" + echo "[e2e-cordova-build] Using JAVA_HOME=$JAVA_HOME (macOS JDK $v) for Android" + return + fi + done + fi + # Non-mac: keep JAVA_HOME / PATH; caller should set CORDOVA_E2E_ANDROID_JAVA_HOME in CI if needed. + local cur_bin="java" + [[ -n "${JAVA_HOME:-}" && -x "${JAVA_HOME}/bin/java" ]] && cur_bin="${JAVA_HOME}/bin/java" + local cur_major + cur_major="$(java_major "$cur_bin" || echo 0)" + [[ -z "$cur_major" ]] && cur_major=0 + if [[ "$cur_major" -ge 23 ]]; then + echo "[e2e-cordova-build] WARN: java reports major version $cur_major; set CORDOVA_E2E_ANDROID_JAVA_HOME to JDK 17 (Cordova Android + Gradle may fail on very new JDKs)." >&2 + fi +} + +# Gradle daemon JVM can come from ~/.gradle/gradle.properties (org.gradle.java.home), ignoring PATH java. +apply_gradle_java_home_override() { + [[ "$PLATFORM" == android ]] || return 0 + [[ -n "${JAVA_HOME:-}" ]] || return 0 + export GRADLE_OPTS="${GRADLE_OPTS:+$GRADLE_OPTS }-Dorg.gradle.java.home=${JAVA_HOME}" + echo "[e2e-cordova-build] GRADLE_OPTS includes -Dorg.gradle.java.home=${JAVA_HOME}" +} + +stop_android_gradle_daemon_if_present() { + [[ "$PLATFORM" == android ]] || return 0 + local gw="${DEST}/platforms/android/tools/gradlew" + [[ -x "$gw" ]] || return 0 + [[ -n "${JAVA_HOME:-}" ]] || return 0 + (cd "${DEST}/platforms/android/tools" && ./gradlew --stop 2>/dev/null) || true +} + +"${ROOT}/scripts/sync-test-app-e2e-copy.sh" + +if [[ -n "${TEST_APP_E2E_COPY_DEST:-}" ]]; then + DEST="$TEST_APP_E2E_COPY_DEST" +else + DEST="$(cat "${ROOT}/.af-e2e/e2e_copy_dest.txt")" +fi + +cd "$DEST" +npm install + +if [[ "$PLATFORM" == android ]]; then + pick_java_home_for_android + apply_gradle_java_home_override + stop_android_gradle_daemon_if_present +fi + +if [[ "$PLATFORM" == android ]] && [[ ! -d platforms/android ]]; then + cordova platform add android --no-interactive +fi +if [[ "$PLATFORM" == ios ]] && [[ ! -d platforms/ios ]]; then + cordova platform add ios --no-interactive +fi + +ios_bc="${CORDOVA_E2E_IOS_BUILDCONFIG:-}" +if [[ -z "$ios_bc" && -f "${PWD}/build.json" ]]; then + ios_bc="${PWD}/build.json" +fi + +if [[ "$PLATFORM" == ios ]]; then + if [[ "$ACTION" == run ]]; then + echo "[e2e-cordova-build] ios run (build + simulator; same build.json as build when present)" + if [[ -n "$ios_bc" ]]; then + cordova run ios --debug --emulator --buildConfig="$ios_bc" + else + cordova run ios --debug --emulator + fi + else + if [[ -n "$ios_bc" ]]; then + cordova build ios --debug --buildConfig="$ios_bc" + else + cordova build ios --debug + fi + fi +else + if [[ "$ACTION" == run ]]; then + echo "[e2e-cordova-build] android run (build + emulator)" + cordova run android --debug --emulator + else + cordova build android --debug + fi +fi diff --git a/scripts/smoke-cordova-build.sh b/scripts/smoke-cordova-build.sh new file mode 100755 index 00000000..ae14d644 --- /dev/null +++ b/scripts/smoke-cordova-build.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# RC smoke: sync test-app/ -> test-app_rc_smoke/ (npm-pinned plugin), then cordova build (or run). +# Used by .af-smoke/rc-test-plan.json build_cmd (CI + local). Paths in that plan are under test-app_rc_smoke/. +# +# Usage (from plugin repo root): +# ./scripts/smoke-cordova-build.sh android +# ./scripts/smoke-cordova-build.sh ios +# ./scripts/smoke-cordova-build.sh android run +# ./scripts/smoke-cordova-build.sh ios run +# +# Env: +# SMOKE_PLUGIN_VERSION Passed to sync-test-app-rc-smoke.sh (default: root package.json version). +# CORDOVA_E2E_ANDROID_JAVA_HOME Android: same semantics as e2e-cordova-build.sh (optional JDK pin). +# CORDOVA_E2E_RESPECT_JAVA_HOME Android: same as e2e script. +# CORDOVA_E2E_IOS_BUILDCONFIG iOS: optional build.json path (default: build.json in smoke app dir). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLATFORM="${1:?usage: $0 android|ios [build|run]}" +ACTION="${2:-build}" +if [[ "$ACTION" != build && "$ACTION" != run ]]; then + echo "usage: $0 android|ios [build|run] (default: build)" >&2 + exit 1 +fi + +DEST="${ROOT}/test-app_rc_smoke" + +java_major() { + local java_bin="${1:-java}" + local line + line="$("$java_bin" -version 2>&1 | head -n1)" || return 1 + if [[ "$line" =~ version\ \"1\. ]]; then + echo 8 + return + fi + if [[ "$line" =~ version\ \"([0-9]+) ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + return 1 +} + +pick_java_home_for_android() { + if [[ -n "${CORDOVA_E2E_ANDROID_JAVA_HOME:-}" ]]; then + export JAVA_HOME="${CORDOVA_E2E_ANDROID_JAVA_HOME}" + echo "[smoke-cordova-build] JAVA_HOME from CORDOVA_E2E_ANDROID_JAVA_HOME=$JAVA_HOME" + return + fi + if [[ "${CORDOVA_E2E_RESPECT_JAVA_HOME:-0}" == "1" ]]; then + echo "[smoke-cordova-build] CORDOVA_E2E_RESPECT_JAVA_HOME=1 — leaving JAVA_HOME unchanged" + return + fi + if [[ "$(uname -s)" == "Darwin" ]] && [[ -x /usr/libexec/java_home ]]; then + local v new_home + for v in 17 21; do + if new_home="$(/usr/libexec/java_home -v "$v" 2>/dev/null)"; then + export JAVA_HOME="$new_home" + echo "[smoke-cordova-build] Using JAVA_HOME=$JAVA_HOME (macOS JDK $v) for Android" + return + fi + done + fi + local cur_bin="java" + [[ -n "${JAVA_HOME:-}" && -x "${JAVA_HOME}/bin/java" ]] && cur_bin="${JAVA_HOME}/bin/java" + local cur_major + cur_major="$(java_major "$cur_bin" || echo 0)" + [[ -z "$cur_major" ]] && cur_major=0 + if [[ "$cur_major" -ge 23 ]]; then + echo "[smoke-cordova-build] WARN: java major $cur_major; set CORDOVA_E2E_ANDROID_JAVA_HOME to JDK 17 if Gradle fails." >&2 + fi +} + +apply_gradle_java_home_override() { + [[ "$PLATFORM" == android ]] || return 0 + [[ -n "${JAVA_HOME:-}" ]] || return 0 + export GRADLE_OPTS="${GRADLE_OPTS:+$GRADLE_OPTS }-Dorg.gradle.java.home=${JAVA_HOME}" + echo "[smoke-cordova-build] GRADLE_OPTS includes -Dorg.gradle.java.home=${JAVA_HOME}" +} + +stop_android_gradle_daemon_if_present() { + [[ "$PLATFORM" == android ]] || return 0 + local gw="${DEST}/platforms/android/tools/gradlew" + [[ -x "$gw" ]] || return 0 + [[ -n "${JAVA_HOME:-}" ]] || return 0 + (cd "${DEST}/platforms/android/tools" && ./gradlew --stop 2>/dev/null) || true +} + +export SMOKE_PLUGIN_VERSION="${SMOKE_PLUGIN_VERSION:-}" +"${ROOT}/scripts/sync-test-app-rc-smoke.sh" + +cd "$DEST" +npm install + +if [[ "$PLATFORM" == android ]]; then + pick_java_home_for_android + apply_gradle_java_home_override + stop_android_gradle_daemon_if_present +fi + +if [[ "$PLATFORM" == android ]] && [[ ! -d platforms/android ]]; then + cordova platform add android --no-interactive +fi +if [[ "$PLATFORM" == ios ]] && [[ ! -d platforms/ios ]]; then + cordova platform add ios --no-interactive +fi + +ios_bc="${CORDOVA_E2E_IOS_BUILDCONFIG:-}" +if [[ -z "$ios_bc" && -f "${PWD}/build.json" ]]; then + ios_bc="${PWD}/build.json" +fi + +if [[ "$PLATFORM" == ios ]]; then + if [[ "$ACTION" == run ]]; then + echo "[smoke-cordova-build] ios run" + if [[ -n "$ios_bc" ]]; then + cordova run ios --debug --emulator --buildConfig="$ios_bc" + else + cordova run ios --debug --emulator + fi + else + if [[ -n "$ios_bc" ]]; then + cordova build ios --debug --buildConfig="$ios_bc" + else + cordova build ios --debug + fi + fi +else + if [[ "$ACTION" == run ]]; then + echo "[smoke-cordova-build] android run" + cordova run android --debug --emulator + else + cordova build android --debug + fi +fi diff --git a/scripts/sync-test-app-e2e-copy.sh b/scripts/sync-test-app-e2e-copy.sh new file mode 100755 index 00000000..ba875c47 --- /dev/null +++ b/scripts/sync-test-app-e2e-copy.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Copy repo test-app/ to a directory OUTSIDE the plugin tree (sibling of this repo by default) +# so Cordova can use file:/path-to-plugin without ENAMETOOLONG (no test-app inside the copied plugin tree). +# +# Source of truth: /test-app/ (reference only; safe to run Cordova only after copy) +# Default dest: /-e2e e.g. ../appsflyer-cordova-plugin-e2e +# +# Usage (from plugin repo root): +# ./scripts/sync-test-app-e2e-copy.sh +# +# Env: +# TEST_APP_E2E_COPY_DEST Absolute path to sync destination (overrides default sibling path) + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLUGIN_ROOT="$ROOT" +BASENAME="$(basename "$PLUGIN_ROOT")" +DEFAULT_DEST="$(dirname "$PLUGIN_ROOT")/${BASENAME}-e2e" +DEST="${TEST_APP_E2E_COPY_DEST:-$DEFAULT_DEST}" + +mkdir -p "$DEST" + +rsync -a --delete \ + --filter='protect .env' \ + --filter='protect .env.local' \ + --exclude node_modules \ + --exclude platforms \ + --exclude plugins \ + --exclude .env \ + --exclude .env.local \ + --exclude package-lock.json \ + --exclude .DS_Store \ + "${ROOT}/test-app/" "${DEST}/" + +export DEST PLUGIN_ROOT +node <<'NODE' +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const dest = process.env.DEST; +const pluginRoot = process.env.PLUGIN_ROOT; +const fileUrl = pathToFileURL(pluginRoot).href; + +const pjsonPath = path.join(dest, 'package.json'); +const pj = JSON.parse(fs.readFileSync(pjsonPath, 'utf8')); +pj.devDependencies = pj.devDependencies || {}; +pj.devDependencies['cordova-plugin-appsflyer-sdk'] = fileUrl; +fs.writeFileSync(pjsonPath, JSON.stringify(pj, null, 2) + '\n'); + +let xml = fs.readFileSync(path.join(dest, 'config.xml'), 'utf8'); +xml = xml.replace( + //, + `` +); +fs.writeFileSync(path.join(dest, 'config.xml'), xml); +NODE + +mkdir -p "${ROOT}/.af-e2e" +echo "${DEST}" > "${ROOT}/.af-e2e/e2e_copy_dest.txt" + +echo "[sync-test-app-e2e-copy] ${ROOT}/test-app/ → ${DEST}" diff --git a/scripts/sync-test-app-rc-smoke.sh b/scripts/sync-test-app-rc-smoke.sh new file mode 100755 index 00000000..9f03bc4a --- /dev/null +++ b/scripts/sync-test-app-rc-smoke.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Copy test-app/ -> test-app_rc_smoke/ (gitignored) and pin cordova-plugin-appsflyer-sdk to an npm semver +# (registry install), matching CI smoke synthesis (Phase 4 / .af-smoke). +# +# Usage (from plugin repo root): +# ./scripts/sync-test-app-rc-smoke.sh +# +# Env: +# SMOKE_PLUGIN_VERSION Exact npm version for cordova-plugin-appsflyer-sdk (default: root package.json "version") + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEST="${ROOT}/test-app_rc_smoke" + +mkdir -p "$DEST" + +rsync -a --delete \ + --filter='protect .env' \ + --filter='protect .env.local' \ + --exclude node_modules \ + --exclude platforms \ + --exclude plugins \ + --exclude .env \ + --exclude .env.local \ + --exclude package-lock.json \ + --exclude .DS_Store \ + "${ROOT}/test-app/" "${DEST}/" + +export SMOKE_PLUGIN_VERSION="${SMOKE_PLUGIN_VERSION:-}" +export DEST ROOT +node <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.env.ROOT; +const dest = process.env.DEST; +const rootPj = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const version = process.env.SMOKE_PLUGIN_VERSION || rootPj.version; + +const pjsonPath = path.join(dest, 'package.json'); +const pj = JSON.parse(fs.readFileSync(pjsonPath, 'utf8')); +pj.devDependencies = pj.devDependencies || {}; +pj.devDependencies['cordova-plugin-appsflyer-sdk'] = version; +fs.writeFileSync(pjsonPath, JSON.stringify(pj, null, 2) + '\n'); + +let xml = fs.readFileSync(path.join(dest, 'config.xml'), 'utf8'); +xml = xml.replace( + //, + `` +); +fs.writeFileSync(path.join(dest, 'config.xml'), xml); + +console.log(`[sync-test-app-rc-smoke] ${root}/test-app/ → ${dest} (cordova-plugin-appsflyer-sdk@${version} from npm)`); +NODE diff --git a/test-app/.env.example b/test-app/.env.example new file mode 100644 index 00000000..41eda05b --- /dev/null +++ b/test-app/.env.example @@ -0,0 +1,3 @@ +# Copy to .env (never commit .env). CI writes this from ENV_FILE. +DEV_KEY= +APP_ID= diff --git a/test-app/.gitignore b/test-app/.gitignore new file mode 100644 index 00000000..4d24b555 --- /dev/null +++ b/test-app/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +platforms/ +plugins/ +.env +.env.local diff --git a/test-app/README.md b/test-app/README.md new file mode 100644 index 00000000..17d764dd --- /dev/null +++ b/test-app/README.md @@ -0,0 +1,153 @@ +# `test-app/` — automation shell (Cordova, **reference only**) + +This folder is the **source of truth** for the small Cordova app used by **`.af-e2e/test-plan.json`** and (after CI synthesis) **`.af-smoke/rc-test-plan.json`**. + +- **`examples/cordovatestapp/`** stays the **manual / legacy** sample; scenario runner targets **`test-app`** contract work, not that tree. +- **`widget id`** is **`com.appsflyer.qa.cordova`** (D5). HQ app + **`.env`** — see repo **`docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md`** §0.5. +- **Phase 3** adds the full [**test-app contract**](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/test-app-contract.md). + +## Why you should not `cordova build` here + +This directory lives **inside** the plugin repository. Cordova resolves **`file:..`** / **`spec=".."`** by copying the **entire plugin tree** into **`test-app/plugins/`**. That tree **still contains `test-app/`**, so the copy can recurse and hit **`ENAMETOOLONG`**. + +**Workflow:** keep editing **`test-app/`** in-repo, then **sync to a sibling directory** (outside this repo folder) and run Cordova there. **`scripts/sync-test-app-e2e-copy.sh`** does the copy and patches **`file:`** + **`config.xml`** to point at the real plugin root. + +## Default sibling layout + +If the plugin repo directory is **`…/appsflyer-cordova-plugin`**, the default E2E copy is: + +```text +…/appsflyer-cordova-plugin/ ← plugin root (this repo) +…/appsflyer-cordova-plugin-e2e/ ← rsync destination (not committed; outside plugin tree) +``` + +The copy is created next to the plugin folder: **`/-e2e`**. If your clone is **not** named `appsflyer-cordova-plugin`, the sibling becomes **`-e2e`** — then update **`.af-e2e/test-plan.json`** `apk_path` / `app_path` to match, **or** set **`TEST_APP_E2E_COPY_DEST`** to an absolute path and adjust JSON paths accordingly. + +## Commands (always from **plugin repo root**) + +**Sync only** (refresh the sibling tree from `test-app/`): + +```sh +./scripts/sync-test-app-e2e-copy.sh +``` + +**Sync + `npm install` + add platform if missing + build** (what **`.af-e2e`** `build_cmd` uses — **build only**, no simulator/emulator launch): + +```sh +./scripts/e2e-cordova-build.sh android +./scripts/e2e-cordova-build.sh ios +``` + +**Build and run** on emulator / simulator — **optional, for local sanity checks** only (see below; **automated E2E** should **not** rely on **`run`**): + +```sh +./scripts/e2e-cordova-build.sh android run +./scripts/e2e-cordova-build.sh ios run +``` + +Second argument **`run`** runs **`cordova run … --emulator`** (forces simulator/emulator so a USB device does not steal **`cordova run android`**). **`ios run`** passes the same **`build.json`** as **`ios`** when present (**`-destination generic/platform=iOS Simulator`**). + +### How automated E2E distinguishes tests (Flutter-style) + +You do **not** get separate “tests” by calling **`cordova run`** repeatedly. **`cordova run`** builds/installs/launches **one** app shell; it has no notion of **`E2E-001`** vs **`E2E-002`**. + +**Separation matches Flutter:** **`build_cmd`** in **`.af-e2e/test-plan.json`** should stay **`./scripts/e2e-cordova-build.sh ios`** (and **`android`**) — **produce the `.app` / APK only**. Then **`./scripts/af-scenario-runner.sh --plan .af-e2e/test-plan.json`** (with **`--build`** omitted once the artifact exists, or with **`--build`** if you want the runner to invoke **`build_cmd`**) **installs, launches, sends deep links / intents, tails logs, and asserts** per **phase** in the JSON. Each phase has **`scenario_ref`** (**`E2E-*`**, **`SMOKE-*`**) and step definitions; that is how runs differ — same binary, different scripted interactions and assertions. + +Use **`ios run` / `android run`** when you want to **click around manually** or confirm the app boots. Use **build + runner** for CI and contract coverage. + +**Run on device / emulator** (after a successful build, from the **sibling** directory): + +```sh +cd ../appsflyer-cordova-plugin-e2e +cordova run android --debug +cordova run ios --debug +``` + +(Adjust the path if you use **`TEST_APP_E2E_COPY_DEST`**. You can read the last sync target from **`.af-e2e/e2e_copy_dest.txt`** in the plugin repo.) + +**Android APK install without `cordova run`** (paths assume default sibling name): + +```sh +adb install -r ../appsflyer-cordova-plugin-e2e/platforms/android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.appsflyer.qa.cordova/.MainActivity +``` + +**Incremental Android rebuild** (after **`platforms/android`** exists, faster than a full **`cordova build`** for native-only tweaks): from the **sibling** project, use the same JDK as **`e2e-cordova-build.sh`** (JDK **17** + **`GRADLE_OPTS=-Dorg.gradle.java.home=$JAVA_HOME`** if your global **`~/.gradle/gradle.properties`** pins another JDK), then: + +```sh +cd ../appsflyer-cordova-plugin-e2e/platforms/android +./tools/gradlew assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +Adjust the sibling path if **`TEST_APP_E2E_COPY_DEST`** is not the default. + +## Environment variables + +| Variable | Purpose | +|----------|---------| +| **`TEST_APP_E2E_COPY_DEST`** | Absolute path of the sync destination. Overrides **`/-e2e`**. If you change it, update **`.af-e2e/test-plan.json`** paths to the same location (relative to repo root). | +| **`CORDOVA_E2E_ANDROID_JAVA_HOME`** | Android only: force **`JAVA_HOME`** (e.g. JDK 17). Use when the default JDK is too new for Cordova’s Gradle/AGP and you are not on macOS auto-pick. | +| **`CORDOVA_E2E_RESPECT_JAVA_HOME`** | Set to **`1`** to skip auto-switching **`JAVA_HOME`** on Android (see below). | +| **`CORDOVA_E2E_IOS_BUILDCONFIG`** | Optional absolute path to a Cordova **`build.json`** for iOS. If unset and **`build.json`** exists in the E2E app root, **`e2e-cordova-build.sh`** uses it (generic simulator destination). | +| **`SMOKE_PLUGIN_VERSION`** | RC smoke only: exact npm version for **`cordova-plugin-appsflyer-sdk`** in **`test-app_rc_smoke/`** (default: root **`package.json`** **`version`**). | + +### Android: JDK / Gradle (`Unsupported class file major version 69`) + +That message means **bytecode for Java 25** (major 69) met a **JVM that cannot load it** — often **Gradle’s daemon is not the same JDK as `java` on your shell**. + +**Typical cause:** **`~/.gradle/gradle.properties`** sets **`org.gradle.java.home`** to a **newer JDK** (e.g. Android Studio’s JBR or JDK 25). The **`gradlew`** script may start with **Java 17** from `PATH`, but Gradle then **forks a daemon** using that property, loads **AGP / Kotlin** jars built for a newer classfile version, and the **older** daemon JVM hits **`Unsupported class file major version 69`**. + +**Fix:** + +1. Run **`./scripts/e2e-cordova-build.sh android`** again after updating the script: it sets **`GRADLE_OPTS=-Dorg.gradle.java.home=…`** from **`JAVA_HOME`** (on macOS it prefers **`/usr/libexec/java_home -v 17`**, then **21**), which **overrides** the global Gradle JDK for this build, and stops existing daemons under **`platforms/android/tools`** first. +2. Or edit **`~/.gradle/gradle.properties`**: remove or point **`org.gradle.java.home`** at JDK **17**. +3. If **`platforms/android/tools/.gradle`** still mentions the wrong Gradle version vs **`gradle/wrapper/gradle-wrapper.properties`**, remove that folder in the E2E sibling and rebuild: + +```sh +rm -rf "$(cat .af-e2e/e2e_copy_dest.txt)/platforms/android/tools/.gradle" +``` + +### iOS: CocoaPods `AppsFlyerFramework` / deployment target + +If **`pod install`** fails with **“required a higher minimum deployment target”** for **`AppsFlyerFramework`**, the Cordova app’s **`IPHONEOS_DEPLOYMENT_TARGET`** is below the SDK pod’s minimum ( **`AppsFlyerFramework` 6.18.x** declares **iOS 12.0** in its podspec). **`test-app/config.xml`** sets **``** under **``**. After changing it, from the **sibling** tree run **`cordova platform rm ios`** then **`cordova platform add ios`**, or wipe **`platforms/ios`** and run **`./scripts/e2e-cordova-build.sh ios`** again. + +### iOS: `xcodebuild` destination / “no available devices matched the request” + +Cordova’s default simulator (**often iPhone SE (3rd generation)**) is passed to **`xcodebuild`** as **name only**; newer Xcode then uses **`OS:latest`**, which may not match any installed runtime for that device type. + +**`test-app/build.json`** (copied into the E2E sibling by **`sync-test-app-e2e-copy.sh`**) sets **`buildFlag`** to **`-destination generic/platform=iOS Simulator`** so the debug build does not depend on a specific simulator model. **`e2e-cordova-build.sh`** passes **`--buildConfig=…/build.json`** when that file exists in the E2E app root. + +Override with **`CORDOVA_E2E_IOS_BUILDCONFIG=/path/to/other-build.json`**, or add your own **`buildFlag`** **`-destination …`** there (e.g. **`platform=iOS Simulator,id=`** from **`xcrun simctl list devices available`**). + +The CocoaPods warnings about **`LD_RUNPATH_SEARCH_PATHS`** are common with Cordova + Pods; they do not block a typical debug simulator build. + +## Scenario runner + +From **plugin repo root** (runner resolves **`apk_path`** / **`app_path`** relative to this root): + +```sh +./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --dry-run +./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --build --verbose +./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --dry-run +``` + +Use **`--build`** only when you want the runner to execute the plan’s **`build_cmd`**; otherwise build with **`e2e-cordova-build.sh`** first, then run the runner without **`--build`** for faster iteration. + +## RC smoke (CI + local) + +**`.af-smoke/rc-test-plan.json`** expects artifacts under **`test-app_rc_smoke/`** (gitignored). **`build_cmd`** runs **`./scripts/smoke-cordova-build.sh android`** / **`ios`**, which: + +1. **`scripts/sync-test-app-rc-smoke.sh`** — **`rsync`** from **`test-app/`** (same excludes as E2E sync), then pins **`cordova-plugin-appsflyer-sdk`** in **`package.json`** and **`config.xml`** to an **npm semver** (default: root **`package.json`** **`version`**; override with **`SMOKE_PLUGIN_VERSION`** for **`X.Y.Z-rcN`**). +2. **`npm install`**, **`cordova platform add`** if needed, **`cordova build`** (same JDK / **`build.json`** behaviour as **`e2e-cordova-build.sh`** on Android/iOS). + +**Local smoke build** (from plugin repo root, after the version exists on npm): + +```sh +./scripts/smoke-cordova-build.sh android +./scripts/smoke-cordova-build.sh ios +``` + +Then **`find test-app_rc_smoke/platforms/...`** and confirm **`.af-smoke/rc-test-plan.json`** **`apk_path`** / **`app_path`** match. + +CI mirrors the same idea: **`rsync`** + pin to the **published RC** version + **`ENV_FILE`** → **`.env`**, then **`smoke-cordova-build.sh`** (or inline equivalent). diff --git a/test-app/config.xml b/test-app/config.xml new file mode 100644 index 00000000..dd384f8f --- /dev/null +++ b/test-app/config.xml @@ -0,0 +1,20 @@ + + + AFQACordova + Minimal AppsFlyer Cordova test shell for af-scenario-runner (E2E + smoke). Manual sample remains under examples/cordovatestapp/. + AppsFlyer + + + + + + + + + + AppsFlyer QA automation + + + diff --git a/test-app/package.json b/test-app/package.json new file mode 100644 index 00000000..e35eedb6 --- /dev/null +++ b/test-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "com.appsflyer.qa.cordova.testapp", + "displayName": "AF QA Cordova", + "version": "1.0.0", + "private": true, + "description": "Minimal Cordova app for af-scenario-runner E2E/smoke. See test-app/README.md.", + "scripts": {}, + "keywords": [ + "cordova", + "appsflyer", + "qa" + ], + "author": "AppsFlyer", + "license": "Apache-2.0", + "devDependencies": { + "cordova-android": "^13.0.0", + "cordova-ios": "^7.1.1", + "cordova-plugin-appsflyer-sdk": "file:.." + }, + "cordova": { + "platforms": [], + "plugins": { + "cordova-plugin-appsflyer-sdk": {} + } + } +} diff --git a/test-app/www/index.html b/test-app/www/index.html new file mode 100644 index 00000000..414af056 --- /dev/null +++ b/test-app/www/index.html @@ -0,0 +1,16 @@ + + + + + + + + + AF QA Cordova + + +

AppsFlyer Cordova QA shell — contract logging and SDK wiring land in Phase 3.

+ + + + diff --git a/test-app/www/js/index.js b/test-app/www/js/index.js new file mode 100644 index 00000000..235da37d --- /dev/null +++ b/test-app/www/js/index.js @@ -0,0 +1,3 @@ +document.addEventListener('deviceready', function () { + console.log('[AF_QA][BOOT] deviceready (placeholder until Phase 3 contract)'); +}, false); From 2f28712b6a167fc48611526d8c9efb51d608a0d3 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 15:01:15 +0300 Subject: [PATCH 03/24] Phase 2. Android CI fix. --- .github/workflows/android-e2e.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 1cc322df..cfd995d8 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -117,18 +117,21 @@ jobs: avd-name: cordova-e2e force-avd-creation: true emulator-options: -no-window -no-snapshot -no-audio -dns-server 8.8.8.8,1.1.1.1 + # android-emulator-runner invokes this block with `sh -c` (dash on Ubuntu): no + # `pipefail`, no `[[`, no bash arrays — use POSIX-only constructs. script: | - set -euo pipefail - cd "${GITHUB_WORKSPACE}" + set -eu + cd "$GITHUB_WORKSPACE" chmod +x scripts/*.sh nslookup appsflyersdk.com || true adb devices - PHASE_ARGS=() - if [[ -n "${SCENARIO_PHASE:-}" ]]; then - PHASE_ARGS=(--phase "${SCENARIO_PHASE}") + if [ -n "${SCENARIO_PHASE:-}" ]; then + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" \ + || ./scripts/dump-android-logs.sh + else + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose \ + || ./scripts/dump-android-logs.sh fi - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose "${PHASE_ARGS[@]:-}" \ - || ./scripts/dump-android-logs.sh - name: Upload af-scenario-runner reports if: always() From 2faaee1a2196a45c44d1b3493842b3ce3d97635b Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 15:17:03 +0300 Subject: [PATCH 04/24] Phase 2. Android CI fix 2. --- .github/workflows/android-e2e.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index cfd995d8..84cbba8d 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -104,6 +104,10 @@ jobs: sudo udevadm control --reload-rules && sudo udevadm trigger || true sudo chgrp kvm /dev/kvm 2>/dev/null || true sudo chmod g+rwx /dev/kvm 2>/dev/null || true + # Runner user is often not in group `kvm`; group perms alone keep `-accel off` (TCG) and + # ~10m boots. World-writable /dev/kvm matches GitHub runner-image guidance so KVM works. + sudo chmod 666 /dev/kvm 2>/dev/null || true + ls -la /dev/kvm || true - name: Run af-scenario-runner (Android) if: ${{ inputs.run_scenarios }} @@ -116,7 +120,14 @@ jobs: target: google_apis avd-name: cordova-e2e force-avd-creation: true - emulator-options: -no-window -no-snapshot -no-audio -dns-server 8.8.8.8,1.1.1.1 + # Prefer KVM; with `auto`, a missed `/dev/kvm` open falls back to TCG and ~10m boots. + disable-linux-hw-accel: false + # Default action timeout is 600s — slow TCG boots burn the whole window. KVM cold boot + # is usually well under 5m; fail earlier if the guest never becomes ready. + emulator-boot-timeout: 420 + # Keep action defaults (gpu + no-boot-anim) and add DNS; overriding `emulator-options` + # replaces the default entirely — without `-gpu`/`-no-boot-anim` the guest is slower. + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 # android-emulator-runner invokes this block with `sh -c` (dash on Ubuntu): no # `pipefail`, no `[[`, no bash arrays — use POSIX-only constructs. script: | From cec852b5ac7cf5a384491302fd5cfb0639976184 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 15:19:57 +0300 Subject: [PATCH 05/24] Phase 2. Android CI fix 3. --- .github/workflows/ios-e2e.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index e9b7e757..cdcc2fff 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -106,11 +106,11 @@ jobs: set -euo pipefail cd "${GITHUB_WORKSPACE}" chmod +x scripts/*.sh - PHASE_ARGS=() - if [[ -n "${SCENARIO_PHASE:-}" ]]; then - PHASE_ARGS=(--phase "${SCENARIO_PHASE}") + if [ -n "${SCENARIO_PHASE:-}" ]; then + ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" + else + ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose fi - ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose "${PHASE_ARGS[@]:-}" - name: Upload af-scenario-runner reports if: always() From f48384215f945b0530a8e52eac0adcc464bdfb3c Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 15:25:25 +0300 Subject: [PATCH 06/24] Phase 2. Android CI fix 4. --- .github/workflows/android-e2e.yml | 19 ++++--------------- .github/workflows/ios-e2e.yml | 5 ----- scripts/ci-android-e2e-scenario.sh | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 20 deletions(-) create mode 100755 scripts/ci-android-e2e-scenario.sh diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 84cbba8d..097d4929 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -128,21 +128,10 @@ jobs: # Keep action defaults (gpu + no-boot-anim) and add DNS; overriding `emulator-options` # replaces the default entirely — without `-gpu`/`-no-boot-anim` the guest is slower. emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 - # android-emulator-runner invokes this block with `sh -c` (dash on Ubuntu): no - # `pipefail`, no `[[`, no bash arrays — use POSIX-only constructs. - script: | - set -eu - cd "$GITHUB_WORKSPACE" - chmod +x scripts/*.sh - nslookup appsflyersdk.com || true - adb devices - if [ -n "${SCENARIO_PHASE:-}" ]; then - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" \ - || ./scripts/dump-android-logs.sh - else - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose \ - || ./scripts/dump-android-logs.sh - fi + # android-emulator-runner runs EACH LINE of `script:` as its own `sh -c` (see job log: + # `/usr/bin/sh -c set -eu`, then `/usr/bin/sh -c cd ...`). Multi-line if/fi therefore + # breaks with "expecting fi". Delegate to one bash script (single `script` line). + script: bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh" - name: Upload af-scenario-runner reports if: always() diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index cdcc2fff..c1787cb0 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -48,7 +48,6 @@ jobs: - name: Ensure CocoaPods run: | - set -euo pipefail if ! command -v pod >/dev/null 2>&1; then sudo gem install cocoapods -N fi @@ -61,7 +60,6 @@ jobs: env: ENV_FILE: ${{ secrets.ENV_FILE }} run: | - set -euo pipefail PLUGIN_ROOT="${GITHUB_WORKSPACE}" E2E_DIR="$(dirname "${PLUGIN_ROOT}")/$(basename "${PLUGIN_ROOT}")-e2e" mkdir -p "${E2E_DIR}" @@ -73,7 +71,6 @@ jobs: - name: Build Cordova E2E (iOS) run: | - set -euo pipefail cd "${GITHUB_WORKSPACE}" chmod +x scripts/*.sh ./scripts/e2e-cordova-build.sh ios @@ -81,7 +78,6 @@ jobs: - name: Shutdown simulators and boot iPhone if: ${{ inputs.run_scenarios }} run: | - set -euo pipefail xcrun simctl shutdown all || true UDID="$( xcrun simctl list devices available -j | jq -r ' @@ -103,7 +99,6 @@ jobs: env: SCENARIO_PHASE: ${{ inputs.scenario_phase }} run: | - set -euo pipefail cd "${GITHUB_WORKSPACE}" chmod +x scripts/*.sh if [ -n "${SCENARIO_PHASE:-}" ]; then diff --git a/scripts/ci-android-e2e-scenario.sh b/scripts/ci-android-e2e-scenario.sh new file mode 100755 index 00000000..bc290fb2 --- /dev/null +++ b/scripts/ci-android-e2e-scenario.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Invoked as a SINGLE command from .github/workflows/android-e2e.yml inside +# reactivecircus/android-emulator-runner (that action runs each *line* of `script:` +# as a separate `sh -c` invocation — multi-line if/fi in YAML breaks). +# +# Env: GITHUB_WORKSPACE (required), SCENARIO_PHASE (optional). + +set -euo pipefail + +cd "${GITHUB_WORKSPACE:?}" +chmod +x scripts/*.sh +nslookup appsflyersdk.com || true +adb devices + +if [[ -n "${SCENARIO_PHASE:-}" ]]; then + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" \ + || ./scripts/dump-android-logs.sh +else + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose \ + || ./scripts/dump-android-logs.sh +fi From 23315b5f8cc8802b169a4316e222c1b286ea6fbc Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Tue, 12 May 2026 15:35:01 +0300 Subject: [PATCH 07/24] Skills. --- .claude/README.md | 19 ++++ .claude/skills/cordova-rc-release/SKILL.md | 50 +++++++++++ .../skills/cordova-scenario-runner/SKILL.md | 87 +++++++++++++++++++ .../cordova-smoke-ci-alignment/SKILL.md | 58 +++++++++++++ .../skills/cordova-test-app-contract/SKILL.md | 62 +++++++++++++ .cursor/skills/cordova-rc-release | 1 + .cursor/skills/cordova-scenario-runner | 1 + .cursor/skills/cordova-smoke-ci-alignment | 1 + .cursor/skills/cordova-test-app-contract | 1 + test-app/build.json | 10 +++ 10 files changed, 290 insertions(+) create mode 100644 .claude/README.md create mode 100644 .claude/skills/cordova-rc-release/SKILL.md create mode 100644 .claude/skills/cordova-scenario-runner/SKILL.md create mode 100644 .claude/skills/cordova-smoke-ci-alignment/SKILL.md create mode 100644 .claude/skills/cordova-test-app-contract/SKILL.md create mode 120000 .cursor/skills/cordova-rc-release create mode 120000 .cursor/skills/cordova-scenario-runner create mode 120000 .cursor/skills/cordova-smoke-ci-alignment create mode 120000 .cursor/skills/cordova-test-app-contract create mode 100644 test-app/build.json diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 00000000..308ac311 --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,19 @@ +# `.claude` — shared agent guidance (Claude Code + Cursor) + +This directory holds **project skills** (`skills/*/SKILL.md`) so the same markdown can be used from: + +- **Claude Code** — load skills from the project `.claude/` tree (per Claude Code project conventions). +- **Cursor** — the same skill folders are **symlinked** under `.cursor/skills/` so Cursor’s skill discovery picks them up. Do not duplicate `SKILL.md` bodies in two places; edit files under **`.claude/skills/`** only. + +## Layout + +| Path | Role | +|------|------| +| `.claude/skills//SKILL.md` | Canonical skill source | +| `.cursor/skills/` | Symlink → `../../.claude/skills/` | + +If a symlink is missing after clone on Windows, enable `git config core.symlinks true` before checkout, or recreate the link from `.cursor/skills/` to `.claude/skills/`. + +## Upstream templates + +Skills were adapted from [`appsflyer-mobile-plugin-tooling/templates/`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/tree/main/templates) (`claude-skills/`, `cursor-skills/`). When contracts change, refresh content from a new tooling tag and update `scripts/TOOLING_PIN.txt` for the vendored runner. diff --git a/.claude/skills/cordova-rc-release/SKILL.md b/.claude/skills/cordova-rc-release/SKILL.md new file mode 100644 index 00000000..e8f17996 --- /dev/null +++ b/.claude/skills/cordova-rc-release/SKILL.md @@ -0,0 +1,50 @@ +--- +name: cordova-rc-release +description: >- + Maps the intended AppsFlyer Cordova RC release pipeline (RC prep, E2E, publish, smoke, promote, + production) to workflow files. Use when planning or adding rc-release/rc-smoke/promote workflows; + not for day-to-day feature work. +globs: >- + .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,.github/workflows/pre-release-workflow.yml, + .github/workflows/release-Production-workflow.yml,.af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh +--- + +# RC release pipeline — AppsFlyer Cordova plugin (target + current) + +Use when: + +- Adding **`rc-release.yml`**, **`rc-smoke.yml`**, **`promote-release.yml`**, or **`production-release.yml`**. +- Wiring **`workflow_call`** from RC into **lint + Android E2E + iOS E2E**. +- Explaining why promotion should wait on **`rc-smoke/npm`** check-run (Flutter parity). + +## Sources of truth (tooling repo) + +- [`contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md) +- [`contracts/e2e-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/e2e-test-contract.md) +- [`contracts/smoke-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/smoke-test-contract.md) +- Live reference: **AppsFlyerSDK/appsflyer-flutter-plugin** `.github/workflows/` + +## Current vs target (this repo) + +| Stage | Target workflow (Flutter parity) | Cordova repo **today** | +|-------|----------------------------------|-------------------------| +| Lint / unit / build gate | `lint-test-build` composite | Legacy **`pre-release-workflow.yml`** / **`mac-os-unit-test-runner.yml`** — not yet unified | +| Pre-publish E2E | `android-e2e.yml`, `ios-e2e.yml` | **Present** — callable via `workflow_dispatch` / `workflow_call` | +| RC publish + tagging | `rc-release.yml` | **Not present** — see `release-Production-workflow.yml` / historical flows | +| Post-publish smoke | `rc-smoke.yml` + npm resolve | **Not present** | +| Promote (strip `-rcN`) | `promote-release.yml` gated on `rc-smoke/npm` | **Not present** | +| Production | `production-release.yml` | Partially covered by **`release-Production-workflow.yml`** (verify before changing) | + +Track incremental adoption in **`docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md`** (Phase 4 / 6). + +## Secrets (names only) + +| Secret | Typical use | +|--------|-------------| +| `ENV_FILE` | Multiline `.env` for E2E sibling + smoke app (`DEV_KEY`, `APP_ID`) | +| Registry / npm tokens | Publish jobs when implemented | + +## Do not + +- Do not bypass **`rc-smoke`** success requirement for promotion if the org adopts Flutter-style gates. +- Do not duplicate long contract text in skills — link tooling + Flutter repo. diff --git a/.claude/skills/cordova-scenario-runner/SKILL.md b/.claude/skills/cordova-scenario-runner/SKILL.md new file mode 100644 index 00000000..82bf4a64 --- /dev/null +++ b/.claude/skills/cordova-scenario-runner/SKILL.md @@ -0,0 +1,87 @@ +--- +name: cordova-scenario-runner +description: >- + Run or triage AppsFlyer Cordova E2E/smoke via ./scripts/af-scenario-runner.sh, sibling E2E copy, + smoke dir, and JSON reports. Use for local reproduction, CI log triage, or report summaries. +--- + +# Scenario runner — AppsFlyer Cordova plugin + +Use when: + +- Android/iOS E2E or smoke failed in CI and you need to reproduce or summarize reports. +- Changing `.af-e2e/test-plan.json`, `.af-smoke/rc-test-plan.json`, or build scripts used by the runner. +- Validating install/launch paths after editing `test-app/` or sync scripts. + +## Cordova-specific layout + +| Piece | Location | +|-------|----------| +| Runner (vendored) | `scripts/af-scenario-runner.sh` (pin in `scripts/TOOLING_PIN.txt`) | +| E2E plan | `.af-e2e/test-plan.json` — build uses **sibling** `../-e2e/` (default `../appsflyer-cordova-plugin-e2e/`) via `scripts/e2e-cordova-build.sh` | +| Smoke plan | `.af-smoke/rc-test-plan.json` — paths under `test-app_rc_smoke/` via `scripts/smoke-cordova-build.sh` | +| Reference app (do not `cordova build` here with `file:..`) | `test-app/` | +| Android CI scenario entry | `scripts/ci-android-e2e-scenario.sh` (single command inside `android-emulator-runner`; see below) | +| Reports | `.af-e2e/reports/`, `.af-smoke/reports/` | + +## Preconditions + +- **Android:** booted emulator; `adb devices` shows `device`. +- **iOS:** booted simulator (`xcrun simctl list devices booted`). +- **`.env`:** for E2E, on the **sibling** copy (e.g. `../appsflyer-cordova-plugin-e2e/.env`) — CI writes from `ENV_FILE`; `sync-test-app-e2e-copy.sh` **protects** `.env` on rsync. +- **`jq`** installed. + +## Commands (repo root) + +Dry-run parse: + +```sh +./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --dry-run +./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --dry-run +``` + +Build + single phase (paths / install smoke): + +```sh +./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --build --phase phase_1 +./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --build --phase phase_1 +``` + +Smoke (after `scripts/sync-test-app-rc-smoke.sh` + local build): + +```sh +./scripts/af-scenario-runner.sh --platform android --plan .af-smoke/rc-test-plan.json --build --phase phase_1 +``` + +## Read the JSON report + +```sh +jq '.overall_status, .total_checks, .passed, .failed' .af-e2e/reports/latest.json +``` + +Failed checks: + +```sh +jq -r ' + .phases[] + | .phase_id as $pid + | .checks | to_entries[] + | select(.value.status != "PASS") + | "\($pid)/\(.key): \(.value.evidence)" +' .af-e2e/reports/latest.json +``` + +## CI: Android `script:` must be one line + +`reactivecircus/android-emulator-runner` runs **each line** of `with: script:` as a **separate** `sh -c` invocation. Multi-line `if`/`fi` in YAML **breaks**. The workflow calls **`bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh`** as a single line instead. + +## Rules + +- Do not edit generated `latest.json` / phase JSON as “fixes”; fix the app, plan, or environment. +- Exit non-zero from the runner means FAIL — do not report PASS. +- No secrets or raw `DEV_KEY` in summaries. +- Contract docs: `appsflyer-mobile-plugin-tooling` — `contracts/e2e-test-contract.md`, `contracts/smoke-test-contract.md`, `docs/troubleshooting.md`. + +## Repo workplan + +See `docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md` for phases (E2E CI, test-app contract, RC smoke). diff --git a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md new file mode 100644 index 00000000..0b73a9e7 --- /dev/null +++ b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md @@ -0,0 +1,58 @@ +--- +name: cordova-smoke-ci-alignment +description: >- + Maps AppsFlyer Cordova CI (android/ios E2E workflows, ENV_FILE, sibling copy, smoke scripts) to + the tooling smoke/E2E story. Use when editing .github/workflows/*e2e*, rc-smoke (when added), + or debugging scenario CI. +globs: >- + .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,scripts/ci-android-e2e-scenario.sh, + scripts/af-scenario-runner.sh,scripts/e2e-cordova-build.sh,scripts/smoke-cordova-build.sh, + scripts/sync-test-app-e2e-copy.sh,scripts/sync-test-app-rc-smoke.sh,.af-e2e/**,.af-smoke/** +--- + +# Smoke / E2E CI alignment — AppsFlyer Cordova plugin + +Use when: + +- Editing or debugging **`android-e2e.yml`** / **`ios-e2e.yml`**. +- Explaining **`ENV_FILE`**, sibling **`.env`**, or artifact paths. +- Preparing **`rc-smoke.yml`** / promote flows (Phase 4 / 6 — not all files exist yet). + +## What exists today + +| Workflow | Role | +|----------|------| +| `.github/workflows/android-e2e.yml` | Ubuntu: Java 17, Android SDK, `e2e-cordova-build.sh android`, then emulator + `ci-android-e2e-scenario.sh` | +| `.github/workflows/ios-e2e.yml` | macOS: CocoaPods, `e2e-cordova-build.sh ios`, boot iPhone sim, `af-scenario-runner.sh` | + +Triggers: `workflow_dispatch`, `workflow_call`. Secrets: **`ENV_FILE`** (optional but required for real SDK checks). + +**Not yet in repo (see workplan):** `rc-smoke.yml`, `rc-release.yml` orchestration, `promote-release.yml`, `production-release.yml` as in Flutter — document when added. + +## E2E vs smoke directories + +| Flow | App directory | Plan | Build command in plan | +|------|---------------|------|------------------------| +| E2E | Sibling `../appsflyer-cordova-plugin-e2e/` (rsync + `file:` plugin URL) | `.af-e2e/test-plan.json` | `./scripts/e2e-cordova-build.sh ` | +| RC smoke | `test-app_rc_smoke/` (gitignored; `sync-test-app-rc-smoke.sh`) | `.af-smoke/rc-test-plan.json` | `./scripts/smoke-cordova-build.sh ` | + +## Android emulator runner quirks + +- **`script:` must be a single line** — use `bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh"`. +- **KVM:** udev + `chmod 666 /dev/kvm` so the job does not fall back to **`-accel off`** (multi‑minute boots). +- **`emulator-options`:** include `-gpu swiftshader_indirect` and `-no-boot-anim` if you override defaults (full override replaces action defaults). + +## Artifacts + +Upload **`.af-e2e/reports/`** on both workflows (`if: always()`, `if-no-files-found: warn`). When smoke CI exists, mirror **`.af-smoke/reports/`**. + +## Do not + +- Do not bypass **`af-scenario-runner.sh`** for release assertions; JSON reports are the contract output. +- Do not commit **`.env`** or embed dev keys in YAML. +- Do not add `SMOKE-*` / `E2E-*` phases to JSON without updating the tooling contracts and test app. + +## Cross-reference + +- `docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md` +- `appsflyer-mobile-plugin-tooling/docs/ci-alignment-guide.md` diff --git a/.claude/skills/cordova-test-app-contract/SKILL.md b/.claude/skills/cordova-test-app-contract/SKILL.md new file mode 100644 index 00000000..6bf92fe5 --- /dev/null +++ b/.claude/skills/cordova-test-app-contract/SKILL.md @@ -0,0 +1,62 @@ +--- +name: cordova-test-app-contract +description: >- + Build or review test-app/ for AppsFlyer Cordova against the tooling test-app contract: .env, + [AF_QA] logs, auto-run SDK order, afqa-cordova deep links, iOS af_qa_logs.txt. Use when + implementing Phase 3 or reviewing test-app PRs. +globs: test-app/**,.af-e2e/test-plan.json,.af-smoke/rc-test-plan.json +--- + +# Test app contract — AppsFlyer Cordova (`test-app/`) + +Use when: + +- Implementing **Phase 3** in `test-app/` (`www/`, `config.xml`, native hooks if needed). +- Reviewing PRs that touch the automation shell, logging, or deep links. +- Explaining why E2E/smoke **phase_1** checks fail (missing `[AF_QA]` lines or iOS file log). + +## Contract source + +Normative: [`appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/test-app-contract.md). + +## Cordova layout + +| Item | Location | +|------|----------| +| Widget / bundle id | `com.appsflyer.qa.cordova` — `test-app/config.xml` | +| JS entry | `test-app/www/js/index.js` (and `www/index.html`) | +| E2E execution | **Sibling copy** only — `scripts/sync-test-app-e2e-copy.sh` + `scripts/e2e-cordova-build.sh`; do not `cordova build` from in-repo `test-app/` with `file:..` (`ENAMETOOLONG` risk) | +| `.env` (E2E sibling) | `../appsflyer-cordova-plugin-e2e/.env` (CI: `ENV_FILE` secret) | +| Deep link scheme (plan) | `afqa-cordova://` — must match `deep_link_url` entries in `.af-e2e` / `.af-smoke` JSON | + +## Required behaviors (summary) + +1. Load **`DEV_KEY`**, **`APP_ID`** from `.env` at runtime; never commit `.env`. Provide `test-app/.env.example`. On missing key, log **`[AF_QA][CONFIG]`** and fail fast per contract. +2. Prefix structured lines with **`[AF_QA]`** — e.g. `[AF_QA][startSDK] result: SUCCESS`, `[AF_QA][CALLBACK][onInstallConversionData] …`. +3. **Auto-run** on launch (no UI): `shouldStartSdk: false` / manual start pattern per Cordova plugin API → register **onInstallConversionData**, **onAppOpenAttribution**, **onDeepLinking** → pre-start APIs → log **`--- Pre-start auto APIs complete ---`** → **`startSdk()`** → post-start APIs → three events (`af_demo_launch`, `af_purchase`, `af_content_view`). +4. **iOS:** append the same `[AF_QA]` lines to **`Documents/af_qa_logs.txt`** (Cordova: small native plugin, hook, or `cordova-plugin-file` — see workplan §Phase 3). The scenario runner prefers this file on iOS because `simctl log show` often misses WebView `console.log` output. + +## Scenario runner “ready” markers + +Plans poll / grep for lines including: + +- `[AF_QA][startSDK] result: SUCCESS` +- `[AF_QA][AUTO_APIS] --- Auto run complete ---` (or pre/post complete lines per plan JSON — keep spelling identical to `.af-e2e/test-plan.json`) + +Until the test app emits these, **phase_1 will fail** even when install/launch succeeds. + +## Reviewer checklist (Cordova) + +- [ ] Bundle/package id matches `.af-e2e` / `.af-smoke` `config.*` +- [ ] No hardcoded keys; `.env` documented +- [ ] Callbacks registered **before** `startSdk` +- [ ] `[AF_QA]` strings match plan patterns exactly +- [ ] Standard event names unchanged +- [ ] `afqa-cordova` URL scheme on Android + iOS +- [ ] iOS **`af_qa_logs.txt`** implemented and flushed +- [ ] Fresh install on clean device passes SMOKE/E2E cold-launch checks when plans are enabled + +## Do not + +- Do not change `[AF_QA]` prefix or plan regexes ad hoc. +- Do not gate auto-run behind a button — CI does not tap UI. diff --git a/.cursor/skills/cordova-rc-release b/.cursor/skills/cordova-rc-release new file mode 120000 index 00000000..1853a8f7 --- /dev/null +++ b/.cursor/skills/cordova-rc-release @@ -0,0 +1 @@ +../../.claude/skills/cordova-rc-release \ No newline at end of file diff --git a/.cursor/skills/cordova-scenario-runner b/.cursor/skills/cordova-scenario-runner new file mode 120000 index 00000000..724502a6 --- /dev/null +++ b/.cursor/skills/cordova-scenario-runner @@ -0,0 +1 @@ +../../.claude/skills/cordova-scenario-runner \ No newline at end of file diff --git a/.cursor/skills/cordova-smoke-ci-alignment b/.cursor/skills/cordova-smoke-ci-alignment new file mode 120000 index 00000000..d98dcad7 --- /dev/null +++ b/.cursor/skills/cordova-smoke-ci-alignment @@ -0,0 +1 @@ +../../.claude/skills/cordova-smoke-ci-alignment \ No newline at end of file diff --git a/.cursor/skills/cordova-test-app-contract b/.cursor/skills/cordova-test-app-contract new file mode 120000 index 00000000..2ca94595 --- /dev/null +++ b/.cursor/skills/cordova-test-app-contract @@ -0,0 +1 @@ +../../.claude/skills/cordova-test-app-contract \ No newline at end of file diff --git a/test-app/build.json b/test-app/build.json new file mode 100644 index 00000000..8570b826 --- /dev/null +++ b/test-app/build.json @@ -0,0 +1,10 @@ +{ + "ios": { + "debug": { + "buildFlag": "-destination generic/platform=iOS Simulator" + }, + "release": { + "buildFlag": "-destination generic/platform=iOS Simulator" + } + } +} From 5cdb4c21b32e76591c7a293cc739121471b72a6b Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 13:57:38 +0300 Subject: [PATCH 08/24] Phase 3. App implementation + Full E2E locally. --- .af-e2e/env.example | 20 ++ .af-e2e/test-plan.json | 18 +- .af-smoke/rc-test-plan.json | 14 +- .github/workflows/android-e2e.yml | 20 +- .github/workflows/ios-e2e.yml | 15 +- scripts/af-scenario-runner.sh | 15 +- scripts/e2e-cordova-build.sh | 5 + scripts/smoke-cordova-build.sh | 3 + scripts/sync-test-app-e2e-copy.sh | 3 + scripts/write-e2e-env-to-dir.sh | 36 +++ test-app/README.md | 20 +- test-app/config.xml | 11 + test-app/hooks/emit-af-qa-env.js | 39 +++ test-app/package.json | 10 +- test-app/www/index.html | 31 +- test-app/www/js/index.js | 513 +++++++++++++++++++++++++++++- 16 files changed, 716 insertions(+), 57 deletions(-) create mode 100644 .af-e2e/env.example create mode 100755 scripts/write-e2e-env-to-dir.sh create mode 100644 test-app/hooks/emit-af-qa-env.js diff --git a/.af-e2e/env.example b/.af-e2e/env.example new file mode 100644 index 00000000..841aa319 --- /dev/null +++ b/.af-e2e/env.example @@ -0,0 +1,20 @@ +# Credentials for the QA app id `com.appsflyer.qa.cordova` (create in AppsFlyer HQ). +# Same key=value lines are used locally and in CI. +# +# Local E2E sibling (default: ../-e2e/.env) +# `./scripts/e2e-cordova-build.sh` runs `write-e2e-env-to-dir.sh` and writes sibling `.env` from: +# `ENV_FILE` (CI), else `.af-e2e/.env.local`, else `test-app/.env`. +# Or seed an empty template: ./scripts/bootstrap-e2e-env.sh then edit sibling `.env`. +# +# GitHub Actions +# Repository secret `ENV_FILE`: pass on the **Build Cordova E2E** step as `env: ENV_FILE: ${{ secrets.ENV_FILE }}`. +# `e2e-cordova-build.sh` calls `write-e2e-env-to-dir.sh`, which writes the sibling `.env` (same body +# as a pasted multiline secret). Typical lines: +# DEV_KEY= +# APP_ID= +# Use the **Apple ID** from App Store Connect (App Information), digits only. Until you have one, +# a throwaway placeholder like `2485629345` can unblock local init; replace before relying on HQ data. +# Lines starting with `#` in `.env` are ignored by `hooks/emit-af-qa-env.js`. +# +DEV_KEY= +APP_ID= diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index ee038aa7..11f15db9 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -2,7 +2,7 @@ "_meta": { "plan_id": "cordova-e2e", "plugin": "cordova", - "version": "1.0.0", + "version": "1.0.3", "description": "End-to-end test plan for the AppsFlyer Cordova plugin. build_cmd syncs test-app/ to a sibling directory (../appsflyer-cordova-plugin-e2e by default) then builds with file: plugin path. Covers E2E-001..E2E-006 per appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.", "platforms": [ "android", @@ -134,18 +134,16 @@ "id": "phase_2", "name": "Background deep link", "scenario_ref": "E2E-002", - "description": "App is brought down after Phase 1 SDK start; the deep-link URL re-launches it. On Android the app is HOME-keyed to background and re-entered via VIEW intent. On iOS the app is terminated and re-launched with `-deepLinkURL `, which the Cordova app's URL handler / AppDelegate hook (same deep link pipeline as production) \u2014 same SDK pipeline as a real custom-scheme open, but without iOS 17/18-simulator's 'Open in ?' confirmation prompt that `simctl openurl` triggers and that nothing in a non-interactive CI run can dismiss. Both paths fire onDeepLinking with Status.FOUND.", + "description": "App is brought down after Phase 1 SDK start; the deep-link URL re-launches it. On Android the app is sent to the background by starting the default launcher (`ACTION_MAIN` + `CATEGORY_HOME`) with **2s on-device dwell** in one `adb shell` compound — then re-entered via VIEW intent. On iOS the app is terminated with **`simctl terminate` then `sleep 2` on the same shell line** (runner `eval`) so the process fully tears down before `-deepLinkURL` relaunch; avoids a separate host-only `sleep` that can race the trigger. Same SDK pipeline as a real custom-scheme open without iOS 17/18-simulator's `simctl openurl` confirmation prompt. Both paths fire onDeepLinking with Status.FOUND.", "requires_fresh_install": false, "wait_after_trigger_sec": 90, "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", "pre_actions": { "android": [ - "adb shell input keyevent KEYCODE_HOME", - "sleep 2" + "adb shell \"am start -a android.intent.action.MAIN -c android.intent.category.HOME && sleep 2\"" ], "ios": [ - "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", - "sleep 1" + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}} && sleep 2" ] }, "trigger": { @@ -183,19 +181,17 @@ "id": "phase_3", "name": "Foreground deep link", "scenario_ref": "E2E-003", - "description": "Fresh install. App is in foreground after SDK start. On Android a brief HOME switch triggers onPause and the VIEW intent brings the app back. On iOS the app is terminated and re-launched with `-deepLinkURL ` (same launch-arg path as phase_2), since `simctl openurl` against a running iOS-simulator app still triggers the 'Open in ?' confirmation prompt; the foreground-vs-killed distinction is Android-only. Both paths fire onDeepLinking with Status.FOUND.", + "description": "Fresh install. App is in foreground after SDK start. On Android a brief switch to the default launcher (`ACTION_MAIN` + `CATEGORY_HOME`) triggers `onPause` without killing the process; the same **2 second on-device dwell** as E2E phase_2 (gesture-navigation friendly) runs before the VIEW deep-link intent brings the app back. On iOS **`simctl terminate` + `sleep 2`** on one shell line (same as phase_2), then `-deepLinkURL` relaunch; `simctl openurl` against a running simulator still triggers the 'Open in ?' prompt. The foreground-vs-killed distinction is Android-only. Both paths fire onDeepLinking with Status.FOUND.", "requires_fresh_install": true, "wait_after_launch_sec": 240, "wait_after_trigger_sec": 90, "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", "pre_actions": { "android": [ - "adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", - "sleep 1" + "adb shell \"am start -a android.intent.action.MAIN -c android.intent.category.HOME && sleep 2\"" ], "ios": [ - "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", - "sleep 1" + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}} && sleep 2" ] }, "trigger": { diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json index e14bded4..bb629f01 100644 --- a/.af-smoke/rc-test-plan.json +++ b/.af-smoke/rc-test-plan.json @@ -2,7 +2,7 @@ "_meta": { "plan_id": "cordova-rc-smoke", "plugin": "cordova", - "version": "1.0.0", + "version": "1.0.3", "description": "Post-publish smoke plan for the AppsFlyer Cordova plugin. SMOKE-001..003 against test-app_rc_smoke/ (CI-synthesized; build_cmd runs scripts/smoke-cordova-build.sh which rsyncs test-app/ then pins cordova-plugin-appsflyer-sdk to the npm version from root package.json or SMOKE_PLUGIN_VERSION).", "platforms": [ "android", @@ -105,12 +105,10 @@ "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", "pre_actions": { "android": [ - "adb shell input keyevent KEYCODE_HOME", - "sleep 2" + "adb shell \"am start -a android.intent.action.MAIN -c android.intent.category.HOME && sleep 2\"" ], "ios": [ - "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", - "sleep 1" + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}} && sleep 2" ] }, "trigger": { @@ -155,12 +153,10 @@ "deep_link_url": "afqa-cordova://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", "pre_actions": { "android": [ - "adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", - "sleep 1" + "adb shell \"am start -a android.intent.action.MAIN -c android.intent.category.HOME && sleep 2\"" ], "ios": [ - "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", - "sleep 1" + "xcrun simctl terminate {{UDID}} {{BUNDLE_ID}} && sleep 2" ] }, "trigger": { diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 097d4929..28c908c9 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -1,5 +1,5 @@ # Cordova plugin: build E2E sibling app + optional af-scenario-runner on an Android emulator. -# Mirrors Flutter appsflyer-flutter-plugin android-e2e layout (KVM, DNS, nslookup, ENV_FILE → sibling .env). +# Mirrors Flutter appsflyer-flutter-plugin android-e2e layout (KVM, DNS, nslookup, ENV_FILE on build step → write-e2e-env-to-dir). # Trigger: workflow_dispatch (manual) or workflow_call (e.g. from rc-release in Phase 6). name: Android E2E (scenario runner) @@ -63,27 +63,17 @@ jobs: - name: Install Cordova CLI run: npm install -g cordova - - name: Write E2E sibling .env - env: - ENV_FILE: ${{ secrets.ENV_FILE }} - run: | - set -euo pipefail - PLUGIN_ROOT="${GITHUB_WORKSPACE}" - E2E_DIR="$(dirname "${PLUGIN_ROOT}")/$(basename "${PLUGIN_ROOT}")-e2e" - mkdir -p "${E2E_DIR}" - if [[ -z "${ENV_FILE:-}" ]]; then - echo "::notice::ENV_FILE secret is unset; scenario phases that need DEV_KEY/APP_ID will fail until the repository (or caller) secret is configured." - else - printf '%s\n' "${ENV_FILE}" > "${E2E_DIR}/.env" - fi - - name: Build Cordova E2E (Android) env: + ENV_FILE: ${{ secrets.ENV_FILE }} CORDOVA_E2E_ANDROID_JAVA_HOME: ${{ env.JAVA_HOME }} run: | set -euo pipefail cd "${GITHUB_WORKSPACE}" chmod +x scripts/*.sh + if [[ -z "${ENV_FILE:-}" ]]; then + echo "::notice::ENV_FILE secret is unset; scenario phases that need DEV_KEY/APP_ID will fail until the repository (or caller) secret is configured." + fi ./scripts/e2e-cordova-build.sh android # Some `ubuntu-latest` runner images ship `/dev/kvm` owned by group diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index c1787cb0..e71a18a4 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -56,23 +56,16 @@ jobs: - name: Install Cordova CLI run: npm install -g cordova - - name: Write E2E sibling .env + - name: Build Cordova E2E (iOS) env: ENV_FILE: ${{ secrets.ENV_FILE }} run: | - PLUGIN_ROOT="${GITHUB_WORKSPACE}" - E2E_DIR="$(dirname "${PLUGIN_ROOT}")/$(basename "${PLUGIN_ROOT}")-e2e" - mkdir -p "${E2E_DIR}" + set -euo pipefail + cd "${GITHUB_WORKSPACE}" + chmod +x scripts/*.sh if [[ -z "${ENV_FILE:-}" ]]; then echo "::notice::ENV_FILE secret is unset; scenario phases that need DEV_KEY/APP_ID will fail until the repository (or caller) secret is configured." - else - printf '%s\n' "${ENV_FILE}" > "${E2E_DIR}/.env" fi - - - name: Build Cordova E2E (iOS) - run: | - cd "${GITHUB_WORKSPACE}" - chmod +x scripts/*.sh ./scripts/e2e-cordova-build.sh ios - name: Shutdown simulators and boot iPhone diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index d26984b4..0b5ebbd9 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -253,8 +253,8 @@ android_collect_logs() { } android_background_app() { - log_info "Backgrounding app (HOME key)..." - adb shell input keyevent KEYCODE_HOME + log_info "Backgrounding app (launcher HOME intent + 2s on device)..." + adb shell "am start -a android.intent.action.MAIN -c android.intent.category.HOME && sleep 2" } android_trigger_deeplink() { @@ -590,7 +590,9 @@ validate_check() { local minimum minimum=$(echo "$check_json" | jq -r '.minimum // 1') local count - count=$(grep -cE "$pattern" "$log_file" 2>/dev/null || echo "0") + count=$(grep -cE "$pattern" "$log_file" 2>/dev/null || true) + count=$(printf '%s' "$count" | tr -d '\n\r') + [[ "$count" =~ ^[0-9]+$ ]] || count=0 if [[ "$count" -ge "$minimum" ]]; then echo "{\"status\":\"PASS\",\"evidence\":\"Found ${count} matches (minimum: ${minimum})\"}" else @@ -606,7 +608,12 @@ validate_check() { while IFS= read -r forbidden_pattern; do forbidden_pattern=$(echo "$forbidden_pattern" | jq -r '.') local found - found=$(grep -F "$forbidden_pattern" "$log_file" 2>/dev/null | head -1 || true) + # `response code:4` / `:5` substring-match unrelated vendor noise (e.g. `response code:590`). + if [[ "$forbidden_pattern" == "response code:4" || "$forbidden_pattern" == "response code:5" ]]; then + found=$(grep -Ei 'appsflyer|\[AF_QA\]|appsflyersdk' "$log_file" 2>/dev/null | grep -F "$forbidden_pattern" | head -1 || true) + else + found=$(grep -F "$forbidden_pattern" "$log_file" 2>/dev/null | head -1 || true) + fi if [[ -n "$found" ]]; then status="FAIL" evidence="Forbidden pattern found: ${forbidden_pattern} -> $(echo "$found" | head -c 200)" diff --git a/scripts/e2e-cordova-build.sh b/scripts/e2e-cordova-build.sh index 5d6eba18..bdaa9d5e 100755 --- a/scripts/e2e-cordova-build.sh +++ b/scripts/e2e-cordova-build.sh @@ -9,6 +9,9 @@ # ./scripts/e2e-cordova-build.sh ios run # build + install/launch on simulator # # Env: +# ENV_FILE Multiline `.env` body (DEV_KEY=, APP_ID=). CI: set from GitHub secret on +# this script’s environment (same pattern as Flutter E2E). Local: optional +# if `.af-e2e/.env.local` or `test-app/.env` exists — see scripts/write-e2e-env-to-dir.sh. # TEST_APP_E2E_COPY_DEST Same as sync-test-app-e2e-copy.sh (must match .af-e2e paths if non-default) # CORDOVA_E2E_ANDROID_JAVA_HOME If set, used as JAVA_HOME for Android builds only (overrides auto-pick). # CORDOVA_E2E_RESPECT_JAVA_HOME If set to 1, do not change JAVA_HOME for Android (Gradle may still use @@ -98,6 +101,8 @@ else DEST="$(cat "${ROOT}/.af-e2e/e2e_copy_dest.txt")" fi +"${ROOT}/scripts/write-e2e-env-to-dir.sh" "$DEST" "$ROOT" + cd "$DEST" npm install diff --git a/scripts/smoke-cordova-build.sh b/scripts/smoke-cordova-build.sh index ae14d644..9a945312 100755 --- a/scripts/smoke-cordova-build.sh +++ b/scripts/smoke-cordova-build.sh @@ -9,6 +9,7 @@ # ./scripts/smoke-cordova-build.sh ios run # # Env: +# ENV_FILE Same as e2e-cordova-build.sh — written to test-app_rc_smoke/.env before build. # SMOKE_PLUGIN_VERSION Passed to sync-test-app-rc-smoke.sh (default: root package.json version). # CORDOVA_E2E_ANDROID_JAVA_HOME Android: same semantics as e2e-cordova-build.sh (optional JDK pin). # CORDOVA_E2E_RESPECT_JAVA_HOME Android: same as e2e script. @@ -89,6 +90,8 @@ stop_android_gradle_daemon_if_present() { export SMOKE_PLUGIN_VERSION="${SMOKE_PLUGIN_VERSION:-}" "${ROOT}/scripts/sync-test-app-rc-smoke.sh" +"${ROOT}/scripts/write-e2e-env-to-dir.sh" "$DEST" "$ROOT" + cd "$DEST" npm install diff --git a/scripts/sync-test-app-e2e-copy.sh b/scripts/sync-test-app-e2e-copy.sh index ba875c47..2341f73b 100755 --- a/scripts/sync-test-app-e2e-copy.sh +++ b/scripts/sync-test-app-e2e-copy.sh @@ -61,3 +61,6 @@ mkdir -p "${ROOT}/.af-e2e" echo "${DEST}" > "${ROOT}/.af-e2e/e2e_copy_dest.txt" echo "[sync-test-app-e2e-copy] ${ROOT}/test-app/ → ${DEST}" +if [[ ! -f "${DEST}/.env" ]]; then + echo "[sync-test-app-e2e-copy] hint: no ${DEST}/.env yet — run ./scripts/e2e-cordova-build.sh (writes .env like Flutter), or ./scripts/bootstrap-e2e-env.sh for a template-only sibling .env." +fi diff --git a/scripts/write-e2e-env-to-dir.sh b/scripts/write-e2e-env-to-dir.sh new file mode 100755 index 00000000..6dbe6bc4 --- /dev/null +++ b/scripts/write-e2e-env-to-dir.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Write Cordova project-root `.env` before `cordova prepare` / `cordova build` (same idea as Flutter +# writing the app `.env` inside the build driver). Called from e2e-cordova-build.sh and +# smoke-cordova-build.sh. +# +# Usage: +# ./scripts/write-e2e-env-to-dir.sh +# +# Precedence (first match wins): +# 1) ENV_FILE — multiline string (GitHub Actions: pass secrets.ENV_FILE on the build step env). +# 2) /.af-e2e/.env.local — gitignored local mirror of ENV_FILE. +# 3) /test-app/.env — gitignored per-dev file. +# Else: leave existing /.env untouched; if missing, print a notice only. + +set -euo pipefail + +DEST="${1:?usage: $0 }" +ROOT="${2:?usage: $0 }" +mkdir -p "$DEST" + +if [[ -n "${ENV_FILE:-}" ]]; then + printf '%s\n' "${ENV_FILE}" > "${DEST}/.env" + echo "[write-e2e-env-to-dir] wrote ${DEST}/.env from ENV_FILE" +elif [[ -f "${ROOT}/.af-e2e/.env.local" ]]; then + cp "${ROOT}/.af-e2e/.env.local" "${DEST}/.env" + echo "[write-e2e-env-to-dir] copied ${ROOT}/.af-e2e/.env.local → ${DEST}/.env" +elif [[ -f "${ROOT}/test-app/.env" ]]; then + cp "${ROOT}/test-app/.env" "${DEST}/.env" + echo "[write-e2e-env-to-dir] copied ${ROOT}/test-app/.env → ${DEST}/.env" +else + if [[ -f "${DEST}/.env" ]]; then + echo "[write-e2e-env-to-dir] leaving existing ${DEST}/.env (no ENV_FILE / .af-e2e/.env.local / test-app/.env)" + else + echo "[write-e2e-env-to-dir] notice: no ENV_FILE, ${ROOT}/.af-e2e/.env.local, or ${ROOT}/test-app/.env — ${DEST}/.env missing (Cordova hook will emit empty DEV_KEY)" >&2 + fi +fi diff --git a/test-app/README.md b/test-app/README.md index 17d764dd..1aabe079 100644 --- a/test-app/README.md +++ b/test-app/README.md @@ -4,7 +4,25 @@ This folder is the **source of truth** for the small Cordova app used by **`.af- - **`examples/cordovatestapp/`** stays the **manual / legacy** sample; scenario runner targets **`test-app`** contract work, not that tree. - **`widget id`** is **`com.appsflyer.qa.cordova`** (D5). HQ app + **`.env`** — see repo **`docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md`** §0.5. -- **Phase 3** adds the full [**test-app contract**](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/test-app-contract.md). +- **Phase 3 (contract)** is implemented in **`www/js/index.js`**: **`[AF_QA]`** structured logs, **`shouldStartSdk: false`** + **`startSdk()`**, three standard events + **`af_qa_custom_purchase`** + identity + **`Stop(true/false)`** cycle, marker **`[AF_QA][AUTO_APIS] --- Auto run complete ---`**. Normative doc: [**test-app contract**](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/test-app-contract.md). + +### `.env` and `www/af-qa-env.js` + +- **Where the hook looks:** **`hooks/emit-af-qa-env.js`** runs on **`cordova prepare`** / **`build`**, reads **the Cordova project root’s** **`.env`**, and writes **`www/af-qa-env.js`** (`window.__AF_QA_ENV__`). **`www/index.html`** loads that file before **`js/index.js`**. For E2E, the project root is the **sibling** (path in **`.af-e2e/e2e_copy_dest.txt`**), not `test-app/` in the plugin repo unless you build there. +- **Credentials:** **`./scripts/e2e-cordova-build.sh`** runs **`scripts/write-e2e-env-to-dir.sh`** after sync and **before** **`npm install` / `cordova build`**, writing the E2E sibling **`.env`**: **`ENV_FILE`** env (GitHub: **`secrets.ENV_FILE`** on the build step), else **`.af-e2e/.env.local`**, else **`test-app/.env`**. **`hooks/emit-af-qa-env.js`** runs on **`before_prepare`** and writes **`www/af-qa-env.js`** so keys are present when www is copied into native projects. +- **E2E sibling only (no `test-app/.env`):** template **`.af-e2e/env.example`**. Run **`./scripts/bootstrap-e2e-env.sh`** once to seed **`$(cat .af-e2e/e2e_copy_dest.txt)/.env`** (from plugin repo root) if missing, then edit. Paste the same two **`KEY=value`** lines into the GitHub Actions secret **`ENV_FILE`** (multiline). +- Never commit **`.env`**. If you still see **`[AF_QA][CONFIG] DEV_KEY missing`**, the sibling **`.env`** was empty or missing at **`cordova prepare`** time — re-sync (or edit sibling **`.env`**) and rebuild. + +### Plugins used by the QA shell only + +- **`cordova-plugin-file`** — append **`[AF_QA]`** lines to **`af_qa_logs.txt`** under **`cordova.file.dataDirectory`** (iOS Documents / Android `files/`) so **`af-scenario-runner.sh`** can read the same markers as **`simctl log`** / **`adb logcat`**. +- **`cordova-plugin-customurlscheme`** — registers **`afqa-cordova://`** for deep-link phases (see **`.af-e2e`** `deep_link_url` values). + +### iOS deep link: `simctl launch … -deepLinkURL` + +E2E **phase_2+** uses **`xcrun simctl launch -deepLinkURL ""`** so CI avoids the iOS Simulator **“Open in …?”** sheet that **`simctl openurl`** shows for custom schemes. That launch flag does **not** call `application:openURL:options:`. + +**Flutter parity:** the Flutter **example** app patches **`AppDelegate.swift`** to read **`-deepLinkURL`** from **`ProcessInfo`** (and optionally **`UserDefaults` `deepLinkURL`**) and, after a **5s** delay, replay through **`application(_:open:options:)`**. This repo’s **`test-app`** does the same in **`hooks/afqa-ios-simctl-deeplink-replay.js`**: on **`after_prepare`**, it injects a small block into the generated **`platforms/ios/**/AppDelegate.m`** that schedules **`[[AppsFlyerAttribution shared] handleOpenUrl:… options:@{}]`** — the same queue the AppsFlyer Cordova plugin uses for real URL opens. Re-run **`cordova prepare ios`** (or **`e2e-cordova-build.sh ios`**) after upgrading **cordova-ios** if the stock **`AppDelegate.m`** template changes (the hook looks for a specific `return [super application:…]` line). ## Why you should not `cordova build` here diff --git a/test-app/config.xml b/test-app/config.xml index dd384f8f..7c004aea 100644 --- a/test-app/config.xml +++ b/test-app/config.xml @@ -8,8 +8,19 @@ + + + + + + + + + + + diff --git a/test-app/hooks/emit-af-qa-env.js b/test-app/hooks/emit-af-qa-env.js new file mode 100644 index 00000000..82031be6 --- /dev/null +++ b/test-app/hooks/emit-af-qa-env.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Writes www/af-qa-env.js from project-root .env so the Cordova webview can read DEV_KEY / APP_ID + * without bundling secrets into source. Runs on **before_prepare** so the file exists before www is + * copied into native projects (after_prepare would be too late for packaged assets). + * + * .env format: KEY=value lines (DEV_KEY, APP_ID). Lines starting with # ignored. + */ +const fs = require('fs'); +const path = require('path'); + +module.exports = function (context) { + const root = context.opts.projectRoot; + const envPath = path.join(root, '.env'); + const outPath = path.join(root, 'www', 'af-qa-env.js'); + let devKey = ''; + let appId = ''; + + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const val = trimmed.slice(eq + 1).trim(); + if (key === 'DEV_KEY') devKey = val; + if (key === 'APP_ID') appId = val; + } + } + + const payload = { DEV_KEY: devKey, APP_ID: appId }; + const body = + "'use strict';\n" + + '// Generated by hooks/emit-af-qa-env.js from .env — do not edit.\n' + + `window.__AF_QA_ENV__ = ${JSON.stringify(payload)};\n`; + fs.writeFileSync(outPath, body, 'utf8'); +}; diff --git a/test-app/package.json b/test-app/package.json index e35eedb6..5de0139b 100644 --- a/test-app/package.json +++ b/test-app/package.json @@ -15,12 +15,18 @@ "devDependencies": { "cordova-android": "^13.0.0", "cordova-ios": "^7.1.1", - "cordova-plugin-appsflyer-sdk": "file:.." + "cordova-plugin-appsflyer-sdk": "file:..", + "cordova-plugin-customurlscheme": "^5.0.2", + "cordova-plugin-file": "^8.1.0" }, "cordova": { "platforms": [], "plugins": { - "cordova-plugin-appsflyer-sdk": {} + "cordova-plugin-appsflyer-sdk": {}, + "cordova-plugin-file": {}, + "cordova-plugin-customurlscheme": { + "URL_SCHEME": "afqa-cordova" + } } } } diff --git a/test-app/www/index.html b/test-app/www/index.html index 414af056..5758bf71 100644 --- a/test-app/www/index.html +++ b/test-app/www/index.html @@ -7,10 +7,39 @@ AF QA Cordova + -

AppsFlyer Cordova QA shell — contract logging and SDK wiring land in Phase 3.

+

AppsFlyer Cordova QA shell — auto-run contract (Phase 3).

+
+
+ QA log (on-device, local testing) +

+            
+
+ diff --git a/test-app/www/js/index.js b/test-app/www/js/index.js index 235da37d..52a5ebd8 100644 --- a/test-app/www/js/index.js +++ b/test-app/www/js/index.js @@ -1,3 +1,510 @@ -document.addEventListener('deviceready', function () { - console.log('[AF_QA][BOOT] deviceready (placeholder until Phase 3 contract)'); -}, false); +/* global cordova */ +(function () { + 'use strict'; + + var fileAppendChain = Promise.resolve(); + + function afQaAppendFileLine(line) { + if (!window.cordova || !cordova.file) { + return fileAppendChain; + } + fileAppendChain = fileAppendChain.then(function () { + return new Promise(function (resolve) { + window.resolveLocalFileSystemURL( + cordova.file.dataDirectory, + function (dirEntry) { + dirEntry.getFile( + 'af_qa_logs.txt', + { create: true }, + function (fileEntry) { + fileEntry.file(function (file) { + var reader = new FileReader(); + reader.onloadend = function () { + var prev = typeof reader.result === 'string' ? reader.result : ''; + fileEntry.createWriter(function (writer) { + writer.onwriteend = function () { + resolve(); + }; + writer.onerror = function () { + resolve(); + }; + writer.write(prev + line + '\n'); + }, function () { + resolve(); + }); + }; + reader.onerror = function () { + resolve(); + }; + reader.readAsText(file); + }, function () { + resolve(); + }); + }, + function () { + resolve(); + } + ); + }, + function () { + resolve(); + } + ); + }); + }); + return fileAppendChain; + } + + function afQaAppendUiLine(line) { + var el = document.getElementById('af-qa-log-view'); + if (!el) { + return; + } + el.appendChild(document.createTextNode(line + '\n')); + el.scrollTop = el.scrollHeight; + } + + async function afQaLog(line) { + console.log(line); + afQaAppendUiLine(line); + afQaAppendFileLine(line); + await fileAppendChain; + } + + function stringifyRes(res) { + if (res === undefined || res === null) { + return ''; + } + if (typeof res === 'string') { + return res; + } + try { + return JSON.stringify(res); + } catch (e) { + return String(res); + } + } + + /** + * Pull deep_link_value / deepLinkValue from UDL JSON (nested maps, link URLs, or JSON string fallbacks). + */ + function extractDeepLinkValueFromUdl(o) { + if (!o || typeof o !== 'object') { + return ''; + } + var visit = function (node, depth) { + if (depth > 8 || node == null) { + return ''; + } + if (typeof node === 'string') { + if (node.length > 1 && (node.charAt(0) === '{' || node.charAt(0) === '[')) { + try { + var parsed = JSON.parse(node); + var fromParsed = visit(parsed, depth + 1); + if (fromParsed) { + return fromParsed; + } + } catch (e0) { + /* not JSON */ + } + } + if (/^qa_deeplink_(bg|fg)$/.test(node)) { + return node; + } + var um = node.match(/[?&]deep_link_value=([^&]+)/); + if (um) { + try { + return decodeURIComponent(um[1]); + } catch (e) { + return um[1]; + } + } + return ''; + } + if (typeof node !== 'object') { + return ''; + } + var k; + for (k in node) { + if (!Object.prototype.hasOwnProperty.call(node, k)) continue; + var lk = k.toLowerCase(); + var val = node[k]; + if ( + (lk === 'deep_link_value' || lk === 'deeplinkvalue' || lk === 'deep_value') && + val != null && + String(val) !== '' + ) { + return String(val); + } + if (lk === 'link' && typeof val === 'string') { + var fromLink = visit(val, depth + 1); + if (fromLink) return fromLink; + } + if ( + typeof val === 'string' && + val !== '' && + (val.indexOf('deep_link_value=') !== -1 || val.indexOf('afqa-') !== -1 || val.indexOf('://') !== -1) + ) { + var fromUrlish = visit(val, depth + 1); + if (fromUrlish) return fromUrlish; + } + if (val && typeof val === 'object') { + var inner = visit(val, depth + 1); + if (inner) return inner; + } + if (typeof val === 'string' && /^qa_deeplink_(bg|fg)$/.test(val)) { + return val; + } + } + return ''; + }; + var out = visit(o, 0); + if (out) { + return out; + } + try { + var s = JSON.stringify(o); + var qm = s.match(/\b(qa_deeplink_(?:bg|fg))\b/); + if (qm) { + return qm[1]; + } + var m = s.match(/"deep_link_value"\s*:\s*"([^"]+)"/); + if (m) return m[1]; + m = s.match(/"deepLinkValue"\s*:\s*"([^"]+)"/); + if (m) return m[1]; + m = s.match(/deep_link_value=([^&"\\]+)/); + if (m) { + try { + return decodeURIComponent(m[1]); + } catch (e2) { + return m[1]; + } + } + m = s.match(/afqa-cordova:\/\/[^"'\\s]*[?&]deep_link_value=([^&"'\\]+)/); + if (m) { + try { + return decodeURIComponent(m[1]); + } catch (e4) { + return m[1]; + } + } + } catch (e3) { + return ''; + } + return ''; + } + + /** + * Normative line shape per appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md — same + * substrings the scenario runner greps (`status=Status.FOUND`, `deepLinkValue=…`). + */ + function parseDeepLinkNativePayload(raw) { + var o = raw; + if (typeof raw === 'string') { + try { + o = JSON.parse(raw); + } catch (e) { + return { statusLabel: 'Status.ERROR', deepLinkValue: '' }; + } + } + if (!o || typeof o !== 'object') { + return { statusLabel: 'Status.ERROR', deepLinkValue: '' }; + } + var ds = o.deepLinkStatus != null ? String(o.deepLinkStatus) : ''; + var statusLabel = 'Status.ERROR'; + if (ds === 'FOUND' || ds === 'Found' || ds.indexOf('FOUND') !== -1) { + statusLabel = 'Status.FOUND'; + } else if (ds === 'NOT_FOUND' || ds === 'NotFound' || ds.indexOf('NOT_FOUND') !== -1) { + statusLabel = 'Status.NOT_FOUND'; + } else if (ds === 'Error' || ds === 'FAILURE' || ds === 'Failure' || ds.indexOf('Error') !== -1) { + statusLabel = 'Status.ERROR'; + } + var dlv = extractDeepLinkValueFromUdl(o); + if (!dlv && o.deepLinkValue != null && String(o.deepLinkValue) !== '') { + dlv = String(o.deepLinkValue); + } + if (!dlv && o.data != null) { + var d = o.data; + if (typeof d === 'string') { + try { + d = JSON.parse(d); + } catch (e1) { + d = null; + } + } + if (d && typeof d === 'object') { + dlv = extractDeepLinkValueFromUdl(d); + } + } + return { statusLabel: statusLabel, deepLinkValue: dlv }; + } + + function formatOnDeepLinkingContractLine(res) { + var n = parseDeepLinkNativePayload(res); + return ( + '[AF_QA][CALLBACK][onDeepLinking] received: status=' + + n.statusLabel + + ', deepLinkValue=' + + n.deepLinkValue + ); + } + + function waitMs(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); + } + + document.addEventListener( + 'deviceready', + function () { + runAfQaContract().catch(function (e) { + var msg = e && e.message ? e.message : String(e); + afQaLog('[AF_QA][startSDK] error: ' + msg); + }); + }, + false + ); + + async function runAfQaContract() { + await afQaLog('[AF_QA][BOOT] deviceready'); + + var env = window.__AF_QA_ENV__ || {}; + if (!env.DEV_KEY) { + await afQaLog('[AF_QA][CONFIG] DEV_KEY missing'); + return; + } + if (!env.APP_ID) { + await afQaLog('[AF_QA][CONFIG] APP_ID missing'); + return; + } + + var af = window.plugins.appsFlyer; + + af.registerOnAppOpenAttribution( + function (res) { + void afQaLog('[AF_QA][CALLBACK][onAppOpenAttribution] received: ' + stringifyRes(res)); + }, + function (err) { + void afQaLog('[AF_QA][CALLBACK][onAppOpenAttribution] error: ' + stringifyRes(err)); + } + ); + + af.registerDeepLink(function (res) { + void (async function () { + await afQaLog(formatOnDeepLinkingContractLine(res)); + await afQaLog('[AF_QA][CALLBACK][onDeepLinking] raw: ' + stringifyRes(res)); + })(); + }); + + var initOpts = { + devKey: env.DEV_KEY, + appId: env.APP_ID, + isDebug: true, + onInstallConversionDataListener: true, + onDeepLinkListener: true, + shouldStartSdk: false + }; + + await new Promise(function (resolve, reject) { + var settled = false; + af.initSdk( + initOpts, + function (gcd) { + void afQaLog( + '[AF_QA][CALLBACK][onInstallConversionData] received: ' + stringifyRes(gcd) + ); + }, + function (err) { + void afQaLog('[AF_QA][startSDK] error: initSdk ' + stringifyRes(err)); + if (!settled) { + settled = true; + reject(new Error(stringifyRes(err))); + } + } + ); + setTimeout(function () { + if (!settled) { + settled = true; + resolve(); + } + }, 1500); + }); + + af.setAppUserId('e2e_user_42'); + await afQaLog('[AF_QA][setCustomerUserId] result: e2e_user_42'); + + af.setCurrencyCode('EUR'); + await afQaLog('[AF_QA][setCurrencyCode] result: EUR'); + + af.setAdditionalData({ tenant: 'e2e_tenant', e2e_flag: '1' }); + await afQaLog( + '[AF_QA][setAdditionalData] keys: tenant,e2e_flag payload=' + + JSON.stringify({ tenant: 'e2e_tenant', e2e_flag: '1' }) + ); + + await afQaLog('[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---'); + + af.startSdk(); + await afQaLog('[AF_QA][startSDK] result: SUCCESS'); + + await waitMs(400); + + await new Promise(function (resolve) { + af.getSdkVersion(function (v) { + afQaLog('[AF_QA][getSDKVersion] result: ' + v); + resolve(); + }); + }); + + await new Promise(function (resolve) { + af.getAppsFlyerUID(function (uid) { + afQaLog('[AF_QA][getAppsFlyerUID] result: ' + uid); + resolve(); + }); + }); + + await afQaLog('[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---'); + + await new Promise(function (resolve) { + af.logEvent( + 'af_demo_launch', + {}, + function () { + afQaLog('[AF_QA][logEvent(af_demo_launch)] result: SUCCESS'); + resolve(); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_demo_launch ' + stringifyRes(err)); + resolve(); + } + ); + }); + + await new Promise(function (resolve) { + af.logEvent( + 'af_purchase', + { + af_revenue: '12.34', + af_currency: 'USD', + af_content_id: 'qa_sku_1' + }, + function () { + afQaLog('[AF_QA][logEvent: af_purchase sent] result: SUCCESS'); + resolve(); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_purchase ' + stringifyRes(err)); + resolve(); + } + ); + }); + + await new Promise(function (resolve) { + af.logEvent( + 'af_content_view', + { af_content_type: 'qa', af_content_id: 'home' }, + function () { + afQaLog('[AF_QA][logEvent: af_content_view sent] result: SUCCESS'); + resolve(); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_content_view ' + stringifyRes(err)); + resolve(); + } + ); + }); + + await new Promise(function (resolve) { + af.logEvent( + 'af_qa_custom_purchase', + { + af_revenue: '9.99', + af_currency: 'USD', + metadata: { tier: 'gold', seats: 2 } + }, + function () { + afQaLog( + '[AF_QA][logEvent] name=af_qa_custom_purchase payload=' + + JSON.stringify({ + af_revenue: '9.99', + af_currency: 'USD', + metadata: { tier: 'gold', seats: 2 } + }) + ); + resolve(); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_qa_custom_purchase ' + stringifyRes(err)); + resolve(); + } + ); + }); + + await new Promise(function (resolve) { + var identityPayload = { + customer_user_id: 'e2e_user_42', + tenant: 'e2e_tenant', + check: 'identity_round_trip' + }; + af.logEvent( + 'af_qa_identity_check', + identityPayload, + function () { + afQaLog( + '[AF_QA][logEvent] name=af_qa_identity_check payload=' + + JSON.stringify(identityPayload) + ).then(function () { + return afQaLog('[AF_QA][event_payload] customer_user_id=e2e_user_42'); + }).then(function () { + resolve(); + }); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_qa_identity_check ' + stringifyRes(err)); + resolve(); + } + ); + }); + + af.Stop(true); + await afQaLog('[AF_QA][stop] result: true'); + + await new Promise(function (resolve) { + af.logEvent( + 'af_qa_suppressed', + { note: 'must_not_http_200_while_stopped' }, + function () { + afQaLog('[AF_QA][logEvent] name=af_qa_suppressed (unexpected success while stopped)'); + resolve(); + }, + function () { + resolve(); + } + ); + }); + + af.Stop(false); + await afQaLog('[AF_QA][stop] result: false'); + + await new Promise(function (resolve) { + af.logEvent( + 'af_qa_resumed', + { note: 'after_stop_false' }, + function () { + afQaLog('[AF_QA][logEvent] name=af_qa_resumed result: SUCCESS'); + resolve(); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_qa_resumed ' + stringifyRes(err)); + resolve(); + } + ); + }); + + await waitMs(1500); + + await afQaLog('[AF_QA][AUTO_APIS] --- Auto run complete ---'); + await fileAppendChain; + } +})(); From 55bbbdc652655f91d44288ba1bb99e80d13af1a8 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 13:57:50 +0300 Subject: [PATCH 09/24] Phase 3. App implementation + Full E2E locally. --- .gitignore | 2 ++ test-app/.gitignore | 1 + 2 files changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d780c9c3..78bc505f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ examples/cordova-unit-tests-app/platforms/ # Scenario runner + contract test app (secrets & machine-local reports) .env .env.local +# Same multiline body as GitHub Actions secret ENV_FILE (DEV_KEY=, APP_ID=); never commit. +.af-e2e/.env.local .af-e2e/reports/ .af-smoke/reports/ diff --git a/test-app/.gitignore b/test-app/.gitignore index 4d24b555..a77f2e61 100644 --- a/test-app/.gitignore +++ b/test-app/.gitignore @@ -3,3 +3,4 @@ platforms/ plugins/ .env .env.local +www/af-qa-env.js From 8a042adf38f596f10fcf0d2e5f848cf0ef149705 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 14:05:31 +0300 Subject: [PATCH 10/24] Phase 3. Updated test app. --- test-app/www/js/index.js | 491 ++++++++++++++++++--------------------- 1 file changed, 228 insertions(+), 263 deletions(-) diff --git a/test-app/www/js/index.js b/test-app/www/js/index.js index 52a5ebd8..a3cd107c 100644 --- a/test-app/www/js/index.js +++ b/test-app/www/js/index.js @@ -4,6 +4,229 @@ var fileAppendChain = Promise.resolve(); + document.addEventListener( + 'deviceready', + function () { + runAfQaContract().catch(function (e) { + var msg = e && e.message ? e.message : String(e); + afQaLog('[AF_QA][startSDK] error: ' + msg); + }); + }, + false + ); + + async function runAfQaContract() { + await afQaLog('[AF_QA][BOOT] deviceready'); + + var env = window.__AF_QA_ENV__ || {}; + if (!env.DEV_KEY) { + await afQaLog('[AF_QA][CONFIG] DEV_KEY missing'); + return; + } + if (!env.APP_ID) { + await afQaLog('[AF_QA][CONFIG] APP_ID missing'); + return; + } + + var af = window.plugins.appsFlyer; + + af.registerOnAppOpenAttribution( + function (res) { + void afQaLog('[AF_QA][CALLBACK][onAppOpenAttribution] received: ' + stringifyRes(res)); + }, + function (err) { + void afQaLog('[AF_QA][CALLBACK][onAppOpenAttribution] error: ' + stringifyRes(err)); + } + ); + + af.registerDeepLink(function (res) { + void (async function () { + await afQaLog(formatOnDeepLinkingContractLine(res)); + await afQaLog('[AF_QA][CALLBACK][onDeepLinking] raw: ' + stringifyRes(res)); + })(); + }); + + var initOpts = { + devKey: env.DEV_KEY, + appId: env.APP_ID, + isDebug: true, + onInstallConversionDataListener: true, + onDeepLinkListener: true, + shouldStartSdk: false + }; + + await initSdkWait(af, initOpts, 1500); + + af.setAppUserId('e2e_user_42'); + await afQaLog('[AF_QA][setCustomerUserId] result: e2e_user_42'); + + af.setCurrencyCode('EUR'); + await afQaLog('[AF_QA][setCurrencyCode] result: EUR'); + + af.setAdditionalData({ tenant: 'e2e_tenant', e2e_flag: '1' }); + await afQaLog( + '[AF_QA][setAdditionalData] keys: tenant,e2e_flag payload=' + + JSON.stringify({ tenant: 'e2e_tenant', e2e_flag: '1' }) + ); + + await afQaLog('[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---'); + + af.startSdk(); + await afQaLog('[AF_QA][startSDK] result: SUCCESS'); + + await waitMs(400); + + await new Promise(function (resolve) { + af.getSdkVersion(function (v) { + afQaLog('[AF_QA][getSDKVersion] result: ' + v); + resolve(); + }); + }); + + await new Promise(function (resolve) { + af.getAppsFlyerUID(function (uid) { + afQaLog('[AF_QA][getAppsFlyerUID] result: ' + uid); + resolve(); + }); + }); + + await afQaLog('[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---'); + + await afLogEvent(af, 'af_demo_launch', {}, '[AF_QA][logEvent(af_demo_launch)] result: SUCCESS'); + + await afLogEvent( + af, + 'af_purchase', + { af_revenue: '12.34', af_currency: 'USD', af_content_id: 'qa_sku_1' }, + '[AF_QA][logEvent: af_purchase sent] result: SUCCESS' + ); + + await afLogEvent( + af, + 'af_content_view', + { af_content_type: 'qa', af_content_id: 'home' }, + '[AF_QA][logEvent: af_content_view sent] result: SUCCESS' + ); + + await afLogEvent( + af, + 'af_qa_custom_purchase', + { + af_revenue: '9.99', + af_currency: 'USD', + metadata: { tier: 'gold', seats: 2 } + }, + '[AF_QA][logEvent] name=af_qa_custom_purchase payload=' + + JSON.stringify({ + af_revenue: '9.99', + af_currency: 'USD', + metadata: { tier: 'gold', seats: 2 } + }) + ); + + await new Promise(function (resolve) { + var identityPayload = { + customer_user_id: 'e2e_user_42', + tenant: 'e2e_tenant', + check: 'identity_round_trip' + }; + af.logEvent( + 'af_qa_identity_check', + identityPayload, + function () { + afQaLog( + '[AF_QA][logEvent] name=af_qa_identity_check payload=' + + JSON.stringify(identityPayload) + ).then(function () { + return afQaLog('[AF_QA][event_payload] customer_user_id=e2e_user_42'); + }).then(function () { + resolve(); + }); + }, + function (err) { + afQaLog('[AF_QA][logEvent] error: af_qa_identity_check ' + stringifyRes(err)); + resolve(); + } + ); + }); + + af.Stop(true); + await afQaLog('[AF_QA][stop] result: true'); + + await new Promise(function (resolve) { + af.logEvent( + 'af_qa_suppressed', + { note: 'must_not_http_200_while_stopped' }, + function () { + afQaLog('[AF_QA][logEvent] name=af_qa_suppressed (unexpected success while stopped)'); + resolve(); + }, + function () { + resolve(); + } + ); + }); + + af.Stop(false); + await afQaLog('[AF_QA][stop] result: false'); + + await afLogEvent( + af, + 'af_qa_resumed', + { note: 'after_stop_false' }, + '[AF_QA][logEvent] name=af_qa_resumed result: SUCCESS' + ); + + await waitMs(1500); + + await afQaLog('[AF_QA][AUTO_APIS] --- Auto run complete ---'); + await fileAppendChain; + } + + function initSdkWait(af, initOpts, timeoutMs) { + return new Promise(function (resolve, reject) { + var settled = false; + af.initSdk( + initOpts, + function (gcd) { + void afQaLog( + '[AF_QA][CALLBACK][onInstallConversionData] received: ' + stringifyRes(gcd) + ); + }, + function (err) { + void afQaLog('[AF_QA][startSDK] error: initSdk ' + stringifyRes(err)); + if (!settled) { + settled = true; + reject(new Error(stringifyRes(err))); + } + } + ); + setTimeout(function () { + if (!settled) { + settled = true; + resolve(); + } + }, timeoutMs); + }); + } + + function afLogEvent(af, eventName, eventValues, successLine) { + return new Promise(function (resolve) { + af.logEvent( + eventName, + eventValues, + function () { + void afQaLog(successLine); + resolve(); + }, + function (err) { + void afQaLog('[AF_QA][logEvent] error: ' + eventName + ' ' + stringifyRes(err)); + resolve(); + } + ); + }); + } + function afQaAppendFileLine(line) { if (!window.cordova || !cordova.file) { return fileAppendChain; @@ -85,14 +308,11 @@ } } - /** - * Pull deep_link_value / deepLinkValue from UDL JSON (nested maps, link URLs, or JSON string fallbacks). - */ function extractDeepLinkValueFromUdl(o) { if (!o || typeof o !== 'object') { return ''; } - var visit = function (node, depth) { + function visit(node, depth) { if (depth > 8 || node == null) { return ''; } @@ -143,7 +363,9 @@ if ( typeof val === 'string' && val !== '' && - (val.indexOf('deep_link_value=') !== -1 || val.indexOf('afqa-') !== -1 || val.indexOf('://') !== -1) + (val.indexOf('deep_link_value=') !== -1 || + val.indexOf('afqa-') !== -1 || + val.indexOf('://') !== -1) ) { var fromUrlish = visit(val, depth + 1); if (fromUrlish) return fromUrlish; @@ -157,7 +379,7 @@ } } return ''; - }; + } var out = visit(o, 0); if (out) { return out; @@ -194,10 +416,6 @@ return ''; } - /** - * Normative line shape per appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md — same - * substrings the scenario runner greps (`status=Status.FOUND`, `deepLinkValue=…`). - */ function parseDeepLinkNativePayload(raw) { var o = raw; if (typeof raw === 'string') { @@ -254,257 +472,4 @@ setTimeout(resolve, ms); }); } - - document.addEventListener( - 'deviceready', - function () { - runAfQaContract().catch(function (e) { - var msg = e && e.message ? e.message : String(e); - afQaLog('[AF_QA][startSDK] error: ' + msg); - }); - }, - false - ); - - async function runAfQaContract() { - await afQaLog('[AF_QA][BOOT] deviceready'); - - var env = window.__AF_QA_ENV__ || {}; - if (!env.DEV_KEY) { - await afQaLog('[AF_QA][CONFIG] DEV_KEY missing'); - return; - } - if (!env.APP_ID) { - await afQaLog('[AF_QA][CONFIG] APP_ID missing'); - return; - } - - var af = window.plugins.appsFlyer; - - af.registerOnAppOpenAttribution( - function (res) { - void afQaLog('[AF_QA][CALLBACK][onAppOpenAttribution] received: ' + stringifyRes(res)); - }, - function (err) { - void afQaLog('[AF_QA][CALLBACK][onAppOpenAttribution] error: ' + stringifyRes(err)); - } - ); - - af.registerDeepLink(function (res) { - void (async function () { - await afQaLog(formatOnDeepLinkingContractLine(res)); - await afQaLog('[AF_QA][CALLBACK][onDeepLinking] raw: ' + stringifyRes(res)); - })(); - }); - - var initOpts = { - devKey: env.DEV_KEY, - appId: env.APP_ID, - isDebug: true, - onInstallConversionDataListener: true, - onDeepLinkListener: true, - shouldStartSdk: false - }; - - await new Promise(function (resolve, reject) { - var settled = false; - af.initSdk( - initOpts, - function (gcd) { - void afQaLog( - '[AF_QA][CALLBACK][onInstallConversionData] received: ' + stringifyRes(gcd) - ); - }, - function (err) { - void afQaLog('[AF_QA][startSDK] error: initSdk ' + stringifyRes(err)); - if (!settled) { - settled = true; - reject(new Error(stringifyRes(err))); - } - } - ); - setTimeout(function () { - if (!settled) { - settled = true; - resolve(); - } - }, 1500); - }); - - af.setAppUserId('e2e_user_42'); - await afQaLog('[AF_QA][setCustomerUserId] result: e2e_user_42'); - - af.setCurrencyCode('EUR'); - await afQaLog('[AF_QA][setCurrencyCode] result: EUR'); - - af.setAdditionalData({ tenant: 'e2e_tenant', e2e_flag: '1' }); - await afQaLog( - '[AF_QA][setAdditionalData] keys: tenant,e2e_flag payload=' + - JSON.stringify({ tenant: 'e2e_tenant', e2e_flag: '1' }) - ); - - await afQaLog('[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---'); - - af.startSdk(); - await afQaLog('[AF_QA][startSDK] result: SUCCESS'); - - await waitMs(400); - - await new Promise(function (resolve) { - af.getSdkVersion(function (v) { - afQaLog('[AF_QA][getSDKVersion] result: ' + v); - resolve(); - }); - }); - - await new Promise(function (resolve) { - af.getAppsFlyerUID(function (uid) { - afQaLog('[AF_QA][getAppsFlyerUID] result: ' + uid); - resolve(); - }); - }); - - await afQaLog('[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---'); - - await new Promise(function (resolve) { - af.logEvent( - 'af_demo_launch', - {}, - function () { - afQaLog('[AF_QA][logEvent(af_demo_launch)] result: SUCCESS'); - resolve(); - }, - function (err) { - afQaLog('[AF_QA][logEvent] error: af_demo_launch ' + stringifyRes(err)); - resolve(); - } - ); - }); - - await new Promise(function (resolve) { - af.logEvent( - 'af_purchase', - { - af_revenue: '12.34', - af_currency: 'USD', - af_content_id: 'qa_sku_1' - }, - function () { - afQaLog('[AF_QA][logEvent: af_purchase sent] result: SUCCESS'); - resolve(); - }, - function (err) { - afQaLog('[AF_QA][logEvent] error: af_purchase ' + stringifyRes(err)); - resolve(); - } - ); - }); - - await new Promise(function (resolve) { - af.logEvent( - 'af_content_view', - { af_content_type: 'qa', af_content_id: 'home' }, - function () { - afQaLog('[AF_QA][logEvent: af_content_view sent] result: SUCCESS'); - resolve(); - }, - function (err) { - afQaLog('[AF_QA][logEvent] error: af_content_view ' + stringifyRes(err)); - resolve(); - } - ); - }); - - await new Promise(function (resolve) { - af.logEvent( - 'af_qa_custom_purchase', - { - af_revenue: '9.99', - af_currency: 'USD', - metadata: { tier: 'gold', seats: 2 } - }, - function () { - afQaLog( - '[AF_QA][logEvent] name=af_qa_custom_purchase payload=' + - JSON.stringify({ - af_revenue: '9.99', - af_currency: 'USD', - metadata: { tier: 'gold', seats: 2 } - }) - ); - resolve(); - }, - function (err) { - afQaLog('[AF_QA][logEvent] error: af_qa_custom_purchase ' + stringifyRes(err)); - resolve(); - } - ); - }); - - await new Promise(function (resolve) { - var identityPayload = { - customer_user_id: 'e2e_user_42', - tenant: 'e2e_tenant', - check: 'identity_round_trip' - }; - af.logEvent( - 'af_qa_identity_check', - identityPayload, - function () { - afQaLog( - '[AF_QA][logEvent] name=af_qa_identity_check payload=' + - JSON.stringify(identityPayload) - ).then(function () { - return afQaLog('[AF_QA][event_payload] customer_user_id=e2e_user_42'); - }).then(function () { - resolve(); - }); - }, - function (err) { - afQaLog('[AF_QA][logEvent] error: af_qa_identity_check ' + stringifyRes(err)); - resolve(); - } - ); - }); - - af.Stop(true); - await afQaLog('[AF_QA][stop] result: true'); - - await new Promise(function (resolve) { - af.logEvent( - 'af_qa_suppressed', - { note: 'must_not_http_200_while_stopped' }, - function () { - afQaLog('[AF_QA][logEvent] name=af_qa_suppressed (unexpected success while stopped)'); - resolve(); - }, - function () { - resolve(); - } - ); - }); - - af.Stop(false); - await afQaLog('[AF_QA][stop] result: false'); - - await new Promise(function (resolve) { - af.logEvent( - 'af_qa_resumed', - { note: 'after_stop_false' }, - function () { - afQaLog('[AF_QA][logEvent] name=af_qa_resumed result: SUCCESS'); - resolve(); - }, - function (err) { - afQaLog('[AF_QA][logEvent] error: af_qa_resumed ' + stringifyRes(err)); - resolve(); - } - ); - }); - - await waitMs(1500); - - await afQaLog('[AF_QA][AUTO_APIS] --- Auto run complete ---'); - await fileAppendChain; - } })(); From 0e06de00d001d32736f809036813d01e1e1c254c Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 14:07:13 +0300 Subject: [PATCH 11/24] Phase 3. Env. --- scripts/bootstrap-e2e-env.sh | 34 ++++++++++++++++++++++++++++++++++ test-app/.env.example | 1 + 2 files changed, 35 insertions(+) create mode 100755 scripts/bootstrap-e2e-env.sh diff --git a/scripts/bootstrap-e2e-env.sh b/scripts/bootstrap-e2e-env.sh new file mode 100755 index 00000000..3ff11046 --- /dev/null +++ b/scripts/bootstrap-e2e-env.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Create the E2E sibling `.env` from `.af-e2e/env.example` if missing (never overwrites). +# Matches the body you paste into the GitHub Actions secret ENV_FILE. +# +# Usage (from plugin repo root): +# ./scripts/bootstrap-e2e-env.sh +# +# Requires a prior sync so `.af-e2e/e2e_copy_dest.txt` exists (this script runs sync if missing). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXAMPLE="${ROOT}/.af-e2e/env.example" +DEST_FILE="${ROOT}/.af-e2e/e2e_copy_dest.txt" + +if [[ ! -f "$EXAMPLE" ]]; then + echo "missing: $EXAMPLE" >&2 + exit 1 +fi + +if [[ ! -f "$DEST_FILE" ]]; then + "${ROOT}/scripts/sync-test-app-e2e-copy.sh" +fi + +DEST="$(cat "$DEST_FILE")" +mkdir -p "$DEST" + +if [[ -f "${DEST}/.env" ]]; then + echo "[bootstrap-e2e-env] keep existing ${DEST}/.env" + exit 0 +fi + +cp "$EXAMPLE" "${DEST}/.env" +echo "[bootstrap-e2e-env] created ${DEST}/.env from .af-e2e/env.example — set DEV_KEY and APP_ID (same text as GitHub secret ENV_FILE)." diff --git a/test-app/.env.example b/test-app/.env.example index 41eda05b..bb6cb1c4 100644 --- a/test-app/.env.example +++ b/test-app/.env.example @@ -1,3 +1,4 @@ # Copy to .env (never commit .env). CI writes this from ENV_FILE. +# APP_ID = Apple App Store numeric id (App Store Connect → App Information). Digits only. DEV_KEY= APP_ID= From a8beda63edc3f92cc981e804c06d67786a6eca30 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 14:53:37 +0300 Subject: [PATCH 12/24] E2E CI. --- .github/workflows/android-e2e.yml | 51 +++++-- .github/workflows/e2e-pull-request.yml | 45 ++++++ .github/workflows/e2e.yml | 90 ++++++++++++ .github/workflows/ios-e2e.yml | 49 +++++-- test-app/README.md | 2 +- .../hooks/afqa-ios-simctl-deeplink-replay.js | 130 ++++++++++++++++++ 6 files changed, 345 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/e2e-pull-request.yml create mode 100644 .github/workflows/e2e.yml create mode 100644 test-app/hooks/afqa-ios-simctl-deeplink-replay.js diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 28c908c9..80b37c44 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -1,12 +1,31 @@ -# Cordova plugin: build E2E sibling app + optional af-scenario-runner on an Android emulator. -# Mirrors Flutter appsflyer-flutter-plugin android-e2e layout (KVM, DNS, nslookup, ENV_FILE on build step → write-e2e-env-to-dir). -# Trigger: workflow_dispatch (manual) or workflow_call (e.g. from rc-release in Phase 6). +# ============================================================================= +# Android E2E — integration tests (pre-publish) +# ============================================================================= +# +# Stage: RC-E2E +# Runs .af-e2e/test-plan.json via scripts/af-scenario-runner.sh on an Android emulator. +# +# Triggers: +# - workflow_call from e2e.yml or future rc-release.yml +# - workflow_dispatch for manual reruns +# - Weekly cron (Sunday 03:00 UTC); checks out default branch +# +# Schedule note: `inputs` is undefined on `schedule`, so checkout / step `if` / SCENARIO_PHASE avoid +# reading `inputs.*` unless the event is not `schedule` (see expressions below). +# +# Secrets: ENV_FILE (multiline .env — see docs/github-actions-ENV_FILE.md). +# ============================================================================= -name: Android E2E (scenario runner) +name: Android E2E on: workflow_dispatch: inputs: + ref: + description: 'Branch, tag, or SHA to check out (default: workflow ref)' + required: false + type: string + default: '' run_scenarios: description: After a successful Cordova build, run af-scenario-runner on the emulator type: boolean @@ -17,6 +36,11 @@ on: default: '' workflow_call: inputs: + ref: + description: 'Branch, tag, or SHA to check out. Defaults to the calling workflow ref.' + required: false + type: string + default: '' run_scenarios: type: boolean default: true @@ -28,20 +52,25 @@ on: secrets: ENV_FILE: required: false + schedule: + - cron: '0 3 * * 0' permissions: contents: read concurrency: - group: android-e2e-${{ github.workflow }}-${{ github.ref }} + group: e2e-android-${{ github.ref }} cancel-in-progress: true jobs: - android-e2e: + e2e-android: + name: E2E Tests (Android) runs-on: ubuntu-latest timeout-minutes: 90 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'schedule' && github.ref || (inputs.ref != '' && inputs.ref || github.ref) }} - uses: actions/setup-node@v4 with: @@ -87,7 +116,7 @@ jobs: # the fix recommended by GitHub's actions/runner-images team and # the one printed by the action's own diagnostic message. - name: Enable KVM group perms (Linux emulator) - if: ${{ inputs.run_scenarios }} + if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} run: | set -euo pipefail echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm-perms.rules @@ -100,10 +129,10 @@ jobs: ls -la /dev/kvm || true - name: Run af-scenario-runner (Android) - if: ${{ inputs.run_scenarios }} + if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} uses: reactivecircus/android-emulator-runner@v2 env: - SCENARIO_PHASE: ${{ inputs.scenario_phase }} + SCENARIO_PHASE: ${{ github.event_name != 'schedule' && inputs.scenario_phase || '' }} with: api-level: 34 arch: x86_64 @@ -123,10 +152,10 @@ jobs: # breaks with "expecting fi". Delegate to one bash script (single `script` line). script: bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh" - - name: Upload af-scenario-runner reports + - name: Upload E2E reports if: always() uses: actions/upload-artifact@v4 with: - name: af-e2e-reports-android-${{ github.run_id }} + name: android-e2e-${{ github.run_number }} path: .af-e2e/reports/ if-no-files-found: warn diff --git a/.github/workflows/e2e-pull-request.yml b/.github/workflows/e2e-pull-request.yml new file mode 100644 index 00000000..69587a23 --- /dev/null +++ b/.github/workflows/e2e-pull-request.yml @@ -0,0 +1,45 @@ +# pull_request does not populate the workflow inputs context; keep PR wiring separate from e2e.yml. +# Fork PRs skip reusable E2E jobs (no repository secrets such as ENV_FILE). + +name: E2E (pull request) + +on: + pull_request: + branches: + - master + paths: + - 'test-app/**' + - 'src/**' + - 'plugin.xml' + - 'package.json' + - 'package-lock.json' + - 'scripts/**' + - '.af-e2e/**' + - '.github/workflows/android-e2e.yml' + - '.github/workflows/ios-e2e.yml' + - '.github/workflows/e2e-pull-request.yml' + +permissions: + contents: read + +concurrency: + group: e2e-pr-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + run-e2e-android: + if: github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/android-e2e.yml + secrets: inherit + + run-e2e-ios: + if: github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/ios-e2e.yml + secrets: inherit + + fork-pr-skip-notice: + if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - name: Notice + run: echo "::notice::E2E not run on fork PR (repository secrets such as ENV_FILE are unavailable)." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..bf85f759 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,90 @@ +# ============================================================================= +# Android + iOS E2E — parallel scenario runner +# ============================================================================= +# +# Calls ./.github/workflows/android-e2e.yml and ios-e2e.yml. +# +# Triggers: +# - workflow_dispatch (manual) +# - workflow_call (e.g. future rc-release.yml quality gate) +# +# PR-triggered E2E: use e2e-pull-request.yml (pull_request cannot use inputs context). +# ============================================================================= + +name: Android + iOS E2E + +on: + workflow_dispatch: + inputs: + ref: + description: 'Branch, tag, or SHA to check out for both platforms (empty = default ref)' + required: false + type: string + default: '' + skip_e2e: + description: Skip all E2E jobs (emergency only; blocks real verification) + type: boolean + default: false + run_scenarios: + description: Run af-scenario-runner after Cordova build (both platforms) + type: boolean + default: true + scenario_phase: + description: Optional --phase for both platforms (e.g. phase_1). Empty = all phases. + type: string + default: '' + workflow_call: + inputs: + ref: + description: 'Branch, tag, or SHA to check out for both platforms' + required: false + type: string + default: '' + skip_e2e: + type: boolean + default: false + required: false + run_scenarios: + type: boolean + default: true + required: false + scenario_phase: + type: string + required: false + default: '' + secrets: + ENV_FILE: + required: false + +permissions: + contents: read + +concurrency: + group: e2e-orchestrator-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + run-e2e-android: + if: ${{ !inputs.skip_e2e }} + uses: ./.github/workflows/android-e2e.yml + with: + ref: ${{ inputs.ref }} + run_scenarios: ${{ inputs.run_scenarios }} + scenario_phase: ${{ inputs.scenario_phase }} + secrets: inherit + + run-e2e-ios: + if: ${{ !inputs.skip_e2e }} + uses: ./.github/workflows/ios-e2e.yml + with: + ref: ${{ inputs.ref }} + run_scenarios: ${{ inputs.run_scenarios }} + scenario_phase: ${{ inputs.scenario_phase }} + secrets: inherit + + e2e-skipped-notice: + if: ${{ inputs.skip_e2e }} + runs-on: ubuntu-latest + steps: + - name: Notice + run: echo "::notice::Android + iOS E2E skipped (skip_e2e=true). No scenario jobs ran." diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index e71a18a4..fe27e49c 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -1,11 +1,30 @@ -# Cordova plugin: build E2E sibling app + optional af-scenario-runner on a booted iOS Simulator. -# Trigger: workflow_dispatch (manual) or workflow_call (e.g. from rc-release in Phase 6). +# ============================================================================= +# iOS E2E — integration tests (pre-publish) +# ============================================================================= +# +# Stage: RC-E2E. +# Runs .af-e2e/test-plan.json via scripts/af-scenario-runner.sh on an iOS Simulator. +# +# Triggers: +# - workflow_call from e2e.yml or future rc-release.yml +# - workflow_dispatch for manual reruns +# - Weekly cron (Sunday 02:00 UTC); checks out default branch +# +# Schedule note: `inputs` is undefined on `schedule`; expressions below avoid reading `inputs.*` on schedule. +# +# Secrets: ENV_FILE (multiline .env — see docs/github-actions-ENV_FILE.md). +# ============================================================================= -name: iOS E2E (scenario runner) +name: iOS E2E on: workflow_dispatch: inputs: + ref: + description: 'Branch, tag, or SHA to check out (default: workflow ref)' + required: false + type: string + default: '' run_scenarios: description: After a successful Cordova build, run af-scenario-runner on the simulator type: boolean @@ -16,6 +35,11 @@ on: default: '' workflow_call: inputs: + ref: + description: 'Branch, tag, or SHA to check out. Defaults to the calling workflow ref.' + required: false + type: string + default: '' run_scenarios: type: boolean default: true @@ -27,20 +51,25 @@ on: secrets: ENV_FILE: required: false + schedule: + - cron: '0 2 * * 0' permissions: contents: read concurrency: - group: ios-e2e-${{ github.workflow }}-${{ github.ref }} + group: e2e-ios-${{ github.ref }} cancel-in-progress: true jobs: - ios-e2e: + e2e-ios: + name: E2E Tests (iOS) runs-on: macos-14 timeout-minutes: 120 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'schedule' && github.ref || (inputs.ref != '' && inputs.ref || github.ref) }} - uses: actions/setup-node@v4 with: @@ -69,7 +98,7 @@ jobs: ./scripts/e2e-cordova-build.sh ios - name: Shutdown simulators and boot iPhone - if: ${{ inputs.run_scenarios }} + if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} run: | xcrun simctl shutdown all || true UDID="$( @@ -88,9 +117,9 @@ jobs: xcrun simctl bootstatus "${UDID}" -b - name: Run af-scenario-runner (iOS) - if: ${{ inputs.run_scenarios }} + if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} env: - SCENARIO_PHASE: ${{ inputs.scenario_phase }} + SCENARIO_PHASE: ${{ github.event_name != 'schedule' && inputs.scenario_phase || '' }} run: | cd "${GITHUB_WORKSPACE}" chmod +x scripts/*.sh @@ -100,10 +129,10 @@ jobs: ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose fi - - name: Upload af-scenario-runner reports + - name: Upload E2E reports if: always() uses: actions/upload-artifact@v4 with: - name: af-e2e-reports-ios-${{ github.run_id }} + name: ios-e2e-${{ github.run_number }} path: .af-e2e/reports/ if-no-files-found: warn diff --git a/test-app/README.md b/test-app/README.md index 1aabe079..982541b1 100644 --- a/test-app/README.md +++ b/test-app/README.md @@ -10,7 +10,7 @@ This folder is the **source of truth** for the small Cordova app used by **`.af- - **Where the hook looks:** **`hooks/emit-af-qa-env.js`** runs on **`cordova prepare`** / **`build`**, reads **the Cordova project root’s** **`.env`**, and writes **`www/af-qa-env.js`** (`window.__AF_QA_ENV__`). **`www/index.html`** loads that file before **`js/index.js`**. For E2E, the project root is the **sibling** (path in **`.af-e2e/e2e_copy_dest.txt`**), not `test-app/` in the plugin repo unless you build there. - **Credentials:** **`./scripts/e2e-cordova-build.sh`** runs **`scripts/write-e2e-env-to-dir.sh`** after sync and **before** **`npm install` / `cordova build`**, writing the E2E sibling **`.env`**: **`ENV_FILE`** env (GitHub: **`secrets.ENV_FILE`** on the build step), else **`.af-e2e/.env.local`**, else **`test-app/.env`**. **`hooks/emit-af-qa-env.js`** runs on **`before_prepare`** and writes **`www/af-qa-env.js`** so keys are present when www is copied into native projects. -- **E2E sibling only (no `test-app/.env`):** template **`.af-e2e/env.example`**. Run **`./scripts/bootstrap-e2e-env.sh`** once to seed **`$(cat .af-e2e/e2e_copy_dest.txt)/.env`** (from plugin repo root) if missing, then edit. Paste the same two **`KEY=value`** lines into the GitHub Actions secret **`ENV_FILE`** (multiline). +- **E2E sibling only (no `test-app/.env`):** template **`.af-e2e/env.example`**. Run **`./scripts/bootstrap-e2e-env.sh`** once to seed **`$(cat .af-e2e/e2e_copy_dest.txt)/.env`** (from plugin repo root) if missing, then edit. Paste the same two **`KEY=value`** lines into the GitHub Actions secret **`ENV_FILE`** (multiline). Step-by-step: **`docs/github-actions-ENV_FILE.md`**; short template: **`.github/e2e.env.example`**. - Never commit **`.env`**. If you still see **`[AF_QA][CONFIG] DEV_KEY missing`**, the sibling **`.env`** was empty or missing at **`cordova prepare`** time — re-sync (or edit sibling **`.env`**) and rebuild. ### Plugins used by the QA shell only diff --git a/test-app/hooks/afqa-ios-simctl-deeplink-replay.js b/test-app/hooks/afqa-ios-simctl-deeplink-replay.js new file mode 100644 index 00000000..069405cf --- /dev/null +++ b/test-app/hooks/afqa-ios-simctl-deeplink-replay.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +'use strict'; + +/** + * After `cordova prepare ios`, patch the generated AppDelegate.m (Flutter-style): + * `simctl launch … -deepLinkURL ""` does not call `application:openURL:options:`. + * Schedule the same URL through AppsFlyerAttribution after a short delay so JS initSdk + * can run first (mirrors appsflyer-flutter-plugin example/ios/Runner/AppDelegate.swift). + * + * Idempotent: wrapped in AFQA_SIMCTL_DEEPLINK_REPLAY markers. + */ + +const fs = require('fs'); +const path = require('path'); + +function walkFiles(dir, predicate) { + const out = []; + if (!fs.existsSync(dir)) return out; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const ent of entries) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (ent.name === 'Pods' || ent.name === 'build') continue; + out.push(...walkFiles(full, predicate)); + } else if (predicate(full)) { + out.push(full); + } + } + return out; +} + +function patchAppDelegateM(filePath) { + let src = fs.readFileSync(filePath, 'utf8'); + const begin = '/* AFQA_SIMCTL_DEEPLINK_REPLAY_BEGIN */'; + const end = '/* AFQA_SIMCTL_DEEPLINK_REPLAY_END */'; + if (src.includes(begin)) { + return false; + } + + const importLine = '#import "AppsFlyerAttribution.h"'; + if (!src.includes(importLine)) { + const anchor = '#import "MainViewController.h"'; + if (!src.includes(anchor)) { + console.warn( + '[afqa-ios-simctl-deeplink-replay] No #import "MainViewController.h" anchor; skipping:', + filePath + ); + return false; + } + src = src.replace(anchor, `${anchor}\n${importLine}`); + } + + const needle = + ' return [super application:application didFinishLaunchingWithOptions:launchOptions];'; + if (!src.includes(needle)) { + console.warn( + '[afqa-ios-simctl-deeplink-replay] Unexpected AppDelegate.m (return [super …] not found); skipping:', + filePath + ); + return false; + } + + const block = [ + ' ' + begin, + ' {', + ' NSArray *argv = [NSProcessInfo processInfo].arguments;', + ' __block NSURL *afqaReplayUrl = nil;', + ' for (NSUInteger afqa_i = 0; afqa_i + 1 < argv.count; afqa_i++) {', + ' if ([argv[afqa_i] isEqualToString:@"-deepLinkURL"]) {', + ' NSString *afqaRaw = argv[afqa_i + 1];', + ' if (afqaRaw.length > 0) {', + ' afqaReplayUrl = [NSURL URLWithString:afqaRaw];', + ' if (afqaReplayUrl == nil) {', + ' afqaReplayUrl = [NSURLComponents componentsWithString:afqaRaw].URL;', + ' }', + ' }', + ' break;', + ' }', + ' }', + ' if (afqaReplayUrl == nil) {', + ' NSString *afqaDef = [[NSUserDefaults standardUserDefaults] stringForKey:@"deepLinkURL"];', + ' if (afqaDef.length > 0) {', + ' afqaReplayUrl = [NSURL URLWithString:afqaDef];', + ' }', + ' }', + ' if (afqaReplayUrl != nil) {', + ' dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{', + ' [[AppsFlyerAttribution shared] handleOpenUrl:afqaReplayUrl options:@{}];', + ' });', + ' }', + ' }', + ' ' + end, + '', + needle + ].join('\n'); + + src = src.replace(needle, block); + fs.writeFileSync(filePath, src, 'utf8'); + console.log('[afqa-ios-simctl-deeplink-replay] Patched', filePath); + return true; +} + +module.exports = function (context) { + const projectRoot = context.opts.projectRoot || context.opts.cordova?.projectRoot; + if (!projectRoot) { + console.warn('[afqa-ios-simctl-deeplink-replay] Missing projectRoot; skipping.'); + return; + } + + const iosRoot = path.join(projectRoot, 'platforms', 'ios'); + if (!fs.existsSync(iosRoot)) { + return; + } + + const delegates = walkFiles( + iosRoot, + (p) => path.basename(p) === 'AppDelegate.m' + ); + + if (delegates.length === 0) { + console.warn( + '[afqa-ios-simctl-deeplink-replay] No AppDelegate.m under platforms/ios — add ios platform first.' + ); + return; + } + + for (const f of delegates) { + patchAppDelegateM(f); + } +}; From faeaf3c7c1a17511ebc53909e5d5bc5c29716b77 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 15:22:53 +0300 Subject: [PATCH 13/24] E2E CI. --- .../skills/cordova-scenario-runner/SKILL.md | 4 ++-- .../cordova-smoke-ci-alignment/SKILL.md | 6 +++--- .github/workflows/android-e2e.yml | 11 +++++----- .github/workflows/ios-e2e.yml | 6 +++--- scripts/ci-android-e2e-scenario.sh | 21 ------------------- 5 files changed, 14 insertions(+), 34 deletions(-) delete mode 100755 scripts/ci-android-e2e-scenario.sh diff --git a/.claude/skills/cordova-scenario-runner/SKILL.md b/.claude/skills/cordova-scenario-runner/SKILL.md index 82bf4a64..73c496f5 100644 --- a/.claude/skills/cordova-scenario-runner/SKILL.md +++ b/.claude/skills/cordova-scenario-runner/SKILL.md @@ -21,7 +21,7 @@ Use when: | E2E plan | `.af-e2e/test-plan.json` — build uses **sibling** `../-e2e/` (default `../appsflyer-cordova-plugin-e2e/`) via `scripts/e2e-cordova-build.sh` | | Smoke plan | `.af-smoke/rc-test-plan.json` — paths under `test-app_rc_smoke/` via `scripts/smoke-cordova-build.sh` | | Reference app (do not `cordova build` here with `file:..`) | `test-app/` | -| Android CI scenario entry | `scripts/ci-android-e2e-scenario.sh` (single command inside `android-emulator-runner`; see below) | +| Android CI scenario entry | **`.github/workflows/android-e2e.yml`** — one-line `script:` (see below) | | Reports | `.af-e2e/reports/`, `.af-smoke/reports/` | ## Preconditions @@ -73,7 +73,7 @@ jq -r ' ## CI: Android `script:` must be one line -`reactivecircus/android-emulator-runner` runs **each line** of `with: script:` as a **separate** `sh -c` invocation. Multi-line `if`/`fi` in YAML **breaks**. The workflow calls **`bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh`** as a single line instead. +`reactivecircus/android-emulator-runner` runs **each line** of `with: script:` as a **separate** `sh -c` invocation (same as **appsflyer-flutter-plugin** `android-e2e.yml`). Multi-line `if`/`fi` **breaks**. **`android-e2e.yml`** keeps **one physical line**: `set -euo pipefail;` … `nslookup` … `adb devices` … optional `--phase` … `af-scenario-runner` \|\| `dump-android-logs`. ## Rules diff --git a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md index 0b73a9e7..16c74a51 100644 --- a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md +++ b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md @@ -5,7 +5,7 @@ description: >- the tooling smoke/E2E story. Use when editing .github/workflows/*e2e*, rc-smoke (when added), or debugging scenario CI. globs: >- - .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,scripts/ci-android-e2e-scenario.sh, + .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml, scripts/af-scenario-runner.sh,scripts/e2e-cordova-build.sh,scripts/smoke-cordova-build.sh, scripts/sync-test-app-e2e-copy.sh,scripts/sync-test-app-rc-smoke.sh,.af-e2e/**,.af-smoke/** --- @@ -22,7 +22,7 @@ Use when: | Workflow | Role | |----------|------| -| `.github/workflows/android-e2e.yml` | Ubuntu: Java 17, Android SDK, `e2e-cordova-build.sh android`, then emulator + `ci-android-e2e-scenario.sh` | +| `.github/workflows/android-e2e.yml` | Ubuntu: Java 17, Android SDK, `e2e-cordova-build.sh android`, then emulator + **one-line** `script:` (nslookup, adb, `af-scenario-runner` \|\| `dump-android-logs`) — same pattern as Flutter | | `.github/workflows/ios-e2e.yml` | macOS: CocoaPods, `e2e-cordova-build.sh ios`, boot iPhone sim, `af-scenario-runner.sh` | Triggers: `workflow_dispatch`, `workflow_call`. Secrets: **`ENV_FILE`** (optional but required for real SDK checks). @@ -38,7 +38,7 @@ Triggers: `workflow_dispatch`, `workflow_call`. Secrets: **`ENV_FILE`** (optiona ## Android emulator runner quirks -- **`script:` must be a single line** — use `bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh"`. +- **`script:` must be a single physical line** inside `android-emulator-runner` (each newline = new `sh -c`). Inline shell in **`android-e2e.yml`**; do not split `if`/`fi` across lines. - **KVM:** udev + `chmod 666 /dev/kvm` so the job does not fall back to **`-accel off`** (multi‑minute boots). - **`emulator-options`:** include `-gpu swiftshader_indirect` and `-no-boot-anim` if you override defaults (full override replaces action defaults). diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 80b37c44..853eb41a 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -128,7 +128,7 @@ jobs: sudo chmod 666 /dev/kvm 2>/dev/null || true ls -la /dev/kvm || true - - name: Run af-scenario-runner (Android) + - name: Run Android E2E if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} uses: reactivecircus/android-emulator-runner@v2 env: @@ -147,10 +147,11 @@ jobs: # Keep action defaults (gpu + no-boot-anim) and add DNS; overriding `emulator-options` # replaces the default entirely — without `-gpu`/`-no-boot-anim` the guest is slower. emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 - # android-emulator-runner runs EACH LINE of `script:` as its own `sh -c` (see job log: - # `/usr/bin/sh -c set -eu`, then `/usr/bin/sh -c cd ...`). Multi-line if/fi therefore - # breaks with "expecting fi". Delegate to one bash script (single `script` line). - script: bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh" + # reactivecircus/android-emulator-runner@v2 runs each newline-separated line of `script:` as its + # own `sh -c` invocation. Keep this block to + # ONE physical line so if/fi and set -e stay in one shell. + script: | + set -euo pipefail; cd "${GITHUB_WORKSPACE}"; chmod +x scripts/*.sh; nslookup appsflyersdk.com || true; adb devices; if [ -n "${SCENARIO_PHASE:-}" ]; then ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" || ./scripts/dump-android-logs.sh; else ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose || ./scripts/dump-android-logs.sh; fi - name: Upload E2E reports if: always() diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index fe27e49c..f1c81980 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -116,7 +116,7 @@ jobs: xcrun simctl boot "${UDID}" xcrun simctl bootstatus "${UDID}" -b - - name: Run af-scenario-runner (iOS) + - name: Run iOS E2E if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} env: SCENARIO_PHASE: ${{ github.event_name != 'schedule' && inputs.scenario_phase || '' }} @@ -124,9 +124,9 @@ jobs: cd "${GITHUB_WORKSPACE}" chmod +x scripts/*.sh if [ -n "${SCENARIO_PHASE:-}" ]; then - ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" + ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --phase "${SCENARIO_PHASE}" else - ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose + ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json fi - name: Upload E2E reports diff --git a/scripts/ci-android-e2e-scenario.sh b/scripts/ci-android-e2e-scenario.sh deleted file mode 100755 index bc290fb2..00000000 --- a/scripts/ci-android-e2e-scenario.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Invoked as a SINGLE command from .github/workflows/android-e2e.yml inside -# reactivecircus/android-emulator-runner (that action runs each *line* of `script:` -# as a separate `sh -c` invocation — multi-line if/fi in YAML breaks). -# -# Env: GITHUB_WORKSPACE (required), SCENARIO_PHASE (optional). - -set -euo pipefail - -cd "${GITHUB_WORKSPACE:?}" -chmod +x scripts/*.sh -nslookup appsflyersdk.com || true -adb devices - -if [[ -n "${SCENARIO_PHASE:-}" ]]; then - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" \ - || ./scripts/dump-android-logs.sh -else - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose \ - || ./scripts/dump-android-logs.sh -fi From ade09594f4d027e13f11a2d3e8525b79e4020bf6 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 15:30:03 +0300 Subject: [PATCH 14/24] E2E CI. --- .github/workflows/android-e2e.yml | 19 +++++++++++++++++++ .github/workflows/ios-e2e.yml | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 853eb41a..4adfaefe 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -72,6 +72,25 @@ jobs: with: ref: ${{ github.event_name == 'schedule' && github.ref || (inputs.ref != '' && inputs.ref || github.ref) }} + # Speed up npm install (global cordova + E2E sibling) and Gradle wrapper / dependency downloads. + - name: Cache npm downloads + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json') }} + restore-keys: | + npm-cordova-e2e-${{ runner.os }}- + + - name: Cache Gradle user home + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json', 'test-app/config.xml') }} + restore-keys: | + gradle-cordova-e2e-${{ runner.os }}- + - uses: actions/setup-node@v4 with: node-version: '20' diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index f1c81980..ca3a5d57 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -71,6 +71,24 @@ jobs: with: ref: ${{ github.event_name == 'schedule' && github.ref || (inputs.ref != '' && inputs.ref || github.ref) }} + - name: Cache npm downloads + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json') }} + restore-keys: | + npm-cordova-e2e-${{ runner.os }}- + + - name: Cache CocoaPods (specs + downloads) + uses: actions/cache@v4 + with: + path: | + ~/.cocoapods + ~/Library/Caches/CocoaPods + key: cocoapods-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json', 'test-app/config.xml') }} + restore-keys: | + cocoapods-cordova-e2e-${{ runner.os }}- + - uses: actions/setup-node@v4 with: node-version: '20' From 7c5f268a4b6241e10f0eb8d0db024e1ebdd9b4e6 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 15:46:03 +0300 Subject: [PATCH 15/24] E2E CI. --- .claude/skills/cordova-scenario-runner/SKILL.md | 4 ++-- .claude/skills/cordova-smoke-ci-alignment/SKILL.md | 6 +++--- .github/workflows/android-e2e.yml | 7 +++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.claude/skills/cordova-scenario-runner/SKILL.md b/.claude/skills/cordova-scenario-runner/SKILL.md index 73c496f5..bb0eb5fc 100644 --- a/.claude/skills/cordova-scenario-runner/SKILL.md +++ b/.claude/skills/cordova-scenario-runner/SKILL.md @@ -21,7 +21,7 @@ Use when: | E2E plan | `.af-e2e/test-plan.json` — build uses **sibling** `../-e2e/` (default `../appsflyer-cordova-plugin-e2e/`) via `scripts/e2e-cordova-build.sh` | | Smoke plan | `.af-smoke/rc-test-plan.json` — paths under `test-app_rc_smoke/` via `scripts/smoke-cordova-build.sh` | | Reference app (do not `cordova build` here with `file:..`) | `test-app/` | -| Android CI scenario entry | **`.github/workflows/android-e2e.yml`** — one-line `script:` (see below) | +| Android CI scenario entry | `scripts/ci-android-e2e-scenario.sh` (single `bash …/ci-android-e2e-scenario.sh` line inside `android-emulator-runner`; see below) | | Reports | `.af-e2e/reports/`, `.af-smoke/reports/` | ## Preconditions @@ -73,7 +73,7 @@ jq -r ' ## CI: Android `script:` must be one line -`reactivecircus/android-emulator-runner` runs **each line** of `with: script:` as a **separate** `sh -c` invocation (same as **appsflyer-flutter-plugin** `android-e2e.yml`). Multi-line `if`/`fi` **breaks**. **`android-e2e.yml`** keeps **one physical line**: `set -euo pipefail;` … `nslookup` … `adb devices` … optional `--phase` … `af-scenario-runner` \|\| `dump-android-logs`. +`reactivecircus/android-emulator-runner` runs **each line** of `with: script:` as a **separate** `/usr/bin/sh -c` invocation (on Ubuntu **`sh` is dash**). Multi-line `if`/`fi` in YAML **breaks**; **`set -o pipefail` is invalid in dash**. **`android-e2e.yml`** uses one line: **`bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh"`** so bash-only options live in that script. ## Rules diff --git a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md index 16c74a51..889f07d7 100644 --- a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md +++ b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md @@ -5,7 +5,7 @@ description: >- the tooling smoke/E2E story. Use when editing .github/workflows/*e2e*, rc-smoke (when added), or debugging scenario CI. globs: >- - .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml, + .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,scripts/ci-android-e2e-scenario.sh, scripts/af-scenario-runner.sh,scripts/e2e-cordova-build.sh,scripts/smoke-cordova-build.sh, scripts/sync-test-app-e2e-copy.sh,scripts/sync-test-app-rc-smoke.sh,.af-e2e/**,.af-smoke/** --- @@ -22,7 +22,7 @@ Use when: | Workflow | Role | |----------|------| -| `.github/workflows/android-e2e.yml` | Ubuntu: Java 17, Android SDK, `e2e-cordova-build.sh android`, then emulator + **one-line** `script:` (nslookup, adb, `af-scenario-runner` \|\| `dump-android-logs`) — same pattern as Flutter | +| `.github/workflows/android-e2e.yml` | Ubuntu: Java 17, Android SDK, `e2e-cordova-build.sh android`, then emulator + **`bash …/ci-android-e2e-scenario.sh`** (single `script:` line) | | `.github/workflows/ios-e2e.yml` | macOS: CocoaPods, `e2e-cordova-build.sh ios`, boot iPhone sim, `af-scenario-runner.sh` | Triggers: `workflow_dispatch`, `workflow_call`. Secrets: **`ENV_FILE`** (optional but required for real SDK checks). @@ -38,7 +38,7 @@ Triggers: `workflow_dispatch`, `workflow_call`. Secrets: **`ENV_FILE`** (optiona ## Android emulator runner quirks -- **`script:` must be a single physical line** inside `android-emulator-runner` (each newline = new `sh -c`). Inline shell in **`android-e2e.yml`**; do not split `if`/`fi` across lines. +- **`script:` must be a single physical line** — use **`bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh"`** (dash `sh` cannot run `set -o pipefail`; logic lives in bash). - **KVM:** udev + `chmod 666 /dev/kvm` so the job does not fall back to **`-accel off`** (multi‑minute boots). - **`emulator-options`:** include `-gpu swiftshader_indirect` and `-no-boot-anim` if you override defaults (full override replaces action defaults). diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 4adfaefe..82745286 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -167,10 +167,9 @@ jobs: # replaces the default entirely — without `-gpu`/`-no-boot-anim` the guest is slower. emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 # reactivecircus/android-emulator-runner@v2 runs each newline-separated line of `script:` as its - # own `sh -c` invocation. Keep this block to - # ONE physical line so if/fi and set -e stay in one shell. - script: | - set -euo pipefail; cd "${GITHUB_WORKSPACE}"; chmod +x scripts/*.sh; nslookup appsflyersdk.com || true; adb devices; if [ -n "${SCENARIO_PHASE:-}" ]; then ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" || ./scripts/dump-android-logs.sh; else ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose || ./scripts/dump-android-logs.sh; fi + # own `sh -c` invocation. Use a single `bash …/ci-android-e2e-scenario.sh` line (bash supports + # `set -o pipefail` inside the script; dash does not). + script: bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh" - name: Upload E2E reports if: always() From ff934b9831eae83df3647349bb5eb457dd8e8566 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 15:48:49 +0300 Subject: [PATCH 16/24] E2E CI. --- scripts/ci-android-e2e-scenario.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100755 scripts/ci-android-e2e-scenario.sh diff --git a/scripts/ci-android-e2e-scenario.sh b/scripts/ci-android-e2e-scenario.sh new file mode 100755 index 00000000..92370217 --- /dev/null +++ b/scripts/ci-android-e2e-scenario.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Invoked as a SINGLE command from .github/workflows/android-e2e.yml inside +# reactivecircus/android-emulator-runner (that action runs each *line* of `script:` +# as a separate `sh -c` invocation — multi-line if/fi in YAML breaks; dash does not +# support `set -o pipefail`, so this file uses bash). +# +# Env: GITHUB_WORKSPACE (required), SCENARIO_PHASE (optional). + +set -euo pipefail + +cd "${GITHUB_WORKSPACE:?}" +chmod +x scripts/*.sh +nslookup appsflyersdk.com || true +adb devices + +if [[ -n "${SCENARIO_PHASE:-}" ]]; then + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" \ + || ./scripts/dump-android-logs.sh +else + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose \ + || ./scripts/dump-android-logs.sh +fi From 1c27ef5c12838122c14d46c0c16be3b8ae24f1b8 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 15:51:27 +0300 Subject: [PATCH 17/24] E2E CI. --- .claude/skills/cordova-rc-release/SKILL.md | 2 +- .github/workflows/build-apps-workflow.yml | 54 ------------ .github/workflows/mac-os-unit-test-runner.yml | 84 ------------------- .github/workflows/scripts/archiveApp.sh | 8 -- .github/workflows/scripts/decryptSecrets.sh | 19 ----- .../scripts/releaseNotesGenerator.sh | 30 ------- 6 files changed, 1 insertion(+), 196 deletions(-) delete mode 100644 .github/workflows/build-apps-workflow.yml delete mode 100644 .github/workflows/mac-os-unit-test-runner.yml delete mode 100644 .github/workflows/scripts/archiveApp.sh delete mode 100644 .github/workflows/scripts/decryptSecrets.sh delete mode 100644 .github/workflows/scripts/releaseNotesGenerator.sh diff --git a/.claude/skills/cordova-rc-release/SKILL.md b/.claude/skills/cordova-rc-release/SKILL.md index e8f17996..4cfd005e 100644 --- a/.claude/skills/cordova-rc-release/SKILL.md +++ b/.claude/skills/cordova-rc-release/SKILL.md @@ -28,7 +28,7 @@ Use when: | Stage | Target workflow (Flutter parity) | Cordova repo **today** | |-------|----------------------------------|-------------------------| -| Lint / unit / build gate | `lint-test-build` composite | Legacy **`pre-release-workflow.yml`** / **`mac-os-unit-test-runner.yml`** — not yet unified | +| Lint / unit / build gate | `lint-test-build` composite | Legacy **`pre-release-workflow.yml`** only — old **`mac-os-unit-test-runner.yml`** / **`build-apps-workflow.yml`** removed; not yet unified with Flutter `lint-test-build` | | Pre-publish E2E | `android-e2e.yml`, `ios-e2e.yml` | **Present** — callable via `workflow_dispatch` / `workflow_call` | | RC publish + tagging | `rc-release.yml` | **Not present** — see `release-Production-workflow.yml` / historical flows | | Post-publish smoke | `rc-smoke.yml` + npm resolve | **Not present** | diff --git a/.github/workflows/build-apps-workflow.yml b/.github/workflows/build-apps-workflow.yml deleted file mode 100644 index cbbdd9ff..00000000 --- a/.github/workflows/build-apps-workflow.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Build apps with AppsFlyer plugin - -on: - workflow_call: - -jobs: - Build-Cordova-android: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: install cordova-appsflyer on an Android app - run: | - echo "skip" -# npm install -g cordova -# cd examples/cordovatestapp -# npm install -# npm install ../../ -# cordova platforms add android -# cordova prepare android -# - name: Build apk -# run: | -# cd examples/cordovatestapp/platforms/android -# echo "pwd: $(pwd)" -# gradle wrapper --gradle-version 7.4.2 -# echo "ls: $(ls)" -# chmod +x ./gradlew -# echo "gradle version: $(./gradlew --version)" -# ./gradlew clean assembleRelease - # Build-RN-ios: - # runs-on: macos-latest - # steps: - # - uses: actions/checkout@v3 - # - name: install react-native-appsflyer on an iOS app - # run: | - # cd demos/appsflyer-react-native-app - # yarn install --force - # yarn add ../../ --save - # - name: Install Dependencies - # run: | - # cd demos/appsflyer-react-native-app/ios - # pod install --repo-update - - # - name: Setup provisioning profile - # env: - # IOS_KEYS: ${{ secrets.IOS_KEYS }} - # run: | - # chmod +x .github/workflows/scripts/decryptSecrets.sh - # ./.github/workflows/scripts/decryptSecrets.sh - # - name: Archive app - # run: | - # sudo xcode-select --switch /Applications/Xcode_12.5.1.app - # chmod +x .github/workflows/scripts/archiveApp.sh - # cd demos/appsflyer-react-native-app/ios - # ./../../../.github/workflows/scripts/archiveApp.sh diff --git a/.github/workflows/mac-os-unit-test-runner.yml b/.github/workflows/mac-os-unit-test-runner.yml deleted file mode 100644 index 8bb03638..00000000 --- a/.github/workflows/mac-os-unit-test-runner.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Run unit tests on Android via emulator -on: - push: - -jobs: - android-unit-tests: - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up JDK 11 - uses: actions/setup-java@v3 - with: - java-version: 11 - distribution: 'adopt' - cache: 'gradle' - - - name: Install dependencies - run: | - npm install - cd examples/cordovatestapp - chmod +x testsScripts/setEnv.sh - ./testsScripts/setEnv.sh - - - name: Run Android unit tests on emulator - continue-on-error: true - uses: reactivecircus/android-emulator-runner@v2 - with: - emulator-options: -no-window -no-snapshot - emulator-build: 7425822 - api-level: 29 - target: google_apis - arch: x86_64 - options: '-gpu swiftshader_indirect' - script: | - cd examples/cordovatestapp && chmod +x testsScripts/cordovaParamedicRunUnitTestsOnAndroid.sh && ./testsScripts/cordovaParamedicRunUnitTestsOnAndroid.sh - - - name: Verify test results Android - uses: dorny/test-reporter@v1 - if: always() - with: - name: Android Test Results - path: examples/cordovatestapp/testResults/android/junitresults-cordovapluginappsflyersdktests.tests.xml - reporter: java-junit - fail-on-error: true - - - ios-unit-tests: - runs-on: macos-11 - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Select Xcode - run: sudo xcode-select -switch /Applications/Xcode_13.0.app && /usr/bin/xcodebuild -version - - - name: Set up Node.js - uses: actions/setup-node@v2 - with: - node-version: '14' - - - name: Install dependencies - run: | - npm install - cd examples/cordovatestapp - chmod +x testsScripts/setEnv.sh - ./testsScripts/setEnv.sh - -# - name: Run iOS unit tests on simulator -# run: | -# npm install -g cordova-paramedic -# npm install -g ios-deploy -# ios-deploy --version -# cd examples/cordovatestapp && chmod +x testsScripts/cordovaParamedicRunUnitTestsOnIos.sh && ./testsScripts/cordovaParamedicRunUnitTestsOnIos.sh -# -# - name: Verify test results -# uses: dorny/test-reporter@v1 -# if: always() -# with: -# name: iOS Test Results -# path: examples/cordovatestapp/testResults/ios/junitresults-cordovapluginappsflyersdktests.tests.xml -# reporter: java-junit -# fail-on-error: true diff --git a/.github/workflows/scripts/archiveApp.sh b/.github/workflows/scripts/archiveApp.sh deleted file mode 100644 index 11b31624..00000000 --- a/.github/workflows/scripts/archiveApp.sh +++ /dev/null @@ -1,8 +0,0 @@ -set -eo pipefail - -xcodebuild -workspace AppsFlyerExample.xcworkspace \ - -scheme AppsFlyerExample \ - -sdk iphoneos \ - -allowProvisioningUpdates \ - -archivePath $PWD/build/AppsFlyerExample.xcarchive \ - clean archive | xcpretty diff --git a/.github/workflows/scripts/decryptSecrets.sh b/.github/workflows/scripts/decryptSecrets.sh deleted file mode 100644 index 48776fbf..00000000 --- a/.github/workflows/scripts/decryptSecrets.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -set -eo pipefail - -gpg --quiet --batch --yes --decrypt --passphrase="$IOS_KEYS" --output ./.github/secrets/GithubCIApp.mobileprovision.mobileprovision ./.github/secrets/GithubCIApp.mobileprovision.gpg -gpg --quiet --batch --yes --decrypt --passphrase="$IOS_KEYS" --output ./.github/secrets/GithubCICer.p12 ./.github/secrets/GithubCICer.p12.gpg - -mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - -cp ./.github/secrets/GithubCIApp.mobileprovision.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/GithubCIApp.mobileprovision.mobileprovision - - -security create-keychain -p "$IOS_KEYS" build.keychain -security import ./.github/secrets/GithubCICer.p12 -t agg -k ~/Library/Keychains/build.keychain -P "$IOS_KEYS" -A - -security list-keychains -s ~/Library/Keychains/build.keychain -security default-keychain -s ~/Library/Keychains/build.keychain -security unlock-keychain -p "$IOS_KEYS" ~/Library/Keychains/build.keychain - -security set-key-partition-list -S apple-tool:,apple: -s -k "$IOS_KEYS" ~/Library/Keychains/build.keychain diff --git a/.github/workflows/scripts/releaseNotesGenerator.sh b/.github/workflows/scripts/releaseNotesGenerator.sh deleted file mode 100644 index c84e0201..00000000 --- a/.github/workflows/scripts/releaseNotesGenerator.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -RELEASE_VERSION="${1:-}" -OUTPUT_FILE="${2:-}" - -if [ -z "$RELEASE_VERSION" ]; then - echo "Usage: $0 [output_file]" - exit 1 -fi - -if [ -z "$OUTPUT_FILE" ]; then - OUTPUT_FILE="${RELEASE_VERSION}-releasenotes.txt" -fi - -echo "Extracting release notes for ${RELEASE_VERSION} from RELEASENOTES.md" - -RELEASE_NOTES=$(awk -v ver="$RELEASE_VERSION" ' - $0 ~ "^## " ver "$" { in_section=1; next } - in_section && /^## / { exit } - in_section { print } -' RELEASENOTES.md | sed '/^[[:space:]]*$/d') - -if [ -z "$RELEASE_NOTES" ]; then - echo "Release notes for ${RELEASE_VERSION} are missing or empty." - exit 1 -fi - -printf '%s\n' "$RELEASE_NOTES" > "$OUTPUT_FILE" -cat "$OUTPUT_FILE" \ No newline at end of file From a44e248aadc614c167e1ca477a8aed4533687014 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Wed, 13 May 2026 16:10:56 +0300 Subject: [PATCH 18/24] E2E CI. --- .af-e2e/test-plan.json | 8 ++++---- .af-smoke/rc-test-plan.json | 2 +- scripts/af-scenario-runner.sh | 18 ++++++++++++++---- scripts/ci-android-e2e-scenario.sh | 4 ++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index 11f15db9..9fbd8ca4 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -104,7 +104,7 @@ "id": "http_200_count", "description": "At least 3 HTTP 200 responses from AppsFlyer servers", "type": "count_matches", - "pattern": "response code:200 OK|response_status=200", + "pattern": "response code:\\s*200(\\s*OK)?|response_status=200", "minimum": 3, "fail_action": "fail" }, @@ -283,7 +283,7 @@ "id": "custom_event_http_200", "description": "Custom event request returns HTTP 200", "type": "count_matches", - "pattern": "response code:200 OK|response_status=200", + "pattern": "response code:\\s*200(\\s*OK)?|response_status=200", "minimum": 1, "fail_action": "fail" }, @@ -357,7 +357,7 @@ "id": "identity_event_http_200", "description": "Identity check event receives HTTP 200", "type": "count_matches", - "pattern": "response code:200 OK|response_status=200", + "pattern": "response code:\\s*200(\\s*OK)?|response_status=200", "minimum": 1, "fail_action": "fail" } @@ -399,7 +399,7 @@ "id": "resumed_event_http_200", "description": "After stop(false), af_qa_resumed produces HTTP 200", "type": "count_matches", - "pattern": "response code:200 OK|response_status=200", + "pattern": "response code:\\s*200(\\s*OK)?|response_status=200", "minimum": 1, "fail_action": "fail" }, diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json index bb629f01..7708209e 100644 --- a/.af-smoke/rc-test-plan.json +++ b/.af-smoke/rc-test-plan.json @@ -76,7 +76,7 @@ "id": "http_200_count", "description": "At least 3 HTTP 200 responses from AppsFlyer servers", "type": "count_matches", - "pattern": "response code:200 OK|response_status=200", + "pattern": "response code:\\s*200(\\s*OK)?|response_status=200", "minimum": 3, "fail_action": "fail" }, diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index 0b5ebbd9..09a0c794 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -219,7 +219,9 @@ android_get_pid() { android_collect_logs() { local log_file="$1" - local tail_lines="${ANDROID_LOGCAT_TAIL_LINES:-2000}" + # Long Cordova/WebView sessions can push native SDK HTTP lines out of a short + # tail; CI emulators are also chatty. Override with ANDROID_LOGCAT_TAIL_LINES. + local tail_lines="${ANDROID_LOGCAT_TAIL_LINES:-8000}" # Always start from an empty file so each phase capture is self-contained. : > "$log_file" @@ -247,9 +249,12 @@ android_collect_logs() { # Strategy 2: Always also append logcat. Picks up AppsFlyer native SDK lines # (HTTP response codes, etc.), Cordova/Chromium **`[AF_QA]`** `console.log` - # output, and other markers used by `count_matches`. Limit to the recent tail - # so CI does not dump the whole emulator buffer every phase. - adb logcat -d -t "$tail_lines" 2>&1 | grep -E "${LOG_TAG}|AppsFlyer|chromium|Console|Cordova|WebView|response code:|preparing data:" >> "$log_file" || true + # output, and other markers used by `count_matches`. Use **-i** and spellings + # aligned with iOS collection: some Android builds log `appsflyer` / AFLogger + # tags or `response_status=` without the exact `response code:200 OK` substring + # on the same line as the `AppsFlyer` brand string — a case-only `AppsFlyer` + # filter drops them entirely. + adb logcat -d -t "$tail_lines" 2>&1 | grep -Ei "${LOG_TAG}|AppsFlyer|appsflyer|AF-AFLogger|chromium|Console|Cordova|WebView|response code|response_status|preparing data:" >> "$log_file" || true } android_background_app() { @@ -701,6 +706,11 @@ run_phase() { # sleeping the full ceiling. Use a slower interval here because each ADB # `run-as cat` is costly on GitHub's emulator. wait_for_qa_marker "[AF_QA][AUTO_APIS] --- Auto run complete ---" "$wait_sec" 10 + # Native HTTP success lines sometimes land slightly after the JS file marker. + if [[ "$PLATFORM" == "android" ]]; then + log_info "Android: brief settle after auto-run marker before deep link / log capture..." + sleep 5 + fi fi # Pre-actions (deep link phases: background the app, etc.) diff --git a/scripts/ci-android-e2e-scenario.sh b/scripts/ci-android-e2e-scenario.sh index 92370217..bd395b12 100755 --- a/scripts/ci-android-e2e-scenario.sh +++ b/scripts/ci-android-e2e-scenario.sh @@ -14,9 +14,9 @@ nslookup appsflyersdk.com || true adb devices if [[ -n "${SCENARIO_PHASE:-}" ]]; then - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose --phase "${SCENARIO_PHASE}" \ + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --phase "${SCENARIO_PHASE}" \ || ./scripts/dump-android-logs.sh else - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json --verbose \ + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json \ || ./scripts/dump-android-logs.sh fi From ce15b03763fa8b42931387ef3ab5a302f50df74d Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Thu, 14 May 2026 15:40:07 +0300 Subject: [PATCH 19/24] Release CI. --- .github/workflows/android-e2e.yml | 34 +-- .github/workflows/e2e.yml | 3 + .github/workflows/ios-e2e.yml | 93 +++++-- .github/workflows/lint-test-build.yml | 120 +++++++++ .github/workflows/promote-release.yml | 111 ++++++++ .github/workflows/rc-release.yml | 357 ++++++++++++++++++++++++++ .github/workflows/rc-smoke.yml | 315 +++++++++++++++++++++++ scripts/af-scenario-runner.sh | 10 +- scripts/ci-android-e2e-scenario.sh | 16 +- scripts/ci-android-rc-smoke.sh | 20 ++ scripts/e2e-cordova-build.sh | 2 +- scripts/rc-promote-strip-rc.sh | 43 ++++ scripts/rc-update-cordova-versions.sh | 63 +++++ scripts/sync-test-app-e2e-copy.sh | 2 +- scripts/write-e2e-env-to-dir.sh | 4 +- 15 files changed, 1140 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/lint-test-build.yml create mode 100644 .github/workflows/promote-release.yml create mode 100644 .github/workflows/rc-release.yml create mode 100644 .github/workflows/rc-smoke.yml create mode 100755 scripts/ci-android-rc-smoke.sh create mode 100755 scripts/rc-promote-strip-rc.sh create mode 100755 scripts/rc-update-cordova-versions.sh diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 82745286..edbb6135 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -6,7 +6,7 @@ # Runs .af-e2e/test-plan.json via scripts/af-scenario-runner.sh on an Android emulator. # # Triggers: -# - workflow_call from e2e.yml or future rc-release.yml +# - workflow_call from e2e.yml or rc-release.yml # - workflow_dispatch for manual reruns # - Weekly cron (Sunday 03:00 UTC); checks out default branch # @@ -68,37 +68,27 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ github.event_name == 'schedule' && github.ref || (inputs.ref != '' && inputs.ref || github.ref) }} - # Speed up npm install (global cordova + E2E sibling) and Gradle wrapper / dependency downloads. - name: Cache npm downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json') }} restore-keys: | npm-cordova-e2e-${{ runner.os }}- - - name: Cache Gradle user home - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json', 'test-app/config.xml') }} - restore-keys: | - gradle-cordova-e2e-${{ runner.os }}- - - uses: actions/setup-node@v4 with: node-version: '20' - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + cache: 'gradle' - name: Setup Android SDK uses: android-actions/setup-android@v4 @@ -138,12 +128,10 @@ jobs: if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} run: | set -euo pipefail - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm-perms.rules + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules && sudo udevadm trigger || true sudo chgrp kvm /dev/kvm 2>/dev/null || true sudo chmod g+rwx /dev/kvm 2>/dev/null || true - # Runner user is often not in group `kvm`; group perms alone keep `-accel off` (TCG) and - # ~10m boots. World-writable /dev/kvm matches GitHub runner-image guidance so KVM works. sudo chmod 666 /dev/kvm 2>/dev/null || true ls -la /dev/kvm || true @@ -158,22 +146,14 @@ jobs: target: google_apis avd-name: cordova-e2e force-avd-creation: true - # Prefer KVM; with `auto`, a missed `/dev/kvm` open falls back to TCG and ~10m boots. disable-linux-hw-accel: false - # Default action timeout is 600s — slow TCG boots burn the whole window. KVM cold boot - # is usually well under 5m; fail earlier if the guest never becomes ready. emulator-boot-timeout: 420 - # Keep action defaults (gpu + no-boot-anim) and add DNS; overriding `emulator-options` - # replaces the default entirely — without `-gpu`/`-no-boot-anim` the guest is slower. emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 - # reactivecircus/android-emulator-runner@v2 runs each newline-separated line of `script:` as its - # own `sh -c` invocation. Use a single `bash …/ci-android-e2e-scenario.sh` line (bash supports - # `set -o pipefail` inside the script; dash does not). script: bash "${GITHUB_WORKSPACE}/scripts/ci-android-e2e-scenario.sh" - name: Upload E2E reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: android-e2e-${{ github.run_number }} path: .af-e2e/reports/ diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bf85f759..58f32205 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,6 +9,9 @@ # - workflow_call (e.g. future rc-release.yml quality gate) # # PR-triggered E2E: use e2e-pull-request.yml (pull_request cannot use inputs context). +# +# RC pipeline: rc-release.yml calls android-e2e.yml + ios-e2e.yml + lint-test-build.yml +# (same reusable workflows as manual e2e.yml). # ============================================================================= name: Android + iOS E2E diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index ca3a5d57..9467bdb8 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -6,12 +6,14 @@ # Runs .af-e2e/test-plan.json via scripts/af-scenario-runner.sh on an iOS Simulator. # # Triggers: -# - workflow_call from e2e.yml or future rc-release.yml +# - workflow_call from e2e.yml or rc-release.yml # - workflow_dispatch for manual reruns # - Weekly cron (Sunday 02:00 UTC); checks out default branch # # Schedule note: `inputs` is undefined on `schedule`; expressions below avoid reading `inputs.*` on schedule. # +# Simulator selection (iPhone 15–17 on iOS 17/18) avoids iOS 17.0 custom-scheme confirmation prompts in CI. +# # Secrets: ENV_FILE (multiline .env — see docs/github-actions-ENV_FILE.md). # ============================================================================= @@ -64,15 +66,15 @@ concurrency: jobs: e2e-ios: name: E2E Tests (iOS) - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 120 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ github.event_name == 'schedule' && github.ref || (inputs.ref != '' && inputs.ref || github.ref) }} - name: Cache npm downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json') }} @@ -80,7 +82,7 @@ jobs: npm-cordova-e2e-${{ runner.os }}- - name: Cache CocoaPods (specs + downloads) - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cocoapods @@ -93,6 +95,13 @@ jobs: with: node-version: '20' + - name: Select Xcode version + if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} + run: | + XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE" + xcodebuild -version + - name: Ensure CocoaPods run: | if ! command -v pod >/dev/null 2>&1; then @@ -115,24 +124,44 @@ jobs: fi ./scripts/e2e-cordova-build.sh ios - - name: Shutdown simulators and boot iPhone + - name: Boot iOS simulator (iPhone 15–17, iOS 17/18) if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} run: | xcrun simctl shutdown all || true - UDID="$( - xcrun simctl list devices available -j | jq -r ' - [(.devices // {}) | to_entries[] | .value[] - | select(.isAvailable == true and ((.name // "") | contains("iPhone")))] - | if length == 0 then "" else .[0].udid end' - )" + DEVICES_JSON=$(xcrun simctl list -j devices available) + UDID=$(echo "$DEVICES_JSON" | jq -r ' + .devices + | to_entries + | map(select(.key | test("iOS-1[78]"))) + | sort_by(.key) | reverse + | map( + . as $rt + | $rt.value + | map(select(.name | test("^iPhone 1[5-7]$"))) + | first + | (if . then {udid, runtime: $rt.key, name} else empty end) + ) + | first // empty + | .udid // empty + ') + if [[ -z "$UDID" ]]; then + echo "No iPhone 15/16/17 on iOS 17/18 found; falling back to first available iPhone." + UDID=$(echo "$DEVICES_JSON" | jq -r '.devices[][] | select(.name | test("^iPhone")) | .udid' | head -1) + fi if [[ -z "${UDID}" ]]; then echo "::error::No available iPhone simulator" xcrun simctl list devices available exit 1 fi - echo "Booting ${UDID}" - xcrun simctl boot "${UDID}" - xcrun simctl bootstatus "${UDID}" -b + echo "Selected simulator UDID: $UDID" + echo "$DEVICES_JSON" | jq -r --arg u "$UDID" ' + .devices | to_entries[] | . as $rt + | $rt.value[] | select(.udid == $u) + | "Runtime: \($rt.key) Device: \(.name)" + ' + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "IOS_SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" - name: Run iOS E2E if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} @@ -147,9 +176,41 @@ jobs: ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json fi + - name: Dump iOS QA log file (debug) + if: always() + run: | + UDID="${IOS_SIMULATOR_UDID:-}" + if [[ -z "$UDID" ]]; then + echo "IOS_SIMULATOR_UDID unset; skipping QA file dump" + exit 0 + fi + SIM_DATA="$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data" + LOG=$(find "$SIM_DATA/Containers/Data/Application" -name "af_qa_logs.txt" 2>/dev/null | head -1) + echo "=== QA log: ${LOG:-not found} ===" + cat "$LOG" 2>/dev/null | head -200 || echo "(empty)" + + - name: Dump simulator state and deep-link events (debug) + if: failure() + run: | + UDID="${IOS_SIMULATOR_UDID:-}" + if [[ -z "$UDID" ]]; then + echo "IOS_SIMULATOR_UDID unset; skipping deep-link triage dump" + exit 0 + fi + echo "=== Booted simulators ===" + xcrun simctl list devices booted || true + echo "=== Installed apps for $UDID ===" + xcrun simctl listapps "$UDID" 2>/dev/null \ + | grep -E "com.appsflyer|CFBundleURLSchemes|cordova|qa" || true + echo "=== Last 90s of simctl URL/launchservices events ===" + xcrun simctl spawn "$UDID" log show \ + --last 90s --style compact 2>/dev/null \ + | grep -E "Opening URL|openURL|launchservices|com.appsflyer|FrontBoard:Process" \ + | tail -200 || true + - name: Upload E2E reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ios-e2e-${{ github.run_number }} path: .af-e2e/reports/ diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml new file mode 100644 index 00000000..cc634449 --- /dev/null +++ b/.github/workflows/lint-test-build.yml @@ -0,0 +1,120 @@ +# ============================================================================= +# Lint, Test & Build — PR gate + reusable from rc-release.yml +# ============================================================================= +# +# 1) JSHint on plugin JS (www, src, tests). +# 2) Optional release-style Cordova builds for Android + iOS (debug simulator/APK), +# mirroring what RC-E2E exercises (same scripts as android-e2e / ios-e2e). +# +# Triggers: pull_request / push to master, workflow_dispatch, workflow_call. +# ============================================================================= + +name: Lint, Test & Build + +on: + pull_request: + branches: + - master + paths-ignore: + - '**.md' + - 'docs/**' + - 'examples/**' + push: + branches: + - master + paths-ignore: + - '**.md' + - 'docs/**' + - 'examples/**' + workflow_dispatch: + workflow_call: + inputs: + skip_unit: + description: 'Skip JSHint job' + required: false + type: boolean + default: false + skip_builds: + description: 'Skip Android + iOS Cordova build jobs' + required: false + type: boolean + default: false + +concurrency: + group: ltb-cordova-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: JSHint + runs-on: ubuntu-latest + if: ${{ inputs.skip_unit != true }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install + - run: npm test + + build-android: + name: Build Cordova E2E app (Android) + runs-on: ubuntu-latest + needs: test + if: ${{ (needs.test.result == 'success' || needs.test.result == 'skipped') && inputs.skip_builds != true }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - uses: android-actions/setup-android@v4 + with: + packages: >- + platforms;android-34 + platform-tools + build-tools;34.0.0 + - run: npm install -g cordova + - name: Build Android (E2E sibling) + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + CORDOVA_E2E_ANDROID_JAVA_HOME: ${{ env.JAVA_HOME }} + run: | + set -euo pipefail + chmod +x scripts/*.sh + ./scripts/e2e-cordova-build.sh android + + build-ios: + name: Build Cordova E2E app (iOS) + runs-on: macos-15 + needs: test + if: ${{ (needs.test.result == 'success' || needs.test.result == 'skipped') && inputs.skip_builds != true }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Select Xcode + run: | + XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE" + xcodebuild -version + - run: | + if ! command -v pod >/dev/null 2>&1; then + sudo gem install cocoapods -N + fi + - run: npm install -g cordova + - name: Build iOS (simulator) + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + set -euo pipefail + chmod +x scripts/*.sh + ./scripts/e2e-cordova-build.sh ios diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml new file mode 100644 index 00000000..6966fcaf --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -0,0 +1,111 @@ +# ============================================================================= +# Promote release — strip -rcN after green rc-smoke/npm +# ============================================================================= +# +# Apply label **pass QA ready for deploy** on a PR from releases/** to master. +# Verifies check_run **rc-smoke/npm** = success on the PR head SHA, then runs +# scripts/rc-promote-strip-rc.sh and pushes. +# ============================================================================= + +name: Promote Release + +on: + pull_request: + types: [labeled] + +concurrency: + group: promote-release-${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + promote: + name: Strip -rcN and ready PR for merge + if: ${{ github.event.label.name == 'pass QA ready for deploy' && startsWith(github.event.pull_request.head.ref, 'releases/') }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: read + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Verify rc-smoke/npm on PR head + env: + GH_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -eo pipefail + STATUS=$(gh api "repos/${{ github.repository }}/commits/${HEAD_SHA}/check-runs" \ + --jq '[.check_runs[] | select(.name=="rc-smoke/npm")] | sort_by(.completed_at) | last | .conclusion') + echo "rc-smoke/npm conclusion = $STATUS" + if [ "$STATUS" != "success" ]; then + gh pr comment ${{ github.event.pull_request.number }} \ + --body "Promote blocked: \`rc-smoke/npm\` is \`$STATUS\` on the head commit. Re-run RC smoke or publish a new RC." + exit 1 + fi + + - name: Strip -rcN + id: ver + run: | + chmod +x scripts/rc-promote-strip-rc.sh + CUR=$(node -p "require('./package.json').version") + echo "current=$CUR" >> "$GITHUB_OUTPUT" + if ! [[ "$CUR" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc[0-9]+$ ]]; then + if [[ "$CUR" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "promoted=$CUR" >> "$GITHUB_OUTPUT" + echo "needs_commit=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::Unexpected version: $CUR" + exit 1 + fi + PROMOTED="${BASH_REMATCH[1]}" + echo "promoted=$PROMOTED" >> "$GITHUB_OUTPUT" + echo "needs_commit=true" >> "$GITHUB_OUTPUT" + ./scripts/rc-promote-strip-rc.sh "$PROMOTED" + + - name: Commit and push + if: ${{ steps.ver.outputs.needs_commit == 'true' }} + env: + GH_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + git add -A + if git diff --cached --quiet; then + echo "Nothing to commit" + else + git commit -m "chore: promote ${{ steps.ver.outputs.current }} -> ${{ steps.ver.outputs.promoted }}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "$BRANCH" + fi + + - name: PR comment + env: + GH_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + run: | + gh pr comment ${{ github.event.pull_request.number }} \ + --body "Promotion applied (package.json + plugin.xml version). Merge to **master** when ready for production publish." + + notify-failure: + name: Slack (promote failure) + needs: promote + if: failure() + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "*Cordova promote-release failed*\nPR: ${{ github.event.pull_request.html_url }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml new file mode 100644 index 00000000..4abdc57b --- /dev/null +++ b/.github/workflows/rc-release.yml @@ -0,0 +1,357 @@ +# ============================================================================= +# RC Release — pre-publish pipeline (Cordova plugin) +# ============================================================================= +# +# Manual dispatch: validates inputs, cuts releases/x.x.x/... branch, runs +# lint-test-build + iOS/Android E2E on that branch, publishes npm --tag QA, +# opens PR to master, optional Slack. +# +# Downstream: rc-smoke.yml (workflow_run) posts check_run rc-smoke/npm. +# Promote: promote-release.yml after smoke + label. +# +# Secrets: CI_DEV_GITHUB_TOKEN, CI_NPM_TOKEN, ENV_FILE (E2E), CI_SLACK_WEBHOOK_URL (optional) +# ============================================================================= + +name: RC Release + +on: + workflow_dispatch: + inputs: + base_branch: + description: 'Base branch to cut the release from' + required: true + type: string + default: master + plugin_version: + description: 'Plugin RC version (X.Y.Z-rcN)' + required: true + type: string + ios_sdk_version: + description: 'iOS native AppsFlyer SDK (X.Y.Z)' + required: true + type: string + android_sdk_version: + description: 'Android native AppsFlyer SDK (X.Y.Z)' + required: true + type: string + skip_unit: + description: 'Skip lint (JSHint) + skip build jobs in lint-test-build' + required: false + type: boolean + default: false + skip_builds: + description: 'Skip Android + iOS build jobs inside lint-test-build' + required: false + type: boolean + default: false + skip_e2e: + description: 'Skip iOS + Android E2E (allowed only with dry_run=true)' + required: false + type: boolean + default: false + dry_run: + description: 'Skip npm publish + GitHub prerelease + PR (rehearsal)' + required: false + type: boolean + default: true + +concurrency: + group: rc-release-${{ github.workflow }}-${{ github.event.inputs.plugin_version }} + cancel-in-progress: false + +env: + VERSION_REGEX: '^[0-9]+\.[0-9]+\.[0-9]+-rc[0-9]+$' + SDK_VERSION_REGEX: '^[0-9]+\.[0-9]+\.[0-9]+$' + +jobs: + validate-release: + name: Validate inputs + runs-on: ubuntu-latest + outputs: + release_branch: ${{ steps.compute.outputs.release_branch }} + tag_name: ${{ steps.compute.outputs.tag_name }} + steps: + - name: Validate version regexes + env: + PLUGIN_VERSION: ${{ inputs.plugin_version }} + IOS_SDK_VERSION: ${{ inputs.ios_sdk_version }} + ANDROID_SDK_VERSION: ${{ inputs.android_sdk_version }} + run: | + if ! [[ "$PLUGIN_VERSION" =~ $VERSION_REGEX ]]; then + echo "Plugin version '$PLUGIN_VERSION' does not match $VERSION_REGEX" + exit 1 + fi + if ! [[ "$IOS_SDK_VERSION" =~ $SDK_VERSION_REGEX ]]; then + echo "iOS SDK version '$IOS_SDK_VERSION' does not match $SDK_VERSION_REGEX" + exit 1 + fi + if ! [[ "$ANDROID_SDK_VERSION" =~ $SDK_VERSION_REGEX ]]; then + echo "Android SDK version '$ANDROID_SDK_VERSION' does not match $SDK_VERSION_REGEX" + exit 1 + fi + echo "Inputs OK" + - id: compute + env: + PLUGIN_VERSION: ${{ inputs.plugin_version }} + run: | + MAJOR=$(echo "$PLUGIN_VERSION" | cut -d'.' -f1) + MINOR=$(echo "$PLUGIN_VERSION" | cut -d'.' -f2) + PATCH=$(echo "$PLUGIN_VERSION" | cut -d'.' -f3 | cut -d'-' -f1) + BRANCH="releases/${MAJOR}.x.x/${MAJOR}.${MINOR}.x/${PLUGIN_VERSION}" + TAG="v${PLUGIN_VERSION}" + echo "release_branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG" >> "$GITHUB_OUTPUT" + echo "Release branch: $BRANCH tag: $TAG" + + prepare-branch: + name: RC-PREP — bump versions and push branch + needs: validate-release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.base_branch }} + token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + persist-credentials: true + fetch-depth: 0 + + - name: Setup Git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Create release branch + run: | + BRANCH="${{ needs.validate-release.outputs.release_branch }}" + if git ls-remote --exit-code --heads origin "$BRANCH"; then + echo "Branch $BRANCH already exists; checking out" + git fetch origin "$BRANCH" + git checkout -B "$BRANCH" "origin/$BRANCH" + else + git checkout -b "$BRANCH" + fi + + - name: Bump plugin + native SDK versions + run: | + chmod +x scripts/rc-update-cordova-versions.sh + ./scripts/rc-update-cordova-versions.sh \ + "${{ inputs.plugin_version }}" \ + "${{ inputs.ios_sdk_version }}" \ + "${{ inputs.android_sdk_version }}" + + - name: Commit and push + env: + GH_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + run: | + BRANCH="${{ needs.validate-release.outputs.release_branch }}" + git add -A + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "chore: bump to ${{ inputs.plugin_version }} (RC prep, CI)" + fi + git push -u "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "$BRANCH" + + run-ci: + name: Lint, Test & Build + needs: [validate-release, prepare-branch] + if: ${{ needs.prepare-branch.result == 'success' }} + uses: ./.github/workflows/lint-test-build.yml + with: + skip_unit: ${{ inputs.skip_unit }} + skip_builds: ${{ inputs.skip_builds }} + secrets: inherit + + run-e2e-ios: + name: iOS E2E + needs: [validate-release, prepare-branch] + if: ${{ inputs.skip_e2e != true }} + uses: ./.github/workflows/ios-e2e.yml + with: + ref: ${{ needs.validate-release.outputs.release_branch }} + run_scenarios: true + scenario_phase: '' + secrets: inherit + + run-e2e-android: + name: Android E2E + needs: [validate-release, prepare-branch] + if: ${{ inputs.skip_e2e != true }} + uses: ./.github/workflows/android-e2e.yml + with: + ref: ${{ needs.validate-release.outputs.release_branch }} + run_scenarios: true + scenario_phase: '' + secrets: inherit + + pre-publish-gate: + name: Pre-publish gate + needs: + - validate-release + - prepare-branch + - run-ci + - run-e2e-ios + - run-e2e-android + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Verify gates + run: | + set -euo pipefail + ci_result="${{ needs.run-ci.result }}" + ios_result="${{ needs.run-e2e-ios.result }}" + android_result="${{ needs.run-e2e-android.result }}" + fail=0 + for pair in "ci:$ci_result" "ios:$ios_result" "android:$android_result"; do + name="${pair%%:*}" + res="${pair##*:}" + case "$res" in + success|skipped) echo "$name: $res" ;; + *) echo "::error::$name gate failed: $res"; fail=1 ;; + esac + done + if [[ "$fail" -ne 0 ]]; then exit 1; fi + if [[ "${{ inputs.skip_e2e }}" == "true" && "${{ inputs.dry_run }}" != "true" ]]; then + echo "::error::skip_e2e is only allowed when dry_run=true" + exit 1 + fi + + publish-rc: + name: RC-PUBLISH — npm publish --tag QA + needs: [validate-release, pre-publish-gate] + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + published: ${{ steps.publish.outputs.published }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.validate-release.outputs.release_branch }} + token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install + + - id: publish + name: Publish RC to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.CI_NPM_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [[ "$DRY_RUN" == "true" ]]; then + echo "Dry run: npm publish --dry-run" + npm publish --tag QA --access public --dry-run + echo "published=false" >> "$GITHUB_OUTPUT" + else + npm publish --tag QA --access public + echo "published=true" >> "$GITHUB_OUTPUT" + fi + + create-prerelease-tag: + name: Create GitHub prerelease + needs: [validate-release, publish-rc] + if: ${{ inputs.dry_run != true && needs.publish-rc.outputs.published == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/github-script@v7 + continue-on-error: true + with: + github-token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + script: | + const tag = "${{ needs.validate-release.outputs.tag_name }}"; + const branch = "${{ needs.validate-release.outputs.release_branch }}"; + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + target_commitish: branch, + name: tag, + prerelease: true, + generate_release_notes: false, + body: `RC \`${tag}\` on npm (\`QA\` tag). Wait for \`rc-smoke/npm\`, then apply promote label.`, + }); + + open-pr: + name: Open PR to master + needs: [validate-release, publish-rc] + if: ${{ inputs.dry_run != true && needs.publish-rc.outputs.published == 'true' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.validate-release.outputs.release_branch }} + token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + - name: Open or update PR + env: + GH_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + BRANCH: ${{ needs.validate-release.outputs.release_branch }} + VERSION: ${{ inputs.plugin_version }} + run: | + existing=$(gh pr list --head "$BRANCH" --base master --json number,state --jq '.[] | select(.state=="OPEN") | .number' | head -1) + body=$(cat <> "$GITHUB_OUTPUT" + echo "ios=${{ needs.run-e2e-ios.result }}" >> "$GITHUB_OUTPUT" + echo "android=${{ needs.run-e2e-android.result }}" >> "$GITHUB_OUTPUT" + echo "gate=${{ needs.pre-publish-gate.result }}" >> "$GITHUB_OUTPUT" + echo "publish=${{ needs.publish-rc.result }}" >> "$GITHUB_OUTPUT" + + - name: Slack (optional) + continue-on-error: true + if: ${{ inputs.dry_run != true }} + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "*Cordova RC* `${{ inputs.plugin_version }}`\n• CI: ${{ steps.s.outputs.ci }}\n• iOS E2E: ${{ steps.s.outputs.ios }}\n• Android E2E: ${{ steps.s.outputs.android }}\n• Gate: ${{ steps.s.outputs.gate }}\n• Publish: ${{ steps.s.outputs.publish }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml new file mode 100644 index 00000000..77c22ed0 --- /dev/null +++ b/.github/workflows/rc-smoke.yml @@ -0,0 +1,315 @@ +# ============================================================================= +# RC Smoke — post-publish validation against npm RC (Cordova) +# ============================================================================= +# +# Fires when "RC Release" completes successfully, polls npm for the RC tarball, +# runs .af-smoke/rc-test-plan.json via scripts/smoke-cordova-build.sh + runner, +# and posts check_run **rc-smoke/npm** on the release branch head SHA. +# +# Post-publish smoke against the npm tarball; posts check_run rc-smoke/npm. +# ============================================================================= + +name: RC Smoke + +on: + workflow_run: + workflows: ['RC Release'] + types: [completed] + workflow_dispatch: + inputs: + rc_version: + description: 'Published RC version (X.Y.Z-rcN)' + required: true + type: string + release_branch: + description: 'Release branch (releases/...)' + required: true + type: string + +concurrency: + group: rc-smoke-${{ github.event.workflow_run.head_sha || github.event.inputs.release_branch }} + cancel-in-progress: false + +permissions: + contents: read + checks: write + +jobs: + decide: + name: Decide if smoke should run + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + rc_version: ${{ steps.gate.outputs.rc_version }} + release_branch: ${{ steps.gate.outputs.release_branch }} + head_sha: ${{ steps.gate.outputs.head_sha }} + steps: + - id: gate + env: + GH_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + run: | + set -eo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + RC="${{ inputs.rc_version }}" + BR="${{ inputs.release_branch }}" + else + CONCLUSION="${{ github.event.workflow_run.conclusion }}" + if [ "$CONCLUSION" != "success" ]; then + echo "RC Release did not succeed (conclusion=$CONCLUSION). Skipping smoke." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + RUN_ID="${{ github.event.workflow_run.id }}" + DRY=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}" --jq -r '.inputs.dry_run // ""') + if [ "$DRY" = "true" ]; then + echo "Parent RC Release was dry_run; skipping smoke." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + EVENT="${{ github.event.workflow_run.event }}" + if [ "$EVENT" != "workflow_dispatch" ]; then + echo "Parent was not workflow_dispatch (event=$EVENT). Skipping smoke." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + RC=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \ + --jq -r '.inputs.plugin_version // empty') + if [[ -z "$RC" ]]; then + RC=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \ + --jq -r '.display_title' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+-rc[0-9]+' | head -1 || true) + fi + BR="${{ github.event.workflow_run.head_branch }}" + fi + if ! [[ "$RC" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc[0-9]+$ ]]; then + echo "Invalid or missing RC version '$RC'. Skipping." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! [[ "$BR" =~ ^releases/ ]]; then + echo "Branch '$BR' is not a release branch. Skipping." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/${BR}" --jq '.object.sha') + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "rc_version=$RC" >> "$GITHUB_OUTPUT" + echo "release_branch=$BR" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + resolve: + name: Poll npm for RC + needs: decide + if: ${{ needs.decide.outputs.should_run == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Wait for registry + env: + RC: ${{ needs.decide.outputs.rc_version }} + run: | + set -eo pipefail + deadline=$(( $(date +%s) + 900 )) + while true; do + if curl -fsSL "https://registry.npmjs.org/cordova-plugin-appsflyer-sdk/$RC" | jq -e '.version == "'"$RC"'"' > /dev/null; then + echo "Found cordova-plugin-appsflyer-sdk@$RC on npm." + break + fi + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + echo "::error::Timed out waiting for $RC on npm" + exit 1 + fi + sleep 30 + done + + smoke-android: + name: Android RC smoke + needs: [decide, resolve] + if: ${{ needs.decide.outputs.should_run == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.decide.outputs.release_branch }} + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: android-actions/setup-android@v4 + with: + packages: >- + platforms;android-34 + platform-tools + build-tools;34.0.0 + + - run: npm install -g cordova + + - name: Run smoke on emulator + uses: reactivecircus/android-emulator-runner@v2 + env: + SMOKE_PLUGIN_VERSION: ${{ needs.decide.outputs.rc_version }} + ENV_FILE: ${{ secrets.ENV_FILE }} + CORDOVA_E2E_ANDROID_JAVA_HOME: ${{ env.JAVA_HOME }} + with: + api-level: 34 + arch: x86_64 + target: google_apis + avd-name: cordova-rc-smoke + force-avd-creation: true + disable-linux-hw-accel: false + emulator-boot-timeout: 420 + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 + script: bash "${GITHUB_WORKSPACE}/scripts/ci-android-rc-smoke.sh" + + - uses: actions/upload-artifact@v5 + if: always() + with: + name: rc-smoke-android-${{ github.run_number }} + path: .af-smoke/reports/ + if-no-files-found: warn + + smoke-ios: + name: iOS RC smoke + needs: [decide, resolve] + if: ${{ needs.decide.outputs.should_run == 'true' }} + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.decide.outputs.release_branch }} + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Select Xcode + run: | + XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE" + xcodebuild -version + + - name: Boot simulator (iOS 17/18 iPhone 15–17) + id: sim + run: | + xcrun simctl shutdown all || true + DEVICES_JSON=$(xcrun simctl list -j devices available) + UDID=$(echo "$DEVICES_JSON" | jq -r ' + .devices + | to_entries + | map(select(.key | test("iOS-1[78]"))) + | sort_by(.key) | reverse + | map( + . as $rt + | $rt.value + | map(select(.name | test("^iPhone 1[5-7]$"))) + | first + | (if . then .udid else empty end) + ) + | first // empty + ') + if [[ -z "$UDID" ]]; then + UDID=$(echo "$DEVICES_JSON" | jq -r '.devices[][] | select(.name | test("^iPhone")) | .udid' | head -1) + fi + [[ -z "$UDID" ]] && { echo "::error::No iPhone simulator"; exit 1; } + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + echo "IOS_SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + + - run: npm install -g cordova + - run: | + if ! command -v pod >/dev/null 2>&1; then + sudo gem install cocoapods -N + fi + + - name: Run smoke on simulator + env: + SMOKE_PLUGIN_VERSION: ${{ needs.decide.outputs.rc_version }} + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + set -euo pipefail + cd "${GITHUB_WORKSPACE}" + chmod +x scripts/*.sh + ./scripts/af-scenario-runner.sh --platform ios --plan .af-smoke/rc-test-plan.json --build --verbose + + - uses: actions/upload-artifact@v5 + if: always() + with: + name: rc-smoke-ios-${{ github.run_number }} + path: .af-smoke/reports/ + if-no-files-found: warn + + post-check-run: + name: Post rc-smoke/npm check + needs: [decide, smoke-android, smoke-ios] + if: ${{ always() && needs.decide.outputs.should_run == 'true' }} + runs-on: ubuntu-latest + steps: + - id: outcome + run: | + a="${{ needs.smoke-android.result }}" + i="${{ needs.smoke-ios.result }}" + if [ "$a" = "success" ] && [ "$i" = "success" ]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + fi + - uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ needs.decide.outputs.head_sha }} + CONCLUSION: ${{ steps.outcome.outputs.conclusion }} + with: + github-token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + script: | + const sha = process.env.HEAD_SHA; + const conclusion = process.env.CONCLUSION; + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'rc-smoke/npm', + head_sha: sha, + status: 'completed', + conclusion, + output: { + title: `rc-smoke/npm — ${conclusion}`, + summary: `Cordova RC smoke. Android: ${{ needs.smoke-android.result }} iOS: ${{ needs.smoke-ios.result }}`, + }, + }); + + post-skipped-check: + name: Post rc-smoke/npm (skipped) + needs: decide + if: ${{ needs.decide.outputs.should_run == 'false' && github.event_name == 'workflow_run' }} + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + if: ${{ github.event.workflow_run.head_sha != '' }} + with: + github-token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} + script: | + const sha = "${{ github.event.workflow_run.head_sha }}"; + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'rc-smoke/npm', + head_sha: sha, + status: 'completed', + conclusion: 'skipped', + output: { + title: 'rc-smoke/npm — skipped', + summary: 'RC Release did not publish an RC (dry-run or skipped smoke inputs).', + }, + }); diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index 09a0c794..0c7bf85f 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -12,7 +12,7 @@ # **This copy** is vendored in `appsflyer-cordova-plugin` from # `AppsFlyerSDK/appsflyer-mobile-plugin-tooling` (see `scripts/TOOLING_PIN.txt`). # Behaviour matches upstream; comments here are Cordova-first where log -# sources differ (WebView / `cordova build` vs Flutter / `flutter build`). +# sources differ (WebView / `cordova build` vs other native test hosts). # # Usage: # ./af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json @@ -231,7 +231,7 @@ android_collect_logs() { # logcat via Chromium/WebView (`console.log`), but a **file** mirror is still # the contract when you need the same markers as iOS `Documents/af_qa_logs.txt`. # Try `files/` first (typical for `cordova-plugin-file` / native helpers), then - # `app_flutter/` (kept for Flutter-style test apps that share this runner). + # `app_flutter/` (alternate app data layout some setups use with this runner). # `run-as` requires a **debug** build (e.g. `cordova build android --debug`). local found=0 for path in files/af_qa_logs.txt app_flutter/af_qa_logs.txt; do @@ -350,9 +350,9 @@ ios_collect_logs() { : > "$log_file" # Strategy 1: Read the app's af_qa_logs.txt from the simulator filesystem - # (test-app contract: `Documents/af_qa_logs.txt`). Same path for Cordova - # (native/file bridge) and Flutter (`af_qa_logger.dart`); the file is the - # reliable source of `[AF_QA]` because `simctl log show` alone misses many lines. + # (test-app contract: `Documents/af_qa_logs.txt`). Cordova uses the same path via + # the native/file bridge; the file is the reliable source of `[AF_QA]` because + # `simctl log show` alone misses many lines. local sim_data_dir sim_data_dir="$HOME/Library/Developer/CoreSimulator/Devices/${IOS_UDID}/data" if [[ -d "$sim_data_dir" ]]; then diff --git a/scripts/ci-android-e2e-scenario.sh b/scripts/ci-android-e2e-scenario.sh index bd395b12..61e0e34e 100755 --- a/scripts/ci-android-e2e-scenario.sh +++ b/scripts/ci-android-e2e-scenario.sh @@ -5,12 +5,26 @@ # support `set -o pipefail`, so this file uses bash). # # Env: GITHUB_WORKSPACE (required), SCENARIO_PHASE (optional). +# +# DNS precheck uses UDP nslookup through the emulator (not ICMP ping), which matches +# how slirp-backed emulators behave on hosted runners. set -euo pipefail cd "${GITHUB_WORKSPACE:?}" chmod +x scripts/*.sh -nslookup appsflyersdk.com || true + +# Connectivity precheck via DNS (UDP), NOT ping (ICMP). The Android emulator on Linux +# uses QEMU slirp NAT; ICMP echo often fails on hosted runners even when HTTPS works. +adb shell 'getprop net.dns1; getprop net.dns2' || true +adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' || { + sleep 10 + adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' +} || { + echo "::error::Emulator DNS cannot resolve AppsFlyer hosts" + exit 1 +} + adb devices if [[ -n "${SCENARIO_PHASE:-}" ]]; then diff --git a/scripts/ci-android-rc-smoke.sh b/scripts/ci-android-rc-smoke.sh new file mode 100755 index 00000000..080cb007 --- /dev/null +++ b/scripts/ci-android-rc-smoke.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Android RC smoke — single script line for android-emulator-runner (bash, pipefail). +# Env: GITHUB_WORKSPACE, SMOKE_PLUGIN_VERSION, ENV_FILE (optional), CORDOVA_E2E_ANDROID_JAVA_HOME. + +set -euo pipefail + +cd "${GITHUB_WORKSPACE:?}" +chmod +x scripts/*.sh + +adb shell 'getprop net.dns1; getprop net.dns2' || true +adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' || { + sleep 10 + adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' +} || { + echo "::error::Emulator DNS cannot resolve AppsFlyer hosts" + exit 1 +} + +./scripts/af-scenario-runner.sh --platform android --plan .af-smoke/rc-test-plan.json --build --verbose \ + || ./scripts/dump-android-logs.sh diff --git a/scripts/e2e-cordova-build.sh b/scripts/e2e-cordova-build.sh index bdaa9d5e..a8c43d7c 100755 --- a/scripts/e2e-cordova-build.sh +++ b/scripts/e2e-cordova-build.sh @@ -10,7 +10,7 @@ # # Env: # ENV_FILE Multiline `.env` body (DEV_KEY=, APP_ID=). CI: set from GitHub secret on -# this script’s environment (same pattern as Flutter E2E). Local: optional +# this script’s environment (CI: GitHub secrets.ENV_FILE). Local: optional # if `.af-e2e/.env.local` or `test-app/.env` exists — see scripts/write-e2e-env-to-dir.sh. # TEST_APP_E2E_COPY_DEST Same as sync-test-app-e2e-copy.sh (must match .af-e2e paths if non-default) # CORDOVA_E2E_ANDROID_JAVA_HOME If set, used as JAVA_HOME for Android builds only (overrides auto-pick). diff --git a/scripts/rc-promote-strip-rc.sh b/scripts/rc-promote-strip-rc.sh new file mode 100755 index 00000000..09f6bc11 --- /dev/null +++ b/scripts/rc-promote-strip-rc.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Strip -rcN from package.json + plugin.xml version (promote-release gate). +# Native SDK pins (pod / gradle / README) are left unchanged. +# +# Usage (repo root): +# ./scripts/rc-promote-strip-rc.sh +# If omitted, reads package.json and strips -rcN when present. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PROMOTED="${1:-}" +if [[ -z "$PROMOTED" ]]; then + CUR=$(node -p "require('./package.json').version") + if [[ "$CUR" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc[0-9]+$ ]]; then + PROMOTED="${BASH_REMATCH[1]}" + else + echo "[rc-promote-strip-rc] package.json version is '$CUR' (no -rcN); nothing to do" + exit 0 + fi +fi + +export RC_PROMOTED_VERSION="$PROMOTED" + +node <<'NODE' +const fs = require('fs'); +const path = require('path'); +const root = process.cwd(); +const pv = process.env.RC_PROMOTED_VERSION; + +const pjPath = path.join(root, 'package.json'); +const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')); +pj.version = pv; +fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2) + '\n'); + +let xml = fs.readFileSync(path.join(root, 'plugin.xml'), 'utf8'); +xml = xml.replace(/version="[^"]+"/, `version="${pv}"`); +fs.writeFileSync(path.join(root, 'plugin.xml'), xml); + +console.log('[rc-promote-strip-rc] set plugin version to', pv); +NODE diff --git a/scripts/rc-update-cordova-versions.sh b/scripts/rc-update-cordova-versions.sh new file mode 100755 index 00000000..6e179f02 --- /dev/null +++ b/scripts/rc-update-cordova-versions.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Bump Cordova plugin marketing version + native SDK pins (RC-prep). +# Used by .github/workflows/rc-release.yml prepare-branch. +# +# Usage (repo root): +# ./scripts/rc-update-cordova-versions.sh +# +# Example: +# ./scripts/rc-update-cordova-versions.sh 6.19.0-rc1 6.19.0 6.19.0 +# +# Updates: package.json "version", plugin.xml (plugin version + AppsFlyerFramework pod spec), +# README.md "This plugin is built for" SDK lines, src/android/cordovaAF.gradle af-android-sdk. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export RC_PLUGIN_VERSION="${1:?usage: $0 }" +export RC_IOS_SDK="${2:?usage: $0 }" +export RC_ANDROID_SDK="${3:?usage: $0 }" + +node <<'NODE' +const fs = require('fs'); +const path = require('path'); +const root = process.env.ROOT || process.cwd(); +const pv = process.env.RC_PLUGIN_VERSION; +const ios = process.env.RC_IOS_SDK; +const and = process.env.RC_ANDROID_SDK; + +const pjPath = path.join(root, 'package.json'); +const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')); +pj.version = pv; +fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2) + '\n'); + +let xml = fs.readFileSync(path.join(root, 'plugin.xml'), 'utf8'); +xml = xml.replace(/version="[^"]+"/, `version="${pv}"`); +xml = xml.replace( + //, + `` +); +fs.writeFileSync(path.join(root, 'plugin.xml'), xml); + +let readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); +readme = readme.replace( + /iOS AppsFlyerSDK \*\*v[0-9.]+\*\*/, + `iOS AppsFlyerSDK **v${ios}**` +); +readme = readme.replace( + /Android AppsFlyerSDK \*\*v[0-9.]+\*\*/, + `Android AppsFlyerSDK **v${and}**` +); +fs.writeFileSync(path.join(root, 'README.md'), readme); + +let gradle = fs.readFileSync(path.join(root, 'src/android/cordovaAF.gradle'), 'utf8'); +gradle = gradle.replace( + /com\.appsflyer:af-android-sdk:[^@]+@aar/, + `com.appsflyer:af-android-sdk:${and}@aar` +); +fs.writeFileSync(path.join(root, 'src/android/cordovaAF.gradle'), gradle); + +console.log('[rc-update-cordova-versions] updated package.json, plugin.xml, README.md, cordovaAF.gradle'); +NODE diff --git a/scripts/sync-test-app-e2e-copy.sh b/scripts/sync-test-app-e2e-copy.sh index 2341f73b..f13253ca 100755 --- a/scripts/sync-test-app-e2e-copy.sh +++ b/scripts/sync-test-app-e2e-copy.sh @@ -62,5 +62,5 @@ echo "${DEST}" > "${ROOT}/.af-e2e/e2e_copy_dest.txt" echo "[sync-test-app-e2e-copy] ${ROOT}/test-app/ → ${DEST}" if [[ ! -f "${DEST}/.env" ]]; then - echo "[sync-test-app-e2e-copy] hint: no ${DEST}/.env yet — run ./scripts/e2e-cordova-build.sh (writes .env like Flutter), or ./scripts/bootstrap-e2e-env.sh for a template-only sibling .env." + echo "[sync-test-app-e2e-copy] hint: no ${DEST}/.env yet — run ./scripts/e2e-cordova-build.sh (writes .env via write-e2e-env-to-dir), or ./scripts/bootstrap-e2e-env.sh for a template-only sibling .env." fi diff --git a/scripts/write-e2e-env-to-dir.sh b/scripts/write-e2e-env-to-dir.sh index 6dbe6bc4..fe82129a 100755 --- a/scripts/write-e2e-env-to-dir.sh +++ b/scripts/write-e2e-env-to-dir.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Write Cordova project-root `.env` before `cordova prepare` / `cordova build` (same idea as Flutter -# writing the app `.env` inside the build driver). Called from e2e-cordova-build.sh and +# Write Cordova project-root `.env` before `cordova prepare` / `cordova build` (CI uses +# secrets.ENV_FILE the same way). Called from e2e-cordova-build.sh and # smoke-cordova-build.sh. # # Usage: From df0c2230edb103e1af8f32eb3fbf2668143182b3 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Thu, 14 May 2026 15:49:41 +0300 Subject: [PATCH 20/24] Release CI. --- test-app/README.md | 4 +--- test-app/build.json | 10 ++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test-app/README.md b/test-app/README.md index 982541b1..e75385cb 100644 --- a/test-app/README.md +++ b/test-app/README.md @@ -134,9 +134,7 @@ If **`pod install`** fails with **“required a higher minimum deployment target Cordova’s default simulator (**often iPhone SE (3rd generation)**) is passed to **`xcodebuild`** as **name only**; newer Xcode then uses **`OS:latest`**, which may not match any installed runtime for that device type. -**`test-app/build.json`** (copied into the E2E sibling by **`sync-test-app-e2e-copy.sh`**) sets **`buildFlag`** to **`-destination generic/platform=iOS Simulator`** so the debug build does not depend on a specific simulator model. **`e2e-cordova-build.sh`** passes **`--buildConfig=…/build.json`** when that file exists in the E2E app root. - -Override with **`CORDOVA_E2E_IOS_BUILDCONFIG=/path/to/other-build.json`**, or add your own **`buildFlag`** **`-destination …`** there (e.g. **`platform=iOS Simulator,id=`** from **`xcrun simctl list devices available`**). +**`test-app/build.json`** (copied into the E2E sibling by **`sync-test-app-e2e-copy.sh`**) uses **`buildFlag`**: **`-destination generic/platform=iOS Simulator`** and **`ARCHS=arm64`**, so the debug build does not depend on a specific simulator model and skips the **x86_64** simulator slice (GitHub **macos-** runners are Apple Silicon; building that slice against **AppsFlyerLib** can fail with missing Swift compatibility symbols on newer Xcode). **`e2e-cordova-build.sh`** passes **`--buildConfig=…/build.json`** when that file exists in the E2E app root. Override with **`CORDOVA_E2E_IOS_BUILDCONFIG=/path/to/other-build.json`** on an **Intel** Mac (omit **`ARCHS=arm64`** or set **`ARCHS=x86_64`**) or to pass a custom **`-destination …`** (e.g. **`platform=iOS Simulator,id=`** from **`xcrun simctl list devices available`**). The CocoaPods warnings about **`LD_RUNPATH_SEARCH_PATHS`** are common with Cordova + Pods; they do not block a typical debug simulator build. diff --git a/test-app/build.json b/test-app/build.json index 8570b826..4c1e2571 100644 --- a/test-app/build.json +++ b/test-app/build.json @@ -1,10 +1,16 @@ { "ios": { "debug": { - "buildFlag": "-destination generic/platform=iOS Simulator" + "buildFlag": [ + "-destination generic/platform=iOS Simulator", + "ARCHS=arm64" + ] }, "release": { - "buildFlag": "-destination generic/platform=iOS Simulator" + "buildFlag": [ + "-destination generic/platform=iOS Simulator", + "ARCHS=arm64" + ] } } } From 9e561b33c90817d07c7892d5fcc17ab88000a278 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Thu, 14 May 2026 16:03:37 +0300 Subject: [PATCH 21/24] Release CI. --- .github/workflows/ios-e2e.yml | 7 +++---- .github/workflows/lint-test-build.yml | 5 ++--- .github/workflows/rc-smoke.yml | 5 ++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 9467bdb8..ff8c7c7f 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -13,6 +13,7 @@ # Schedule note: `inputs` is undefined on `schedule`; expressions below avoid reading `inputs.*` on schedule. # # Simulator selection (iPhone 15–17 on iOS 17/18) avoids iOS 17.0 custom-scheme confirmation prompts in CI. +# Do not `xcode-select` the newest Xcode_* on the image: it can break Swift/Pods simulator links (e.g. AppsFlyerLib). # # Secrets: ENV_FILE (multiline .env — see docs/github-actions-ENV_FILE.md). # ============================================================================= @@ -95,11 +96,9 @@ jobs: with: node-version: '20' - - name: Select Xcode version - if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} + - name: Xcode toolchain (runner default) run: | - XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) - sudo xcode-select -s "/Applications/$XCODE" + xcode-select -p xcodebuild -version - name: Ensure CocoaPods diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index cc634449..7afaf79b 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -101,10 +101,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' - - name: Select Xcode + - name: Xcode toolchain (runner default) run: | - XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) - sudo xcode-select -s "/Applications/$XCODE" + xcode-select -p xcodebuild -version - run: | if ! command -v pod >/dev/null 2>&1; then diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index 77c22ed0..de624418 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -195,10 +195,9 @@ jobs: with: node-version: '20' - - name: Select Xcode + - name: Xcode toolchain (runner default) run: | - XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) - sudo xcode-select -s "/Applications/$XCODE" + xcode-select -p xcodebuild -version - name: Boot simulator (iOS 17/18 iPhone 15–17) From 76aaa72900c431cb9ead1f29e4f79e910108ad7e Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Thu, 14 May 2026 16:24:56 +0300 Subject: [PATCH 22/24] Release CI. --- .github/workflows/lint-test-build.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 7afaf79b..0e1afaa4 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -2,7 +2,7 @@ # Lint, Test & Build — PR gate + reusable from rc-release.yml # ============================================================================= # -# 1) JSHint on plugin JS (www, src, tests). +# 1) JSHint on plugin JS (www, src). Cordova Jasmine tests under tests/ are not linted in CI. # 2) Optional release-style Cordova builds for Android + iOS (debug simulator/APK), # mirroring what RC-E2E exercises (same scripts as android-e2e / ios-e2e). # diff --git a/package.json b/package.json index f4ef8199..6b47b553 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "test": "npm run jshint", - "jshint": "node node_modules/jshint/bin/jshint www && node node_modules/jshint/bin/jshint src && node node_modules/jshint/bin/jshint tests", + "jshint": "node node_modules/jshint/bin/jshint www && node node_modules/jshint/bin/jshint src", "setupIonicCordova": "cd examples/ionic-cordova; npm install; ionic cordova plugin rm cordova-plugin-appsflyer-sdk --save; ionic cordova platform rm android; ionic cordova platform rm ios; ionic cordova plugin add cordova-plugin-appsflyer-sdk --save; ionic cordova platform add android; ionic cordova platform add ios", From 1c4879610ff217e03db986498848cceed4cef499 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Thu, 14 May 2026 16:51:54 +0300 Subject: [PATCH 23/24] Clean up. --- .github/workflows/android-e2e.yml | 2 +- .github/workflows/e2e-pull-request.yml | 45 -------- .github/workflows/e2e.yml | 93 ----------------- .github/workflows/ios-e2e.yml | 2 +- .github/workflows/pre-release-workflow.yml | 113 --------------------- 5 files changed, 2 insertions(+), 253 deletions(-) delete mode 100644 .github/workflows/e2e-pull-request.yml delete mode 100644 .github/workflows/e2e.yml delete mode 100644 .github/workflows/pre-release-workflow.yml diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index edbb6135..4d3d3501 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -6,7 +6,7 @@ # Runs .af-e2e/test-plan.json via scripts/af-scenario-runner.sh on an Android emulator. # # Triggers: -# - workflow_call from e2e.yml or rc-release.yml +# - workflow_call from rc-release.yml # - workflow_dispatch for manual reruns # - Weekly cron (Sunday 03:00 UTC); checks out default branch # diff --git a/.github/workflows/e2e-pull-request.yml b/.github/workflows/e2e-pull-request.yml deleted file mode 100644 index 69587a23..00000000 --- a/.github/workflows/e2e-pull-request.yml +++ /dev/null @@ -1,45 +0,0 @@ -# pull_request does not populate the workflow inputs context; keep PR wiring separate from e2e.yml. -# Fork PRs skip reusable E2E jobs (no repository secrets such as ENV_FILE). - -name: E2E (pull request) - -on: - pull_request: - branches: - - master - paths: - - 'test-app/**' - - 'src/**' - - 'plugin.xml' - - 'package.json' - - 'package-lock.json' - - 'scripts/**' - - '.af-e2e/**' - - '.github/workflows/android-e2e.yml' - - '.github/workflows/ios-e2e.yml' - - '.github/workflows/e2e-pull-request.yml' - -permissions: - contents: read - -concurrency: - group: e2e-pr-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - run-e2e-android: - if: github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/workflows/android-e2e.yml - secrets: inherit - - run-e2e-ios: - if: github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/workflows/ios-e2e.yml - secrets: inherit - - fork-pr-skip-notice: - if: github.event.pull_request.head.repo.full_name != github.repository - runs-on: ubuntu-latest - steps: - - name: Notice - run: echo "::notice::E2E not run on fork PR (repository secrets such as ENV_FILE are unavailable)." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 58f32205..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,93 +0,0 @@ -# ============================================================================= -# Android + iOS E2E — parallel scenario runner -# ============================================================================= -# -# Calls ./.github/workflows/android-e2e.yml and ios-e2e.yml. -# -# Triggers: -# - workflow_dispatch (manual) -# - workflow_call (e.g. future rc-release.yml quality gate) -# -# PR-triggered E2E: use e2e-pull-request.yml (pull_request cannot use inputs context). -# -# RC pipeline: rc-release.yml calls android-e2e.yml + ios-e2e.yml + lint-test-build.yml -# (same reusable workflows as manual e2e.yml). -# ============================================================================= - -name: Android + iOS E2E - -on: - workflow_dispatch: - inputs: - ref: - description: 'Branch, tag, or SHA to check out for both platforms (empty = default ref)' - required: false - type: string - default: '' - skip_e2e: - description: Skip all E2E jobs (emergency only; blocks real verification) - type: boolean - default: false - run_scenarios: - description: Run af-scenario-runner after Cordova build (both platforms) - type: boolean - default: true - scenario_phase: - description: Optional --phase for both platforms (e.g. phase_1). Empty = all phases. - type: string - default: '' - workflow_call: - inputs: - ref: - description: 'Branch, tag, or SHA to check out for both platforms' - required: false - type: string - default: '' - skip_e2e: - type: boolean - default: false - required: false - run_scenarios: - type: boolean - default: true - required: false - scenario_phase: - type: string - required: false - default: '' - secrets: - ENV_FILE: - required: false - -permissions: - contents: read - -concurrency: - group: e2e-orchestrator-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - run-e2e-android: - if: ${{ !inputs.skip_e2e }} - uses: ./.github/workflows/android-e2e.yml - with: - ref: ${{ inputs.ref }} - run_scenarios: ${{ inputs.run_scenarios }} - scenario_phase: ${{ inputs.scenario_phase }} - secrets: inherit - - run-e2e-ios: - if: ${{ !inputs.skip_e2e }} - uses: ./.github/workflows/ios-e2e.yml - with: - ref: ${{ inputs.ref }} - run_scenarios: ${{ inputs.run_scenarios }} - scenario_phase: ${{ inputs.scenario_phase }} - secrets: inherit - - e2e-skipped-notice: - if: ${{ inputs.skip_e2e }} - runs-on: ubuntu-latest - steps: - - name: Notice - run: echo "::notice::Android + iOS E2E skipped (skip_e2e=true). No scenario jobs ran." diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index ff8c7c7f..71013068 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -6,7 +6,7 @@ # Runs .af-e2e/test-plan.json via scripts/af-scenario-runner.sh on an iOS Simulator. # # Triggers: -# - workflow_call from e2e.yml or rc-release.yml +# - workflow_call from rc-release.yml # - workflow_dispatch for manual reruns # - Weekly cron (Sunday 02:00 UTC); checks out default branch # diff --git a/.github/workflows/pre-release-workflow.yml b/.github/workflows/pre-release-workflow.yml deleted file mode 100644 index e8ca9471..00000000 --- a/.github/workflows/pre-release-workflow.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Prepare plugin for production - -on: - pull_request: - types: - - opened - branches: - - 'master' - -jobs: - Release-To-QA: - if: startsWith(github.head_ref, 'releases/') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} - persist-credentials: true - ref: ${{ github.head_ref }} - - - name: Login to Github - env: - COMMIT_AUTHOR: ${{ secrets.CI_COMMIT_AUTHOR }} - COMMIT_EMAIL: ${{ secrets.CI_COMMIT_EMAIL }} - MY_PERSONAL_ACCESS_TOKEN: ${{ secrets.CI_DEV_GITHUB_TOKEN }} - run: | - git config --global user.name $COMMIT_AUTHOR - git config --global user.email $COMMIT_EMAIL - git config --global credential.helper 'cache --timeout=300' - git config --global --add "credential.https://github.com.username" "x-access-token" - echo "https://x-access-token:$MY_PERSONAL_ACCESS_TOKEN@github.com" > ~/.git-credentials - - uses: mdecoleman/pr-branch-name@1.2.0 - id: vars - with: - repo-token: ${{ secrets.CI_DEV_GITHUB_TOKEN }} - - name: Extract release versions from branch - run: | - plugin_version=$(echo "${{ steps.vars.outputs.branch }}" | grep -Eo '[0-9].[0-9]+.[0-9]+') - plugin_rc_version=$(echo "${{ steps.vars.outputs.branch }}" | grep -Eo '[0-9].[0-9]+.[0-9]+-rc[0-9]+') - # we export plugin_version and release branch name as env so we can use them in the next step - echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV - echo "PLUGIN_RC_VERSION=$plugin_rc_version" >> $GITHUB_ENV - echo "GIT_BRANCH_RELEASE=${{ steps.vars.outputs.branch }}" >> $GITHUB_ENV - - - name: Validate RELEASENOTES.md has release section - run: | - RELEASE_NOTES=$(awk -v ver="${{ env.PLUGIN_VERSION }}" ' - $0 ~ "^## " ver "$" { in_section=1; next } - in_section && /^## / { exit } - in_section { print } - ' RELEASENOTES.md | sed '/^[[:space:]]*$/d') - - if [ -z "$RELEASE_NOTES" ]; then - echo "Missing release notes for version ${{ env.PLUGIN_VERSION }} in RELEASENOTES.md" - exit 1 - fi - - { - echo "RELEASE_NOTES<> "$GITHUB_ENV" - - - name: Prepare RC version locally for QA publish - run: | - sed -i -E -e "s/\"version\": \"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?\"/\"version\": \"${{ env.PLUGIN_RC_VERSION }}\"/g" package.json - sed -i -E -e "s/version=\"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?\"/version=\"${{ env.PLUGIN_RC_VERSION }}\"/g" plugin.xml - - - name: Push RC version to NPM QA tag - env: - CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} - run: | - echo "//registry.npmjs.org/:_authToken=$CI_NPM_TOKEN" > ~/.npmrc - npm publish --tag QA - - - name: Commit final release version to branch - run: | - sed -i -E -e "s/\"version\": \"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?\"/\"version\": \"${{ env.PLUGIN_VERSION }}\"/g" package.json - sed -i -E -e "s/version=\"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?\"/version=\"${{ env.PLUGIN_VERSION }}\"/g" plugin.xml - git add package.json plugin.xml - - if git diff --cached --quiet; then - echo "No package/plugin version change detected; skipping commit" - exit 0 - fi - - git commit -m "${{ env.PLUGIN_VERSION }}" - git push origin HEAD:${{ env.GIT_BRANCH_RELEASE }} - - - name: Generate and send slack report - env: - SLACK_TOKEN: ${{ secrets.CI_SLACK_TOKEN }} - run: | - ios_sdk_version=$(cat README.md | grep 'iOS AppsFlyerSDK' | grep -Eo '[0-9].[0-9]+.[0-9]+') - android_sdk_version=$(cat README.md | grep 'Android AppsFlyerSDK' | grep -Eo '[0-9].[0-9]+.[0-9]+') - payload=$(jq -n \ - --arg jira_fixed_version "${{ env.PLUGIN_RC_VERSION }}" \ - --arg deploy_type "QA" \ - --arg install_tag "QA" \ - --arg git_branch "${{ env.GIT_BRANCH_RELEASE }}" \ - --arg changes_and_fixes "${{ env.RELEASE_NOTES }}" \ - --arg android_dependency "$android_sdk_version" \ - --arg ios_dependency "$ios_sdk_version" \ - '{ - jira_fixed_version: $jira_fixed_version, - deploy_type: $deploy_type, - install_tag: $install_tag, - git_branch: $git_branch, - changes_and_fixes: $changes_and_fixes, - android_dependency: $android_dependency, - ios_dependency: $ios_dependency - }') - curl -fS -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_TOKEN" From 119054bf27bc4be852d7681e86d69ceb400859e3 Mon Sep 17 00:00:00 2001 From: Taras Lozovyi Date: Thu, 14 May 2026 17:07:26 +0300 Subject: [PATCH 24/24] Clean up. --- .claude/skills/cordova-rc-release/SKILL.md | 51 ++++++++++--------- .../cordova-smoke-ci-alignment/SKILL.md | 9 ++-- .github/workflows/lint-test-build.yml | 22 ++------ 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/.claude/skills/cordova-rc-release/SKILL.md b/.claude/skills/cordova-rc-release/SKILL.md index 4cfd005e..2ac2267c 100644 --- a/.claude/skills/cordova-rc-release/SKILL.md +++ b/.claude/skills/cordova-rc-release/SKILL.md @@ -1,50 +1,51 @@ --- name: cordova-rc-release description: >- - Maps the intended AppsFlyer Cordova RC release pipeline (RC prep, E2E, publish, smoke, promote, - production) to workflow files. Use when planning or adding rc-release/rc-smoke/promote workflows; - not for day-to-day feature work. + Maps the AppsFlyer Cordova RC pipeline (RC prep, lint-test-build, E2E, npm publish, rc-smoke, + promote) to workflow files in this repo. Use when editing rc-release, rc-smoke, promote-release, + or lint-test-build; not for day-to-day feature work. globs: >- - .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,.github/workflows/pre-release-workflow.yml, - .github/workflows/release-Production-workflow.yml,.af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh + .github/workflows/rc-release.yml,.github/workflows/rc-smoke.yml,.github/workflows/promote-release.yml, + .github/workflows/lint-test-build.yml, + .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml, + .github/workflows/pre-release-workflow.yml,.github/workflows/release-Production-workflow.yml, + .af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh --- -# RC release pipeline — AppsFlyer Cordova plugin (target + current) +# RC release pipeline — AppsFlyer Cordova plugin Use when: -- Adding **`rc-release.yml`**, **`rc-smoke.yml`**, **`promote-release.yml`**, or **`production-release.yml`**. -- Wiring **`workflow_call`** from RC into **lint + Android E2E + iOS E2E**. -- Explaining why promotion should wait on **`rc-smoke/npm`** check-run (Flutter parity). +- Editing **`rc-release.yml`**, **`rc-smoke.yml`**, **`promote-release.yml`**, or **`lint-test-build.yml`**. +- Wiring **`workflow_call`** from RC into **lint-test-build** + **Android E2E** + **iOS E2E**. +- Explaining why promotion should wait on **`rc-smoke/npm`** check-run **success** on the release branch head. -## Sources of truth (tooling repo) +## Contract sources (shared tooling repo) - [`contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md) - [`contracts/e2e-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/e2e-test-contract.md) - [`contracts/smoke-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/smoke-test-contract.md) -- Live reference: **AppsFlyerSDK/appsflyer-flutter-plugin** `.github/workflows/` -## Current vs target (this repo) +## Stages in this repo (Cordova) -| Stage | Target workflow (Flutter parity) | Cordova repo **today** | -|-------|----------------------------------|-------------------------| -| Lint / unit / build gate | `lint-test-build` composite | Legacy **`pre-release-workflow.yml`** only — old **`mac-os-unit-test-runner.yml`** / **`build-apps-workflow.yml`** removed; not yet unified with Flutter `lint-test-build` | -| Pre-publish E2E | `android-e2e.yml`, `ios-e2e.yml` | **Present** — callable via `workflow_dispatch` / `workflow_call` | -| RC publish + tagging | `rc-release.yml` | **Not present** — see `release-Production-workflow.yml` / historical flows | -| Post-publish smoke | `rc-smoke.yml` + npm resolve | **Not present** | -| Promote (strip `-rcN`) | `promote-release.yml` gated on `rc-smoke/npm` | **Not present** | -| Production | `production-release.yml` | Partially covered by **`release-Production-workflow.yml`** (verify before changing) | +| Stage | Workflow(s) | +|-------|----------------| +| RC pre-publish | **`rc-release.yml`**: **`lint-test-build.yml`** (`workflow_call` only) + **`ios-e2e.yml`** + **`android-e2e.yml`**, then npm **`QA`** publish, prerelease, PR to `master` | +| Post-publish smoke | **`rc-smoke.yml`** → check **`rc-smoke/npm`** | +| Promote | **`promote-release.yml`** (label + **`rc-smoke/npm`** gate) → **`rc-promote-strip-rc.sh`** | +| Production (legacy) | **`release-Production-workflow.yml`** on merge to `master` — verify before changing | -Track incremental adoption in **`docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md`** (Phase 4 / 6). +Track adoption details in **`docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md`**. ## Secrets (names only) | Secret | Typical use | |--------|-------------| -| `ENV_FILE` | Multiline `.env` for E2E sibling + smoke app (`DEV_KEY`, `APP_ID`) | -| Registry / npm tokens | Publish jobs when implemented | +| `ENV_FILE` | Multiline `.env` for E2E sibling + smoke (`DEV_KEY`, `APP_ID`) | +| `CI_NPM_TOKEN` | `npm publish` in RC Release | +| `CI_DEV_GITHUB_TOKEN` | Branch push, PR, prerelease, promote, `gh` in smoke | ## Do not -- Do not bypass **`rc-smoke`** success requirement for promotion if the org adopts Flutter-style gates. -- Do not duplicate long contract text in skills — link tooling + Flutter repo. +- Do not bypass **`rc-smoke/npm` success** for promotion when using the designed promote gate. +- Do not duplicate long contract text in skills — link tooling contracts above. diff --git a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md index 889f07d7..9423a6c0 100644 --- a/.claude/skills/cordova-smoke-ci-alignment/SKILL.md +++ b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md @@ -5,7 +5,8 @@ description: >- the tooling smoke/E2E story. Use when editing .github/workflows/*e2e*, rc-smoke (when added), or debugging scenario CI. globs: >- - .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,scripts/ci-android-e2e-scenario.sh, + .github/workflows/android-e2e.yml,.github/workflows/ios-e2e.yml,.github/workflows/lint-test-build.yml, + scripts/ci-android-e2e-scenario.sh, scripts/af-scenario-runner.sh,scripts/e2e-cordova-build.sh,scripts/smoke-cordova-build.sh, scripts/sync-test-app-e2e-copy.sh,scripts/sync-test-app-rc-smoke.sh,.af-e2e/**,.af-smoke/** --- @@ -16,7 +17,7 @@ Use when: - Editing or debugging **`android-e2e.yml`** / **`ios-e2e.yml`**. - Explaining **`ENV_FILE`**, sibling **`.env`**, or artifact paths. -- Preparing **`rc-smoke.yml`** / promote flows (Phase 4 / 6 — not all files exist yet). +- Preparing or debugging **`rc-smoke.yml`** / **`promote-release.yml`**. ## What exists today @@ -25,9 +26,9 @@ Use when: | `.github/workflows/android-e2e.yml` | Ubuntu: Java 17, Android SDK, `e2e-cordova-build.sh android`, then emulator + **`bash …/ci-android-e2e-scenario.sh`** (single `script:` line) | | `.github/workflows/ios-e2e.yml` | macOS: CocoaPods, `e2e-cordova-build.sh ios`, boot iPhone sim, `af-scenario-runner.sh` | -Triggers: `workflow_dispatch`, `workflow_call`. Secrets: **`ENV_FILE`** (optional but required for real SDK checks). +Triggers: `workflow_dispatch`, `workflow_call` (e.g. from **`rc-release.yml`**). Secrets: **`ENV_FILE`** (optional but required for real SDK checks). -**Not yet in repo (see workplan):** `rc-smoke.yml`, `rc-release.yml` orchestration, `promote-release.yml`, `production-release.yml` as in Flutter — document when added. +**Note:** **`lint-test-build.yml`** is **`workflow_call` only** (invoked from **`rc-release.yml`** today). ## E2E vs smoke directories diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 0e1afaa4..c65ed8bd 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -1,32 +1,16 @@ # ============================================================================= -# Lint, Test & Build — PR gate + reusable from rc-release.yml +# Lint, Test & Build — reusable only (workflow_call) # ============================================================================= # # 1) JSHint on plugin JS (www, src). Cordova Jasmine tests under tests/ are not linted in CI. -# 2) Optional release-style Cordova builds for Android + iOS (debug simulator/APK), -# mirroring what RC-E2E exercises (same scripts as android-e2e / ios-e2e). +# 2) Optional Cordova debug builds for Android + iOS (same scripts as android-e2e / ios-e2e). # -# Triggers: pull_request / push to master, workflow_dispatch, workflow_call. +# Trigger: workflow_call only. Caller: rc-release.yml (and any future workflows that reuse it). # ============================================================================= name: Lint, Test & Build on: - pull_request: - branches: - - master - paths-ignore: - - '**.md' - - 'docs/**' - - 'examples/**' - push: - branches: - - master - paths-ignore: - - '**.md' - - 'docs/**' - - 'examples/**' - workflow_dispatch: workflow_call: inputs: skip_unit: