diff --git a/.changeset/apple-pay-shared-sdk-load.md b/.changeset/apple-pay-shared-sdk-load.md new file mode 100644 index 00000000..ebf896d4 --- /dev/null +++ b/.changeset/apple-pay-shared-sdk-load.md @@ -0,0 +1,5 @@ +--- +"@evervault/browser": patch +--- + +Fix Apple Pay availability when multiple buttons are created before Apple's SDK loads. Also report failed Apple Pay credential exchanges through the `error` event and fail the payment sheet. diff --git a/packages/browser/lib/ui/ApplePay/index.ts b/packages/browser/lib/ui/ApplePay/index.ts index 5191bf6e..462b8761 100644 --- a/packages/browser/lib/ui/ApplePay/index.ts +++ b/packages/browser/lib/ui/ApplePay/index.ts @@ -31,6 +31,96 @@ import { Transaction } from "../../resources/transaction"; const APPLE_PAY_SCRIPT_URL = "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"; +const SCRIPT_LOAD_TIMEOUT = 10000; + +let sdkLoadPromise: Promise | null = null; + +function isApplePaySDKReady() { + return ( + typeof ApplePaySession !== "undefined" && + typeof ApplePaySession.applePayCapabilities === "function" + ); +} + +function isApplePayButtonDefined() { + return ( + typeof customElements !== "undefined" && + customElements.get("apple-pay-button") !== undefined + ); +} + +function loadApplePaySDK(): Promise { + if (sdkLoadPromise) return sdkLoadPromise; + + const loadPromise = new Promise((resolve, reject) => { + const existing = document.querySelector( + `script[src="${APPLE_PAY_SCRIPT_URL}"]` + ); + + if (existing && isApplePayButtonDefined()) { + resolve(); + return; + } + + const script = existing ?? document.createElement("script"); + const fail = (error: Error) => { + clearTimeout(timeoutId); + script.removeEventListener("load", onLoad); + script.removeEventListener("error", onError); + if (!existing) script.remove(); + reject(error); + }; + const onLoad = () => { + clearTimeout(timeoutId); + script.removeEventListener("error", onError); + resolve(); + }; + const onError = () => fail(new Error("Apple Pay SDK script load failed")); + const timeoutId = setTimeout(() => { + if (isApplePayButtonDefined()) { + resolve(); + return; + } + + fail(new Error("Apple Pay SDK script load timeout")); + }, SCRIPT_LOAD_TIMEOUT); + + script.addEventListener("load", onLoad, { once: true }); + script.addEventListener("error", onError, { once: true }); + + if (!existing) { + script.src = APPLE_PAY_SCRIPT_URL; + script.async = true; + script.crossOrigin = "anonymous"; + document.head.appendChild(script); + } + }); + + const sharedPromise = loadPromise.catch((error) => { + if (sdkLoadPromise === sharedPromise) sdkLoadPromise = null; + throw error; + }); + sdkLoadPromise = sharedPromise; + + return sharedPromise; +} + +/** Test-only: drops the shared SDK load so each test is isolated */ +export function resetApplePaySDKLoader() { + sdkLoadPromise = null; +} + +type ApiErrorBody = { detail?: string; title?: string }; + +async function credentialsFailureMessage(res: Response): Promise { + const [body] = await tryCatch(res.json()); + const detail = body?.detail ?? body?.title; + + return detail + ? `Apple Pay credentials exchange failed (${res.status}): ${detail}` + : `Apple Pay credentials exchange failed (${res.status})`; +} + export type ApplePayButtonOptions = { type?: ApplePayButtonType; style?: ApplePayButtonStyle; @@ -146,9 +236,6 @@ export default class ApplePayButton { #button: HTMLElement | null = null; #options: ApplePayButtonOptions; #events = new EventManager(); - #scriptLoaded = false; - #scriptLoadPromise: Promise; - #resolveScriptLoad!: () => void; #activeSession: PaymentRequest | null = null; #abortRequested = false; #sessionInProgress = false; @@ -165,31 +252,7 @@ export default class ApplePayButton { this.client = client; this.#options = options; this.transaction = transaction; - this.#scriptLoadPromise = new Promise((resolve) => { - this.#resolveScriptLoad = resolve; - }); - this.#injectScript(); - } - - #injectScript() { - const selector = `script[src="${APPLE_PAY_SCRIPT_URL}"]`; - const existing = document.querySelector(selector); - if (existing) { - this.#scriptLoaded = true; - this.#resolveScriptLoad(); - return; - } - - const script = document.createElement("script"); - script.src = APPLE_PAY_SCRIPT_URL; - script.async = true; - script.crossOrigin = "anonymous"; - script.onload = () => { - this.#scriptLoaded = true; - this.#resolveScriptLoad(); - }; - - document.body.appendChild(script); + void loadApplePaySDK().catch(() => {}); } async #handleClick() { @@ -269,6 +332,7 @@ export default class ApplePayButton { if (encryptedError) { this.#events.dispatch("error", encryptedError.message); + await response.complete("fail"); return; } @@ -385,7 +449,19 @@ export default class ApplePayButton { body: JSON.stringify(requestBody), }); - return res.json(); + if (!res.ok) { + throw new Error(await credentialsFailureMessage(res)); + } + + const [encrypted] = await tryCatch(res.json()); + + if (!encrypted?.card) { + throw new Error( + "Apple Pay credentials exchange returned no card credentials" + ); + } + + return encrypted; } on( @@ -427,24 +503,6 @@ export default class ApplePayButton { this.#abortRequested = true; } - async #waitForScript() { - if (this.#scriptLoaded) return; - const TIMEOUT = 10000; - - let timeoutId: ReturnType; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error("Apple Pay SDK script load timeout")); - }, TIMEOUT); - }); - - try { - await Promise.race([this.#scriptLoadPromise, timeout]); - } finally { - clearTimeout(timeoutId!); - } - } - /** * Checks the availability of Apple Pay on the current device. * @@ -455,11 +513,21 @@ export default class ApplePayButton { */ async availability(): Promise<"available" | "unavailable" | "unsupported"> { if (!this.#availabilityPromise) { - this.#availabilityPromise = this.#computeAvailability().catch((error) => { - // Don't cache a failed probe — allow a later call to retry. - this.#availabilityPromise = null; - throw error; - }); + this.#availabilityPromise = this.#computeAvailability() + .then((result) => { + // The SDK may not have defined ApplePaySession yet. Do not cache + // this ambiguous result, so a later call can re-probe. + if (result === "unsupported" && !isApplePaySDKReady()) { + this.#availabilityPromise = null; + } + + return result; + }) + .catch((error) => { + // Don't cache a failed probe — allow a later call to retry. + this.#availabilityPromise = null; + throw error; + }); } return this.#availabilityPromise; @@ -469,7 +537,7 @@ export default class ApplePayButton { "available" | "unavailable" | "unsupported" > { if (typeof window.PaymentRequest === "undefined") return "unsupported"; - await this.#waitForScript(); + if (!isApplePaySDKReady()) await loadApplePaySDK(); if ( typeof ApplePaySession === "undefined" || @@ -510,6 +578,8 @@ export default class ApplePayButton { console.info("Apple Pay may be unavailable on this device."); } + await loadApplePaySDK(); + const element = resolveSelector(selector); this.#button = document.createElement("apple-pay-button"); diff --git a/packages/browser/test/applePay.test.ts b/packages/browser/test/applePay.test.ts index cdb4ee38..6b2f4c5b 100644 --- a/packages/browser/test/applePay.test.ts +++ b/packages/browser/test/applePay.test.ts @@ -13,7 +13,7 @@ import { } from "vitest"; import * as applePayUtilities from "../lib/ui/ApplePay/utilities"; import type { ApplePayMerchantCapability } from "types"; -import ApplePayButton from "../lib/ui/ApplePay"; +import ApplePayButton, { resetApplePaySDKLoader } from "../lib/ui/ApplePay"; import { Transaction } from "../lib/resources/transaction"; import type EvervaultClient from "../lib/main"; import { setupCrypto } from "./setup"; @@ -33,6 +33,8 @@ const apiUrl = "https://api.test.evervault.com"; const app = "app_test123"; const merchantId = "merchant_abc"; const merchantName = "Acme Co"; +const applePaySDKSelector = + 'script[src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"]'; const paymentRequestCalls: ApplePayPaymentDetailsInit[] = []; const paymentMethodDataCalls: Array<{ @@ -150,6 +152,7 @@ beforeAll(() => { }); beforeEach(() => { + resetApplePaySDKLoader(); paymentRequestCalls.length = 0; paymentMethodDataCalls.length = 0; paymentOptionsCalls.length = 0; @@ -162,6 +165,9 @@ beforeEach(() => { }); afterEach(() => { + document.querySelectorAll(applePaySDKSelector).forEach((script) => { + script.remove(); + }); server.resetHandlers(); }); @@ -1389,9 +1395,16 @@ function createMockSession() { }; } +function dispatchApplePaySDKLoad() { + document + .querySelector(applePaySDKSelector) + ?.dispatchEvent(new Event("load")); +} + async function clickApplePayButton(apple: ApplePayButton) { const container = document.createElement("div"); document.body.appendChild(container); + dispatchApplePaySDKLoad(); await apple.mount(container); const button = container.querySelector("apple-pay-button"); button?.dispatchEvent(new Event("click")); @@ -1413,32 +1426,127 @@ describe("ApplePayButton script loading", () => { vi.unstubAllGlobals(); }); - it("resolves availability() as soon as the SDK script's onload fires, without polling", async () => { + it("resolves availability() before the SDK loads when its capability API is ready", async () => { const apple = new ApplePayButton(createMockClient(), createTransaction(), { process: vi.fn(), }); - const script = document.querySelector( - 'script[src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"]' - ); + await expect(apple.availability()).resolves.toBe("available"); + const script = + document.querySelector(applePaySDKSelector); expect(script).not.toBeNull(); + dispatchApplePaySDKLoad(); + }); - let resolved = false; - const availabilityPromise = apple.availability().then((result) => { - resolved = true; - return result; + it("waits for the SDK before mounting a button when its capability API is ready", async () => { + const apple = new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), }); + const container = document.createElement("div"); + document.body.appendChild(container); + const mountPromise = apple.mount(container); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(resolved).toBe(false); + expect(container.querySelector("apple-pay-button")).toBeNull(); - script!.dispatchEvent(new Event("load")); + dispatchApplePaySDKLoad(); - await expect(availabilityPromise).resolves.toBe("available"); - expect(resolved).toBe(true); + await mountPromise; + expect(container.querySelector("apple-pay-button")).not.toBeNull(); + }); + + it("mounts without waiting when a merchant-loaded SDK defined the button", async () => { + const script = document.createElement("script"); + script.src = + "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"; + document.body.appendChild(script); + vi.stubGlobal("customElements", { + get: vi.fn().mockReturnValue(class ApplePayButtonElement {}), + }); + + const apple = new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }); + const container = document.createElement("div"); + document.body.appendChild(container); + + await apple.mount(container); + expect(container.querySelector("apple-pay-button")).not.toBeNull(); + }); + + it("rejects after an existing SDK script fails to load", async () => { + vi.stubGlobal("ApplePaySession", undefined); + vi.useFakeTimers(); + try { + const script = document.createElement("script"); + script.src = + "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"; + document.head.appendChild(script); + const apple = new ApplePayButton( + createMockClient(), + createTransaction(), + { process: vi.fn() } + ); + + const assertion = expect(apple.availability()).rejects.toThrow( + "Apple Pay SDK script load timeout" + ); + + await vi.advanceTimersByTimeAsync(10000); + await assertion; + expect(script.isConnected).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("removes a failed SDK script and allows a later retry", async () => { + vi.stubGlobal("ApplePaySession", undefined); + const first = new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }); + + const firstScript = + document.querySelector(applePaySDKSelector); + firstScript!.dispatchEvent(new Event("error")); + + await expect(first.availability()).rejects.toThrow( + "Apple Pay SDK script load failed" + ); + expect(document.querySelector(applePaySDKSelector)).toBeNull(); + + vi.stubGlobal("ApplePaySession", { + applePayCapabilities: vi.fn().mockResolvedValue({ + paymentCredentialStatus: "paymentCredentialsAvailable", + }), + }); + const second = new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }); + + await expect(second.availability()).resolves.toBe("available"); + dispatchApplePaySDKLoad(); + }); + + it("loads the SDK when constructed before document.body exists", () => { + vi.stubGlobal("ApplePaySession", undefined); + const body = document.body; + body.remove(); + + try { + new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }); + + expect(document.head.querySelector(applePaySDKSelector)).not.toBeNull(); + } finally { + document.documentElement.appendChild(body); + dispatchApplePaySDKLoad(); + } }); it("rejects with a timeout error if the SDK script never loads", async () => { + vi.stubGlobal("ApplePaySession", undefined); vi.useFakeTimers(); try { const apple = new ApplePayButton( @@ -1457,6 +1565,61 @@ describe("ApplePayButton script loading", () => { vi.useRealTimers(); } }); + + it("resolves availability() on a second instance once the shared script loads", async () => { + vi.stubGlobal("ApplePaySession", undefined); + + const buttons = [ + new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }), + new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }), + ]; + + let resolved = false; + const availabilityPromise = buttons[1].availability().then((result) => { + resolved = true; + return result; + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(resolved).toBe(false); + + const script = + document.querySelector(applePaySDKSelector); + expect(script).not.toBeNull(); + + vi.stubGlobal("ApplePaySession", { + applePayCapabilities: vi.fn().mockResolvedValue({ + paymentCredentialStatus: "paymentCredentialsAvailable", + }), + }); + dispatchApplePaySDKLoad(); + + await expect(availabilityPromise).resolves.toBe("available"); + }); + + it("injects a single SDK script tag for multiple instances", () => { + vi.stubGlobal("ApplePaySession", undefined); + + const buttons = [ + new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }), + new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }), + ]; + + expect(buttons).toHaveLength(2); + expect( + document.querySelectorAll( + 'script[src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"]' + ) + ).toHaveLength(1); + }); }); describe("ApplePayButton.abort", () => { @@ -1744,6 +1907,148 @@ describe("ApplePayButton process() payload", () => { }); }); +describe("ApplePayButton credentials exchange", () => { + function createSessionWithResponse() { + const response = { + details: { + token: { + paymentData: {}, + paymentMethod: { displayName: "Visa 1234", type: "credit" }, + }, + }, + complete: vi.fn().mockResolvedValue(undefined), + }; + + return { + response, + session: { show: vi.fn().mockResolvedValue(response), abort: vi.fn() }, + }; + } + + function mountButton() { + const { response, session } = createSessionWithResponse(); + buildSessionMock.mockResolvedValue(session); + + const error = vi.fn(); + const process = vi.fn().mockResolvedValue(undefined); + const apple = new ApplePayButton(createMockClient(), createTransaction(), { + process, + }); + apple.on("error", error); + + return { apple, error, process, response }; + } + + beforeEach(() => { + buildSessionMock.mockReset(); + vi.spyOn(applePayUtilities, "buildSession").mockImplementation( + buildSessionMock + ); + + vi.stubGlobal("PaymentRequest", class PaymentRequest {}); + + vi.stubGlobal("ApplePaySession", { + applePayCapabilities: vi.fn().mockResolvedValue({ + paymentCredentialStatus: "paymentCredentialsAvailable", + }), + }); + + const script = document.createElement("script"); + script.src = + "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"; + document.body.appendChild(script); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("dispatches error and fails the sheet when the exchange returns a non-2xx", async () => { + server.use( + http.post(`${apiUrl}/frontend/apple-pay/credentials`, () => + HttpResponse.json( + { + code: "internal-error", + title: "Internal Error", + detail: "Unable to decrypt the payment token", + }, + { status: 500 } + ) + ) + ); + + const { apple, error, process, response } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()); + expect(error).toHaveBeenCalledWith( + "Apple Pay credentials exchange failed (500): Unable to decrypt the payment token" + ); + expect(process).not.toHaveBeenCalled(); + expect(response.complete).toHaveBeenCalledOnce(); + expect(response.complete).toHaveBeenCalledWith("fail"); + }); + + it("falls back to the status when the error body carries no detail", async () => { + server.use( + http.post( + `${apiUrl}/frontend/apple-pay/credentials`, + () => new HttpResponse(null, { status: 502 }) + ) + ); + + const { apple, error, process } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()); + expect(error).toHaveBeenCalledWith( + "Apple Pay credentials exchange failed (502)" + ); + expect(process).not.toHaveBeenCalled(); + }); + + it("dispatches error when a 200 response carries no card credentials", async () => { + server.use( + http.post(`${apiUrl}/frontend/apple-pay/credentials`, () => + HttpResponse.json({}) + ) + ); + + const { apple, error, process, response } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()); + expect(error).toHaveBeenCalledWith( + "Apple Pay credentials exchange returned no card credentials" + ); + expect(process).not.toHaveBeenCalled(); + expect(response.complete).toHaveBeenCalledOnce(); + expect(response.complete).toHaveBeenCalledWith("fail"); + }); + + it("completes the sheet successfully when the exchange succeeds", async () => { + server.use( + http.post(`${apiUrl}/frontend/apple-pay/credentials`, () => + HttpResponse.json({ card: {} }) + ) + ); + + const { apple, error, process, response } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(process).toHaveBeenCalledOnce()); + expect(error).not.toHaveBeenCalled(); + expect(response.complete).toHaveBeenCalledOnce(); + expect(response.complete).toHaveBeenCalledWith("success"); + }); +}); + describe("ApplePayButton.availability", () => { function stubApplePaySession( capabilities: @@ -1789,6 +2094,18 @@ describe("ApplePayButton.availability", () => { expect(ApplePaySession.applePayCapabilities).toHaveBeenCalledOnce(); }); + it("caches a genuine unsupported capability result", async () => { + stubApplePaySession({ paymentCredentialStatus: "applePayUnsupported" }); + + const apple = new ApplePayButton(createMockClient(), createTransaction(), { + process: vi.fn(), + }); + + await expect(apple.availability()).resolves.toBe("unsupported"); + await expect(apple.availability()).resolves.toBe("unsupported"); + expect(ApplePaySession.applePayCapabilities).toHaveBeenCalledOnce(); + }); + it("only calls applePayCapabilities once when availability() is followed by mount()", async () => { const apple = new ApplePayButton(createMockClient(), createTransaction(), { process: vi.fn(), @@ -1798,6 +2115,7 @@ describe("ApplePayButton.availability", () => { const container = document.createElement("div"); document.body.appendChild(container); + dispatchApplePaySDKLoad(); await apple.mount(container); expect(ApplePaySession.applePayCapabilities).toHaveBeenCalledOnce();