diff --git a/e2e/README.md b/e2e/README.md index 9a317eccf..d4a5ff04b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -40,6 +40,16 @@ dev rn e2e ios --tags checkout dev android e2e --tags launch,checkout ``` +Apple Pay is an explicit Swift-only profile and must be requested by itself: + +```bash +dev swift e2e --tags apple-pay +``` + +The command checks that the generated Swift `Storefront.xcconfig` contains a +nonblank Apple Pay merchant identifier before it builds. Normal runs exclude the +`apple-pay` tag, so wallet configuration cannot leak into unrelated runs. + Both options match **any** listed tag, because that is how Maestro filters. `--tags launch,checkout` runs the launch tests and the checkout tests. `--exclude-tags` skips tests carrying any listed tag. `config.yaml` quarantines @@ -56,6 +66,7 @@ enforces it. | Cost tier | `smoke`, `full` | Exactly one per test | | Quarantine | `flaky`, `wip` | Excluded by default, in `config.yaml` | | Platform capability | `ios-only`, `android-only` | Needs a `# Platform capability:` comment | +| Feature | `apple-pay` | Explicit opt-in profile | A platform tag marks a capability only one platform has, such as Apple Pay. It must never mark a test that is merely not ported yet. @@ -148,11 +159,15 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count - `tests/shared/launch-smoke.yaml` is the shared launch smoke test. - `tests/shared/checkout-present-and-close.yaml` seeds a cart through the control link, presents checkout, closes it, and asserts dismissal. +- `tests/shared/checkout-apple-pay.yaml` presents, closes, and reopens the Apple Pay + sheet on the Swift iOS sample. - `tests/react-native/checkout-guest.yaml` composes the React Native guest checkout smoke test from those subflows. - `tests/react-native/checkout-hardcoded-buyer-identity.yaml` verifies checkout from a bootstrapped cart with hardcoded buyer identity. - `scripts/run_local_e2e` builds and installs any of the four local targets. +- `scripts/check_swift_apple_pay_config` fails before an Apple Pay build when the + generated Swift merchant identifier is missing or blank. - `scripts/run_maestro` is their single Maestro invocation. It holds the environment contract and target-specific test-file selection in one place. - `config/matrix.yml`, `lib/e2e_matrix_to_browserstack_run_plan.rb`, and diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index c342e7d8d..739f4363b 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -14,6 +14,8 @@ tags: # so they cannot run on the Swift and Kotlin rows. Drop this entry when the shared # ordering tests replace them. - full + # Wallet tests require an explicitly configured, provisioned application row. + - apple-pay applications: - id: react-native-ios target: react-native diff --git a/e2e/flows/checkout/close-apple-pay.yaml b/e2e/flows/checkout/close-apple-pay.yaml new file mode 100644 index 000000000..d12798356 --- /dev/null +++ b/e2e/flows/checkout/close-apple-pay.yaml @@ -0,0 +1,14 @@ +appId: ${E2E_APP_ID} +--- +- tapOn: + id: dismiss +- waitForAnimationToEnd +- extendedWaitUntil: + notVisible: + id: payment-sheet + timeout: 10000 +- extendedWaitUntil: + visible: + id: apple-pay-button + enabled: true + timeout: 10000 diff --git a/e2e/flows/checkout/present-apple-pay.yaml b/e2e/flows/checkout/present-apple-pay.yaml new file mode 100644 index 000000000..3f3d55d24 --- /dev/null +++ b/e2e/flows/checkout/present-apple-pay.yaml @@ -0,0 +1,10 @@ +appId: ${E2E_APP_ID} +--- +- tapOn: + id: apple-pay-button + enabled: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: payment-sheet + timeout: 10000 diff --git a/e2e/scripts/check_swift_apple_pay_config b/e2e/scripts/check_swift_apple_pay_config new file mode 100755 index 000000000..ef954b362 --- /dev/null +++ b/e2e/scripts/check_swift_apple_pay_config @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CONFIG_PATH="${1:-${REPO_ROOT}/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig}" +KEY="APPLE_PAY_MERCHANT_IDENTIFIER" + +if [ "$#" -gt 1 ]; then + echo "Usage: e2e/scripts/check_swift_apple_pay_config [xcconfig-path]" >&2 + exit 1 +fi + +if [ ! -f "$CONFIG_PATH" ]; then + echo "Apple Pay E2E requires ${KEY} in ${CONFIG_PATH}, but that file does not exist." >&2 + echo "Configure the Swift sample storefront before retrying." >&2 + exit 1 +fi + +# Check the assignment without returning its value to the shell or printing it. +if ! awk -v key="$KEY" ' + /^[[:space:]]*\/\// || /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + $0 !~ /=/ { next } + { + line = $0 + candidate = line + sub(/^[[:space:]]*/, "", candidate) + sub(/[[:space:]]*=.*/, "", candidate) + + if (candidate == key) { + sub(/^[^=]*=/, "", line) + sub(/^[[:space:]]*/, "", line) + sub(/[[:space:]]*$/, "", line) + found = 1 + exit(line == "" ? 1 : 0) + } + } + END { if (!found) exit 1 } +' "$CONFIG_PATH"; then + echo "Apple Pay E2E requires a nonblank ${KEY} assignment in ${CONFIG_PATH}." >&2 + echo "Configure the Swift sample storefront before retrying." >&2 + exit 1 +fi diff --git a/e2e/scripts/run_local_e2e b/e2e/scripts/run_local_e2e index 599d6826a..e97793eb9 100755 --- a/e2e/scripts/run_local_e2e +++ b/e2e/scripts/run_local_e2e @@ -12,7 +12,8 @@ Usage: $DEV_COMMAND [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] Runs the shared and $TARGET_DESCRIPTION-specific Maestro tests. Tags come from the taxonomy: journey (launch, cart, checkout, account), cost tier (smoke, full), -quarantine (flaky, wip) and platform capability (ios-only, android-only). +quarantine (flaky, wip), platform capability (ios-only, android-only), and +feature (apple-pay). Options: --tags TAG[,TAG...] Run only tests carrying any of these tags. @@ -119,6 +120,28 @@ parse_maestro_tag_args() { done } +select_run_profile() { + case ",${INCLUDE_TAGS}," in + *",apple-pay,"*) + # Other targets stay on the normal profile, where run_maestro excludes + # Apple Pay. Only the Swift iOS sample can opt into this profile. + [ "$TARGET" = "swift-ios" ] || return 0 + + if [ "$INCLUDE_TAGS" != "apple-pay" ]; then + echo "--tags apple-pay must be requested by itself" >&2 + return 1 + fi + case ",${EXCLUDE_TAGS}," in + *",apple-pay,"*) + echo "apple-pay cannot be both included and excluded" >&2 + return 1 + ;; + esac + RUN_PROFILE="swift-apple-pay" + ;; + esac +} + metro_running() { curl --silent --fail http://localhost:8081/status | grep -q "packager-status:running" } @@ -245,10 +268,16 @@ main() { INCLUDE_TAGS="" EXCLUDE_TAGS="" + RUN_PROFILE="normal" METRO_PID="" E2E_ENV_FILE="" parse_maestro_tag_args "$@" + select_run_profile + + if [ "$RUN_PROFILE" = "swift-apple-pay" ]; then + "$REPO_ROOT/e2e/scripts/check_swift_apple_pay_config" + fi "$REPO_ROOT/e2e/scripts/maestro_bin" >/dev/null trap cleanup EXIT @@ -258,6 +287,7 @@ main() { build_and_install E2E_DEVICE_ID="$DEVICE_ID" \ + E2E_RUN_PROFILE="$RUN_PROFILE" \ "$REPO_ROOT/e2e/scripts/run_maestro" \ "$PLATFORM" "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" "$TEST_NAMESPACE" } diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro index 9e34eacae..a9247aff5 100755 --- a/e2e/scripts/run_maestro +++ b/e2e/scripts/run_maestro @@ -15,6 +15,7 @@ INCLUDE_TAGS="${4:-}" EXCLUDE_TAGS="${5:-}" TEST_NAMESPACE="${6:?test namespace is required}" DEVICE_ID="${E2E_DEVICE_ID:-}" +RUN_PROFILE="${E2E_RUN_PROFILE:-normal}" E2E_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$E2E_ROOT" @@ -43,6 +44,34 @@ exclude_tag() { REQUESTED_TAGS="$INCLUDE_TAGS" +# Normal runs must never select Apple Pay accidentally. The Swift local runner opts in +# only after confirming that its generated app configuration contains a merchant ID. +case "$RUN_PROFILE" in + normal) + exclude_tag "apple-pay" + ;; + swift-apple-pay) + if [ "$PLATFORM" != "ios" ] || [ "$APP_ID" != "com.shopify.checkoutkit.swiftdemo" ]; then + echo "run_maestro: swift-apple-pay requires the Swift iOS sample app" >&2 + exit 1 + fi + if [ "$INCLUDE_TAGS" != "apple-pay" ]; then + echo "run_maestro: swift-apple-pay requires --tags apple-pay by itself" >&2 + exit 1 + fi + case ",${EXCLUDE_TAGS}," in + *",apple-pay,"*) + echo "run_maestro: swift-apple-pay cannot exclude apple-pay" >&2 + exit 1 + ;; + esac + ;; + *) + echo "run_maestro: profile must be normal or swift-apple-pay, got '${RUN_PROFILE}'" >&2 + exit 1 + ;; +esac + # Every local target executes the shared tests folder. A platform capability tag marks # a test that cannot run on the other platform, so derive the mandatory exclusion from # the platform instead of repeating it in four wrappers. diff --git a/e2e/test/check_swift_apple_pay_config_test.rb b/e2e/test/check_swift_apple_pay_config_test.rb new file mode 100644 index 000000000..ef0d62459 --- /dev/null +++ b/e2e/test/check_swift_apple_pay_config_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "open3" +require "tempfile" + +class CheckSwiftApplePayConfigTest < Minitest::Test + REPO_ROOT = File.expand_path("../..", __dir__) + SCRIPT = File.join(REPO_ROOT, "e2e", "scripts", "check_swift_apple_pay_config") + SYNTHETIC_IDENTIFIER = "merchant.com.example.e2e" + + def test_script_is_executable_bash + assert File.executable?(SCRIPT) + + _output, error, status = Open3.capture3("bash", "-n", SCRIPT) + + assert status.success?, error + end + + def test_missing_config_fails_without_printing_a_value + Tempfile.create("missing-swift-storefront") do |file| + path = file.path + file.close + File.unlink(path) + + output, error, status = Open3.capture3(SCRIPT, path) + + refute status.success? + assert_empty output + assert_includes error, "APPLE_PAY_MERCHANT_IDENTIFIER" + refute_includes error, SYNTHETIC_IDENTIFIER + end + end + + def test_missing_or_blank_assignment_fails + [ + "STOREFRONT_DOMAIN = example.invalid\n", + "APPLE_PAY_MERCHANT_IDENTIFIER = \n" + ].each do |contents| + with_config(contents) do |path| + output, error, status = Open3.capture3(SCRIPT, path) + + refute status.success? + assert_empty output + assert_includes error, "nonblank APPLE_PAY_MERCHANT_IDENTIFIER" + refute_includes error, SYNTHETIC_IDENTIFIER + end + end + end + + def test_nonblank_assignment_succeeds_without_printing_the_value + with_config("APPLE_PAY_MERCHANT_IDENTIFIER = #{SYNTHETIC_IDENTIFIER}\n") do |path| + output, error, status = Open3.capture3(SCRIPT, path) + + assert status.success?, error + assert_empty output + assert_empty error + refute_includes "#{output}#{error}", SYNTHETIC_IDENTIFIER + end + end + + private + + def with_config(contents) + Tempfile.create(["swift-storefront", ".xcconfig"]) do |file| + file.write(contents) + file.flush + yield file.path + end + end +end diff --git a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb index 99228a2b8..c599268ab 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -48,8 +48,14 @@ def test_runs_carry_default_tags_and_the_other_platform_exclusion android_run = run_for("kotlin-android") assert_equal ["launch", "checkout"], ios_run.fetch("include_tags") - assert_equal ["flaky", "wip", "full", "android-only"], ios_run.fetch("exclude_tags") - assert_equal ["flaky", "wip", "full", "ios-only"], android_run.fetch("exclude_tags") + assert_equal ["flaky", "wip", "full", "apple-pay", "android-only"], ios_run.fetch("exclude_tags") + assert_equal ["flaky", "wip", "full", "apple-pay", "ios-only"], android_run.fetch("exclude_tags") + end + + def test_apple_pay_is_excluded_from_every_application_by_default + plan.expand.each do |run| + assert_includes run.fetch("exclude_tags"), "apple-pay", run.fetch("application_id") + end end def test_an_application_overrides_the_default_tags diff --git a/e2e/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb index cf17b50af..3d5fdca3b 100644 --- a/e2e/test/maestro_test_tags_test.rb +++ b/e2e/test/maestro_test_tags_test.rb @@ -9,7 +9,8 @@ class MaestroTestTagsTest < Minitest::Test COST_TIER_TAGS = ["smoke", "full"].freeze QUARANTINE_TAGS = ["flaky", "wip"].freeze PLATFORM_TAGS = ["ios-only", "android-only"].freeze - KNOWN_TAGS = (JOURNEY_TAGS + COST_TIER_TAGS + QUARANTINE_TAGS + PLATFORM_TAGS).freeze + FEATURE_TAGS = ["apple-pay"].freeze + KNOWN_TAGS = (JOURNEY_TAGS + COST_TIER_TAGS + QUARANTINE_TAGS + PLATFORM_TAGS + FEATURE_TAGS).freeze def test_files Dir.glob("tests/**/*.yaml", base: E2E_ROOT).sort diff --git a/e2e/test/run_local_e2e_test.rb b/e2e/test/run_local_e2e_test.rb index 094beab37..b773e4b5a 100644 --- a/e2e/test/run_local_e2e_test.rb +++ b/e2e/test/run_local_e2e_test.rb @@ -111,6 +111,63 @@ def test_maestro_runs_shared_and_target_specific_test_files refute_includes swift_arguments, "." end + def selected_profile(target, include_tags, exclude_tags = "") + output, error, status = Open3.capture3( + "bash", + "-c", + <<~'SH', + source "$1" + configure_target "$2" + INCLUDE_TAGS="$3" + EXCLUDE_TAGS="$4" + RUN_PROFILE="normal" + select_run_profile + printf '%s\n' "$RUN_PROFILE" + SH + "run-local-e2e-test", + RUNNER, + target, + include_tags, + exclude_tags + ) + + [output.lines.map(&:chomp), error, status] + end + + def test_swift_apple_pay_selects_its_profile + selected, error, status = selected_profile("swift-ios", "apple-pay") + + assert status.success?, error + assert_equal ["swift-apple-pay"], selected + end + + def test_swift_apple_pay_rejects_mixed_or_excluded_tags + _selected, error, status = selected_profile("swift-ios", "apple-pay,checkout") + refute status.success? + assert_includes error, "must be requested by itself" + + _selected, error, status = selected_profile("swift-ios", "apple-pay", "apple-pay") + refute status.success? + assert_includes error, "cannot be both included and excluded" + end + + def test_non_swift_target_stays_on_the_normal_profile + selected, error, status = selected_profile("kotlin-android", "apple-pay") + + assert status.success?, error + assert_equal ["normal"], selected + end + + def test_apple_pay_config_is_checked_before_the_swift_build + script = File.read(RUNNER) + check_index = script.index('"$REPO_ROOT/e2e/scripts/check_swift_apple_pay_config"') + build_index = script.index(" build_and_install\n", check_index) + + refute_nil check_index + refute_nil build_index + assert_operator check_index, :<, build_index + end + def test_target_specific_and_old_named_runners_are_removed REMOVED_RUNNERS.each do |path| refute_path_exists File.join(REPO_ROOT, path) diff --git a/e2e/test/run_maestro_test.rb b/e2e/test/run_maestro_test.rb index 32f437a66..8fd3c6284 100644 --- a/e2e/test/run_maestro_test.rb +++ b/e2e/test/run_maestro_test.rb @@ -18,7 +18,7 @@ class RunMaestroTest < Minitest::Test # tag arguments and supplies a namespace rather than repeating both at every call. DEFAULT_TEST_NAMESPACE = "swift" - def run_script(*args) + def run_script(*args, profile: nil) args = args.dup args << "" while args.length < 5 args << DEFAULT_TEST_NAMESPACE while args.length < 6 @@ -39,6 +39,7 @@ def run_script(*args) "E2E_CUSTOMER_ACCOUNT_EMAIL" => "maestro@example.com", "E2E_CUSTOMER_ACCOUNT_CODE" => "000000" } + env["E2E_RUN_PROFILE"] = profile if profile stdout, stderr, status = Open3.capture3(env, SCRIPT, *args) { @@ -59,26 +60,26 @@ def test_ios_excludes_android_only result = run_script("ios", "app.id", "ready") assert_predicate result.fetch(:status), :success? - assert_equal "android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + assert_equal "apple-pay,android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") end def test_android_excludes_ios_only result = run_script("android", "app.id", "ready") assert_predicate result.fetch(:status), :success? - assert_equal "ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + assert_equal "apple-pay,ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") end def test_caller_exclusions_are_preserved result = run_script("ios", "app.id", "ready", "", "slow") - assert_equal "slow,android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + assert_equal "slow,apple-pay,android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") end def test_mandatory_exclusion_is_not_duplicated result = run_script("android", "app.id", "ready", "", "ios-only") - assert_equal "ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + assert_equal "ios-only,apple-pay", flag_value(result.fetch(:maestro_args), "--exclude-tags") end def test_incompatible_tag_leaves_compatible_requested_tags @@ -86,7 +87,7 @@ def test_incompatible_tag_leaves_compatible_requested_tags assert_predicate result.fetch(:status), :success? assert_equal "checkout", flag_value(result.fetch(:maestro_args), "--include-tags") - assert_equal "ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + assert_equal "apple-pay,ios-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") end # Maestro treats an empty include list as "run everything". If the caller explicitly @@ -99,6 +100,58 @@ def test_incompatible_only_request_runs_nothing assert_includes result.fetch(:stderr), "nothing runs" end + def test_normal_profile_excludes_an_explicit_apple_pay_request + result = run_script("ios", "com.shopify.checkoutkit.swiftdemo", "ready", "apple-pay") + + assert_predicate result.fetch(:status), :success? + assert_nil result.fetch(:maestro_args) + assert_includes result.fetch(:stderr), "nothing runs" + end + + def test_swift_apple_pay_profile_allows_only_the_apple_pay_tag + result = run_script( + "ios", + "com.shopify.checkoutkit.swiftdemo", + "ready", + "apple-pay", + profile: "swift-apple-pay" + ) + + assert_predicate result.fetch(:status), :success? + assert_equal "apple-pay", flag_value(result.fetch(:maestro_args), "--include-tags") + assert_equal "android-only", flag_value(result.fetch(:maestro_args), "--exclude-tags") + end + + def test_swift_apple_pay_profile_rejects_another_app + result = run_script("ios", "com.example.other", "ready", "apple-pay", profile: "swift-apple-pay") + + refute_predicate result.fetch(:status), :success? + assert_nil result.fetch(:maestro_args) + assert_includes result.fetch(:stderr), "requires the Swift iOS sample app" + end + + def test_swift_apple_pay_profile_rejects_mixed_tags + result = run_script( + "ios", + "com.shopify.checkoutkit.swiftdemo", + "ready", + "apple-pay,smoke", + profile: "swift-apple-pay" + ) + + refute_predicate result.fetch(:status), :success? + assert_nil result.fetch(:maestro_args) + assert_includes result.fetch(:stderr), "requires --tags apple-pay by itself" + end + + def test_unknown_profile_fails_before_maestro + result = run_script("ios", "app.id", "ready", profile: "unknown") + + refute_predicate result.fetch(:status), :success? + assert_nil result.fetch(:maestro_args) + assert_includes result.fetch(:stderr), "profile must be normal or swift-apple-pay" + end + def test_unknown_platform_fails_before_maestro result = run_script("windows", "app.id", "ready") diff --git a/e2e/tests/shared/checkout-apple-pay.yaml b/e2e/tests/shared/checkout-apple-pay.yaml new file mode 100644 index 000000000..8d9615b84 --- /dev/null +++ b/e2e/tests/shared/checkout-apple-pay.yaml @@ -0,0 +1,19 @@ +appId: ${E2E_APP_ID} +name: Present and reopen Apple Pay +# Platform capability: Apple Pay requires PassKit on iOS. +tags: + - checkout + - smoke + - apple-pay + - ios-only + +env: + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=guest" +--- +- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml +- runFlow: ../../flows/checkout/present-apple-pay.yaml +- runFlow: ../../flows/checkout/close-apple-pay.yaml +- runFlow: ../../flows/checkout/present-apple-pay.yaml +- runFlow: ../../flows/checkout/close-apple-pay.yaml +- assertVisible: + id: cart-checkout-ready