From dd7d82d635f8ec721491dfecdc34e0b104fb3116 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 14 Aug 2026 15:14:20 +0100 Subject: [PATCH] Decorate checkout URLs in CheckoutViewController Assisted-By: devx/e431cd55-e9d8-49d7-9154-66877c8dda59 --- .../CheckoutViewController.swift | 12 ++-- .../ShopifyCheckoutKit.swift | 6 +- .../CheckoutProtocolTests.swift | 12 ++++ .../CheckoutURLDecoratorTests.swift | 15 +++-- .../CheckoutURLTestAssertions.swift | 34 ++++++++++ .../ShopifyCheckoutKitTests.swift | 26 +++++++- .../SwiftUITests.swift | 63 ++++++++++++++++--- 7 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLTestAssertions.swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index dbc701305..bb4f131ca 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -7,14 +7,16 @@ import UIKit @MainActor public class CheckoutViewController: UINavigationController { public init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) { - let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: nil) + let decoratedURL = CheckoutURLDecorator.decorate(url) + let rootViewController = CheckoutWebViewController(checkoutURL: decoratedURL, delegate: delegate, client: client, entryPoint: nil) super.init(rootViewController: rootViewController) configureNavigationBar() presentationController?.delegate = rootViewController } package init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { - let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: entryPoint) + let decoratedURL = CheckoutURLDecorator.decorate(url) + let rootViewController = CheckoutWebViewController(checkoutURL: decoratedURL, delegate: delegate, client: client, entryPoint: entryPoint) super.init(rootViewController: rootViewController) configureNavigationBar() presentationController?.delegate = rootViewController @@ -48,12 +50,8 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab checkoutURL = url } - var decoratedCheckoutURL: URL { - CheckoutURLDecorator.decorate(checkoutURL) - } - public func makeUIViewController(context _: Self.Context) -> CheckoutViewController { - let viewController = CheckoutViewController(checkout: decoratedCheckoutURL, client: client) + let viewController = CheckoutViewController(checkout: checkoutURL, client: client) configureWebViewController(viewController) return viewController } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index c02196eff..1535ba2ea 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -65,8 +65,7 @@ public func invalidate() { @MainActor @discardableResult public func present(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { - let decorated = CheckoutURLDecorator.decorate(url) - let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client) + let viewController = CheckoutViewController(checkout: url, delegate: delegate, client: client) from.present(viewController, animated: true) return viewController } @@ -74,8 +73,7 @@ public func present(checkout url: URL, from: UIViewController, delegate: (any Ch @MainActor @discardableResult package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { - let decorated = CheckoutURLDecorator.decorate(url) - let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client, entryPoint: entryPoint) + let viewController = CheckoutViewController(checkout: url, delegate: delegate, client: client, entryPoint: entryPoint) from.present(viewController, animated: true) return viewController } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift index 658fb5ef7..c55e90ccf 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutProtocolTests.swift @@ -11,6 +11,18 @@ struct CheckoutProtocolTests { #expect(CheckoutProtocol.defaultDelegations == ["window.open"]) } + @Test func urlDecoratesCheckoutForEmbeddedProtocol() throws { + let url = try #require(URL(string: "https://shop.com/cart/c/abc?key=cart_token")) + let decoratedURL = CheckoutProtocol.url(for: url) + let items = try #require(URLComponents(url: decoratedURL, resolvingAgainstBaseURL: false)?.queryItems) + + #expect(items.filter { $0.name == "key" }.map(\.value) == ["cart_token"]) + #expect(items.filter { $0.name == "ec_version" }.map(\.value) == [EmbeddedCheckoutProtocol.specVersion]) + #expect(items.filter { $0.name == "ec_delegate" }.map(\.value) == ["window.open"]) + #expect(items.filter { $0.name == "ec_color_scheme" }.count == 1) + #expect(items.filter { $0.name == "ck_branding" }.count == 1) + } + @Test func supportedProtocolMethodsCoverReadyCuratedNotificationsAndWindowOpen() { #expect(CheckoutProtocol.supportedProtocolMethods == [ "ec.ready", diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift index 5e63b53e2..d49bb873a 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift @@ -1,3 +1,4 @@ +import EmbeddedCheckoutProtocol import Foundation @testable import ShopifyCheckoutKit import Testing @@ -11,22 +12,26 @@ struct CheckoutURLDecoratorTests { let url = try #require(URL(string: "https://shop.com/cart/c/abc?key=cart_token")) let items = queryItems(CheckoutURLDecorator.decorate(url, configuration: configuration)) - #expect(items.first(where: { $0.name == "key" })?.value == "cart_token") - #expect(items.first(where: { $0.name == "ec_color_scheme" })?.value == "dark") - #expect(items.first(where: { $0.name == "ck_branding" })?.value == "app") + #expect(items.filter { $0.name == "key" }.map(\.value) == ["cart_token"]) + #expect(items.filter { $0.name == "ec_version" }.map(\.value) == [EmbeddedCheckoutProtocol.specVersion]) + #expect(items.filter { $0.name == "ec_delegate" }.map(\.value) == ["window.open"]) + #expect(items.filter { $0.name == "ec_color_scheme" }.map(\.value) == ["dark"]) + #expect(items.filter { $0.name == "ck_branding" }.map(\.value) == ["app"]) } @Test func replacesCallerSuppliedBrandingAndIsIdempotent() throws { var configuration = Configuration() configuration.appearance = .app(.light) - let url = try #require(URL(string: "https://shop.com/cart/c/abc?ck_branding=app&ec_color_scheme=dark")) + let url = try #require(URL(string: "https://shop.com/cart/c/abc?ec_version=stale&ec_delegate=custom&ck_branding=shop&ec_color_scheme=dark")) let once = CheckoutURLDecorator.decorate(url, configuration: configuration) let twice = CheckoutURLDecorator.decorate(once, configuration: configuration) let items = queryItems(twice) - #expect(items.filter { $0.name == "ck_branding" }.map(\.value) == ["app"]) + #expect(items.filter { $0.name == "ec_version" }.map(\.value) == [EmbeddedCheckoutProtocol.specVersion]) + #expect(items.filter { $0.name == "ec_delegate" }.map(\.value) == ["window.open"]) #expect(items.filter { $0.name == "ec_color_scheme" }.map(\.value) == ["light"]) + #expect(items.filter { $0.name == "ck_branding" }.map(\.value) == ["app"]) } @Test func derivesCheckoutParamsForEachAppearance() throws { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLTestAssertions.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLTestAssertions.swift new file mode 100644 index 000000000..a433bbfab --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLTestAssertions.swift @@ -0,0 +1,34 @@ +import EmbeddedCheckoutProtocol +import Foundation +@testable import ShopifyCheckoutKit +import XCTest + +func assertDecoratedCheckoutURL( + _ url: URL?, + colorScheme: String = "dark", + branding: String = "app", + file: StaticString = #filePath, + line: UInt = #line +) throws { + let url = try XCTUnwrap(url, file: file, line: line) + let queryItems = try XCTUnwrap( + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + file: file, + line: line + ) + + XCTAssertEqual(queryItems.filter { $0.name == "ec_version" }.map(\.value), [EmbeddedCheckoutProtocol.specVersion], file: file, line: line) + XCTAssertEqual(queryItems.filter { $0.name == "ec_delegate" }.map(\.value), ["window.open"], file: file, line: line) + XCTAssertEqual(queryItems.filter { $0.name == "ec_color_scheme" }.map(\.value), [colorScheme], file: file, line: line) + XCTAssertEqual(queryItems.filter { $0.name == "ck_branding" }.map(\.value), [branding], file: file, line: line) + XCTAssertEqual(queryItems.filter { $0.name == "key" }.map(\.value), ["cart_token"], file: file, line: line) +} + +@MainActor +func loadedCheckoutURL(from viewController: CheckoutViewController) throws -> URL { + let webViewController = try XCTUnwrap( + viewController.viewControllers.compactMap { $0 as? CheckoutWebViewController }.first + ) + webViewController.loadViewIfNeeded() + return try XCTUnwrap(webViewController.checkoutView?.loadedCheckoutURL) +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift index 5e826f875..188fc7b8a 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift @@ -3,13 +3,14 @@ import XCTest @MainActor class ShopifyCheckoutKitTests: XCTestCase { - let checkoutURL = URL(string: "https://shop.example/checkouts/cn/123")! + let checkoutURL = URL(string: "https://shop.example/checkouts/cn/123?key=cart_token")! private var originalConfiguration: Configuration! override func setUp() async throws { try await super.setUp() originalConfiguration = ShopifyCheckoutKit.configuration + ShopifyCheckoutKit.configuration.appearance = .app(.dark) CheckoutWebView.invalidate() } @@ -63,6 +64,15 @@ class ShopifyCheckoutKitTests: XCTestCase { ) } + func test_present_decoratesCheckoutURL() throws { + let viewController = ShopifyCheckoutKit.present( + checkout: checkoutURL, + from: UIViewController() + ) + + try assertDecoratedCheckoutURL(loadedCheckoutURL(from: viewController)) + } + func test_present_propagatesDelegateAndClientToWebViewController() throws { let delegate = MockCheckoutDelegate() let client = MockBridgeClient() @@ -118,6 +128,20 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertNil(ShopifyCheckoutKit.preload(checkout: checkoutURL)) } + func test_preload_decoratesCheckoutURL() throws { + ShopifyCheckoutKit.configuration.preloading.enabled = true + let preload = ShopifyCheckoutKit.preload(checkout: checkoutURL) + let expectedURL = CheckoutURLDecorator.decorate(checkoutURL) + let checkoutView = try XCTUnwrap( + CheckoutWebView.preloadCache.view( + for: PreloadKey(url: expectedURL, entryPoint: nil) + ) + ) + + try assertDecoratedCheckoutURL(checkoutView.loadedCheckoutURL) + withExtendedLifetime(preload) {} + } + func test_preload_returnsPreloadWhenEnabled() { ShopifyCheckoutKit.configuration.preloading.enabled = true let preload = ShopifyCheckoutKit.preload(checkout: checkoutURL) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift index 12b2b5909..baea8a8ed 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift @@ -1,4 +1,5 @@ @testable import ShopifyCheckoutKit +import SwiftUI import XCTest @MainActor @@ -8,13 +9,34 @@ class CheckoutViewControllerTests: XCTestCase { override func setUp() async throws { try await super.setUp() - checkoutURL = URL(string: "https://www.shopify.com") + ShopifyCheckoutKit.configuration = Configuration() + ShopifyCheckoutKit.configuration.appearance = .app(.dark) + ShopifyCheckoutKit.configuration.preloading.enabled = false + checkoutURL = URL(string: "https://www.shopify.com?key=cart_token") checkoutViewController = CheckoutViewController(checkout: checkoutURL) } + override func tearDown() async throws { + ShopifyCheckoutKit.configuration = Configuration() + try await super.tearDown() + } + func testInit() { XCTAssertNotNil(checkoutViewController) } + + func testInitDecoratesCheckoutURL() throws { + try assertDecoratedCheckoutURL(loadedCheckoutURL(from: checkoutViewController)) + } + + func testEntryPointInitDecoratesCheckoutURL() throws { + let viewController = CheckoutViewController( + checkout: checkoutURL, + entryPoint: .acceleratedCheckouts + ) + + try assertDecoratedCheckoutURL(loadedCheckoutURL(from: viewController)) + } } @MainActor @@ -25,7 +47,9 @@ class ShopifyCheckoutTests: XCTestCase { override func setUp() async throws { try await super.setUp() ShopifyCheckoutKit.configuration = Configuration() - checkoutURL = URL(string: "https://www.shopify.com") + ShopifyCheckoutKit.configuration.appearance = .app(.dark) + ShopifyCheckoutKit.configuration.preloading.enabled = false + checkoutURL = URL(string: "https://www.shopify.com?key=cart_token") shopifyCheckout = ShopifyCheckout(checkout: checkoutURL) } @@ -64,6 +88,33 @@ class ShopifyCheckoutTests: XCTestCase { let sheet = shopifyCheckout.connect(client) XCTAssertNotNil(sheet.client) } + + func testCheckoutViewControllerDecoratesCheckoutURL() async throws { + let hostingController = UIHostingController(rootView: shopifyCheckout) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = hostingController + window.makeKeyAndVisible() + hostingController.loadViewIfNeeded() + + var descendant: CheckoutViewController? + for _ in 0 ..< 10 where descendant == nil { + hostingController.view.layoutIfNeeded() + descendant = descendantCheckoutViewController(from: hostingController) + await Task.yield() + } + + let checkoutViewController = try XCTUnwrap(descendant) + try assertDecoratedCheckoutURL(loadedCheckoutURL(from: checkoutViewController)) + withExtendedLifetime(window) {} + } + + private func descendantCheckoutViewController(from viewController: UIViewController) -> CheckoutViewController? { + if let checkoutViewController = viewController as? CheckoutViewController { + return checkoutViewController + } + + return viewController.children.lazy.compactMap(descendantCheckoutViewController).first + } } @MainActor @@ -95,14 +146,6 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, appearance) } - func testAppearanceDecoratesCheckoutURLAfterModifierRuns() throws { - let sheet = shopifyCheckout.appearance(.storefront) - let items = try XCTUnwrap(URLComponents(url: sheet.decoratedCheckoutURL, resolvingAgainstBaseURL: false)?.queryItems) - - XCTAssertEqual(items.first(where: { $0.name == "ec_color_scheme" })?.value, "light") - XCTAssertEqual(items.first(where: { $0.name == "ck_branding" })?.value, "shop") - } - func testTintColor() { let color = UIColor.blue shopifyCheckout.tintColor(color)