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..9fbd8ca4 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", @@ -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" }, @@ -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": { @@ -287,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" }, @@ -361,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" } @@ -403,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 e14bded4..7708209e 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", @@ -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" }, @@ -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/.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..2ac2267c --- /dev/null +++ b/.claude/skills/cordova-rc-release/SKILL.md @@ -0,0 +1,51 @@ +--- +name: cordova-rc-release +description: >- + 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/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 + +Use when: + +- 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. + +## 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) + +## Stages in this repo (Cordova) + +| 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 adoption details in **`docs/SCENARIO_RUNNER_ADOPTION_WORKPLAN.md`**. + +## Secrets (names only) + +| Secret | Typical use | +|--------|-------------| +| `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/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-scenario-runner/SKILL.md b/.claude/skills/cordova-scenario-runner/SKILL.md new file mode 100644 index 00000000..bb0eb5fc --- /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 `bash …/ci-android-e2e-scenario.sh` line 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** `/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 + +- 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..9423a6c0 --- /dev/null +++ b/.claude/skills/cordova-smoke-ci-alignment/SKILL.md @@ -0,0 +1,59 @@ +--- +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,.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/** +--- + +# 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 or debugging **`rc-smoke.yml`** / **`promote-release.yml`**. + +## What exists today + +| Workflow | Role | +|----------|------| +| `.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` (e.g. from **`rc-release.yml`**). Secrets: **`ENV_FILE`** (optional but required for real SDK checks). + +**Note:** **`lint-test-build.yml`** is **`workflow_call` only** (invoked from **`rc-release.yml`** today). + +## 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 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). + +## 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/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 1cc322df..4d3d3501 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 → sibling .env). -# 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 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,29 +52,43 @@ 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 + - 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@v5 + with: + path: ~/.npm + key: npm-cordova-e2e-${{ runner.os }}-${{ hashFiles('test-app/package.json') }} + restore-keys: | + npm-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 @@ -63,27 +101,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 @@ -97,43 +125,36 @@ 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 + 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 + sudo chmod 666 /dev/kvm 2>/dev/null || true + ls -la /dev/kvm || true - - name: Run af-scenario-runner (Android) - if: ${{ inputs.run_scenarios }} + - name: Run Android E2E + 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 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 + 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-e2e-scenario.sh" - - name: Upload af-scenario-runner reports + - name: Upload E2E reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 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/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/ios-e2e.yml b/.github/workflows/ios-e2e.yml index e9b7e757..71013068 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -1,11 +1,33 @@ -# 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 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. +# 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). +# ============================================================================= -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 +38,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,28 +54,55 @@ 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: - runs-on: macos-14 + e2e-ios: + name: E2E Tests (iOS) + 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@v5 + 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@v5 + 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' + - name: Xcode toolchain (runner default) + run: | + xcode-select -p + xcodebuild -version + - name: Ensure CocoaPods run: | - set -euo pipefail if ! command -v pod >/dev/null 2>&1; then sudo gem install cocoapods -N fi @@ -57,65 +111,106 @@ 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: | set -euo pipefail - PLUGIN_ROOT="${GITHUB_WORKSPACE}" - E2E_DIR="$(dirname "${PLUGIN_ROOT}")/$(basename "${PLUGIN_ROOT}")-e2e" - mkdir -p "${E2E_DIR}" + 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: | - 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 }} + - name: Boot iOS simulator (iPhone 15–17, iOS 17/18) + if: ${{ github.event_name == 'schedule' || inputs.run_scenarios != false }} 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' - )" + 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 af-scenario-runner (iOS) - if: ${{ inputs.run_scenarios }} + - name: Run iOS E2E + 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: | - 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 --phase "${SCENARIO_PHASE}" + else + ./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 - ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json --verbose "${PHASE_ARGS[@]:-}" + 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 af-scenario-runner reports + - name: Upload E2E reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 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/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml new file mode 100644 index 00000000..c65ed8bd --- /dev/null +++ b/.github/workflows/lint-test-build.yml @@ -0,0 +1,103 @@ +# ============================================================================= +# 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 Cordova debug builds for Android + iOS (same scripts as android-e2e / ios-e2e). +# +# Trigger: workflow_call only. Caller: rc-release.yml (and any future workflows that reuse it). +# ============================================================================= + +name: Lint, Test & Build + +on: + 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: Xcode toolchain (runner default) + run: | + xcode-select -p + 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/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/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" 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..de624418 --- /dev/null +++ b/.github/workflows/rc-smoke.yml @@ -0,0 +1,314 @@ +# ============================================================================= +# 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: Xcode toolchain (runner default) + run: | + xcode-select -p + 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/.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 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/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", diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index d26984b4..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 @@ -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" @@ -229,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 @@ -247,14 +249,17 @@ 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() { - 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() { @@ -345,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 @@ -590,7 +595,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 +613,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)" @@ -694,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/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/scripts/ci-android-e2e-scenario.sh b/scripts/ci-android-e2e-scenario.sh new file mode 100755 index 00000000..61e0e34e --- /dev/null +++ b/scripts/ci-android-e2e-scenario.sh @@ -0,0 +1,36 @@ +#!/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). +# +# 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 + +# 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 + ./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 \ + || ./scripts/dump-android-logs.sh +fi 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 5d6eba18..a8c43d7c 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 (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). # 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/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/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..f13253ca 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 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 new file mode 100755 index 00000000..fe82129a --- /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` (CI uses +# secrets.ENV_FILE the same way). 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/.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= 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 diff --git a/test-app/README.md b/test-app/README.md index 17d764dd..e75385cb 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). 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 + +- **`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 @@ -116,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 new file mode 100644 index 00000000..4c1e2571 --- /dev/null +++ b/test-app/build.json @@ -0,0 +1,16 @@ +{ + "ios": { + "debug": { + "buildFlag": [ + "-destination generic/platform=iOS Simulator", + "ARCHS=arm64" + ] + }, + "release": { + "buildFlag": [ + "-destination generic/platform=iOS Simulator", + "ARCHS=arm64" + ] + } + } +} 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/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); + } +}; 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..a3cd107c 100644 --- a/test-app/www/js/index.js +++ b/test-app/www/js/index.js @@ -1,3 +1,475 @@ -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(); + + 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; + } + 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); + } + } + + function extractDeepLinkValueFromUdl(o) { + if (!o || typeof o !== 'object') { + return ''; + } + function visit(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 ''; + } + + 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); + }); + } +})();