diff --git a/.env.example b/.env.example index a909903d8..7ff2a0ed8 100644 --- a/.env.example +++ b/.env.example @@ -27,15 +27,27 @@ CUSTOMER_ACCOUNT_API_CLIENT_ID= CUSTOMER_ACCOUNT_API_SHOP_ID= CUSTOMER_ACCOUNT_API_VERSION=2026-04 -# Buyer identity defaults used by sample apps +# Customer account used by the Maestro account journey (optional). +# CI supplies these from Bitrise secrets. Leave both blank to skip the account tests. +E2E_CUSTOMER_ACCOUNT_EMAIL= +E2E_CUSTOMER_ACCOUNT_CODE= + +# User agent suffix the samples add to the customer account login web view (optional). +# CI supplies this from Bitrise secrets. Leave it blank for normal sample use. +CUSTOM_USER_AGENT= + +# Buyer identity defaults used by sample apps. +# Keep the address in the E2E shop's own country. A foreign address changes the billing +# country, the province list, the postal field label and the currency, so the checkout +# flows then look for fields that the form does not show. EMAIL=checkout-kit@example.com -ADDRESS_1=650 King Street +ADDRESS_1=700 S Flower St ADDRESS_2=Shopify HQ -CITY=Toronto +CITY=Los Angeles COMPANY=Shopify -COUNTRY=CA +COUNTRY=US FIRST_NAME=Evelyn LAST_NAME=Hartley -PROVINCE=ON -ZIP=M5V 1M7 -PHONE=+14165550100 +PROVINCE=CA +ZIP=90017 +PHONE=+12135550142 diff --git a/e2e/BITRISE.md b/e2e/BITRISE.md index 55c8372fb..8480db95b 100644 --- a/e2e/BITRISE.md +++ b/e2e/BITRISE.md @@ -156,9 +156,24 @@ The launch smoke suite sends only non-sensitive Maestro environment values to Br - `E2E_APP_ID` - `E2E_READY_MARKER` +- `E2E_CONTROL_LINK` Do not pass storefront tokens or customer data through BrowserStack Maestro environment variables without explicit review, because those values are visible in BrowserStack dashboards. +## Account journey secrets + +The account journey signs a test customer in, so it needs two more Maestro values. Both are reviewed exceptions to the rule above, because the BrowserStack dashboard is private to this organization and the values never enter the repository. + +| Variable | Where it is used | Purpose | +| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------- | +| `E2E_CUSTOMER_ACCOUNT_EMAIL` | Maestro environment | Address the sign-in flow types on the hosted login page. | +| `E2E_CUSTOMER_ACCOUNT_CODE` | Maestro environment | Verification code the sign-in flow types. | +| `CUSTOM_USER_AGENT` | Sample app build, through `.env` | Marks the login web view as a test client. The code fails without it. | + +Add all three as secret Bitrise environment variables. Leave them out of any log, any pull request, and any file in this repository. `scripts/setup_storefront_env` reads `CUSTOM_USER_AGENT` from the root `.env` and writes it into each generated sample configuration, all of which are ignored by git. + +A run with these values missing skips the account tests instead of failing, so a fork without the secrets still gets a green suite. + ## GitHub reporting The `e2e-report` workflow creates commit statuses, Check Runs, and sticky PR comments using the short-lived token generated by the Bitrise GitHub App. diff --git a/e2e/README.md b/e2e/README.md index 5c25561e4..49cd92da2 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -148,6 +148,8 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count already carries the contact and the delivery address. - `tests/shared/checkout-guest.yaml` orders from an empty identity, so checkout asks for the contact and the delivery address as well as the payment. +- `tests/shared/checkout-customer-account.yaml` signs a customer in, then orders. + Checkout reads the contact and the delivery address from the account. - `scripts/run_local_e2e` builds and installs any of the four local targets. - `scripts/run_maestro` is their single Maestro invocation. It holds the environment contract and the workspace root rule in one place. diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index e53e19285..6489448e4 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -7,6 +7,8 @@ tags: include: - launch - checkout + # execute_browserstack_run drops this tag again when the account secrets are absent. + - account exclude: - flaky - wip diff --git a/e2e/flows/app/bootstrap-cart-from-link.yaml b/e2e/flows/app/bootstrap-cart-from-link.yaml index bc6592be9..ad075343d 100644 --- a/e2e/flows/app/bootstrap-cart-from-link.yaml +++ b/e2e/flows/app/bootstrap-cart-from-link.yaml @@ -1,61 +1,6 @@ appId: ${E2E_APP_ID} --- -- launchApp: - clearState: true - arguments: - # iOS-only launch arguments; Android ignores them. - AppleLocale: en_US - AppleLanguages: "(en)" -# Avoid relaunching healthy apps. BrowserStack can report the state-clearing reinstall and -# launch as complete while iOS remains on SpringBoard, so probe briefly before recovering. -- extendedWaitUntil: - visible: - id: ${E2E_READY_MARKER} - timeout: 10000 - optional: true -- runFlow: - when: - notVisible: - id: ${E2E_READY_MARKER} - commands: - - retry: - maxRetries: 1 - commands: - - launchApp: - arguments: - AppleLocale: en_US - AppleLanguages: "(en)" - - extendedWaitUntil: - visible: - id: ${E2E_READY_MARKER} - timeout: 60000 -# The wait above can match a view hierarchy the dying process left behind, which sends the -# link seconds before the new activity exists. Android then drops it, because a deep link -# has no delivery guarantee. One repeat covers that, and the command is safe to repeat: -# every cart command resets the cart before it seeds one. -- retry: - maxRetries: 1 - commands: - # The link goes to the running app. launchApp cleared the state above, so the cart is - # already empty, and a running app is what the account journey needs later: iOS delivers - # a cold-start URL through the scene connect options and Android through the launch - # intent, so a stopped app would need a second entry point on every platform. - # Each test supplies E2E_CART_PARAMS, so tests that do not care about buyer - # identity leave the app's configured mode alone. - - openLink: "${E2E_CONTROL_LINK}/cart?${E2E_CART_PARAMS}" - - waitForAnimationToEnd - # iOS asks the user to confirm before it hands a custom scheme to an app. Android - # does not, so the tap is conditional rather than platform-gated. - - runFlow: - when: - visible: "Open" - commands: - - tapOn: "Open" - - waitForAnimationToEnd - # The cart marker only appears after the controller resolved a variant, created a - # cart, added the line, and navigated. Reaching it proves the whole command path. - # That is two network round trips, so the timeout covers a slow device on a slow shop. - - extendedWaitUntil: - visible: - id: cart-checkout-ready - timeout: 60000 +# Most tests want a clean app holding one seeded cart. The account journey needs the two +# halves apart, because it signs in between them, so each half is its own flow. +- runFlow: launch.yaml +- runFlow: seed-cart-from-link.yaml diff --git a/e2e/flows/app/confirm-open-link.yaml b/e2e/flows/app/confirm-open-link.yaml new file mode 100644 index 000000000..157e82ea0 --- /dev/null +++ b/e2e/flows/app/confirm-open-link.yaml @@ -0,0 +1,10 @@ +appId: ${E2E_APP_ID} +--- +# iOS asks the user to confirm before it hands a custom scheme to an app. Android does +# not, so the tap is conditional rather than platform-gated. +- runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" + - waitForAnimationToEnd diff --git a/e2e/flows/app/launch.yaml b/e2e/flows/app/launch.yaml new file mode 100644 index 000000000..b9de518ba --- /dev/null +++ b/e2e/flows/app/launch.yaml @@ -0,0 +1,31 @@ +appId: ${E2E_APP_ID} +--- +- launchApp: + clearState: true + arguments: + # iOS-only launch arguments; Android ignores them. + AppleLocale: en_US + AppleLanguages: "(en)" +# Avoid relaunching healthy apps. BrowserStack can report the state-clearing reinstall and +# launch as complete while iOS remains on SpringBoard, so probe briefly before recovering. +- extendedWaitUntil: + visible: + id: ${E2E_READY_MARKER} + timeout: 10000 + optional: true +- runFlow: + when: + notVisible: + id: ${E2E_READY_MARKER} + commands: + - retry: + maxRetries: 1 + commands: + - launchApp: + arguments: + AppleLocale: en_US + AppleLanguages: "(en)" + - extendedWaitUntil: + visible: + id: ${E2E_READY_MARKER} + timeout: 60000 diff --git a/e2e/flows/app/seed-cart-from-link.yaml b/e2e/flows/app/seed-cart-from-link.yaml new file mode 100644 index 000000000..f3b112fe7 --- /dev/null +++ b/e2e/flows/app/seed-cart-from-link.yaml @@ -0,0 +1,24 @@ +appId: ${E2E_APP_ID} +--- +# The launch wait can match a view hierarchy the dying process left behind, which sends +# the link seconds before the new activity exists. Android then drops it, because a deep +# link has no delivery guarantee. One repeat covers that, and the command is safe to +# repeat: every cart command resets the cart before it seeds one. +- retry: + maxRetries: 1 + commands: + # The link goes to the running app. A running app is what the account journey needs: + # iOS delivers a cold-start URL through the scene connect options and Android through + # the launch intent, so a stopped app would need a second entry point on every + # platform. Each test supplies E2E_CART_PARAMS, so tests that do not care about buyer + # identity leave the app's configured mode alone. + - openLink: "${E2E_CONTROL_LINK}/cart?${E2E_CART_PARAMS}" + - waitForAnimationToEnd + - runFlow: confirm-open-link.yaml + # The cart marker only appears after the controller resolved a variant, created a + # cart, added the line, and navigated. Reaching it proves the whole command path. + # That is two network round trips, so the timeout covers a slow device on a slow shop. + - extendedWaitUntil: + visible: + id: cart-checkout-ready + timeout: 60000 diff --git a/e2e/flows/app/sign-in-customer-account.yaml b/e2e/flows/app/sign-in-customer-account.yaml new file mode 100644 index 000000000..b190b5e18 --- /dev/null +++ b/e2e/flows/app/sign-in-customer-account.yaml @@ -0,0 +1,52 @@ +appId: ${E2E_APP_ID} +--- +# The control link opens the sample's login screen, which hosts the Shopify sign-in page +# in a WebView. Every target configures that WebView without persistence, so no session +# survives a launch and every run types the address again. +- openLink: "${E2E_CONTROL_LINK}/signIn" +- waitForAnimationToEnd +- runFlow: confirm-open-link.yaml +- extendedWaitUntil: + visible: + text: "^Email( address)?$" + timeout: 60000 +- tapOn: + text: "^Email( address)?$" +- inputText: ${E2E_CUSTOMER_ACCOUNT_EMAIL} +# The page reads the address as it is typed. An address that owns a Shop account gets a +# "Continue with shop" button, and every other address gets "Continue". Only the code route +# works on all four targets, so this flow takes "Use email instead" whenever the page shows +# it, and falls back to "Continue". +- extendedWaitUntil: + visible: + text: "^(Continue|Use email instead)$" + timeout: 30000 +# The "Continue" branch comes first and reads the same element the second branch taps, so +# only one of the two ever runs. The other order lets a "Continue" on the code page match +# after "Use email instead" has already moved the page on. +- runFlow: + when: + notVisible: "^Use email instead$" + visible: "^Continue$" + commands: + - tapOn: + text: "^Continue$" +- runFlow: + when: + visible: "^Use email instead$" + commands: + - tapOn: + text: "^Use email instead$" +- extendedWaitUntil: + visible: + text: "^6-digit code$" + timeout: 30000 +- tapOn: + text: "^6-digit code$" +- inputText: ${E2E_CUSTOMER_ACCOUNT_CODE} +# The page submits the code itself, and each sample then shows its account screen. Swift +# dismisses the login sheet, Android leaves the login route, and React Native goes back. +- extendedWaitUntil: + visible: + id: account-signed-in-view + timeout: 60000 diff --git a/e2e/flows/checkout/assert-buyer-prefilled.yaml b/e2e/flows/checkout/assert-buyer-prefilled.yaml new file mode 100644 index 000000000..c81c3d3b2 --- /dev/null +++ b/e2e/flows/checkout/assert-buyer-prefilled.yaml @@ -0,0 +1,12 @@ +appId: ${E2E_APP_ID} +--- +# The signed-in buyer is the point of this journey. Checkout reads the contact address and +# the saved delivery address from the account, so both appear before the test types +# anything. A guest cart shows an empty contact field and a delivery form instead, so these +# two assertions separate a real customer cart from a cart that lost its buyer identity. +- extendedWaitUntil: + visible: + text: "${E2E_CUSTOMER_ACCOUNT_EMAIL}" + timeout: 30000 +- assertVisible: + text: "^Ship to$" diff --git a/e2e/flows/checkout/fill-billing-address.yaml b/e2e/flows/checkout/fill-billing-address.yaml index 29678ca51..7ce5543f5 100644 --- a/e2e/flows/checkout/fill-billing-address.yaml +++ b/e2e/flows/checkout/fill-billing-address.yaml @@ -16,7 +16,7 @@ appId: ${E2E_APP_ID} # Country/Region and Province already hold the shop defaults, so this flow leaves # them alone and supplies an address that suits those defaults. - tapOn: - text: "^First name$" + text: "^First name( \\(optional\\))?$" - inputText: "${BILLING_FIRST_NAME}" - runFlow: dismiss-active-field.yaml - tapOn: @@ -32,7 +32,7 @@ appId: ${E2E_APP_ID} - inputText: "${BILLING_CITY}" - runFlow: dismiss-active-field.yaml - tapOn: - text: "^Postal code$" + text: "^${BILLING_POSTAL_FIELD_LABEL}$" - inputText: "${BILLING_POSTAL_CODE}" - runFlow: dismiss-active-field.yaml - waitForAnimationToEnd diff --git a/e2e/flows/checkout/fill-contact.yaml b/e2e/flows/checkout/fill-contact.yaml index 3e0d6dde2..46f0ef4a3 100644 --- a/e2e/flows/checkout/fill-contact.yaml +++ b/e2e/flows/checkout/fill-contact.yaml @@ -1,25 +1,6 @@ appId: ${E2E_APP_ID} --- -- tapOn: - text: "^Email( or mobile phone number)?$" -- waitForAnimationToEnd -- inputText: "${EMAIL}" -- runFlow: dismiss-active-field.yaml -- extendedWaitUntil: - visible: "^${EMAIL}$" -- tapOn: - text: "^First name( \\(optional\\))?$" -- waitForAnimationToEnd -- inputText: "${FIRST_NAME}" -- runFlow: dismiss-active-field.yaml -- scrollUntilVisible: - element: - text: "^Last name$" - direction: DOWN - visibilityPercentage: 100 - centerElement: true -- tapOn: - text: "^Last name$" -- waitForAnimationToEnd -- inputText: "${LAST_NAME}" -- runFlow: dismiss-active-field.yaml +# A guest types both halves. A signed-in buyer arrives with the email already attached, +# so the account journey runs fill-name.yaml on its own. +- runFlow: fill-email.yaml +- runFlow: fill-name.yaml diff --git a/e2e/flows/checkout/fill-email.yaml b/e2e/flows/checkout/fill-email.yaml new file mode 100644 index 000000000..22516ebbb --- /dev/null +++ b/e2e/flows/checkout/fill-email.yaml @@ -0,0 +1,9 @@ +appId: ${E2E_APP_ID} +--- +- tapOn: + text: "^Email( or mobile phone number)?$" +- waitForAnimationToEnd +- inputText: "${EMAIL}" +- runFlow: dismiss-active-field.yaml +- extendedWaitUntil: + visible: "^${EMAIL}$" diff --git a/e2e/flows/checkout/fill-name.yaml b/e2e/flows/checkout/fill-name.yaml new file mode 100644 index 000000000..b15c75c3c --- /dev/null +++ b/e2e/flows/checkout/fill-name.yaml @@ -0,0 +1,22 @@ +appId: ${E2E_APP_ID} +--- +# A signed-in buyer can arrive with either name already filled, and `inputText` appends +# rather than replaces, so each field is cleared before it is typed. +- tapOn: + text: "^First name( \\(optional\\))?$" +- waitForAnimationToEnd +- eraseText +- inputText: "${FIRST_NAME}" +- runFlow: dismiss-active-field.yaml +- scrollUntilVisible: + element: + text: "^Last name$" + direction: DOWN + visibilityPercentage: 100 + centerElement: true +- tapOn: + text: "^Last name$" +- waitForAnimationToEnd +- eraseText +- inputText: "${LAST_NAME}" +- runFlow: dismiss-active-field.yaml diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index 3b34da15e..29f8b2bca 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -91,14 +91,21 @@ def check_run_payload def results_table lines = [] - lines << "| Status | Suite | Target | Platform | OS version tag | Device |" + lines << "| Status | Tags | Target | Platform | OS version tag | Device |" lines << "|---|---|---|---|---|---|" @results.each do |result| - lines << "| #{status_icon(result)} | `#{result["application_id"]}` | #{result["target"]} | #{result["platform"]} | #{result["os_version_tag"]} | #{device_cell(result)} |" + lines << "| #{status_icon(result)} | `#{tags_cell(result)}` | #{result["target"]} | #{result["platform"]} | #{result["os_version_tag"]} | #{device_cell(result)} |" end lines end + # `execute` is the tests folder on every row now that tags select the flows, so it carries + # no information. The include tags name what the row actually ran. + def tags_cell(result) + tags = result.fetch("include_tags", []).to_a + tags.empty? ? "all" : tags.join(", ") + end + def completeness_lines blocked? ? blocked_lines : shortfall_lines end @@ -225,7 +232,7 @@ def missing_runs end def missing_run_label(run) - "`#{run["application_id"] || run["target"]}` (#{run["platform"]})" + "`#{run["application_id"] || run["target"]}` · #{tags_cell(run)} (#{run["platform"]})" end def missing_count @@ -263,6 +270,7 @@ def failure_details(result) lines << "" lines << "### #{failure_heading(result)}" lines << "" + lines.concat(setup_error_lines(result)) lines << "| Test | Status | Artifacts |" lines << "|---|---|---|" tests = result.fetch("failed_tests", []) @@ -276,8 +284,17 @@ def failure_details(result) lines end + # A row that fails before it starts a build reports no test cases, so the message from + # result.json is the only place the cause appears. + def setup_error_lines(result) + return [] if blank?(result["error"]) + + message = result["error"].to_s.split("\n").map(&:strip).reject(&:empty?).join(" ") + ["> `#{result["error_class"] || "Error"}`: #{message}", ""] + end + def failure_heading(result) - "#{os_label(result["platform"])} — #{result["application_id"]}" + "#{os_label(result["platform"])} — #{result["target"]}" end def artifact_links(testcase, result) diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index 70f2560e9..58f94ae9c 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -137,6 +137,52 @@ class BrowserStackRunExecutor response end + # BrowserStack rejects a build when one tag appears in both lists, so the account tag has + # to leave the include list at the same moment it joins the exclude list. + def self.resolve_tags(run, account_enabled:) + include_tags = run.fetch("include_tags") + exclude_tags = run.fetch("exclude_tags") + + return {includeTags: include_tags, excludeTags: exclude_tags} if account_enabled + + { + includeTags: include_tags - ["account"], + excludeTags: (exclude_tags + ["account"]).uniq + } + end + + # The repository is public, so every account value lives in Bitrise secrets only. A run + # without them skips the account journey instead of failing inside checkout. Maestro reads + # the first two at run time. The sample app reads the last two at build time, and without + # them the sign-in web view opens http://null/oauth/authorize with an empty client_id. + ACCOUNT_VARIABLES = [ + "E2E_CUSTOMER_ACCOUNT_EMAIL", + "E2E_CUSTOMER_ACCOUNT_CODE", + "CUSTOMER_ACCOUNT_API_CLIENT_ID", + "CUSTOMER_ACCOUNT_API_SHOP_ID" + ].freeze + + def self.account_enabled?(env) + ACCOUNT_VARIABLES.all? { |variable| !env[variable].to_s.strip.empty? } + end + + def self.missing_account_variables(env) + ACCOUNT_VARIABLES.select { |variable| env[variable].to_s.strip.empty? } + end + + def account_enabled? + self.class.account_enabled?(ENV) + end + + def resolve_tags(run) + unless account_enabled? + missing = self.class.missing_account_variables(ENV) + warn("no #{missing.join(", ")}, excluding the account tag") + end + + self.class.resolve_tags(run, account_enabled: account_enabled?) + end + def start_build(run, app_url, test_suite_url, device) body = { app: app_url, @@ -147,14 +193,13 @@ class BrowserStackRunExecutor customBuildName: run.fetch("id"), devices: [device], execute: [run.fetch("execute")], - tags: { - includeTags: run.fetch("include_tags"), - excludeTags: run.fetch("exclude_tags") - }, + tags: resolve_tags(run), setEnvVariables: { E2E_APP_ID: run.fetch("app_id"), E2E_READY_MARKER: run.fetch("ready_marker"), - E2E_CONTROL_LINK: run.fetch("control_link") + E2E_CONTROL_LINK: run.fetch("control_link"), + E2E_CUSTOMER_ACCOUNT_EMAIL: ENV.fetch("E2E_CUSTOMER_ACCOUNT_EMAIL", ""), + E2E_CUSTOMER_ACCOUNT_CODE: ENV.fetch("E2E_CUSTOMER_ACCOUNT_CODE", "") } } response = @client.start_build(run.fetch("platform"), body) diff --git a/e2e/scripts/run_local_e2e b/e2e/scripts/run_local_e2e index 5ea72bb46..59c7f0974 100755 --- a/e2e/scripts/run_local_e2e +++ b/e2e/scripts/run_local_e2e @@ -199,7 +199,12 @@ build_and_install() { -derivedDataPath "$DERIVED_DATA" \ -skipPackagePluginValidation \ -disableAutomaticPackageResolution \ - CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGN_STYLE=Manual \ + PROVISIONING_PROFILE_SPECIFIER="" \ + DEVELOPMENT_TEAM="" APP_PATH="$(find "$DERIVED_DATA/Build/Products" -maxdepth 2 -name "${APP_NAME}.app" -print -quit)" if [ -z "$APP_PATH" ]; then diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro index 265207e05..adf60da98 100755 --- a/e2e/scripts/run_maestro +++ b/e2e/scripts/run_maestro @@ -30,6 +30,39 @@ MAESTRO="$("$E2E_ROOT/scripts/maestro_bin")" # than passed. e2e/lib/e2e_matrix_to_browserstack_run_plan.rb derives it the same way for CI. CONTROL_LINK="${APP_ID}://e2e" +# Account credentials never live in a flow file. CI exports them from Bitrise secrets. +# Local runs read them from the untracked root .env, which .env.example documents. +ENV_FILE="$(cd "$E2E_ROOT/.." && pwd)/.env" + +read_env_value() { + local key="$1" + + if [ ! -f "$ENV_FILE" ]; then + return 0 + fi + + sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1 +} + +CUSTOMER_ACCOUNT_EMAIL="${E2E_CUSTOMER_ACCOUNT_EMAIL:-$(read_env_value E2E_CUSTOMER_ACCOUNT_EMAIL)}" +CUSTOMER_ACCOUNT_CODE="${E2E_CUSTOMER_ACCOUNT_CODE:-$(read_env_value E2E_CUSTOMER_ACCOUNT_CODE)}" + +if [ -z "$CUSTOMER_ACCOUNT_EMAIL" ] || [ -z "$CUSTOMER_ACCOUNT_CODE" ]; then + echo "run_maestro: no customer account credentials, excluding the account tag" >&2 + # The account tag has to leave the include list at the same moment it joins the exclude + # list, because a tag named in both lists makes the run ambiguous. + REQUESTED_TAGS="$INCLUDE_TAGS" + INCLUDE_TAGS="$(printf '%s' "$INCLUDE_TAGS" | tr ',' '\n' | { grep -vx account || true; } | paste -sd, -)" + EXCLUDE_TAGS="${EXCLUDE_TAGS:+${EXCLUDE_TAGS},}account" + + # An empty include list means "run everything", so a caller who asked only for the + # account tag would get the whole suite instead of nothing. + if [ -n "$REQUESTED_TAGS" ] && [ -z "$INCLUDE_TAGS" ]; then + echo "run_maestro: the requested tags need customer account credentials, so nothing runs" >&2 + exit 0 + fi +fi + MAESTRO_ARGS=(--platform "$PLATFORM" test --config config.yaml) [ -n "$INCLUDE_TAGS" ] && MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") [ -n "$EXCLUDE_TAGS" ] && MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") @@ -44,4 +77,6 @@ exec "$MAESTRO" "${MAESTRO_ARGS[@]}" \ -e "E2E_APP_ID=${APP_ID}" \ -e "E2E_READY_MARKER=${READY_MARKER}" \ -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ + -e "E2E_CUSTOMER_ACCOUNT_EMAIL=${CUSTOMER_ACCOUNT_EMAIL}" \ + -e "E2E_CUSTOMER_ACCOUNT_CODE=${CUSTOMER_ACCOUNT_CODE}" \ . diff --git a/e2e/test/bootstrap_cart_from_link_test.rb b/e2e/test/bootstrap_cart_from_link_test.rb index 39d1d3643..b31113cfe 100644 --- a/e2e/test/bootstrap_cart_from_link_test.rb +++ b/e2e/test/bootstrap_cart_from_link_test.rb @@ -6,8 +6,11 @@ class BootstrapCartFromLinkTest < Minitest::Test FLOW_PATH = File.expand_path("../flows/app/bootstrap-cart-from-link.yaml", __dir__) - def commands - YAML.load_stream(File.read(FLOW_PATH)).fetch(1) + def commands(path = FLOW_PATH) + YAML.load_stream(File.read(path)).fetch(1).flat_map do |command| + nested_flow = command["runFlow"] + nested_flow.is_a?(String) ? commands(File.expand_path(nested_flow, File.dirname(path))) : command + end end def test_clears_state_then_relaunches_only_if_the_app_is_not_ready diff --git a/e2e/test/browserstack_run_executor_test.rb b/e2e/test/browserstack_run_executor_test.rb index ac0366d95..7cb69a0c8 100644 --- a/e2e/test/browserstack_run_executor_test.rb +++ b/e2e/test/browserstack_run_executor_test.rb @@ -7,6 +7,11 @@ class BrowserStackRunExecutorTest < Minitest::Test E2E_ROOT = File.expand_path("..", __dir__) + RUN = { + "include_tags" => %w[launch cart checkout account], + "exclude_tags" => %w[flaky wip] + }.freeze + def with_version_file(contents) Dir.mktmpdir do |dir| path = File.join(dir, ".maestro-version") @@ -55,4 +60,68 @@ def test_a_blank_override_falls_back_to_the_pin_file assert_equal "2.4.0", version end end + + def test_account_credentials_present_keeps_both_lists + tags = BrowserStackRunExecutor.resolve_tags(RUN, account_enabled: true) + + assert_equal %w[launch cart checkout account], tags.fetch(:includeTags) + assert_equal %w[flaky wip], tags.fetch(:excludeTags) + end + + def test_account_credentials_missing_drops_the_account_tag_from_the_include_list + tags = BrowserStackRunExecutor.resolve_tags(RUN, account_enabled: false) + + assert_equal %w[launch cart checkout], tags.fetch(:includeTags) + assert_equal %w[flaky wip account], tags.fetch(:excludeTags) + end + + def test_account_credentials_missing_never_repeats_a_tag_across_both_lists + tags = BrowserStackRunExecutor.resolve_tags(RUN, account_enabled: false) + + assert_empty(tags.fetch(:includeTags) & tags.fetch(:excludeTags)) + end + + def test_empty_include_list_still_excludes_the_account_tag + run = {"include_tags" => [], "exclude_tags" => []} + tags = BrowserStackRunExecutor.resolve_tags(run, account_enabled: false) + + assert_empty tags.fetch(:includeTags) + assert_equal ["account"], tags.fetch(:excludeTags) + end + + ACCOUNT_ENV = { + "E2E_CUSTOMER_ACCOUNT_EMAIL" => "buyer@example.com", + "E2E_CUSTOMER_ACCOUNT_CODE" => "000000", + "CUSTOMER_ACCOUNT_API_CLIENT_ID" => "synthetic-client-id", + "CUSTOMER_ACCOUNT_API_SHOP_ID" => "synthetic-shop-id" + }.freeze + + def test_every_account_value_present_enables_the_account_journey + assert BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV) + end + + def test_a_missing_test_credential_disables_the_account_journey + ACCOUNT_ENV.keys.grep(/^E2E_/).each do |key| + refute( + BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV.merge(key => "")), + "a blank #{key} must disable the account journey" + ) + end + end + + # The app reads the OAuth client id and shop id at build time. Without them the sign-in + # web view requests http://null/oauth/authorize with an empty client_id, and the test + # fails inside the web view instead of never running. + def test_a_missing_oauth_build_value_disables_the_account_journey + ACCOUNT_ENV.keys.grep(/^CUSTOMER_ACCOUNT_API_/).each do |key| + refute( + BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV.merge(key => "")), + "a blank #{key} must disable the account journey" + ) + end + end + + def test_a_whitespace_account_value_counts_as_missing + refute BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV.merge("CUSTOMER_ACCOUNT_API_SHOP_ID" => " ")) + end end diff --git a/e2e/test/e2e_github_reporter_test.rb b/e2e/test/e2e_github_reporter_test.rb index a69c7c042..089bfb90d 100644 --- a/e2e/test/e2e_github_reporter_test.rb +++ b/e2e/test/e2e_github_reporter_test.rb @@ -35,7 +35,8 @@ def swift_ios_run "target" => "swift", "platform" => "ios", "os_version_tag" => "latest", - "execute" => "." + "execute" => ".", + "include_tags" => ["launch"] } end @@ -46,7 +47,8 @@ def react_native_ios_run "target" => "react-native", "platform" => "ios", "os_version_tag" => "latest", - "execute" => "." + "execute" => ".", + "include_tags" => ["launch"] } end @@ -67,7 +69,7 @@ def target(id) end def result(target) - {"target" => target, "application_id" => target, "passed" => true, "execute" => "."} + {"target" => target, "passed" => true, "execute" => "flow.yaml"} end def test_install_table_lists_every_produced_target @@ -120,14 +122,14 @@ def test_blocked_report_names_the_failed_stage_and_lists_the_skipped_runs assert_includes summary, "> - `e2e-build-react-native-ios` — [build log]" assert_includes summary, "/build/a7111bcd)" assert_includes summary, "> None of the 2 planned runs executed:" - assert_includes summary, "> - `swift-ios` (ios)" + assert_includes summary, "> - `swift-ios` · launch (ios)" assert_includes summary, "> [Pipeline build](https://app.bitrise.io/app/" end def test_blocked_report_omits_the_empty_tables body = blocked_reporter.comment_body - refute_includes body, "| Status | Suite |" + refute_includes body, "| Status | Tags |" refute_includes body, "## Install this build" refute_includes body, "| SDK | Install |" end @@ -140,18 +142,10 @@ def test_missing_runs_are_named_without_a_stage_roster summary = reporter(results: [], run_plan: [swift_ios_run], expected: 1).markdown_summary assert_includes summary, "did not report" - assert_includes summary, "> - `swift-ios` (ios)" + assert_includes summary, "> - `swift-ios` · launch (ios)" refute_includes summary, "[!CAUTION]" end - def test_results_table_suite_column_shows_the_application_id_not_the_execute_path - reported = swift_ios_run.merge("passed" => true, "resolved_device" => "iPhone") - body = reporter(results: [reported]).comment_body - - assert_includes body, "| ✅ | `swift-ios` |" - refute_includes body, "| ✅ | `.` |" - end - def test_partial_report_keeps_the_table_and_names_the_failed_stage reported = swift_ios_run.merge("passed" => true, "resolved_device" => "iPhone") summary = reporter( @@ -161,7 +155,7 @@ def test_partial_report_keeps_the_table_and_names_the_failed_stage expected: 2 ).markdown_summary - assert_includes summary, "| Status | Suite |" + assert_includes summary, "| Status | Tags |" assert_includes summary, "did not report" assert_includes summary, "> 1 pipeline stage failed:" assert_includes summary, "> - `e2e-execute-browserstack-run` — [build log]" @@ -204,4 +198,79 @@ def test_complete_run_has_no_missing_run_lines refute_includes summary, "did not report" end + + def test_results_table_names_the_tags_instead_of_the_execute_path + result = swift_ios_run.merge( + "passed" => true, + "resolved_device" => "iPhone", + "include_tags" => %w[cart checkout], + "exclude_tags" => %w[flaky] + ) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "| Status | Tags | Target | Platform | OS version tag | Device |" + assert_includes summary, "| `cart, checkout` |" + refute_includes summary, "tests/shared/launch-smoke.yaml" + end + + def test_results_table_reads_all_when_the_run_carries_no_include_tags + result = swift_ios_run.merge("passed" => true, "resolved_device" => "iPhone", "include_tags" => []) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "| `all` |" + end + + def test_failure_heading_names_the_target_rather_than_the_execute_path + result = swift_ios_run.merge("passed" => false, "execute" => ".", "failed_tests" => []) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "### iOS — swift" + refute_includes summary, "### iOS — ." + end + + def test_setup_error_names_the_class_and_the_message + result = swift_ios_run.merge( + "passed" => false, + "status" => "error", + "failed_tests" => [], + "error_class" => "RuntimeError", + "error" => "BrowserStack request failed 422: duplicate tags" + ) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "> `RuntimeError`: BrowserStack request failed 422: duplicate tags" + end + + def test_setup_error_message_collapses_to_one_line + result = swift_ios_run.merge( + "passed" => false, + "failed_tests" => [], + "error_class" => "RuntimeError", + "error" => "first line\nsecond line" + ) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "> `RuntimeError`: first line second line" + end + + def test_missing_run_label_names_the_tags_rather_than_the_execute_path + planned = swift_ios_run.merge("include_tags" => %w[cart checkout]) + summary = reporter(results: [], run_plan: [planned], expected: 1).markdown_summary + + assert_includes summary, "> - `swift-ios` · cart, checkout (ios)" + end + + def test_missing_run_label_reads_all_without_include_tags + planned = swift_ios_run.merge("include_tags" => []) + summary = reporter(results: [], run_plan: [planned], expected: 1).markdown_summary + + assert_includes summary, "> - `swift-ios` · all (ios)" + end + + def test_failure_without_an_error_field_adds_no_error_line + result = swift_ios_run.merge("passed" => false, "failed_tests" => []) + summary = reporter(results: [result]).markdown_summary + + refute_includes summary, "> `" + 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 9dd50f269..cc4476897 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -51,7 +51,7 @@ def test_runs_carry_default_tags_and_the_other_platform_exclusion ios_run = run_for("swift-ios") android_run = run_for("kotlin-android") - assert_equal ["launch", "checkout"], ios_run.fetch("include_tags") + assert_equal ["launch", "checkout", "account"], ios_run.fetch("include_tags") assert_equal ["flaky", "wip", "android-only"], ios_run.fetch("exclude_tags") assert_equal ["flaky", "wip", "ios-only"], android_run.fetch("exclude_tags") end diff --git a/e2e/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb index cf17b50af..79e53becb 100644 --- a/e2e/test/maestro_test_tags_test.rb +++ b/e2e/test/maestro_test_tags_test.rb @@ -124,6 +124,44 @@ def test_the_matrix_selects_every_journey_a_shared_test_declares ) end + CREDENTIAL_VARIABLES = ["E2E_CUSTOMER_ACCOUNT_EMAIL", "E2E_CUSTOMER_ACCOUNT_CODE"].freeze + + def authored_files + Dir.glob("{tests,flows}/**/*.yaml", base: E2E_ROOT).sort + end + + # This repository is public. The runner reads the account credentials from Bitrise + # secrets or an untracked .env, so no committed file may carry a value for them. + def test_no_authored_file_assigns_a_customer_account_credential + authored_files.each do |path| + body = File.read(File.join(E2E_ROOT, path)) + + CREDENTIAL_VARIABLES.each do |variable| + refute_match( + /^\s*#{variable}\s*:/, + body, + "#{path} assigns #{variable}, which must reach Maestro from the runner instead" + ) + end + end + end + + def env(path) + header(path)["env"] || {} + end + + # Every test that submits an order states the identity it wants instead of inheriting + # whichever mode the sample app configured. + def test_every_order_test_declares_a_buyer_identity_mode + shared_test_files.select { |path| tags(path).include?("full") }.each do |path| + assert_match( + /buyerIdentityMode=\w+/, + env(path)["E2E_CART_PARAMS"].to_s, + "#{path} submits an order, so E2E_CART_PARAMS must set buyerIdentityMode" + ) + end + end + def test_there_is_at_least_one_test_to_check refute_empty(test_files) refute_empty(shared_test_files) diff --git a/e2e/tests/shared/checkout-customer-account.yaml b/e2e/tests/shared/checkout-customer-account.yaml new file mode 100644 index 000000000..69a5f10d5 --- /dev/null +++ b/e2e/tests/shared/checkout-customer-account.yaml @@ -0,0 +1,43 @@ +appId: ${E2E_APP_ID} +name: Checkout with a customer account +tags: + - account + # This test signs in and submits a real order, so it belongs to the expensive tier. + - full + +env: + # The cart attaches the signed-in buyer, so checkout reads the contact address and the + # saved delivery address from the account. Only payment is left to type. + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=customerAccount" + + # Checkout payment fixture. The shop runs the bogus gateway, which rejects real card + # brands and reads a single digit instead: 1 approves, 2 declines, 3 fails the gateway. + CARD_NUMBER: "1" + CARD_NUMBER_DISPLAY: "1" + CARD_SECURITY_CODE: "123" + CARD_HOLDER_NAME: "Maestro Shopify" + + # Checkout billing fixture. Checkout copies the delivery address when it can, so the + # billing section usually stays closed. These values cover the target that opens it. + BILLING_FIRST_NAME: "Maestro" + BILLING_LAST_NAME: "Shopify" + BILLING_ADDRESS_LINE1: "700 S Flower St" + BILLING_CITY: "Los Angeles" + BILLING_POSTAL_FIELD_LABEL: "ZIP code" + BILLING_POSTAL_CODE: "90017" + + # Accepted successful checkout states for this smoke test. + POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" +--- +# Sign in before the cart exists. The buyer identity is attached when the cart is created, +# so a cart seeded ahead of the session would carry no customer. +- runFlow: ../../flows/app/launch.yaml +- runFlow: ../../flows/app/sign-in-customer-account.yaml +- runFlow: ../../flows/app/seed-cart-from-link.yaml +- runFlow: ../../flows/checkout/present.yaml +- runFlow: ../../flows/checkout/assert-buyer-prefilled.yaml +- runFlow: ../../flows/checkout/fill-payment-card.yaml +- runFlow: ../../flows/checkout/fill-billing-address.yaml +- runFlow: ../../flows/checkout/submit.yaml +- runFlow: ../../flows/checkout/assert-complete.yaml +- runFlow: ../../flows/checkout/assert-returned-to-empty-cart.yaml diff --git a/e2e/tests/shared/checkout-guest.yaml b/e2e/tests/shared/checkout-guest.yaml index 7a6e6ca6a..1f138bc16 100644 --- a/e2e/tests/shared/checkout-guest.yaml +++ b/e2e/tests/shared/checkout-guest.yaml @@ -15,9 +15,9 @@ env: FIRST_NAME: "Maestro" LAST_NAME: "Shopify" - # Checkout delivery fixture. The shop opens on its own country, so this address picks a - # different one and makes the flow use the country picker. The two field labels follow - # the country: the United States names them State and ZIP code. + # Checkout delivery fixture. This address keeps the shop's own country, so the country + # picker stays closed and only the state picker runs. The two field labels follow the + # country: the United States names them State and ZIP code. COUNTRY_LABEL: "United States" ADDRESS_LINE1: "700 S Flower St" CITY: "Los Angeles" @@ -29,10 +29,10 @@ env: # reserved for fiction, so this value reaches nobody. PHONE: "213 555 0142" - # Checkout payment fixture. The shop runs its credit card gateway in test mode, which - # accepts this number only. Checkout groups the digits, so the flow needs both forms. - CARD_NUMBER: "4242424242424242" - CARD_NUMBER_DISPLAY: "4242 4242 4242 4242" + # Checkout payment fixture. The shop runs the bogus gateway, which rejects real card + # brands and reads a single digit instead: 1 approves, 2 declines, 3 fails the gateway. + CARD_NUMBER: "1" + CARD_NUMBER_DISPLAY: "1" CARD_SECURITY_CODE: "123" CARD_HOLDER_NAME: "Maestro Shopify" @@ -43,6 +43,7 @@ env: BILLING_LAST_NAME: "Shopify" BILLING_ADDRESS_LINE1: "700 S Flower St" BILLING_CITY: "Los Angeles" + BILLING_POSTAL_FIELD_LABEL: "ZIP code" BILLING_POSTAL_CODE: "90017" # Accepted successful checkout states for this smoke test. diff --git a/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml index c21b019fa..561cdc5b0 100644 --- a/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml @@ -11,21 +11,22 @@ env: # breaks. E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=hardcoded" - # Checkout payment fixture. The shop runs its credit card gateway in test mode, which - # accepts this number only. Checkout groups the digits, so the flow needs both forms. - CARD_NUMBER: "4242424242424242" - CARD_NUMBER_DISPLAY: "4242 4242 4242 4242" + # Checkout payment fixture. The shop runs the bogus gateway, which rejects real card + # brands and reads a single digit instead: 1 approves, 2 declines, 3 fails the gateway. + CARD_NUMBER: "1" + CARD_NUMBER_DISPLAY: "1" CARD_SECURITY_CODE: "123" CARD_HOLDER_NAME: "Maestro Shopify" # Checkout billing fixture. The cart supplies a one-time delivery address, which the # billing section cannot copy, so the test types a billing address of its own. Country - # and province keep the shop defaults, so this address stays in Canada. + # and state keep the shop defaults, so this address stays in the United States. BILLING_FIRST_NAME: "Maestro" BILLING_LAST_NAME: "Shopify" - BILLING_ADDRESS_LINE1: "620 King Street West" - BILLING_CITY: "Toronto" - BILLING_POSTAL_CODE: "M5V 1M7" + BILLING_ADDRESS_LINE1: "700 S Flower St" + BILLING_CITY: "Los Angeles" + BILLING_POSTAL_FIELD_LABEL: "ZIP code" + BILLING_POSTAL_CODE: "90017" # Accepted successful checkout states for this smoke test. POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle index b2eded034..1e66b2706 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle @@ -40,6 +40,9 @@ def customerAccountApiRedirectUri = properties.getProperty("CUSTOMER_ACCOUNT_API def customerAccountApiGraphQLBaseUrl = properties.getProperty("CUSTOMER_ACCOUNT_API_GRAPHQL_BASE_URL") def customerAccountApiAuthBaseUrl = properties.getProperty("CUSTOMER_ACCOUNT_API_AUTH_BASE_URL") +// User agent suffix the customer account login web view appends. Empty outside CI. +def customUserAgent = propertyOrDefault("CUSTOM_USER_AGENT", "") + if (!customerAccountApiRedirectUri && customerAccountApiShopId) { customerAccountApiRedirectUri = "shop.${customerAccountApiShopId}.app://callback" } @@ -58,16 +61,16 @@ def customerAccountRedirectHost = customerAccountRedirect?.host ?: "callback" // Demo buyer identity (prefill toggle in Settings) def prefillEmail = properties.getProperty("EMAIL", properties.getProperty("PREFILL_EMAIL", "test.buyer@example.com")) -def prefillPhone = properties.getProperty("PHONE", properties.getProperty("PREFILL_PHONE", "+14165550100")) +def prefillPhone = properties.getProperty("PHONE", properties.getProperty("PREFILL_PHONE", "+12135550142")) def prefillFirstName = propertyOrDefault("FIRST_NAME", "Evelyn") def prefillLastName = propertyOrDefault("LAST_NAME", "Hartley") -def prefillAddress1 = propertyOrDefault("ADDRESS_1", "650 King Street") +def prefillAddress1 = propertyOrDefault("ADDRESS_1", "700 S Flower St") def prefillAddress2 = propertyOrDefault("ADDRESS_2", "Shopify HQ") def prefillCompany = propertyOrDefault("COMPANY", "Shopify") -def prefillCity = propertyOrDefault("CITY", "Toronto") -def prefillProvince = propertyOrDefault("PROVINCE", "ON") -def prefillZip = propertyOrDefault("ZIP", "M5V 1M7") -def prefillCountry = propertyOrDefault("COUNTRY", "CA") +def prefillCity = propertyOrDefault("CITY", "Los Angeles") +def prefillProvince = propertyOrDefault("PROVINCE", "CA") +def prefillZip = propertyOrDefault("ZIP", "90017") +def prefillCountry = propertyOrDefault("COUNTRY", "US") if (!storefrontDomain || !accessToken) { println("**** Please add a .env file with STOREFRONT_DOMAIN and STOREFRONT_ACCESS_TOKEN set *****") @@ -100,6 +103,7 @@ android { buildConfigField "String", "customerAccountApiRedirectUri", "\"$customerAccountApiRedirectUri\"" buildConfigField "String", "customerAccountApiAuthBaseUrl", "\"$customerAccountApiAuthBaseUrl\"" buildConfigField "String", "customerAccountApiGraphQLBaseUrl", "\"$customerAccountApiGraphQLBaseUrl\"" + buildConfigField "String", "customUserAgent", "\"$customUserAgent\"" buildConfigField "String", "prefillEmail", "\"$prefillEmail\"" buildConfigField "String", "prefillPhone", "\"$prefillPhone\"" buildConfigField "String", "prefillFirstName", "\"$prefillFirstName\"" diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt index 3b4e154b1..3dec70039 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLink.kt @@ -25,7 +25,7 @@ sealed interface E2EControlLink { val buyerIdentityMode: E2EBuyerIdentityMode? = null, ) : E2EControlLink - data class SignIn(val email: String? = null) : E2EControlLink + data object SignIn : E2EControlLink companion object { const val HOST = "e2e" @@ -35,7 +35,7 @@ sealed interface E2EControlLink { private val RESET_PARAMETERS = emptySet() private val CART_PARAMETERS = setOf("variantId", "productIndex", "quantity", "buyerIdentityMode") - private val SIGN_IN_PARAMETERS = setOf("email") + private val SIGN_IN_PARAMETERS = emptySet() fun parse(url: String): E2EControlLink? { val separatorIndex = url.indexOf(SCHEME_SEPARATOR) @@ -73,7 +73,7 @@ sealed interface E2EControlLink { "signIn" -> { rejectUnknownParameters("signIn", parameters, SIGN_IN_PARAMETERS) - SignIn(email = signInEmail(parameters)) + SignIn } else -> throw IllegalArgumentException("Unsupported e2e command") @@ -134,14 +134,6 @@ sealed interface E2EControlLink { } } - private fun signInEmail(parameters: Map): String? { - val email = parameters["email"] ?: return null - - require(email.isNotEmpty()) { "email must not be blank" } - - return email - } - private fun parameters(rawQuery: String?): Map { if (rawQuery.isNullOrEmpty()) { return emptyMap() diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt index 0c83167b4..7ba3540c9 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt @@ -14,6 +14,8 @@ interface E2ECommandTarget { suspend fun showCart() + suspend fun presentSignIn() + suspend fun report(failure: String) } @@ -47,7 +49,7 @@ class E2EController(private val target: E2ECommandTarget) { when (link) { is E2EControlLink.Reset -> target.resetCart() is E2EControlLink.Cart -> seedCart(link) - is E2EControlLink.SignIn -> throw UnsupportedOperationException("signIn is not implemented yet") + is E2EControlLink.SignIn -> target.presentSignIn() } } catch (error: Exception) { target.report(message(error)) diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt index a6dca8f0e..3ffeb051d 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt @@ -53,6 +53,10 @@ class E2ESampleAppTarget : E2ECommandTarget, KoinComponent { E2ENavigation.go(Screen.Cart) } + override suspend fun presentSignIn() { + E2ENavigation.go(Screen.Login) + } + override suspend fun report(failure: String) { Timber.e("[E2E] $failure") } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt index 555ca0dd9..158759a94 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt @@ -12,4 +12,8 @@ object E2ETestIds { object Tabs { const val CART = "cart-tab" } + + object Account { + const val SIGNED_IN_VIEW = "account-signed-in-view" + } } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/account/AccountView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/account/AccountView.kt index 5676c05ea..c18bc590a 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/account/AccountView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/account/AccountView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -22,6 +23,7 @@ import com.shopify.checkoutkit.androiddemo.common.components.Header3 import com.shopify.checkoutkit.androiddemo.common.components.ProgressIndicator import com.shopify.checkoutkit.androiddemo.common.components.RemoteImage import com.shopify.checkoutkit.androiddemo.common.navigation.Screen +import com.shopify.checkoutkit.androiddemo.e2e.E2ETestIds import org.koin.androidx.compose.koinViewModel @Composable @@ -47,7 +49,9 @@ fun AccountView( is UIState.Loaded -> { Column( verticalArrangement = Arrangement.spacedBy(30.dp), - modifier = Modifier.padding(vertical = 20.dp, horizontal = 15.dp) + modifier = Modifier + .padding(vertical = 20.dp, horizontal = 15.dp) + .testTag(E2ETestIds.Account.SIGNED_IN_VIEW) ) { val customer = uiState.customer diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/authentication/LoginView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/authentication/LoginView.kt index 9d7738447..b8edc6f86 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/authentication/LoginView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/settings/authentication/LoginView.kt @@ -64,8 +64,10 @@ fun LoginView( } is Status.LoggedIn -> { - // Navigate back to settings when login is complete - navController.navigate(Screen.Settings.route) + // Show the account when login is complete + navController.navigate(Screen.Account.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } } is Status.Error -> { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt index da3a6de65..02b88c604 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkTest.kt @@ -131,27 +131,13 @@ class E2EControlLinkTest { } @Test - fun `parses a sign in command without an email`() { - assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn()) + fun `parses a sign in command`() { + assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn) } @Test - fun `parses a sign in command with an email`() { - assertThat(parse("/signIn?email=shopper%2Be2e@example.com")) - .isEqualTo(E2EControlLink.SignIn(email = "shopper+e2e@example.com")) - } - - @Test - fun `decodes a literal plus in a query value as a space`() { - assertThat(parse("/signIn?email=shopper+e2e@example.com")) - .isEqualTo(E2EControlLink.SignIn(email = "shopper e2e@example.com")) - } - - @Test - fun `rejects a blank sign in email`() { - assertRejects("/signIn?email=", "email must not be blank") - assertRejects("/signIn?email=%20", "email must not be blank") - assertRejects("/signIn?email=%0A", "email must not be blank") + fun `rejects sign in parameters`() { + assertRejects("/signIn?email=shopper@example.com", "Unknown signIn parameters: email") } private fun parse(path: String) = E2EControlLink.parse("com.shopify.checkoutkit.androiddemo://e2e$path") diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt index 211e45137..567e99d62 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt @@ -90,12 +90,12 @@ class E2EControllerTest { } @Test - fun `reports that sign in is not implemented`() { + fun `presents sign in`() { val target = E2ECommandTargetSpy() handle("/signIn", target) - assertThat(target.calls).containsExactly("report(signIn is not implemented yet)") + assertThat(target.calls).containsExactly("presentSignIn") } @Test @@ -159,6 +159,10 @@ private class E2ECommandTargetSpy : E2ECommandTarget { calls.add("showCart") } + override suspend fun presentSignIn() { + calls.add("presentSignIn") + } + override suspend fun report(failure: String) { calls.add("report($failure)") } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt index 24e9676a2..3133846f3 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt @@ -20,4 +20,9 @@ class E2ETestIdsTest { fun `tab markers match the maestro flows`() { assertThat(E2ETestIds.Tabs.CART).isEqualTo("cart-tab") } + + @Test + fun `account markers match the maestro flows`() { + assertThat(E2ETestIds.Account.SIGNED_IN_VIEW).isEqualTo("account-signed-in-view") + } } diff --git a/platforms/react-native/sample/src/App.tsx b/platforms/react-native/sample/src/App.tsx index bf5fe84f6..06e138b3b 100644 --- a/platforms/react-native/sample/src/App.tsx +++ b/platforms/react-native/sample/src/App.tsx @@ -12,6 +12,7 @@ import { NavigationContainer, useNavigation, type NavigationProp, + type NavigatorScreenParams, } from '@react-navigation/native'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {createNativeStackNavigator} from '@react-navigation/native-stack'; @@ -79,7 +80,7 @@ export type RootStackParamList = { ProductDetails: {product: ShopifyProduct; variant?: ProductVariant}; Cart: undefined; CartModal: undefined; - Account: undefined; + Account: NavigatorScreenParams; Settings: undefined; }; @@ -435,8 +436,12 @@ function Routes() { const navigateToCart = useCallback(() => { navigation.navigate('Cart'); }, [navigation]); + const navigateToSignIn = useCallback(() => { + navigation.navigate('Account', {screen: 'Login', initial: false}); + }, [navigation]); const handleE2ECartBootstrap = useE2ECartBootstrap({ onCartReady: navigateToCart, + onSignInRequested: navigateToSignIn, }); useEffect(() => { diff --git a/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts index f7745a3a4..b4169d322 100644 --- a/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts +++ b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts @@ -153,28 +153,14 @@ describe('parseControlLink', () => { }); }); - it('parses a sign in command without an email', () => { + it('parses a sign in command', () => { expect(parse('/signIn')).toEqual({command: 'signIn'}); }); - it('parses a sign in command with an email', () => { - expect(parse('/signIn?email=shopper%2Be2e@example.com')).toEqual({ - command: 'signIn', - email: 'shopper+e2e@example.com', - }); - }); - - it('decodes a literal plus in a query value as a space', () => { - expect(parse('/signIn?email=shopper+e2e@example.com')).toEqual({ - command: 'signIn', - email: 'shopper e2e@example.com', - }); + it('rejects sign in parameters', () => { + expectRejection( + '/signIn?email=shopper@example.com', + 'Unknown signIn parameters: email', + ); }); - - it.each(['/signIn?email=', '/signIn?email=%20', '/signIn?email=%0A'])( - 'rejects the blank sign in email in %s', - path => { - expectRejection(path, 'email must not be blank'); - }, - ); }); diff --git a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts index 6e6aa73e3..7e9c9cf2b 100644 --- a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts +++ b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts @@ -24,6 +24,10 @@ class E2ECommandTargetSpy implements E2ECommandTarget { return `variant-${productIndex}`; } + async presentSignIn() { + this.calls.push('presentSignIn'); + } + async addCartLine(variantId: string, quantity: number) { this.calls.push(`addCartLine(${variantId}, ${quantity})`); @@ -127,11 +131,11 @@ describe('E2EController', () => { ]); }); - it('reports that sign in is not implemented', async () => { + it('presents sign in', async () => { const target = new E2ECommandTargetSpy(); await handle('/signIn', target); - expect(target.calls).toEqual(['report(signIn is not implemented yet)']); + expect(target.calls).toEqual(['presentSignIn']); }); }); diff --git a/platforms/react-native/sample/src/e2e/controlLink.ts b/platforms/react-native/sample/src/e2e/controlLink.ts index 9761ff91f..d45ae1ec7 100644 --- a/platforms/react-native/sample/src/e2e/controlLink.ts +++ b/platforms/react-native/sample/src/e2e/controlLink.ts @@ -12,7 +12,7 @@ const CART_PARAMETERS = [ 'quantity', 'buyerIdentityMode', ]; -const SIGN_IN_PARAMETERS = ['email']; +const SIGN_IN_PARAMETERS: string[] = []; export type E2EResetCommand = { command: 'reset'; @@ -28,7 +28,6 @@ export type E2ECartCommand = { export type E2ESignInCommand = { command: 'signIn'; - email?: string; }; export type E2EControlLink = @@ -145,20 +144,6 @@ function parseCart(parameters: Parameters): E2ECartCommand { return {command: 'cart', productIndex, quantity, buyerIdentityMode}; } -function parseSignIn(parameters: Parameters): E2ESignInCommand { - const email = parameters.get('email'); - - if (email === undefined) { - return {command: 'signIn'}; - } - - if (email === '') { - throw new Error('email must not be blank'); - } - - return {command: 'signIn', email}; -} - export function parseControlLink(url: string): E2EControlLink | null { const separatorIndex = url.indexOf(SCHEME_SEPARATOR); @@ -194,7 +179,7 @@ export function parseControlLink(url: string): E2EControlLink | null { case 'signIn': rejectUnknownParameters('signIn', parameters, SIGN_IN_PARAMETERS); - return parseSignIn(parameters); + return {command: 'signIn'}; default: throw new Error('Unsupported e2e command'); } diff --git a/platforms/react-native/sample/src/e2e/controller.ts b/platforms/react-native/sample/src/e2e/controller.ts index d2ede28aa..97d53c33d 100644 --- a/platforms/react-native/sample/src/e2e/controller.ts +++ b/platforms/react-native/sample/src/e2e/controller.ts @@ -11,6 +11,7 @@ export interface E2ECommandTarget { variantId(productIndex: number): Promise; addCartLine(variantId: string, quantity: number): Promise; showCart(): Promise; + presentSignIn(): Promise; report(failure: string): Promise; } @@ -50,7 +51,8 @@ export class E2EController { await this.seedCart(link); break; case 'signIn': - throw new Error('signIn is not implemented yet'); + await this.target.presentSignIn(); + break; } } catch (error) { await this.target.report(message(error)); diff --git a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts index 816521534..f43103101 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -7,9 +7,13 @@ import {E2EController, type E2ECommandTarget} from './controller'; type UseE2ECartBootstrapOptions = { onCartReady: () => void; + onSignInRequested: () => void; }; -export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { +export function useE2ECartBootstrap({ + onCartReady, + onSignInRequested, +}: UseE2ECartBootstrapOptions) { const {seedCart, clearCart} = useCart(); const {queries} = useShopify(); const [fetchProducts] = queries.products; @@ -41,11 +45,14 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { async showCart() { onCartReady(); }, + async presentSignIn() { + onSignInRequested(); + }, async report(failure) { Alert.alert('E2E command failed', failure); }, }; - }, [clearCart, fetchProducts, onCartReady, seedCart]); + }, [clearCart, fetchProducts, onCartReady, onSignInRequested, seedCart]); return useCallback((url: string) => new E2EController(target).handle(url), [ target, diff --git a/platforms/react-native/sample/src/screens/LoginScreen.tsx b/platforms/react-native/sample/src/screens/LoginScreen.tsx index f11be9a96..834c02a84 100644 --- a/platforms/react-native/sample/src/screens/LoginScreen.tsx +++ b/platforms/react-native/sample/src/screens/LoginScreen.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import {ActivityIndicator, StyleSheet, View} from 'react-native'; +import Config from 'react-native-config'; import {WebView} from 'react-native-webview'; import type {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'; import type {NativeStackScreenProps} from '@react-navigation/native-stack'; @@ -80,6 +81,7 @@ function LoginScreen({navigation}: Props) { source={{uri: authorizationURL}} onShouldStartLoadWithRequest={handleNavigationRequest} originWhitelist={['https://*', `${callbackScheme}://*`]} + applicationNameForUserAgent={Config.CUSTOM_USER_AGENT || undefined} incognito={true} style={styles.webview} /> diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift index 984f87bcb..5c413bf83 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift @@ -9,7 +9,6 @@ enum E2EControlLinkError: LocalizedError, Equatable, Hashable { case invalidQuantity case invalidProductIndex case invalidBuyerIdentityMode - case blankEmail var errorDescription: String? { switch self { @@ -29,8 +28,6 @@ enum E2EControlLinkError: LocalizedError, Equatable, Hashable { return "productIndex must be a non-negative integer" case .invalidBuyerIdentityMode: return "buyerIdentityMode must be guest, hardcoded, or customerAccount" - case .blankEmail: - return "email must not be blank" } } } @@ -38,7 +35,7 @@ enum E2EControlLinkError: LocalizedError, Equatable, Hashable { enum E2EControlLink: Equatable { case reset case cart(CartCommand) - case signIn(email: String?) + case signIn struct CartCommand: Equatable { var variantId: String? @@ -54,7 +51,7 @@ enum E2EControlLink: Equatable { private static let resetParameters: Set = [] private static let cartParameters: Set = ["variantId", "productIndex", "quantity", "buyerIdentityMode"] - private static let signInParameters: Set = ["email"] + private static let signInParameters: Set = [] static func parse(_ url: String) throws -> E2EControlLink? { guard let separator = url.range(of: schemeSeparator) else { @@ -80,7 +77,7 @@ enum E2EControlLink: Equatable { return try .cart(cartCommand(from: parameters)) case "signIn": try rejectUnknownParameters("signIn", parameters, signInParameters) - return try .signIn(email: signInEmail(from: parameters)) + return .signIn default: throw E2EControlLinkError.unsupportedCommand } @@ -164,18 +161,6 @@ enum E2EControlLink: Equatable { return buyerIdentityMode } - private static func signInEmail(from parameters: Parameters) throws -> String? { - guard let email = parameters.value("email") else { - return nil - } - - guard !email.isEmpty else { - throw E2EControlLinkError.blankEmail - } - - return email - } - private struct Parameters { private let values: [String: String] diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift index f7e940f4f..db5cf1891 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift @@ -7,17 +7,15 @@ protocol E2ECommandTarget { func variantId(atProductIndex index: Int) async throws -> String func addCartLine(variantId: String, quantity: Int) async throws func showCart() async + func presentSignIn() async func report(failure message: String) async } enum E2EControllerError: LocalizedError, Equatable { - case unimplementedCommand(String) case productIndexOutOfRange(Int) var errorDescription: String? { switch self { - case let .unimplementedCommand(command): - return "\(command) is not implemented yet" case let .productIndexOutOfRange(index): return "No product at index \(index)" } @@ -76,7 +74,7 @@ final class E2EController { case let .cart(command): try await seedCart(command) case .signIn: - throw E2EControllerError.unimplementedCommand("signIn") + await target.presentSignIn() } } catch { await target.report(failure: E2EController.message(for: error)) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift index af02f363f..3d0394c4b 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift @@ -49,6 +49,16 @@ struct E2ESampleAppTarget: E2ECommandTarget { sceneDelegate?.navigateTo(.cart) } + func presentSignIn() async { + E2ESignInRequest.shared.request() + + let sceneDelegate = UIApplication.shared.connectedScenes + .compactMap { $0.delegate as? SceneDelegate } + .first + + sceneDelegate?.navigateTo(.account) + } + func report(failure message: String) async { print("[E2E] \(message)") } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift new file mode 100644 index 000000000..e0f233139 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift @@ -0,0 +1,16 @@ +import Foundation + +@MainActor +final class E2ESignInRequest: ObservableObject { + static let shared = E2ESignInRequest() + + @Published private(set) var isPending = false + + func request() { + isPending = true + } + + func fulfil() { + isPending = false + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift index f591a5954..7343ea251 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift @@ -10,4 +10,8 @@ enum E2ETestIds { enum Tabs { static let cart = "cart-tab" } + + enum Account { + static let signedInView = "account-signed-in-view" + } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift index 548a0e608..a65df82a1 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift @@ -13,6 +13,9 @@ final class InfoDictionary: Sendable { let customerAccountApiClientId: String? let customerAccountApiShopId: String? + /// User agent suffix the customer account login web view appends. Empty outside CI. + let customUserAgent: String? + var customerAccountApiRedirectUri: String? { guard let shopId = customerAccountApiShopId, !shopId.isEmpty else { return nil @@ -64,5 +67,6 @@ final class InfoDictionary: Sendable { // Customer Account API configuration (optional) customerAccountApiClientId = infoPlist["CustomerAccountApiClientId"] as? String customerAccountApiShopId = infoPlist["CustomerAccountApiShopId"] as? String + customUserAgent = (infoPlist["CustomUserAgent"] as? String).flatMap { $0.isEmpty ? nil : $0 } } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift index c103ba1c5..9de7a3a26 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift @@ -51,6 +51,9 @@ final class KeychainHelper { ] let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { + logger.error("Failed to save \(key) to keychain, OSStatus: \(status)") + } return status == errSecSuccess } @@ -94,9 +97,15 @@ final class KeychainHelper { func getTokens() -> OAuthTokenResult? { guard let data = read(key: tokensKey) else { + logger.debug("No token data found in keychain") + return nil + } + do { + return try JSONDecoder().decode(OAuthTokenResult.self, from: data) + } catch { + logger.error("Failed to decode keychain tokens: \(error)") return nil } - return try? JSONDecoder().decode(OAuthTokenResult.self, from: data) } func clearTokens() { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift index cde885258..92c69934c 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift @@ -4,6 +4,7 @@ import UIKit struct AccountView: View { @ObservedObject var accountManager = CustomerAccountManager.shared + @ObservedObject private var e2eSignInRequest = E2ESignInRequest.shared @State private var errorMessage = "" @State private var showingError = false @@ -23,6 +24,12 @@ struct AccountView: View { } message: { Text(errorMessage) } + .onReceive(e2eSignInRequest.$isPending) { isPending in + guard isPending else { return } + + signIn() + e2eSignInRequest.fulfil() + } } private func signIn() { @@ -109,6 +116,7 @@ struct AuthenticatedAccountView: View { .padding(.bottom, 32) } .background(Color(.systemGroupedBackground)) + .accessibilityIdentifier(E2ETestIds.Account.signedInView) } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift index c283b423d..64a2274ef 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift @@ -100,22 +100,12 @@ class E2EControlLinkTests: XCTestCase { XCTAssertEqual(try parse("/cart/?productIndex=3"), .cart(.init(productIndex: 3, quantity: 1))) } - func testParsesASignInCommandWithoutAnEmail() throws { - XCTAssertEqual(try parse("/signIn"), .signIn(email: nil)) + func testParsesASignInCommand() throws { + XCTAssertEqual(try parse("/signIn"), .signIn) } - func testParsesASignInCommandWithAnEmail() throws { - XCTAssertEqual(try parse("/signIn?email=shopper%2Be2e@example.com"), .signIn(email: "shopper+e2e@example.com")) - } - - func testDecodesALiteralPlusInAQueryValueAsASpace() throws { - XCTAssertEqual(try parse("/signIn?email=shopper+e2e@example.com"), .signIn(email: "shopper e2e@example.com")) - } - - func testRejectsABlankSignInEmail() { - assertThrows(.blankEmail, "/signIn?email=") - assertThrows(.blankEmail, "/signIn?email=%20") - assertThrows(.blankEmail, "/signIn?email=%0A") + func testRejectsSignInParameters() { + assertThrows(.unknownParameters(command: "signIn", names: ["email"]), "/signIn?email=shopper@example.com") } func testErrorMessagesMatchTheOtherPlatforms() { @@ -130,8 +120,7 @@ class E2EControlLinkTests: XCTestCase { E2EControlLinkError.blankVariantId: "variantId must not be blank", E2EControlLinkError.invalidQuantity: "quantity must be a positive integer", E2EControlLinkError.invalidProductIndex: "productIndex must be a non-negative integer", - E2EControlLinkError.invalidBuyerIdentityMode: "buyerIdentityMode must be guest, hardcoded, or customerAccount", - E2EControlLinkError.blankEmail: "email must not be blank" + E2EControlLinkError.invalidBuyerIdentityMode: "buyerIdentityMode must be guest, hardcoded, or customerAccount" ] for (error, message) in messages { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift index 0cef923ea..edff38cdf 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift @@ -77,12 +77,13 @@ class E2EControllerTests: XCTestCase { ]) } - func testReportsThatSignInIsNotImplemented() async { + func testPresentsSignIn() async { let target = E2ECommandTargetSpy() - await handle("/signIn", target) + let handled = await handle("/signIn", target) - XCTAssertEqual(target.calls, ["report(signIn is not implemented yet)"]) + XCTAssertTrue(handled) + XCTAssertEqual(target.calls, ["presentSignIn"]) } func testSerializesConcurrentHandleCallsSoTheSecondDoesNotStartUntilTheFirstFinishes() async { @@ -168,6 +169,10 @@ private class E2ECommandTargetSpy: E2ECommandTarget { } } + func presentSignIn() async { + calls.append("presentSignIn") + } + func showCart() async { calls.append("showCart") } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift index f8a8184a1..5bf2b9703 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift @@ -15,4 +15,8 @@ class E2ETestIdsTests: XCTestCase { func testTabMarkersMatchTheMaestroFlows() { XCTAssertEqual(E2ETestIds.Tabs.cart, "cart-tab") } + + func testAccountMarkersMatchTheMaestroFlows() { + XCTAssertEqual(E2ETestIds.Account.signedInView, "account-signed-in-view") + } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml index 5d08c64e7..ae3db9911 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml @@ -89,6 +89,7 @@ targets: StorefrontAccessToken: $(STOREFRONT_ACCESS_TOKEN) CustomerAccountApiClientId: $(CUSTOMER_ACCOUNT_API_CLIENT_ID) CustomerAccountApiShopId: $(CUSTOMER_ACCOUNT_API_SHOP_ID) + CustomUserAgent: $(CUSTOM_USER_AGENT) CFBundleURLTypes: - CFBundleURLSchemes: - shop.$(CUSTOMER_ACCOUNT_API_SHOP_ID).app diff --git a/scripts/setup_storefront_env b/scripts/setup_storefront_env index 0e9461a93..ee6761e13 100755 --- a/scripts/setup_storefront_env +++ b/scripts/setup_storefront_env @@ -243,7 +243,8 @@ root_has_canonical_keys() { LAST_NAME \ PROVINCE \ ZIP \ - PHONE; do + PHONE \ + CUSTOM_USER_AGENT; do env_has_key "$key" "$ROOT_ENV" || return 1 done } @@ -464,17 +465,29 @@ load_values() { "$(read_env_value CUSTOMER_ACCOUNT_API_VERSION "$SWIFT_ACCELERATED_XCCONFIG")" \ "$DEFAULT_CUSTOMER_ACCOUNT_API_VERSION")" + CUSTOM_USER_AGENT_VALUE="$(root_or_source_value CUSTOM_USER_AGENT \ + "$(env_fallback CUSTOM_USER_AGENT)" \ + "$(read_env_value CUSTOM_USER_AGENT "$ANDROID_ENV")" \ + "$(read_env_value CUSTOM_USER_AGENT "$REACT_NATIVE_ENV")" \ + "$(read_env_value CUSTOM_USER_AGENT "$SWIFT_DEMO_XCCONFIG")")" + + E2E_CUSTOMER_ACCOUNT_EMAIL_VALUE="$(root_or_source_value E2E_CUSTOMER_ACCOUNT_EMAIL \ + "$(env_fallback E2E_CUSTOMER_ACCOUNT_EMAIL)")" + + E2E_CUSTOMER_ACCOUNT_CODE_VALUE="$(root_or_source_value E2E_CUSTOMER_ACCOUNT_CODE \ + "$(env_fallback E2E_CUSTOMER_ACCOUNT_CODE)")" + EMAIL_VALUE="$(root_or_source_value EMAIL "$(env_fallback EMAIL)" "$(read_env_value EMAIL "$ANDROID_ENV")" "$(read_env_value PREFILL_EMAIL "$ANDROID_ENV")" "$(read_env_value EMAIL "$REACT_NATIVE_ENV")" "$(read_env_value EMAIL "$SWIFT_DEMO_XCCONFIG")" "checkout-kit@example.com")" - ADDRESS_1_VALUE="$(root_or_source_value ADDRESS_1 "$(env_fallback ADDRESS_1)" "$(read_env_value ADDRESS_1 "$REACT_NATIVE_ENV")" "$(read_env_value ADDRESS_1 "$SWIFT_DEMO_XCCONFIG")" "650 King Street")" + ADDRESS_1_VALUE="$(root_or_source_value ADDRESS_1 "$(env_fallback ADDRESS_1)" "$(read_env_value ADDRESS_1 "$REACT_NATIVE_ENV")" "$(read_env_value ADDRESS_1 "$SWIFT_DEMO_XCCONFIG")" "700 S Flower St")" ADDRESS_2_VALUE="$(root_or_source_value ADDRESS_2 "$(env_fallback ADDRESS_2)" "$(read_env_value ADDRESS_2 "$REACT_NATIVE_ENV")" "$(read_env_value ADDRESS_2 "$SWIFT_DEMO_XCCONFIG")" "Shopify HQ")" - CITY_VALUE="$(root_or_source_value CITY "$(env_fallback CITY)" "$(read_env_value CITY "$REACT_NATIVE_ENV")" "$(read_env_value CITY "$SWIFT_DEMO_XCCONFIG")" "Toronto")" + CITY_VALUE="$(root_or_source_value CITY "$(env_fallback CITY)" "$(read_env_value CITY "$REACT_NATIVE_ENV")" "$(read_env_value CITY "$SWIFT_DEMO_XCCONFIG")" "Los Angeles")" COMPANY_VALUE="$(root_or_source_value COMPANY "$(env_fallback COMPANY)" "$(read_env_value COMPANY "$REACT_NATIVE_ENV")" "$(read_env_value COMPANY "$SWIFT_DEMO_XCCONFIG")" "Shopify")" - COUNTRY_VALUE="$(root_or_source_value COUNTRY "$(env_fallback COUNTRY)" "$(read_env_value COUNTRY "$REACT_NATIVE_ENV")" "$(read_env_value COUNTRY "$SWIFT_DEMO_XCCONFIG")" "CA")" + COUNTRY_VALUE="$(root_or_source_value COUNTRY "$(env_fallback COUNTRY)" "$(read_env_value COUNTRY "$REACT_NATIVE_ENV")" "$(read_env_value COUNTRY "$SWIFT_DEMO_XCCONFIG")" "US")" FIRST_NAME_VALUE="$(root_or_source_value FIRST_NAME "$(env_fallback FIRST_NAME)" "$(read_env_value FIRST_NAME "$REACT_NATIVE_ENV")" "$(read_env_value FIRST_NAME "$SWIFT_DEMO_XCCONFIG")" "Evelyn")" LAST_NAME_VALUE="$(root_or_source_value LAST_NAME "$(env_fallback LAST_NAME)" "$(read_env_value LAST_NAME "$REACT_NATIVE_ENV")" "$(read_env_value LAST_NAME "$SWIFT_DEMO_XCCONFIG")" "Hartley")" - PROVINCE_VALUE="$(root_or_source_value PROVINCE "$(env_fallback PROVINCE)" "$(read_env_value PROVINCE "$REACT_NATIVE_ENV")" "$(read_env_value PROVINCE "$SWIFT_DEMO_XCCONFIG")" "ON")" - ZIP_VALUE="$(root_or_source_value ZIP "$(env_fallback ZIP)" "$(read_env_value ZIP "$REACT_NATIVE_ENV")" "$(read_env_value ZIP "$SWIFT_DEMO_XCCONFIG")" "M5V 1M7")" - PHONE_VALUE="$(root_or_source_value PHONE "$(env_fallback PHONE)" "$(read_env_value PHONE "$ANDROID_ENV")" "$(read_env_value PREFILL_PHONE "$ANDROID_ENV")" "$(read_env_value PHONE "$REACT_NATIVE_ENV")" "$(read_env_value PHONE "$SWIFT_DEMO_XCCONFIG")" "+14165550100")" + PROVINCE_VALUE="$(root_or_source_value PROVINCE "$(env_fallback PROVINCE)" "$(read_env_value PROVINCE "$REACT_NATIVE_ENV")" "$(read_env_value PROVINCE "$SWIFT_DEMO_XCCONFIG")" "CA")" + ZIP_VALUE="$(root_or_source_value ZIP "$(env_fallback ZIP)" "$(read_env_value ZIP "$REACT_NATIVE_ENV")" "$(read_env_value ZIP "$SWIFT_DEMO_XCCONFIG")" "90017")" + PHONE_VALUE="$(root_or_source_value PHONE "$(env_fallback PHONE)" "$(read_env_value PHONE "$ANDROID_ENV")" "$(read_env_value PREFILL_PHONE "$ANDROID_ENV")" "$(read_env_value PHONE "$REACT_NATIVE_ENV")" "$(read_env_value PHONE "$SWIFT_DEMO_XCCONFIG")" "+12135550142")" # No default and no generated-file fallback: a cleared DEVELOPMENT_TEAM stays cleared, and # the Xcode build fails at signing until the developer supplies their own team. @@ -556,6 +569,14 @@ EOF write_env_assignment CUSTOMER_ACCOUNT_API_VERSION "$CUSTOMER_ACCOUNT_API_VERSION_VALUE" cat <"$output" 2>&1 assert_output_is_sanitized "$output" @@ -219,6 +232,27 @@ test_required_values_only() { assert_output_is_sanitized "$output" } +test_buyer_address_defaults_match_the_e2e_shop_country() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ + STOREFRONT_ACCESS_TOKEN=synthetic-token \ + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + + assert_output_is_sanitized "$output" + assert_contains "$fixture/.env" "ADDRESS_1=700 S Flower St" + assert_contains "$fixture/.env" "CITY=Los Angeles" + assert_contains "$fixture/.env" "COUNTRY=US" + assert_contains "$fixture/.env" "PROVINCE=CA" + assert_contains "$fixture/.env" "ZIP=90017" + assert_contains "$fixture/.env" "PHONE=+12135550142" + assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "COUNTRY=US" + assert_contains "$fixture/platforms/react-native/sample/.env" "COUNTRY=\"US\"" + assert_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "COUNTRY = US" +} + test_optional_sync_updates_blank_optional_values_after_required_setup() { local fixture output fixture="$(make_fixture)" @@ -438,6 +472,7 @@ test_merchant_identifier_propagates_to_sample_apps test_sample_projects_read_generated_merchant_identifier test_sync_and_check test_required_values_only +test_buyer_address_defaults_match_the_e2e_shop_country test_optional_sync_updates_blank_optional_values_after_required_setup test_shared_merchant_identifier_migrates_to_app_specific_keys test_normalizes_existing_root_env