From eaf5c1679e8ef223d0fab9b086bfbd54c7446463 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 10 Aug 2026 15:50:44 -0400 Subject: [PATCH] test(e2e): preflight native preload logs on BrowserStack --- e2e/bitrise.yml | 10 +- e2e/config/preload-log-preflight-matrix.yml | 33 +++ e2e/lib/browserstack_client.rb | 32 +++ e2e/lib/e2e_github_reporter.rb | 5 + e2e/scripts/execute_browserstack_run | 153 ++++++++++++-- e2e/test/browserstack_run_executor_test.rb | 194 ++++++++++++++++++ e2e/test/e2e_github_reporter_test.rb | 17 ++ .../preflight/native-preload-cache-hit.yaml | 97 +++++++++ .../checkoutkit/androiddemo/cart/CartView.kt | 10 +- .../androiddemo/cart/CartViewModel.kt | 6 + .../checkoutkit/androiddemo/e2e/E2ETestIds.kt | 1 + .../androiddemo/e2e/PreloadStateMarker.kt | 31 +++ .../androiddemo/e2e/PreloadStateMarkerTest.kt | 43 ++++ .../Sources/App/AppDelegate.swift | 2 +- .../Sources/E2E/E2ETestIds.swift | 3 + .../Sources/E2E/PreloadCacheHitSignal.swift | 36 ++++ .../Sources/E2E/PreloadStateMarker.swift | 44 ++++ .../Sources/Scenes/Cart/CartView.swift | 17 ++ .../E2E/PreloadStateMarkerTests.swift | 42 ++++ .../ShopifyCheckoutKit/CheckoutWebView.swift | 2 + .../CheckoutWebViewTests.swift | 46 +++++ 21 files changed, 796 insertions(+), 28 deletions(-) create mode 100644 e2e/config/preload-log-preflight-matrix.yml create mode 100644 e2e/tests/preflight/native-preload-cache-hit.yaml create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarker.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarkerTest.kt create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadCacheHitSignal.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadStateMarker.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/PreloadStateMarkerTests.swift diff --git a/e2e/bitrise.yml b/e2e/bitrise.yml index 4231a2ff2..c3f04026f 100644 --- a/e2e/bitrise.yml +++ b/e2e/bitrise.yml @@ -31,7 +31,7 @@ tools: trigger_map: - pull_request_source_branch: "*" - draft_pull_request_enabled: false + draft_pull_request_enabled: true changed_files: # Bitrise evaluates this with Go's RE2 engine. Use only basic syntax # (anchors, groups, alternation, character classes, quantifiers). Do not @@ -197,7 +197,7 @@ workflows: else git diff --name-only "${BITRISE_GIT_BRANCH_DEST:-origin/main}...HEAD" > "$changed_files" fi - ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan validate --changed-files-file "$changed_files" + ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan validate --config e2e/config/preload-log-preflight-matrix.yml --changed-files-file "$changed_files" # Guard against config-vs-matrix drift: Bitrise resolves the pipeline graph from # the branch-head bitrise.yml (before the merge checkout), while the matrix here is # generated from the merged tree. A stale branch can select a target whose build @@ -208,12 +208,12 @@ workflows: if [ -z "${BITRISE_PULL_REQUEST:-}" ] || [ -z "${BITRISE_GIT_COMMIT:-}" ] || ! git show "${BITRISE_GIT_COMMIT}:e2e/bitrise.yml" > "$branch_config" 2>/dev/null; then cp e2e/bitrise.yml "$branch_config" fi - ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan assert-pipeline-coverage --pipeline-config "$branch_config" --changed-files-file "$changed_files" + ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan assert-pipeline-coverage --config e2e/config/preload-log-preflight-matrix.yml --pipeline-config "$branch_config" --changed-files-file "$changed_files" e2e_log "Producing BrowserStack run plan" - ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan expand --changed-files-file "$changed_files" > "$BITRISE_DEPLOY_DIR/browserstack-run-plan.json" + ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan expand --config e2e/config/preload-log-preflight-matrix.yml --changed-files-file "$changed_files" > "$BITRISE_DEPLOY_DIR/browserstack-run-plan.json" e2e_log "Publishing BrowserStack run plan environment" bitrise_env_file="$(e2e_deploy_dir)/bitrise-env.txt" - ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan bitrise-env --changed-files-file "$changed_files" > "$bitrise_env_file" + ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan bitrise-env --config e2e/config/preload-log-preflight-matrix.yml --changed-files-file "$changed_files" > "$bitrise_env_file" while IFS='=' read -r key value; do envman add --key "$key" --value "$value" done < "$bitrise_env_file" diff --git a/e2e/config/preload-log-preflight-matrix.yml b/e2e/config/preload-log-preflight-matrix.yml new file mode 100644 index 000000000..f618d89e7 --- /dev/null +++ b/e2e/config/preload-log-preflight-matrix.yml @@ -0,0 +1,33 @@ +version: 1 +changed_file_filters: .ci/changed-file-filters.yml +applications: + - id: kotlin-android + target: kotlin + platform: android + app_id: com.shopify.checkoutkit.androiddemo + artifact_env: E2E_KOTLIN_ANDROID_APP_PATH + ready_marker: checkout-kit-sample-ready + changed_files_filters: + - android + - protocolKotlin + - protocolShared + - e2e + - ciFilters + - id: swift-ios + target: swift + platform: ios + app_id: com.shopify.checkoutkit.swiftdemo + artifact_env: E2E_SWIFT_IOS_APP_PATH + ready_marker: checkout-kit-sample-ready + changed_files_filters: + - swift + - protocolSwift + - protocolShared + - packageSwift + - e2e + - ciFilters +os_version_tags: + - latest +suites: + - id: native-preload-cache-hit-preflight + execute: tests/preflight/native-preload-cache-hit.yaml diff --git a/e2e/lib/browserstack_client.rb b/e2e/lib/browserstack_client.rb index 251a05143..4f70d56c1 100644 --- a/e2e/lib/browserstack_client.rb +++ b/e2e/lib/browserstack_client.rb @@ -2,12 +2,14 @@ require "net/http" require "securerandom" +require "uri" require_relative "../../scripts/lib/json_http_client" # BrowserStack App Automate API client. Owns endpoint paths, HTTP, and response # parsing; callers assemble request payloads and orchestrate build lifecycles. class BrowserStackClient API_HOST = "api-cloud.browserstack.com" + ARTIFACT_HOSTS = [API_HOST, "api.browserstack.com"].freeze DASHBOARD_BASE = "https://app-automate.browserstack.com/dashboard/v2/builds" def self.build_url(build_id) @@ -15,6 +17,8 @@ def self.build_url(build_id) end def initialize(username:, access_key:, retries: 0) + @username = username + @access_key = access_key @client = JsonHttpClient.new( host: API_HOST, error_label: "BrowserStack", @@ -65,4 +69,32 @@ def stop_build(build_id) def get_session(build_id, session_id) @client.get("/app-automate/maestro/v2/builds/#{build_id}/sessions/#{session_id}") end + + def get_artifact_text(url, redirects_remaining: 3) + uri = URI.parse(url) + unless uri.scheme == "https" && ARTIFACT_HOSTS.include?(uri.host) + raise "BrowserStack artifact URL has an unexpected origin" + end + + request = Net::HTTP::Get.new(uri) + request.basic_auth(@username, @access_key) + response = Net::HTTP.start( + uri.host, + uri.port, + use_ssl: true, + open_timeout: 10, + read_timeout: 120 + ) { |http| http.request(request) } + + return response.body.to_s if response.is_a?(Net::HTTPSuccess) + + if response.is_a?(Net::HTTPRedirection) && redirects_remaining.positive? + location = response["location"] + raise "BrowserStack artifact redirect omitted Location" if location.to_s.empty? + + return get_artifact_text(URI.join(uri, location).to_s, redirects_remaining: redirects_remaining - 1) + end + + raise "BrowserStack artifact request failed #{response.code}" + end end diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index b713190fb..129fe1c39 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -269,6 +269,11 @@ def failure_details(result) tests = result.fetch("failed_tests", []) if tests.empty? lines << "| — | #{status_icon(result)} | #{artifact_links(nil, result)} |" + unless blank?(result["error"]) + error = result["error"].to_s.gsub(/\s+/, " ").gsub("`", "'") + lines << "" + lines << "> Diagnostic: `#{error}`" + end else tests.each do |testcase| lines << "| `#{testcase.fetch("name", "unknown")}` | ❌ | #{artifact_links(testcase, result)} |" diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index 5b313beaa..4996e8f41 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -18,6 +18,16 @@ class BrowserStackRunExecutor # and the version BrowserStack runs cannot drift apart. # https://www.browserstack.com/docs/app-automate/maestro/set-up-test-env/configure-tests/set-maestro-version MAESTRO_VERSION_FILE = File.expand_path("../.maestro-version", __dir__) + PRELOAD_LOG_PREFLIGHT_FLOW = "tests/preflight/native-preload-cache-hit.yaml" + PRELOAD_CACHE_HIT_LOGS = { + "kotlin" => "Returning cached preloaded WebView." + }.freeze + ANDROID_CONCURRENT_PRESENTATION_LOG = "Preloaded WebView is already presented; creating a new WebView." + BROWSERSTACK_LOG_ARTIFACT_KEYS = %w[device_log instrumentation_log maestro_log].freeze + IOS_PRELOAD_DIAGNOSTIC_SIGNALS = [ + "Presenting cached entry", + "Presenting preloaded checkout from cache" + ].freeze def self.failure_result(context, error) context.merge( @@ -82,6 +92,93 @@ class BrowserStackRunExecutor value end + def self.build_request_body(run:, app_url:, test_suite_url:, device:, env: ENV) + { + app: app_url, + testSuite: test_suite_url, + project: env.fetch("E2E_BROWSERSTACK_PROJECT", "checkout-kit-e2e"), + maestroVersion: resolve_maestro_version(env), + buildTag: env.fetch("BITRISE_GIT_COMMIT", "local"), + customBuildName: run.fetch("id"), + devices: [device], + execute: [run.fetch("execute")], + deviceLogs: true, + setEnvVariables: { + E2E_APP_ID: run.fetch("app_id"), + E2E_READY_MARKER: run.fetch("ready_marker") + } + } + end + + def self.testcases_for(sessions) + sessions.flat_map do |session| + session.dig("testcases", "data").to_a.flat_map do |group| + group.fetch("testcases", []) + end + end + end + + def self.sessions_have_testcases?(sessions) + sessions.any? && sessions.all? { |session| testcases_for([session]).any? } + end + + # Temporary preflight-only diagnostics: failing iOS Maestro testcases are the + # only ones that publish log artifacts, so mine them for the failed steps and + # for whether the SDK's cache-hit messages reached the physical device log. + # Lines containing URLs are excluded so no checkout values can leak into the + # PR report. + def self.diagnose_ios_preflight_failure!(run:, sessions:, fetch_log:) + return unless run.fetch("execute") == PRELOAD_LOG_PREFLIGHT_FLOW + return unless run.fetch("target") == "swift" + + failed = testcases_for(sessions).reject { |testcase| testcase.fetch("status", "") == "passed" } + return if failed.empty? + + reports = failed.map do |testcase| + maestro_log = testcase["maestro_log"] ? fetch_log.call(testcase["maestro_log"]) : "" + device_log = testcase["device_log"] ? fetch_log.call(testcase["device_log"]) : "" + failed_steps = maestro_log.lines + .select { |line| line.include?("FAILED") && !line.include?("://") } + .last(3) + .map(&:strip) + .join(" | ") + signals = IOS_PRELOAD_DIAGNOSTIC_SIGNALS + .map { |signal| "#{signal}=#{device_log.include?(signal)}" } + .join(", ") + "failed steps: #{failed_steps}; device log signals: #{signals}" + end + + raise "iOS preload preflight diagnostics — #{reports.join(" || ")}" + end + + def self.verify_preload_cache_hit_log!(run:, sessions:, fetch_log:) + return unless run.fetch("execute") == PRELOAD_LOG_PREFLIGHT_FLOW + + expected = PRELOAD_CACHE_HIT_LOGS[run.fetch("target")] + return unless expected + + passing_testcases = testcases_for(sessions).select do |testcase| + testcase.fetch("status", "") == "passed" + end + log_urls = passing_testcases.flat_map do |testcase| + BROWSERSTACK_LOG_ARTIFACT_KEYS.filter_map { |key| testcase[key] } + end + if log_urls.empty? + available_keys = passing_testcases.flat_map(&:keys).uniq.sort.join(", ") + raise "Passing BrowserStack testcase did not expose a supported log artifact; available keys: #{available_keys}" + end + + device_output = log_urls.map { |url| fetch_log.call(url) }.join("\n") + unless device_output.include?(expected) + raise "BrowserStack log artifacts did not contain the #{run.fetch("target")} preload cache-hit signal" + end + if run.fetch("target") == "kotlin" && device_output.include?(ANDROID_CONCURRENT_PRESENTATION_LOG) + raise "Android BrowserStack logs recorded a concurrent fresh presentation" + end + + puts "Verified #{run.fetch("target")} preload cache-hit signal in BrowserStack logs." + end + def initialize(options) @options = options @client = BrowserStackClient.new( @@ -102,6 +199,16 @@ class BrowserStackRunExecutor puts "BrowserStack build: #{BrowserStackClient.build_url(@build.fetch("build_id"))}" build_status = poll_build(@build.fetch("build_id")) sessions = fetch_sessions(build_status) + self.class.verify_preload_cache_hit_log!( + run: @run, + sessions: sessions, + fetch_log: ->(url) { @client.get_artifact_text(url) } + ) + self.class.diagnose_ios_preflight_failure!( + run: @run, + sessions: sessions, + fetch_log: ->(url) { @client.get_artifact_text(url) } + ) result = normalize_result(@run, @device, @app, @suite, @build, build_status, sessions) write_json("result.json", result) rescue StandardError => error @@ -138,20 +245,12 @@ class BrowserStackRunExecutor end def start_build(run, app_url, test_suite_url, device) - body = { - app: app_url, - testSuite: test_suite_url, - project: ENV.fetch("E2E_BROWSERSTACK_PROJECT", "checkout-kit-e2e"), - maestroVersion: self.class.resolve_maestro_version(ENV), - buildTag: ENV.fetch("BITRISE_GIT_COMMIT", "local"), - customBuildName: run.fetch("id"), - devices: [device], - execute: [run.fetch("execute")], - setEnvVariables: { - E2E_APP_ID: run.fetch("app_id"), - E2E_READY_MARKER: run.fetch("ready_marker") - } - } + body = self.class.build_request_body( + run: run, + app_url: app_url, + test_suite_url: test_suite_url, + device: device + ) response = @client.start_build(run.fetch("platform"), body) write_json("build-start.json", response) response @@ -183,19 +282,31 @@ class BrowserStackRunExecutor def fetch_sessions(build_status) build_id = build_status.fetch("id") - build_status.fetch("devices", []).flat_map do |device| - device.fetch("sessions", []).map do |session| - @client.get_session(build_id, session.fetch("id")) + session_ids = build_status.fetch("devices", []).flat_map do |device| + device.fetch("sessions", []).map { |session| session.fetch("id") } + end + deadline = Time.now + ENV.fetch("E2E_BROWSERSTACK_SESSION_DETAILS_TIMEOUT_SECONDS", "120").to_i + needs_testcase_artifacts = + @run.fetch("execute") == PRELOAD_LOG_PREFLIGHT_FLOW && + PRELOAD_CACHE_HIT_LOGS.key?(@run.fetch("target")) + + loop do + sessions = session_ids.map { |session_id| @client.get_session(build_id, session_id) } + return sessions unless needs_testcase_artifacts + return sessions if self.class.sessions_have_testcases?(sessions) + + if Time.now >= deadline + raise "BrowserStack session details did not publish testcase artifacts before timeout" end + + sleep ENV.fetch("E2E_BROWSERSTACK_SESSION_DETAILS_POLL_SECONDS", "5").to_i end end def normalize_result(run, device, app, suite, build, build_status, sessions) status = build_status.fetch("status").to_s.downcase - failed_tests = sessions.flat_map do |session| - session.dig("testcases", "data").to_a.flat_map do |group| - group.fetch("testcases", []).select { |testcase| testcase.fetch("status", "") != "passed" } - end + failed_tests = self.class.testcases_for(sessions).reject do |testcase| + testcase.fetch("status", "") == "passed" end { diff --git a/e2e/test/browserstack_run_executor_test.rb b/e2e/test/browserstack_run_executor_test.rb index ac0366d95..164121b66 100644 --- a/e2e/test/browserstack_run_executor_test.rb +++ b/e2e/test/browserstack_run_executor_test.rb @@ -55,4 +55,198 @@ def test_a_blank_override_falls_back_to_the_pin_file assert_equal "2.4.0", version end end + + def test_build_request_enables_device_logs_for_passing_testcase_inspection + body = BrowserStackRunExecutor.build_request_body( + run: { + "id" => "swift-ios-latest", + "execute" => "tests", + "app_id" => "com.shopify.checkoutkit.swiftdemo", + "ready_marker" => "checkout-kit-sample-ready" + }, + app_url: "bs://app", + test_suite_url: "bs://suite", + device: "iPhone 15-27", + env: { + "E2E_BROWSERSTACK_PROJECT" => "test-project", + "E2E_MAESTRO_VERSION" => "2.4.0", + "BITRISE_GIT_COMMIT" => "test-sha" + } + ) + + assert_equal( + { + app: "bs://app", + testSuite: "bs://suite", + project: "test-project", + maestroVersion: "2.4.0", + buildTag: "test-sha", + customBuildName: "swift-ios-latest", + devices: ["iPhone 15-27"], + execute: ["tests"], + deviceLogs: true, + setEnvVariables: { + E2E_APP_ID: "com.shopify.checkoutkit.swiftdemo", + E2E_READY_MARKER: "checkout-kit-sample-ready" + } + }, + body + ) + end + + def test_session_details_are_not_ready_until_each_session_has_a_testcase + refute BrowserStackRunExecutor.sessions_have_testcases?([]) + refute BrowserStackRunExecutor.sessions_have_testcases?([{"testcases" => {"data" => []}}]) + assert BrowserStackRunExecutor.sessions_have_testcases?(sessions_with_device_log) + end + + def test_preflight_accepts_the_expected_cache_hit_signal + output, = capture_io do + BrowserStackRunExecutor.verify_preload_cache_hit_log!( + run: preflight_run("kotlin"), + sessions: sessions_with_device_log, + fetch_log: ->(_) { "Returning cached preloaded WebView." } + ) + end + + assert_includes output, "Verified kotlin preload cache-hit signal" + end + + def test_preflight_can_use_an_instrumentation_log_when_device_log_is_unavailable + output, = capture_io do + BrowserStackRunExecutor.verify_preload_cache_hit_log!( + run: preflight_run("kotlin"), + sessions: sessions_with_device_log( + device_log: nil, + instrumentation_log: "https://api.browserstack.com/instrumentation-log" + ), + fetch_log: ->(_) { "Returning cached preloaded WebView." } + ) + end + + assert_includes output, "Verified kotlin preload cache-hit signal" + end + + def test_swift_preflight_uses_the_in_app_accessibility_assertion_instead + BrowserStackRunExecutor.verify_preload_cache_hit_log!( + run: preflight_run("swift"), + sessions: [], + fetch_log: ->(_) { flunk "Swift should not fetch BrowserStack log artifacts" } + ) + end + + def test_preflight_requires_a_log_artifact_from_a_passing_testcase + error = assert_raises(RuntimeError) do + BrowserStackRunExecutor.verify_preload_cache_hit_log!( + run: preflight_run("kotlin"), + sessions: sessions_with_device_log(device_log: nil), + fetch_log: ->(_) { "Returning cached preloaded WebView." } + ) + end + + assert_includes error.message, "did not expose a supported log artifact" + end + + def test_preflight_requires_the_platform_cache_hit_signal + error = assert_raises(RuntimeError) do + BrowserStackRunExecutor.verify_preload_cache_hit_log!( + run: preflight_run("kotlin"), + sessions: sessions_with_device_log, + fetch_log: ->(_) { "unrelated device output" } + ) + end + + assert_includes error.message, "kotlin preload cache-hit signal" + end + + def test_ios_preflight_failure_diagnostics_report_steps_and_signals_without_urls + sessions = [ + { + "testcases" => { + "data" => [ + { + "testcases" => [ + { + "status" => "failed", + "maestro_log" => "https://api.browserstack.com/maestro-log", + "device_log" => "https://api.browserstack.com/device-log" + } + ] + } + ] + } + } + ] + logs = { + "https://api.browserstack.com/maestro-log" => <<~LOG, + Launch app "https://example.test/secret" ... FAILED + Assert that id: preload-cache-hit is visible... FAILED + LOG + "https://api.browserstack.com/device-log" => "Presenting cached entry" + } + + error = assert_raises(RuntimeError) do + BrowserStackRunExecutor.diagnose_ios_preflight_failure!( + run: preflight_run("swift"), + sessions: sessions, + fetch_log: ->(url) { logs.fetch(url) } + ) + end + + assert_includes error.message, "Assert that id: preload-cache-hit is visible... FAILED" + assert_includes error.message, "Presenting cached entry=true" + assert_includes error.message, "Presenting preloaded checkout from cache=false" + refute_includes error.message, "https://example.test" + end + + def test_android_preflight_rejects_a_concurrent_fresh_presentation + error = assert_raises(RuntimeError) do + BrowserStackRunExecutor.verify_preload_cache_hit_log!( + run: preflight_run("kotlin"), + sessions: sessions_with_device_log, + fetch_log: lambda do |_| + <<~LOG + Returning cached preloaded WebView. + Preloaded WebView is already presented; creating a new WebView. + LOG + end + ) + end + + assert_includes error.message, "concurrent fresh presentation" + end + + private + + def preflight_run(target) + { + "target" => target, + "execute" => BrowserStackRunExecutor::PRELOAD_LOG_PREFLIGHT_FLOW + } + end + + def sessions_with_device_log( + device_log: "https://api.browserstack.com/device-log", + instrumentation_log: nil, + maestro_log: nil + ) + [ + { + "testcases" => { + "data" => [ + { + "testcases" => [ + { + "status" => "passed", + "device_log" => device_log, + "instrumentation_log" => instrumentation_log, + "maestro_log" => maestro_log + } + ] + } + ] + } + } + ] + end end diff --git a/e2e/test/e2e_github_reporter_test.rb b/e2e/test/e2e_github_reporter_test.rb index ec7c709cb..ce921455e 100644 --- a/e2e/test/e2e_github_reporter_test.rb +++ b/e2e/test/e2e_github_reporter_test.rb @@ -196,4 +196,21 @@ def test_complete_run_has_no_missing_run_lines refute_includes summary, "did not report" end + + def test_setup_failure_includes_its_diagnostic + result = swift_ios_run.merge( + "passed" => false, + "resolved_device" => "iPhone", + "build_id" => "build-id", + "failed_tests" => [], + "error" => "device log did not contain cache hit" + ) + summary = reporter( + results: [result], + run_plan: [swift_ios_run], + expected: 1 + ).markdown_summary + + assert_includes summary, "> Diagnostic: `device log did not contain cache hit`" + end end diff --git a/e2e/tests/preflight/native-preload-cache-hit.yaml b/e2e/tests/preflight/native-preload-cache-hit.yaml new file mode 100644 index 000000000..fe0536001 --- /dev/null +++ b/e2e/tests/preflight/native-preload-cache-hit.yaml @@ -0,0 +1,97 @@ +appId: ${E2E_APP_ID} +name: Native preload cache-hit log preflight +--- +# First launches on fresh BrowserStack devices can be slow or dropped; retry the +# launch itself before treating a missing ready marker as a failure. +- retry: + maxRetries: 2 + commands: + - launchApp: + clearState: true + arguments: + # iOS-only launch arguments; Android ignores them. + AppleLocale: en_US + AppleLanguages: "(en)" + - extendedWaitUntil: + visible: + id: ${E2E_READY_MARKER} + timeout: 60000 + +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: 25%,30% + - extendedWaitUntil: + visible: "Add to Cart" + timeout: 30000 + - tapOn: "Add to Cart" + - extendedWaitUntil: + visible: "Added" + timeout: 30000 + - tapOn: + point: 90%,11% + - extendedWaitUntil: + visible: "Catalog" + timeout: 15000 + - tapOn: "Cart" + - extendedWaitUntil: + visible: + id: checkoutSheetButton + timeout: 30000 + +- runFlow: + when: + platform: Android + commands: + - tapOn: "Shop" + - extendedWaitUntil: + notVisible: "Collections" + timeout: 15000 + - tapOn: "Masters Podcast (Digital Download)" + - extendedWaitUntil: + visible: "Add to cart" + timeout: 30000 + - tapOn: "Add to cart" + - tapOn: "Go to cart icon" + - extendedWaitUntil: + visible: "Your cart" + timeout: 30000 + +- extendedWaitUntil: + visible: + id: preload-state-ready + timeout: 60000 + +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: checkoutSheetButton + +- runFlow: + when: + platform: Android + commands: + - tapOn: "Check out" + +- extendedWaitUntil: + visible: "^(Email( or mobile phone number)?|Delivery|Card number)$" + timeout: 60000 + +# iOS cache-consumption proof: the SDK logs its cache-hit diagnostic through the +# public configured logger at present time; the sample converts it into the +# invisible `preload-cache-hit` ID on the cart screen. Close the sheet first so +# the cart hierarchy is inspectable again. +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: shopify_checkout_kit_close_button + - extendedWaitUntil: + visible: + id: preload-cache-hit + timeout: 30000 diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartView.kt index 162c61bd8..62cda4300 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartView.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -60,6 +61,7 @@ fun CartView( val state = cartViewModel.cartState.collectAsState().value val loading = cartViewModel.loadingState.collectAsState().value val checkoutPresentationMode = cartViewModel.checkoutPresentationMode.collectAsState().value + val preloadStateTestId = cartViewModel.preloadStateTestId.collectAsState().value val activity = LocalActivity.current as ComponentActivity var mutableQuantity by remember { mutableStateOf>(mutableMapOf()) } @@ -88,7 +90,13 @@ fun CartView( it.title to it.quantity } - Column(modifier = Modifier.padding(top = 4.dp)) { + // Invisible preload-state seam for the Maestro preload flows, following the + // checkout-kit-sample-ready pattern: automation metadata only, no rendered UI. + Column( + modifier = Modifier + .padding(top = 4.dp) + .testTag(preloadStateTestId) + ) { CartLines( lines = state.cartLines, loading = loading, diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt index 5c2a2e396..a7731f607 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartViewModel.kt @@ -12,6 +12,7 @@ import com.shopify.checkoutkit.CheckoutException import com.shopify.checkoutkit.CheckoutPreload import com.shopify.checkoutkit.CheckoutPresentation import com.shopify.checkoutkit.CheckoutProtocol +import com.shopify.checkoutkit.PreloadState import com.shopify.checkoutkit.ShopifyCheckoutKit import com.shopify.checkoutkit.androiddemo.MainActivity import com.shopify.checkoutkit.androiddemo.R @@ -23,6 +24,7 @@ import com.shopify.checkoutkit.androiddemo.common.SnackbarEvent import com.shopify.checkoutkit.androiddemo.common.logs.LogLevel import com.shopify.checkoutkit.androiddemo.common.logs.Logger import com.shopify.checkoutkit.androiddemo.common.navigation.Screen +import com.shopify.checkoutkit.androiddemo.e2e.PreloadStateMarker import com.shopify.checkoutkit.androiddemo.settings.PreferencesManager import com.shopify.checkoutkit.androiddemo.settings.authentication.data.CustomerRepository import com.shopify.checkoutkit.androiddemo.settings.data.CheckoutPresentationMode @@ -57,6 +59,9 @@ class CartViewModel( private val _checkoutPresentationMode = MutableStateFlow(CheckoutPresentationMode.CheckoutKitSheet) val checkoutPresentationMode: StateFlow = _checkoutPresentationMode.asStateFlow() + private val _preloadStateTestId = MutableStateFlow(PreloadStateMarker.testId(PreloadState.Idle)) + val preloadStateTestId: StateFlow = _preloadStateTestId.asStateFlow() + private var demoBuyerIdentityEnabled = false private var checkoutPreloadingEnabled = true private var checkoutPreload: CheckoutPreload? = null @@ -159,6 +164,7 @@ class CartViewModel( ShopifyCheckoutKit.invalidate() checkoutPreload = ShopifyCheckoutKit.preload(url, activity) { state -> Timber.i("Preload state changed to $state") + _preloadStateTestId.value = PreloadStateMarker.testId(state) } } 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 9adcefc03..c081cebf2 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 @@ -2,4 +2,5 @@ package com.shopify.checkoutkit.androiddemo.e2e object E2ETestIds { const val APP_READY = "checkout-kit-sample-ready" + const val PRELOAD_STATE_PREFIX = "preload-state-" } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarker.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarker.kt new file mode 100644 index 000000000..9bdd05139 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarker.kt @@ -0,0 +1,31 @@ +package com.shopify.checkoutkit.androiddemo.e2e + +import com.shopify.checkoutkit.PreloadState + +/** + * Maps [PreloadState] to the dynamic test ID the cart screen exposes for the + * Maestro preload flows, e.g. `preload-state-ready`. + * + * The ID is invisible automation metadata, never rendered UI — the same seam + * style as [E2ETestIds.APP_READY]. The Swift sample exposes identical IDs, so + * shared flow files work on both platforms. Do not change a value without + * updating the flows and the Swift mapping. + */ +object PreloadStateMarker { + fun testId(state: PreloadState): String = "${E2ETestIds.PRELOAD_STATE_PREFIX}${text(state)}" + + fun text(state: PreloadState): String = when (state) { + is PreloadState.Idle -> "idle" + is PreloadState.Loading -> "loading" + is PreloadState.Ready -> "ready" + is PreloadState.Expired -> "expired" + is PreloadState.Failed -> failedText(state.reason) + } + + private fun failedText(reason: PreloadState.FailureReason): String = when (reason) { + is PreloadState.FailureReason.HttpError -> "failed-http-${reason.statusCode}" + is PreloadState.FailureReason.NavigationFailed -> "failed-navigation" + is PreloadState.FailureReason.WebContentProcessTerminated -> "failed-web-process" + is PreloadState.FailureReason.ProtocolError -> "failed-protocol" + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarkerTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarkerTest.kt new file mode 100644 index 000000000..683e1f8dc --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/PreloadStateMarkerTest.kt @@ -0,0 +1,43 @@ +package com.shopify.checkoutkit.androiddemo.e2e + +import com.shopify.checkoutkit.PreloadState +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class PreloadStateMarkerTest { + @Test + fun `lifecycle marker texts match the maestro flow assertions`() { + assertThat(PreloadStateMarker.text(PreloadState.Idle)).isEqualTo("idle") + assertThat(PreloadStateMarker.text(PreloadState.Loading)).isEqualTo("loading") + assertThat(PreloadStateMarker.text(PreloadState.Ready)).isEqualTo("ready") + assertThat(PreloadStateMarker.text(PreloadState.Expired)).isEqualTo("expired") + } + + @Test + fun `http failure marker text includes the status code`() { + assertThat(markerFor(PreloadState.FailureReason.HttpError(statusCode = 403))) + .isEqualTo("failed-http-403") + assertThat(markerFor(PreloadState.FailureReason.HttpError(statusCode = 500))) + .isEqualTo("failed-http-500") + } + + @Test + fun `non-http failure marker texts`() { + assertThat(markerFor(PreloadState.FailureReason.NavigationFailed)).isEqualTo("failed-navigation") + assertThat(markerFor(PreloadState.FailureReason.WebContentProcessTerminated)) + .isEqualTo("failed-web-process") + assertThat(markerFor(PreloadState.FailureReason.ProtocolError)).isEqualTo("failed-protocol") + } + + @Test + fun `dynamic test ids match the maestro flow assertions`() { + assertThat(PreloadStateMarker.testId(PreloadState.Idle)).isEqualTo("preload-state-idle") + assertThat(PreloadStateMarker.testId(PreloadState.Ready)).isEqualTo("preload-state-ready") + assertThat( + PreloadStateMarker.testId(PreloadState.Failed(PreloadState.FailureReason.HttpError(statusCode = 403))) + ).isEqualTo("preload-state-failed-http-403") + } + + private fun markerFor(reason: PreloadState.FailureReason): String = + PreloadStateMarker.text(PreloadState.Failed(reason)) +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift index 479f11352..18d291184 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift @@ -28,7 +28,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ShopifyCheckoutKit.configure { $0.appearance = .app(.automatic) $0.tintColor = ColorPalette.primaryColor - $0.logger = FileLogger("log.txt") + $0.logger = PreloadCacheHitSignalLogger(wrapping: FileLogger("log.txt")) $0.logLevel = checkoutKitLogLevel $0.preloading.enabled = checkoutPreloadingEnabled } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift index 0835e4d03..8c00c7241 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift @@ -1,3 +1,6 @@ enum E2ETestIds { static let appReady = "checkout-kit-sample-ready" + static let preloadStatePrefix = "preload-state-" + static let preloadCacheHit = "preload-cache-hit" + static let preloadCacheHitPending = "preload-cache-hit-pending" } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadCacheHitSignal.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadCacheHitSignal.swift new file mode 100644 index 000000000..694fd9ba2 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadCacheHitSignal.swift @@ -0,0 +1,36 @@ +import Foundation +import ShopifyCheckoutKit + +/// Decorates the sample's configured ``Logger`` so the SDK's preload cache-hit +/// diagnostic becomes observable by the Maestro preload flows. +/// +/// The SDK logs ``PreloadCacheHitSignal/cacheHitLogMessage`` through the public +/// `Configuration.logger` when a matching presentation reuses the preloaded +/// WebView. This wrapper forwards every message unchanged and posts +/// ``PreloadCacheHitSignal/notification`` when that message arrives, which the +/// cart screen converts into the invisible `preload-cache-hit` test ID. +enum PreloadCacheHitSignal { + static let notification = Notification.Name("CheckoutKitSampleDidLogPreloadCacheHit") + + /// Keep in sync with `CheckoutWebView.preloadCacheHitLogMessage`. + static let cacheHitLogMessage = "Presenting preloaded checkout from cache" +} + +final class PreloadCacheHitSignalLogger: Logger { + private let wrapped: any Logger + + init(wrapping wrapped: any Logger) { + self.wrapped = wrapped + } + + func log(_ message: String) { + if message == PreloadCacheHitSignal.cacheHitLogMessage { + NotificationCenter.default.post(name: PreloadCacheHitSignal.notification, object: nil) + } + wrapped.log(message) + } + + func clearLogs() { + wrapped.clearLogs() + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadStateMarker.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadStateMarker.swift new file mode 100644 index 000000000..41dd8414e --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/PreloadStateMarker.swift @@ -0,0 +1,44 @@ +import ShopifyCheckoutKit + +/// Maps ``PreloadState`` to the dynamic test ID the cart screen exposes for the +/// Maestro preload flows, e.g. `preload-state-ready`. +/// +/// The ID is invisible automation metadata, never rendered UI — the same seam +/// style as ``E2ETestIds/appReady``. The Android sample exposes identical IDs, +/// so shared flow files work on both platforms. Do not change a value without +/// updating the flows and the Android mapping. +enum PreloadStateMarker { + static func testId(for state: PreloadState) -> String { + "\(E2ETestIds.preloadStatePrefix)\(text(for: state))" + } + + static func text(for state: PreloadState) -> String { + switch state { + case .idle: + return "idle" + case .loading: + return "loading" + case .ready: + return "ready" + case .expired: + return "expired" + case let .failed(reason): + return failedText(for: reason) + } + } + + private static func failedText(for reason: PreloadState.FailureReason) -> String { + switch reason { + case let .httpError(statusCode): + return "failed-http-\(statusCode)" + case .navigationFailed: + return "failed-navigation" + case .keepAliveLost: + return "failed-keep-alive" + case .webContentProcessTerminated: + return "failed-web-process" + case .protocolError: + return "failed-protocol" + } + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift index b65b48c0a..316e0d34c 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -1,4 +1,5 @@ import ApolloAPI +import Combine import EmbeddedCheckoutProtocol import ShopifyAcceleratedCheckouts import ShopifyCheckoutKit @@ -12,6 +13,8 @@ struct CartView: View { @State var isCompleted: Bool = false @State var showCheckoutSheet: Bool = false @State private var checkoutPreload: CheckoutPreload? + @State private var preloadStateTestId = PreloadStateMarker.testId(for: .idle) + @State private var preloadCacheHitTestId = E2ETestIds.preloadCacheHitPending @ObservedObject var cartManager: CartManager = .shared @@ -37,6 +40,10 @@ struct CartView: View { } .padding(.bottom, 130) } + // Invisible cache-consumption seam for the Maestro preload flows: flips to + // `preload-cache-hit` when the SDK logs its cache-hit diagnostic at present time. + .accessibilityElement(children: .contain) + .accessibilityIdentifier(preloadCacheHitTestId) VStack(spacing: DesignSystem.buttonSpacing) { if let cartID = cartManager.cart?.id { @@ -94,6 +101,8 @@ struct CartView: View { .padding(.horizontal, 20) .padding(.bottom, 20) } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(preloadStateTestId) .sheet(isPresented: $showCheckoutSheet) { if let url = cartManager.cart?.checkoutURL { ShopifyCheckout(checkout: url) @@ -141,6 +150,13 @@ struct CartView: View { .onChange(of: checkoutPreloadingEnabled) { _ in preloadCheckoutIfNeeded() } + .onReceive( + NotificationCenter.default + .publisher(for: PreloadCacheHitSignal.notification) + .receive(on: RunLoop.main) + ) { _ in + preloadCacheHitTestId = E2ETestIds.preloadCacheHit + } } else { EmptyState() } @@ -158,6 +174,7 @@ struct CartView: View { ShopifyCheckoutKit.invalidate() checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) checkoutPreload?.onStateChange = { state in + preloadStateTestId = PreloadStateMarker.testId(for: state) print("[Preload] state changed to \(state)") ShopifyCheckoutKit.configuration.logger.log("Preload state changed to \(state)") } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/PreloadStateMarkerTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/PreloadStateMarkerTests.swift new file mode 100644 index 000000000..79d72fc40 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/PreloadStateMarkerTests.swift @@ -0,0 +1,42 @@ +@testable import CheckoutKitSwiftDemo +import ShopifyCheckoutKit +import XCTest + +class PreloadStateMarkerTests: XCTestCase { + func testLifecycleMarkerTextsMatchTheMaestroFlowAssertions() { + XCTAssertEqual(PreloadStateMarker.text(for: .idle), "idle") + XCTAssertEqual(PreloadStateMarker.text(for: .loading), "loading") + XCTAssertEqual(PreloadStateMarker.text(for: .ready), "ready") + XCTAssertEqual(PreloadStateMarker.text(for: .expired), "expired") + } + + func testHttpFailureMarkerTextIncludesTheStatusCode() { + XCTAssertEqual( + PreloadStateMarker.text(for: .failed(reason: .httpError(statusCode: 403))), + "failed-http-403" + ) + XCTAssertEqual( + PreloadStateMarker.text(for: .failed(reason: .httpError(statusCode: 500))), + "failed-http-500" + ) + } + + func testNonHttpFailureMarkerTexts() { + XCTAssertEqual(PreloadStateMarker.text(for: .failed(reason: .navigationFailed)), "failed-navigation") + XCTAssertEqual(PreloadStateMarker.text(for: .failed(reason: .keepAliveLost)), "failed-keep-alive") + XCTAssertEqual( + PreloadStateMarker.text(for: .failed(reason: .webContentProcessTerminated)), + "failed-web-process" + ) + XCTAssertEqual(PreloadStateMarker.text(for: .failed(reason: .protocolError)), "failed-protocol") + } + + func testDynamicTestIdsMatchTheMaestroFlowAssertions() { + XCTAssertEqual(PreloadStateMarker.testId(for: .idle), "preload-state-idle") + XCTAssertEqual(PreloadStateMarker.testId(for: .ready), "preload-state-ready") + XCTAssertEqual( + PreloadStateMarker.testId(for: .failed(reason: .httpError(statusCode: 403))), + "preload-state-failed-http-403" + ) + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index fc96e61e9..8b49b8379 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -252,6 +252,7 @@ protocol CheckoutWebViewDelegate: AnyObject { @MainActor class CheckoutWebView: WKWebView { static let preloadCache = PreloadCache() + static let preloadCacheHitLogMessage = "Presenting preloaded checkout from cache" private static let purposeHeader = "Shopify-Purpose" private static let prefetchPurpose = "prefetch" @@ -346,6 +347,7 @@ class CheckoutWebView: WKWebView { } OSLogger.shared.debug("Presenting cached entry") + ShopifyCheckoutKit.configuration.logger.log(preloadCacheHitLogMessage) return cachedView } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 272ce8456..dc87e4864 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -327,6 +327,29 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) } + func testMatchingPresentLogsTheCacheHitThroughTheConfiguredLogger() { + let originalLogger = ShopifyCheckoutKit.configuration.logger + defer { ShopifyCheckoutKit.configuration.logger = originalLogger } + let logger = RecordingLogger() + ShopifyCheckoutKit.configuration.logger = logger + ShopifyCheckoutKit.preload(checkout: url) + + _ = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) + + XCTAssertTrue(logger.messages.contains(CheckoutWebView.preloadCacheHitLogMessage)) + } + + func testFreshPresentDoesNotLogTheCacheHit() { + let originalLogger = ShopifyCheckoutKit.configuration.logger + defer { ShopifyCheckoutKit.configuration.logger = originalLogger } + let logger = RecordingLogger() + ShopifyCheckoutKit.configuration.logger = logger + + _ = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) + + XCTAssertFalse(logger.messages.contains(CheckoutWebView.preloadCacheHitLogMessage)) + } + func testPresentWithDifferentURLDoesNotReusePreloadedWebView() throws { ShopifyCheckoutKit.preload(checkout: url) let otherURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/456")) @@ -1242,6 +1265,29 @@ private actor RecordingBridgeClient: CheckoutCommunicationProtocol { } @MainActor +final class RecordingLogger: Logger, @unchecked Sendable { + private let lock = NSLock() + private nonisolated(unsafe) var storage: [String] = [] + + nonisolated var messages: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } + + nonisolated func log(_ message: String) { + lock.lock() + defer { lock.unlock() } + storage.append(message) + } + + nonisolated func clearLogs() { + lock.lock() + defer { lock.unlock() } + storage.removeAll() + } +} + class LoadedRequestObservableWebView: CheckoutWebView { var lastLoadedURLRequest: URLRequest?