Skip to content

Commit ab99908

Browse files
committed
dismiss on unrecoverable
1 parent 0e821b5 commit ab99908

7 files changed

Lines changed: 323 additions & 50 deletions

File tree

platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
*/
2323
package com.shopify.checkoutkit
2424

25-
import android.content.Context
2625
import android.webkit.JavascriptInterface
2726
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
2827
import kotlinx.serialization.Serializable
@@ -51,7 +50,7 @@ internal class EmbeddedCheckoutProtocol(
5150
@Volatile private var client: CheckoutCommunicationClient? = null,
5251
) {
5352
private val decoder = Json { ignoreUnknownKeys = true }
54-
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient(view.context)
53+
private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient()
5554

5655
internal fun setClient(client: CheckoutCommunicationClient?) {
5756
this.client = client
@@ -132,12 +131,23 @@ internal class EmbeddedCheckoutProtocol(
132131
defaultClient.process(message)?.let { sendRaw(it) }
133132
}
134133

134+
/**
135+
* Dispatch a message through the consumer client; when the consumer returns no response,
136+
* fall through to the kit-owned [defaultClient]. For notifications (e.g. `ec.error`), both
137+
* clients return `null`, so each gets to fire its own typed handler — the consumer's
138+
* `on(CheckoutProtocol.error) { ... }` runs first, then the kit's default error handler
139+
* decides whether to dismiss based on severity.
140+
*/
135141
private fun handleClientMessage(method: String, message: String) {
136142
log.d(LOG_TAG, "Delegating $method to client.")
137143
onMainThread {
138144
val response = client?.process(message)
139145
log.d(LOG_TAG, " client response: $response")
140-
response?.let { sendRaw(it) }
146+
if (response != null) {
147+
sendRaw(response)
148+
} else {
149+
defaultClient.process(message)?.let { sendRaw(it) }
150+
}
141151
}
142152
}
143153

@@ -166,6 +176,39 @@ internal class EmbeddedCheckoutProtocol(
166176
}
167177
}
168178

179+
/**
180+
* Kit-owned client that handles delegations and notifications the consumer did not
181+
* register, mirroring Swift's `defaultsClient`. Currently:
182+
* - [CheckoutProtocol.windowOpen] — launches the URI via `Intent.ACTION_VIEW`, or
183+
* returns [WindowOpenResult.Rejected] with `window_open_rejected_error` semantics.
184+
* - [CheckoutProtocol.error] — when the first message carries `severity:
185+
* "unrecoverable"`, dismiss the kit via the listener. Per UCP spec, `unrecoverable`
186+
* means no valid resource exists to act on, so consumers don't have to wire dismissal
187+
* in every error handler. Inspects the full `messages` list — any message with
188+
* `severity: "unrecoverable"` triggers dismissal.
189+
*/
190+
private fun defaultDelegationClient(): CheckoutProtocol.Client =
191+
CheckoutProtocol.Client()
192+
.on(CheckoutProtocol.windowOpen) { request ->
193+
when (val result = ExternalUriLauncher.launch(view.context, request.url)) {
194+
is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success
195+
is ExternalUriLauncher.Result.Rejected -> {
196+
log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}")
197+
WindowOpenResult.Rejected(reason = result.reason)
198+
}
199+
}
200+
}
201+
.on(CheckoutProtocol.error) { payload ->
202+
if (payload.messages.none { it.severity == "unrecoverable" }) return@on
203+
log.d(LOG_TAG, "ec.error unrecoverable — dismissing checkout via event processor")
204+
view.getListener().onCheckoutViewFailedWithError(
205+
ClientException(
206+
errorDescription = "Embedded checkout reported unrecoverable error.",
207+
isRecoverable = false,
208+
),
209+
)
210+
}
211+
169212
companion object {
170213
private val LOG_TAG = BaseWebView.ECP_LOG_TAG
171214

@@ -197,24 +240,6 @@ internal class EmbeddedCheckoutProtocol(
197240

198241
private const val CODE_PARSE_ERROR = -32700
199242
private const val CODE_METHOD_NOT_SUPPORTED = -32601
200-
201-
/**
202-
* The kit's default [CheckoutProtocol.windowOpen] handler.
203-
*
204-
* Mirrors Swift's `defaultsClient`: launches the URI via `Intent.ACTION_VIEW`
205-
* if any activity resolves it, otherwise returns [WindowOpenResult.Rejected]
206-
* with `window_open_rejected_error` semantics.
207-
*/
208-
internal fun defaultDelegationClient(context: Context): CheckoutProtocol.Client =
209-
CheckoutProtocol.Client().on(CheckoutProtocol.windowOpen) { request ->
210-
when (val result = ExternalUriLauncher.launch(context, request.url)) {
211-
is ExternalUriLauncher.Result.Launched -> WindowOpenResult.Success
212-
is ExternalUriLauncher.Result.Rejected -> {
213-
log.d(LOG_TAG, "window.open rejected for ${request.url}: ${result.reason}")
214-
WindowOpenResult.Rejected(reason = result.reason)
215-
}
216-
}
217-
}
218243
}
219244
}
220245

platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.junit.Test
3434
import org.junit.runner.RunWith
3535
import org.mockito.Mockito
3636
import org.mockito.kotlin.any
37+
import org.mockito.kotlin.argThat
3738
import org.mockito.kotlin.argumentCaptor
3839
import org.mockito.kotlin.isNull
3940
import org.mockito.kotlin.mock
@@ -334,11 +335,11 @@ class EmbeddedCheckoutProtocolTest {
334335

335336
// endregion
336337

337-
// region delegated notifications
338+
// region ec.error — severity-driven dismissal
338339

339340
@Test
340-
fun `ec error is delegated to client`() {
341-
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"error":{"code":-1,"message":"fail"}}}"""
341+
fun `ec error is forwarded to client regardless of severity`() {
342+
val rawMessage = ecErrorMessage(severity = "recoverable")
342343
val client = mock<CheckoutCommunicationClient>()
343344
ecp.setClient(client)
344345

@@ -348,6 +349,88 @@ class EmbeddedCheckoutProtocolTest {
348349
verify(client).process(rawMessage)
349350
}
350351

352+
@Test
353+
fun `ec error with unrecoverable severity dismisses via listener`() {
354+
val rawMessage = ecErrorMessage(severity = "unrecoverable")
355+
val client = mock<CheckoutCommunicationClient>()
356+
ecp.setClient(client)
357+
358+
ecp.postMessage(rawMessage)
359+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
360+
361+
verify(client).process(rawMessage)
362+
val captor = argumentCaptor<CheckoutException>()
363+
verify(mockListener).onCheckoutViewFailedWithError(captor.capture())
364+
assertThat(captor.firstValue).isInstanceOf(ClientException::class.java)
365+
assertThat(captor.firstValue.isRecoverable).isFalse()
366+
}
367+
368+
@Test
369+
fun `ec error with recoverable severity does not dismiss`() {
370+
val rawMessage = ecErrorMessage(severity = "recoverable")
371+
ecp.setClient(mock())
372+
373+
ecp.postMessage(rawMessage)
374+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
375+
376+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
377+
}
378+
379+
@Test
380+
fun `ec error with requires_buyer_input severity does not dismiss`() {
381+
val rawMessage = ecErrorMessage(severity = "requires_buyer_input")
382+
ecp.setClient(mock())
383+
384+
ecp.postMessage(rawMessage)
385+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
386+
387+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
388+
}
389+
390+
@Test
391+
fun `ec error with requires_buyer_review severity does not dismiss`() {
392+
val rawMessage = ecErrorMessage(severity = "requires_buyer_review")
393+
ecp.setClient(mock())
394+
395+
ecp.postMessage(rawMessage)
396+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
397+
398+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
399+
}
400+
401+
@Test
402+
fun `ec error dismisses when any message has unrecoverable severity`() {
403+
val messages = """[
404+
|{"type":"error","code":"a","content":"x","severity":"recoverable"},
405+
|{"type":"error","code":"b","content":"y","severity":"unrecoverable"}
406+
|]
407+
""".trimMargin()
408+
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}"""
409+
ecp.setClient(mock())
410+
411+
ecp.postMessage(rawMessage)
412+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
413+
414+
verify(mockListener).onCheckoutViewFailedWithError(
415+
argThat { (this as? ClientException)?.isRecoverable == false },
416+
)
417+
}
418+
419+
@Test
420+
fun `ec error without messages field does not dismiss`() {
421+
val rawMessage = """{"jsonrpc":"2.0","method":"ec.error","params":{}}"""
422+
ecp.setClient(mock())
423+
424+
ecp.postMessage(rawMessage)
425+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
426+
427+
verify(mockListener, never()).onCheckoutViewFailedWithError(any())
428+
}
429+
430+
// endregion
431+
432+
// region delegated notifications
433+
351434
@Test
352435
fun `ec complete is delegated to client`() {
353436
val rawMessage = """{"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{}}}"""
@@ -464,6 +547,12 @@ class EmbeddedCheckoutProtocolTest {
464547
private fun windowOpenRequest(id: String, url: String): String =
465548
"""{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}"""
466549

550+
private fun ecErrorMessage(severity: String): String {
551+
val messages =
552+
"""[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]"""
553+
return """{"jsonrpc":"2.0","method":"ec.error","params":{"messages":$messages}}"""
554+
}
555+
467556
/**
468557
* Runs [block], drains the main-thread queue, captures the first JS string
469558
* passed to [CheckoutWebView.evaluateJavascript].

platforms/android/samples/MobileBuyIntegration/.kotlin/sessions/kotlin-compiler-5704622805265865419.salive

Whitespace-only changes.

platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ class CheckoutWebView: WKWebView {
4949

5050
var client: (any CheckoutCommunicationProtocol)?
5151

52+
/// Kit-owned client that handles delegations and notifications the consumer did not
53+
/// register. Currently:
54+
/// - `window.open` — falls back to `UIApplication.shared.open(...)` after a
55+
/// `canOpenURL` check (consumers may still override via their own client).
56+
/// - `ec.error` — when the payload carries `severity: "unrecoverable"`, dismiss
57+
/// the kit via `viewDelegate`. Per UCP spec, `unrecoverable` means no valid
58+
/// resource exists to act on, so consumers don't have to wire dismissal in
59+
/// every error handler.
60+
lazy var defaultsClient: CheckoutProtocol.Client = .init()
61+
.on(CheckoutProtocol.windowOpen) { request in
62+
guard UIApplication.shared.canOpenURL(request.url) else {
63+
return .rejected(reason: "canOpenURL returned false")
64+
}
65+
UIApplication.shared.open(request.url)
66+
return .success
67+
}
68+
.on(CheckoutProtocol.error) { [weak self] payload in
69+
guard payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return }
70+
self?.viewDelegate?.checkoutViewDidFailWithError(
71+
error: .checkoutUnavailable(
72+
message: "Embedded checkout reported unrecoverable error.",
73+
code: .clientError(code: .unknown),
74+
recoverable: false
75+
)
76+
)
77+
}
78+
5279
var isRecovery = false {
5380
didSet {
5481
isBridgeAttached = false
@@ -256,26 +283,11 @@ extension CheckoutWebView: WKScriptMessageHandler {
256283
Task {
257284
if let response = await client?.process(body) {
258285
checkoutBridge.sendResponse(self, messageBody: response)
259-
return
260-
}
261-
262-
if let response = await CheckoutWebView.defaultsClient.process(body) {
286+
} else if let response = await defaultsClient.process(body) {
263287
checkoutBridge.sendResponse(self, messageBody: response)
264288
}
265289
}
266290
}
267-
268-
/// Kit-owned client that handles delegations the consumer did not register.
269-
/// Today the only default is `window.open`, which falls back to
270-
/// `UIApplication.shared.open(...)` after a `canOpenURL` check.
271-
static let defaultsClient = CheckoutProtocol.Client()
272-
.on(CheckoutProtocol.windowOpen) { request in
273-
guard UIApplication.shared.canOpenURL(request.url) else {
274-
return .rejected(reason: "canOpenURL returned false")
275-
}
276-
UIApplication.shared.open(request.url)
277-
return .success
278-
}
279291
}
280292

281293
extension CheckoutWebView: WKNavigationDelegate {

platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ class CheckoutWebViewTests: XCTestCase {
489489
func testDefaultsClientRejectsUnopenableScheme() async throws {
490490
let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"req-window-1","params":{"url":"unhandled-scheme://nowhere"}}"#
491491

492-
let raw = await CheckoutWebView.defaultsClient.process(body)
492+
let raw = await view.defaultsClient.process(body)
493493
let response = try XCTUnwrap(raw)
494494
let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any])
495495
XCTAssertEqual(parsed["id"] as? String, "req-window-1")
@@ -515,6 +515,92 @@ class CheckoutWebViewTests: XCTestCase {
515515
await fulfillment(of: [notFired], timeout: 1.0)
516516
XCTAssertFalse(MockCheckoutBridge.sendResponseCalled)
517517
}
518+
519+
// MARK: - ec.error severity-based dismissal
520+
521+
/// Builds a minimal valid `ec.error` payload with the given severity. `ErrorResponse`
522+
/// requires both `messages` and `ucp` to decode — Codec routes it via the typed
523+
/// `params.error` field, so missing fields would make the message decode to `.unknown`
524+
/// and bypass the handler entirely.
525+
private func ecErrorBody(severity: String) -> String {
526+
return """
527+
{"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)"}]}}}
528+
"""
529+
}
530+
531+
@MainActor
532+
func testEcErrorWithUnrecoverableSeverityDismissesViaDelegate() async {
533+
let dismissed = expectation(description: "viewDelegate received failure")
534+
mockDelegate.didFailWithErrorExpectation = dismissed
535+
view.client = nil
536+
let message = MockScriptMessage(body: ecErrorBody(severity: "unrecoverable"))
537+
538+
view.userContentController(WKUserContentController(), didReceive: message)
539+
540+
await fulfillment(of: [dismissed], timeout: 2.0)
541+
let error = try? XCTUnwrap(mockDelegate.errorReceived)
542+
XCTAssertEqual(error?.isRecoverable, false, "Unrecoverable ec.error must not be recoverable")
543+
}
544+
545+
@MainActor
546+
func testEcErrorWithRecoverableSeverityDoesNotDismiss() async {
547+
let notDismissed = expectation(description: "viewDelegate must not receive failure")
548+
notDismissed.isInverted = true
549+
mockDelegate.didFailWithErrorExpectation = notDismissed
550+
view.client = nil
551+
let message = MockScriptMessage(body: ecErrorBody(severity: "recoverable"))
552+
553+
view.userContentController(WKUserContentController(), didReceive: message)
554+
555+
await fulfillment(of: [notDismissed], timeout: 1.0)
556+
XCTAssertNil(mockDelegate.errorReceived)
557+
}
558+
559+
@MainActor
560+
func testEcErrorWithRequiresBuyerInputSeverityDoesNotDismiss() async {
561+
let notDismissed = expectation(description: "viewDelegate must not receive failure")
562+
notDismissed.isInverted = true
563+
mockDelegate.didFailWithErrorExpectation = notDismissed
564+
view.client = nil
565+
let message = MockScriptMessage(body: ecErrorBody(severity: "requires_buyer_input"))
566+
567+
view.userContentController(WKUserContentController(), didReceive: message)
568+
569+
await fulfillment(of: [notDismissed], timeout: 1.0)
570+
XCTAssertNil(mockDelegate.errorReceived)
571+
}
572+
573+
@MainActor
574+
func testEcErrorWithRequiresBuyerReviewSeverityDoesNotDismiss() async {
575+
let notDismissed = expectation(description: "viewDelegate must not receive failure")
576+
notDismissed.isInverted = true
577+
mockDelegate.didFailWithErrorExpectation = notDismissed
578+
view.client = nil
579+
let message = MockScriptMessage(body: ecErrorBody(severity: "requires_buyer_review"))
580+
581+
view.userContentController(WKUserContentController(), didReceive: message)
582+
583+
await fulfillment(of: [notDismissed], timeout: 1.0)
584+
XCTAssertNil(mockDelegate.errorReceived)
585+
}
586+
587+
@MainActor
588+
func testEcErrorStillForwardsToConsumerClient() async {
589+
let consumerHandlerFired = expectation(description: "consumer handler fired")
590+
let dismissed = expectation(description: "viewDelegate received failure")
591+
mockDelegate.didFailWithErrorExpectation = dismissed
592+
view.client = CheckoutProtocol.Client()
593+
.on(CheckoutProtocol.error) { _ in
594+
consumerHandlerFired.fulfill()
595+
}
596+
let message = MockScriptMessage(body: ecErrorBody(severity: "unrecoverable"))
597+
598+
view.userContentController(WKUserContentController(), didReceive: message)
599+
600+
// Consumer handler runs first (via `view.client?.process(body)`), then the
601+
// defaultsClient handler runs and dismisses. Both must fire.
602+
await fulfillment(of: [consumerHandlerFired, dismissed], timeout: 2.0, enforceOrder: true)
603+
}
518604
}
519605

520606
class LoadedRequestObservableWebView: CheckoutWebView {

0 commit comments

Comments
 (0)