diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt index 81772024..dbdd7e72 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt @@ -26,6 +26,7 @@ import android.net.Uri import android.os.Looper import androidx.core.net.toUri import com.shopify.checkoutkit.ShopifyCheckoutKit.log +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -69,16 +70,7 @@ public object CheckoutProtocol { public val totalsChange: NotificationDescriptor = checkoutDescriptor("ec.totals.change") public val error: NotificationDescriptor = NotificationDescriptor( method = "ec.error", - decode = { params -> - params?.jsonObject?.get("error")?.let { - try { - json.decodeFromJsonElement(it) - } catch (e: Exception) { - log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$it") - null - } - } - } + decode = ::decodeErrorResponse, ) // Delegations — kit-internal. Consumers cannot override; [EmbeddedCheckoutProtocol] @@ -109,6 +101,30 @@ public object CheckoutProtocol { } ) + private fun decodeErrorResponse(params: JsonElement?): ErrorResponse? { + val paramsObject = params as? JsonObject ?: return null + val payload = paramsObject["error"] ?: paramsObject + return try { + val rawError = json.decodeFromJsonElement(payload) + rawError.toErrorResponse() + } catch (e: Exception) { + log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$payload") + null + } + } + + private fun RawErrorResponse.toErrorResponse(): ErrorResponse? { + val messages = messages ?: return null + return ErrorResponse( + continueURL = continueURL, + messages = messages, + ucp = ucp ?: ErrorResponseUcp( + status = StatusEnum.Error, + version = specVersion, + ), + ) + } + private fun encodeWindowOpenResult(result: WindowOpenResult): JsonObject = when (result) { is WindowOpenResult.Success -> json.encodeToJsonElement( @@ -246,6 +262,14 @@ public object CheckoutProtocol { private const val LOG_TAG = BaseWebView.ECP_LOG_TAG private const val CODE_INVALID_PARAMS = -32602 + @Serializable + private data class RawErrorResponse( + @SerialName("continue_url") + val continueURL: String? = null, + val messages: List? = null, + val ucp: ErrorResponseUcp? = null, + ) + private fun jsonRpcResult(id: JsonElement?, result: JsonElement): String = json.encodeToString( JsonObject.serializer(), 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 d8e923bc..976a958c 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 @@ -22,7 +22,6 @@ */ package com.shopify.checkoutkit -import android.content.Context import android.webkit.JavascriptInterface import com.shopify.checkoutkit.ShopifyCheckoutKit.log import kotlinx.serialization.Serializable @@ -51,7 +50,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 @@ -132,12 +131,23 @@ internal class EmbeddedCheckoutProtocol( defaultClient.process(message)?.let { sendRaw(it) } } + /** + * Dispatch a message through the consumer client; when the consumer returns no response, + * fall through to the kit-owned [defaultClient]. For notifications (e.g. `ec.error`), both + * clients return `null`, so each gets to fire its own typed handler — the consumer's + * `on(CheckoutProtocol.error) { ... }` runs first, then the kit's default error handler + * decides whether to dismiss based on severity. + */ 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 (response != null) { + sendRaw(response) + } else { + defaultClient.process(message)?.let { sendRaw(it) } + } } } @@ -166,6 +176,39 @@ internal class EmbeddedCheckoutProtocol( } } + /** + * Kit-owned client that handles delegations and notifications the consumer did not + * register, 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 the first 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. Inspects the full `messages` list — any message with + * `severity: "unrecoverable"` triggers dismissal. + */ + 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.", + isRecoverable = false, + ), + ) + } + companion object { private val LOG_TAG = BaseWebView.ECP_LOG_TAG @@ -197,24 +240,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/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt index 1aa4b403..df33643f 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 @@ -34,6 +34,7 @@ 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 @@ -334,11 +335,11 @@ class EmbeddedCheckoutProtocolTest { // endregion - // region delegated notifications + // region ec.error — severity-driven dismissal @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 is forwarded to client regardless of severity`() { + val rawMessage = ecErrorMessage(severity = "recoverable") val client = mock() ecp.setClient(client) @@ -348,6 +349,88 @@ class EmbeddedCheckoutProtocolTest { 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.isRecoverable).isFalse() + } + + @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 = """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}""" + ecp.setClient(mock()) + + ecp.postMessage(rawMessage) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + verify(mockListener).onCheckoutViewFailedWithError( + argThat { (this as? ClientException)?.isRecoverable == false }, + ) + } + + @Test + fun `ec error without messages field does not dismiss`() { + val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{}}""" + ecp.setClient(mock()) + + 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":{}}}""" @@ -464,6 +547,12 @@ 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 """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}""" + } + /** * Runs [block], drains the main-thread queue, captures the first JS string * passed to [CheckoutWebView.evaluateJavascript]. diff --git a/platforms/android/samples/MobileBuyIntegration/.kotlin/sessions/kotlin-compiler-5704622805265865419.salive b/platforms/android/samples/MobileBuyIntegration/.kotlin/sessions/kotlin-compiler-5704622805265865419.salive deleted file mode 100644 index e69de29b..00000000 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index fcba9431..75b94a47 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -49,6 +49,33 @@ class CheckoutWebView: WKWebView { var client: (any CheckoutCommunicationProtocol)? + /// Kit-owned client that handles delegations and notifications the consumer did not + /// register. 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), + recoverable: false + ) + ) + } + var isRecovery = false { didSet { isBridgeAttached = false @@ -256,26 +283,11 @@ extension CheckoutWebView: WKScriptMessageHandler { Task { if let response = await client?.process(body) { checkoutBridge.sendResponse(self, messageBody: response) - return - } - - if let response = await CheckoutWebView.defaultsClient.process(body) { + } else if 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 - } } extension CheckoutWebView: WKNavigationDelegate { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 1a30425a..7345f86b 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -489,7 +489,7 @@ class CheckoutWebViewTests: XCTestCase { 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") @@ -515,6 +515,92 @@ 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) + XCTAssertEqual(error?.isRecoverable, false, "Unrecoverable ec.error must not be recoverable") + } + + @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) + } } class LoadedRequestObservableWebView: CheckoutWebView { 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 3e393904..19def09c 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -579,6 +579,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": {