diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt index ae1f7d62..411046c6 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt @@ -1,6 +1,5 @@ package com.shopify.checkoutkit -import android.content.Context import android.webkit.JavascriptInterface import com.shopify.checkoutkit.ShopifyCheckoutKit.log import kotlinx.serialization.Serializable @@ -29,7 +28,7 @@ internal class EmbeddedCheckoutProtocol( @Volatile private var client: CheckoutCommunicationClient? = null, ) { private val decoder = Json { ignoreUnknownKeys = true } - private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient(view.context) + private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient() internal fun setClient(client: CheckoutCommunicationClient?) { this.client = client @@ -48,9 +47,9 @@ internal class EmbeddedCheckoutProtocol( // ep.cart.* is out of scope for the checkout bridge request.method.startsWith("ep.") -> log.d(LOG_TAG, "Ignoring out-of-scope ep method: ${request.method}.") - request.method == METHOD_WINDOW_OPEN_REQUEST -> handleWindowOpenRequest(message) - request.method == METHOD_START -> handleStart(message) - request.method == METHOD_COMPLETE -> handleComplete(message) + request.method == CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message) + request.method == CheckoutProtocol.start.method -> handleStart(message) + request.method == CheckoutProtocol.complete.method -> handleComplete(message) else -> handleClientMessage(request.method, message) } } catch (e: SerializationException) { @@ -101,7 +100,7 @@ internal class EmbeddedCheckoutProtocol( ) private fun handleStart(message: String) { - log.d(LOG_TAG, "Handling $METHOD_START: hiding progress bar and bubbling up.") + log.d(LOG_TAG, "Handling ${CheckoutProtocol.start.method}: hiding progress bar and bubbling up.") onMainThread { view.getListener().onCheckoutViewLoadComplete() client?.process(message) @@ -109,7 +108,7 @@ internal class EmbeddedCheckoutProtocol( } private fun handleComplete(message: String) { - log.d(LOG_TAG, "Handling $METHOD_COMPLETE: bubbling up.") + log.d(LOG_TAG, "Handling ${CheckoutProtocol.complete.method}: bubbling up.") onMainThread { client?.process(message) } @@ -124,7 +123,7 @@ internal class EmbeddedCheckoutProtocol( * `Intent.ACTION_VIEW` (see [defaultDelegationClient]). */ private fun handleWindowOpenRequest(message: String) { - log.d(LOG_TAG, "Handling $METHOD_WINDOW_OPEN_REQUEST") + log.d(LOG_TAG, "Handling ${CheckoutProtocol.windowOpen.method}") onMainThread { val merchantResponse = client?.process(message) if (merchantResponse != null) { @@ -135,12 +134,22 @@ internal class EmbeddedCheckoutProtocol( } } + /** + * Dispatch a message through the consumer client. `ec.error` also runs through the + * kit-owned [defaultClient] regardless of the consumer response so unrecoverable + * session errors always close checkout while still reaching `CheckoutProtocol.error`. + */ private fun handleClientMessage(method: String, message: String) { log.d(LOG_TAG, "Delegating $method to client.") onMainThread { val response = client?.process(message) log.d(LOG_TAG, " client response: $response") response?.let { sendRaw(it) } + if (method == CheckoutProtocol.error.method) { + // Unrecoverable ec.error is kit behavior: emit to the client, then run + // the default handler so checkout is dismissed even if the client responded. + defaultClient.process(message)?.let { sendRaw(it) } + } } } @@ -169,6 +178,37 @@ internal class EmbeddedCheckoutProtocol( } } + /** + * Kit-owned client that handles delegations and kit-mandated notifications, + * mirroring Swift's `defaultsClient`. Currently: + * - [CheckoutProtocol.windowOpen] - launches the URI via `Intent.ACTION_VIEW`, or + * returns [WindowOpenResult.Rejected] with `window_open_rejected_error` semantics. + * - [CheckoutProtocol.error] - when any message carries `severity: "unrecoverable"`, + * dismiss the kit via the listener. Per UCP spec, `unrecoverable` means no valid + * resource exists to act on, so consumers don't have to wire dismissal in every + * error handler. + */ + private fun defaultDelegationClient(): CheckoutProtocol.Client = + CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request -> + when (val result = ExternalUriLauncher.launch(view.context, request.url)) { + is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success + is ExternalUriLauncher.Result.Rejected -> { + log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}") + WindowOpenResult.Rejected(reason = result.reason) + } + } + } + .on(CheckoutProtocol.error) { payload -> + if (payload.messages.none { it.severity == Severity.Unrecoverable }) return@on + log.d(LOG_TAG, "ec.error unrecoverable; dismissing checkout via event processor") + view.getListener().onCheckoutViewFailedWithError( + ClientException( + errorDescription = "Embedded checkout reported unrecoverable error.", + ), + ) + } + companion object { private const val LOG_TAG = BaseWebView.ECP_LOG_TAG @@ -179,10 +219,6 @@ internal class EmbeddedCheckoutProtocol( private const val ECP_RESPONSE_GLOBAL = "EmbeddedCheckoutProtocol" internal const val METHOD_READY = "ec.ready" - internal const val METHOD_START = "ec.start" - internal const val METHOD_COMPLETE = "ec.complete" - - private const val METHOD_WINDOW_OPEN_REQUEST = "ec.window.open_request" // Delegations this SDK supports. Echoed back in the ec.ready response as the // intersection of checkout-accepted ∩ kit-supported. Must align with the @@ -200,24 +236,6 @@ internal class EmbeddedCheckoutProtocol( private const val CODE_PARSE_ERROR = -32700 private const val CODE_METHOD_NOT_SUPPORTED = -32601 - - /** - * The kit's default [CheckoutProtocol.windowOpen] handler. - * - * Mirrors Swift's `defaultsClient`: launches the URI via `Intent.ACTION_VIEW` - * if any activity resolves it, otherwise returns [WindowOpenResult.Rejected] - * with `window_open_rejected_error` semantics. - */ - internal fun defaultDelegationClient(context: Context): CheckoutProtocol.Client = - CheckoutProtocol.Client().on(CheckoutProtocol.windowOpen) { request -> - when (val result = ExternalUriLauncher.launch(context, request.url)) { - is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success - is ExternalUriLauncher.Result.Rejected -> { - log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}") - WindowOpenResult.Rejected(reason = result.reason) - } - } - } } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt index 6d8d2577..60dc7adb 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt @@ -81,7 +81,7 @@ public object ShopifyCheckoutKit { * @param communicationClient optional handler for Embedded Checkout Protocol (ECP) messages. * Implement [CheckoutCommunicationClient] to intercept arbitrary ECP messages from the checkout * web page. Built-in messages ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and - * [ec.start][EmbeddedCheckoutProtocol.METHOD_START]) are handled automatically by the SDK. + * [ec.start][CheckoutProtocol.start]) are handled automatically by the SDK. * @return An instance of [CheckoutKitDialog] if the dialog was successfully created and displayed. */ @JvmOverloads diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt index dba4cd5f..64a6bd27 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt @@ -7,11 +7,13 @@ import android.net.Uri import android.os.Looper import androidx.activity.ComponentActivity import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.isNull import org.mockito.kotlin.mock @@ -360,20 +362,121 @@ class EmbeddedCheckoutProtocolTest { // endregion - // region delegated notifications + // region ec.error — severity-driven dismissal + + @Test + fun `ec error is forwarded to client regardless of severity`() { + val rawMessage = ecErrorMessage(severity = "recoverable") + val client = mock() + ecp.setClient(client) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(client).process(rawMessage) + } + + @Test + fun `ec error with unrecoverable severity dismisses via listener`() { + val rawMessage = ecErrorMessage(severity = "unrecoverable") + val client = mock() + ecp.setClient(client) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(client).process(rawMessage) + val captor = argumentCaptor() + verify(mockListener).onCheckoutViewFailedWithError(captor.capture()) + assertThat(captor.firstValue).isInstanceOf(ClientException::class.java) + assertThat(captor.firstValue.errorDescription) + .isEqualTo("Embedded checkout reported unrecoverable error.") + } @Test - fun `ec error is delegated to client`() { - val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{"code":-1,"message":"fail"}}}""" + fun `ec error with unrecoverable severity dismisses even when client returns response`() { + val rawMessage = ecErrorMessage(severity = "unrecoverable") val client = mock() + whenever(client.process(rawMessage)).thenReturn("""{"jsonrpc":"2.0","id":null,"result":{}}""") ecp.setClient(client) ecp.postMessage(rawMessage) shadowOf(Looper.getMainLooper()).runToEndOfTasks() verify(client).process(rawMessage) + verify(mockListener).onCheckoutViewFailedWithError( + argThat { this is ClientException }, + ) } + @Test + fun `ec error with recoverable severity does not dismiss`() { + val rawMessage = ecErrorMessage(severity = "recoverable") + ecp.setClient(mock()) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(mockListener, never()).onCheckoutViewFailedWithError(any()) + } + + @Test + fun `ec error with requires_buyer_input severity does not dismiss`() { + val rawMessage = ecErrorMessage(severity = "requires_buyer_input") + ecp.setClient(mock()) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(mockListener, never()).onCheckoutViewFailedWithError(any()) + } + + @Test + fun `ec error with requires_buyer_review severity does not dismiss`() { + val rawMessage = ecErrorMessage(severity = "requires_buyer_review") + ecp.setClient(mock()) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(mockListener, never()).onCheckoutViewFailedWithError(any()) + } + + @Test + fun `ec error dismisses when any message has unrecoverable severity`() { + val messages = """[ + |{"type":"error","code":"a","content":"x","severity":"recoverable"}, + |{"type":"error","code":"b","content":"y","severity":"unrecoverable"} + |] + """.trimMargin() + val rawMessage = ecErrorMessageWithMessages(messages) + ecp.setClient(mock()) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(mockListener).onCheckoutViewFailedWithError( + argThat { this is ClientException }, + ) + } + + @Test + fun `ec error without required messages field is ignored by typed handler`() { + val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{$ERROR_RESPONSE_UCP}}}""" + val client = CheckoutProtocol.Client() + .on(CheckoutProtocol.error) { fail("Malformed ec.error should not dispatch") } + ecp.setClient(client) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(mockListener, never()).onCheckoutViewFailedWithError(any()) + } + + // endregion + + // region delegated notifications + @Test fun `ec complete is delegated to client`() { val rawMessage = """{"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{}}}""" @@ -491,6 +594,21 @@ class EmbeddedCheckoutProtocolTest { private fun windowOpenRequest(id: String, url: String): String = """{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}""" + private fun ecErrorMessage(severity: String): String { + val messages = + """[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]""" + return ecErrorMessageWithMessages(messages) + } + + private fun ecErrorMessageWithMessages(messages: String): String { + val error = """{$ERROR_RESPONSE_UCP,"messages":$messages}""" + return """{"jsonrpc":"2.0","method":"ec.error","params":{"error":$error}}""" + } + + private companion object { + private const val ERROR_RESPONSE_UCP = """"ucp":{"version":"2026-04-08","status":"error"}""" + } + /** * Runs [block], drains the main-thread queue, captures the first JS string * passed to [CheckoutWebView.evaluateJavascript]. diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 3cd7b430..74cf8147 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -19,6 +19,31 @@ class CheckoutWebView: WKWebView { var client: (any CheckoutCommunicationProtocol)? + /// Kit-owned client that handles delegations and kit-mandated notifications. Currently: + /// - `window.open` - falls back to `UIApplication.shared.open(...)` after a + /// `canOpenURL` check (consumers may still override via their own client). + /// - `ec.error` - when the payload carries `severity: "unrecoverable"`, dismiss + /// the kit via `viewDelegate`. Per UCP spec, `unrecoverable` means no valid + /// resource exists to act on, so consumers don't have to wire dismissal in + /// every error handler. + lazy var defaultsClient: CheckoutProtocol.Client = .init() + .on(CheckoutProtocol.windowOpen) { request in + guard UIApplication.shared.canOpenURL(request.url) else { + return .rejected(reason: "canOpenURL returned false") + } + UIApplication.shared.open(request.url) + return .success + } + .on(CheckoutProtocol.error) { [weak self] payload in + guard payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return } + self?.viewDelegate?.checkoutViewDidFailWithError( + error: .checkoutUnavailable( + message: "Embedded checkout reported unrecoverable error.", + code: .clientError(code: .unknown) + ) + ) + } + static func `for`(checkout url: URL, entryPoint: MetaData.EntryPoint? = nil) -> CheckoutWebView { OSLogger.shared.debug("Creating webview for URL: \(url.absoluteString)") return CheckoutWebView(entryPoint: entryPoint) @@ -133,28 +158,31 @@ extension CheckoutWebView: WKScriptMessageHandler { } Task { - if let response = await client?.process(body) { + let isErrorNotification = CheckoutWebView.message(body, hasMethod: CheckoutProtocol.error.method) + let clientResponse = await client?.process(body) + if let response = clientResponse { checkoutBridge.sendResponse(self, messageBody: response) - return } - if let response = await CheckoutWebView.defaultsClient.process(body) { + // Defaults are fallback handlers except for ec.error: unrecoverable errors + // must still dismiss checkout after being emitted to the client. + if isErrorNotification || clientResponse == nil, + let response = await defaultsClient.process(body) + { checkoutBridge.sendResponse(self, messageBody: response) } } } - /// Kit-owned client that handles delegations the consumer did not register. - /// Today the only default is `window.open`, which falls back to - /// `UIApplication.shared.open(...)` after a `canOpenURL` check. - static let defaultsClient = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { request in - guard UIApplication.shared.canOpenURL(request.url) else { - return .rejected(reason: "canOpenURL returned false") - } - UIApplication.shared.open(request.url) - return .success + private static func message(_ body: String, hasMethod method: String) -> Bool { + guard + let object = try? JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any], + object["method"] as? String == method + else { + return false } + return true + } } extension CheckoutWebView: WKNavigationDelegate { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 56350ca1..60fc5989 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -362,13 +362,14 @@ class CheckoutWebViewTests: XCTestCase { let messages = try XCTUnwrap(resultBody["messages"] as? [[String: Any]]) XCTAssertEqual(messages.first?["content"] as? String, "consumer override") XCTAssertEqual(messages.first?["code"] as? String, "window_open_rejected_error") + XCTAssertEqual(MockCheckoutBridge.sendResponseCount, 1) } @MainActor func testDefaultsClientRejectsUnopenableScheme() async throws { let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"req-window-1","params":{"url":"unhandled-scheme://nowhere"}}"# - let raw = await CheckoutWebView.defaultsClient.process(body) + let raw = await view.defaultsClient.process(body) let response = try XCTUnwrap(raw) let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) XCTAssertEqual(parsed["id"] as? String, "req-window-1") @@ -394,6 +395,112 @@ class CheckoutWebViewTests: XCTestCase { await fulfillment(of: [notFired], timeout: 1.0) XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) } + + // MARK: - ec.error severity-based dismissal + + /// Builds a minimal valid `ec.error` payload with the given severity. `ErrorResponse` + /// requires both `messages` and `ucp` to decode — Codec routes it via the typed + /// `params.error` field, so missing fields would make the message decode to `.unknown` + /// and bypass the handler entirely. + private func ecErrorBody(severity: String) -> String { + return """ + {"jsonrpc":"2.0","method":"ec.error","params":{"error":{"ucp":{"status":"error","version":"\(CheckoutProtocol.specVersion)"},"messages":[{"type":"error","code":"session_failed","content":"Session failed","severity":"\(severity)"}]}}} + """ + } + + @MainActor + func testEcErrorWithUnrecoverableSeverityDismissesViaDelegate() async { + let dismissed = expectation(description: "viewDelegate received failure") + mockDelegate.didFailWithErrorExpectation = dismissed + view.client = nil + let message = MockScriptMessage(body: ecErrorBody(severity: "unrecoverable")) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [dismissed], timeout: 2.0) + let error = try? XCTUnwrap(mockDelegate.errorReceived) + guard case let .checkoutUnavailable(message, _) = error else { + return XCTFail("Expected checkoutUnavailable error") + } + XCTAssertEqual(message, "Embedded checkout reported unrecoverable error.") + } + + @MainActor + func testEcErrorWithRecoverableSeverityDoesNotDismiss() async { + let notDismissed = expectation(description: "viewDelegate must not receive failure") + notDismissed.isInverted = true + mockDelegate.didFailWithErrorExpectation = notDismissed + view.client = nil + let message = MockScriptMessage(body: ecErrorBody(severity: "recoverable")) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [notDismissed], timeout: 1.0) + XCTAssertNil(mockDelegate.errorReceived) + } + + @MainActor + func testEcErrorWithRequiresBuyerInputSeverityDoesNotDismiss() async { + let notDismissed = expectation(description: "viewDelegate must not receive failure") + notDismissed.isInverted = true + mockDelegate.didFailWithErrorExpectation = notDismissed + view.client = nil + let message = MockScriptMessage(body: ecErrorBody(severity: "requires_buyer_input")) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [notDismissed], timeout: 1.0) + XCTAssertNil(mockDelegate.errorReceived) + } + + @MainActor + func testEcErrorWithRequiresBuyerReviewSeverityDoesNotDismiss() async { + let notDismissed = expectation(description: "viewDelegate must not receive failure") + notDismissed.isInverted = true + mockDelegate.didFailWithErrorExpectation = notDismissed + view.client = nil + let message = MockScriptMessage(body: ecErrorBody(severity: "requires_buyer_review")) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [notDismissed], timeout: 1.0) + XCTAssertNil(mockDelegate.errorReceived) + } + + @MainActor + func testEcErrorStillForwardsToConsumerClient() async { + let consumerHandlerFired = expectation(description: "consumer handler fired") + let dismissed = expectation(description: "viewDelegate received failure") + mockDelegate.didFailWithErrorExpectation = dismissed + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.error) { _ in + consumerHandlerFired.fulfill() + } + let message = MockScriptMessage(body: ecErrorBody(severity: "unrecoverable")) + + view.userContentController(WKUserContentController(), didReceive: message) + + // Consumer handler runs first (via `view.client?.process(body)`), then the + // defaultsClient handler runs and dismisses. Both must fire. + await fulfillment(of: [consumerHandlerFired, dismissed], timeout: 2.0, enforceOrder: true) + } + + @MainActor + func testEcErrorDismissesEvenWhenConsumerClientReturnsResponse() async { + let responseSent = expectation(description: "consumer response sent") + let dismissed = expectation(description: "viewDelegate received failure") + MockCheckoutBridge.sendResponseExpectation = responseSent + mockDelegate.didFailWithErrorExpectation = dismissed + view.client = MockBridgeClient(responseMessage: #"{"jsonrpc":"2.0","id":"consumer","result":{}}"#) + let message = MockScriptMessage(body: ecErrorBody(severity: "unrecoverable")) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent, dismissed], timeout: 2.0) + guard case .checkoutUnavailable = mockDelegate.errorReceived else { + return XCTFail("Expected checkoutUnavailable error") + } + } } class LoadedRequestObservableWebView: CheckoutWebView { @@ -412,6 +519,7 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static var instrumentCalled = false static var sendMessageCalled = false static var sendResponseCalled = false + static var sendResponseCount = 0 static var lastResponseBody: String? static var sendResponseExpectation: XCTestExpectation? @@ -419,6 +527,7 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { instrumentCalled = false sendMessageCalled = false sendResponseCalled = false + sendResponseCount = 0 lastResponseBody = nil sendResponseExpectation = nil } @@ -433,6 +542,7 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static func sendResponse(_: WKWebView, messageBody: String) { sendResponseCalled = true + sendResponseCount += 1 lastResponseBody = messageBody sendResponseExpectation?.fulfill() } diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index b583b388..e392748e 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Checkout, CheckoutProtocolMessageMap, UcpErrorResponse } from "./checkout.types"; +import type { CheckoutMessageError } from "./ucp-embed-types"; import "./checkout-web-component"; import { DEFAULT_POPUP_WIDTH, @@ -543,7 +544,10 @@ describe("", () => { const { checkout, mockWindow } = openWithRealOverlay(); const link = checkout.shadowRoot!.querySelector("#overlay-link")!; - const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); link.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); @@ -713,7 +717,9 @@ describe("", () => { const listenForEvent = waitForEvent(checkout, "checkout:start", onStartSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow }); + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -744,7 +750,7 @@ describe("", () => { const onErrorSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "checkout:error", onErrorSpy); - const errorPayload = makeErrorPayload(); + const errorPayload = makeErrorPayload({ severity: "recoverable" }); simulateProtocolMessageEvent(checkout, "ec.error", errorPayload, { source: mockCheckoutWindow, }); @@ -753,6 +759,44 @@ describe("", () => { expect(checkout.error).toStrictEqual(errorPayload); expect(onErrorSpy).toHaveBeenCalledOnce(); }); + + it("auto-closes when any message has severity 'unrecoverable'", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const errorOrder: string[] = []; + checkout.addEventListener("checkout:error", () => errorOrder.push("error")); + checkout.addEventListener("checkout:close", () => errorOrder.push("close")); + + simulateProtocolMessageEvent( + checkout, + "ec.error", + makeErrorPayload({ severity: "unrecoverable" }), + { source: mockCheckoutWindow }, + ); + await Promise.resolve(); + + expect(errorOrder).toStrictEqual(["error", "close"]); + }); + + const NON_FATAL_SEVERITIES: ReadonlyArray = [ + "recoverable", + "requires_buyer_input", + "requires_buyer_review", + ]; + it.each(NON_FATAL_SEVERITIES)( + "does not auto-close when severity is %s", + async (severity: CheckoutMessageError["severity"]) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const closeSpy = vi.fn(); + checkout.addEventListener("checkout:close", closeSpy); + + simulateProtocolMessageEvent(checkout, "ec.error", makeErrorPayload({ severity }), { + source: mockCheckoutWindow, + }); + await Promise.resolve(); + + expect(closeSpy).not.toHaveBeenCalled(); + }, + ); }); describe("checkout:lineItemsChange", () => { @@ -838,7 +882,9 @@ describe("", () => { const wait = waitForEvent(checkout, "checkout:start", spy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow }); + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + }); await wait; const event = spy.mock.calls[0]![0] as CustomEvent; @@ -850,7 +896,10 @@ describe("", () => { const spy = vi.fn(); const wait = waitForEvent(checkout, "checkout:complete", spy); - const order = { id: "order-1", permalink_url: "https://example.com/orders/1" }; + const order = { + id: "order-1", + permalink_url: "https://example.com/orders/1", + }; const payload = makeCheckoutPayload({ order }); simulateProtocolMessageEvent(checkout, "ec.complete", payload, { source: mockCheckoutWindow, @@ -1266,7 +1315,9 @@ describe("", () => { }); it("includes ck_version on the overlay link", () => { - const checkout = renderCheckout({ src: "https://shop.example.com/checkout" }); + const checkout = renderCheckout({ + src: "https://shop.example.com/checkout", + }); const link = checkout.shadowRoot!.querySelector("#overlay-link"); const url = new URL(link!.getAttribute("href") ?? ""); expect(url.searchParams.get("ck_version")).toBe(CK_VERSION); @@ -1508,7 +1559,10 @@ function renderCheckout(attributes: Record = {}) { function mockWindowSize(width = 1200, height = 800) { Object.defineProperty(window, "outerWidth", { value: width, writable: true }); - Object.defineProperty(window, "outerHeight", { value: height, writable: true }); + Object.defineProperty(window, "outerHeight", { + value: height, + writable: true, + }); Object.defineProperty(window, "screenLeft", { value: 0, writable: true }); Object.defineProperty(window, "screenTop", { value: 0, writable: true }); Object.defineProperty(document.documentElement, "clientWidth", { @@ -1577,7 +1631,9 @@ function makeCheckoutPayload(overrides: Partial = {}): { }; } -function makeErrorPayload(): UcpErrorResponse { +function makeErrorPayload(overrides?: { + severity?: CheckoutMessageError["severity"]; +}): UcpErrorResponse { return { ucp: { version: "2026-04-08", status: "error" }, messages: [ @@ -1585,7 +1641,7 @@ function makeErrorPayload(): UcpErrorResponse { type: "error", code: "session_failed", content: "Session failed", - severity: "unrecoverable", + severity: overrides?.severity ?? "unrecoverable", }, ], }; diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 3098373e..4cf1e6ea 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -556,6 +556,11 @@ export class ShopifyCheckout const error = message.body as CheckoutProtocolMessageMap["ec.error"]; this.#error = error; this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error })); + // Per UCP spec, `unrecoverable` means no valid resource exists to act on — + // the kit closes so consumers don't have to wire dismissal in every handler. + if (error.messages.some((m) => m.severity === "unrecoverable")) { + this.close(); + } break; } case "ec.line_items.change": {