Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions platforms/web/src/checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,16 +750,39 @@ describe("<shopify-checkout>", () => {
const onErrorSpy = vi.fn();
const listenForEvent = waitForEvent(checkout, "checkout:error", onErrorSpy);

const errorPayload = makeErrorPayload({ severity: "recoverable" });
simulateProtocolMessageEvent(checkout, "ec.error", errorPayload, {
const errorParams = makeErrorParams({ severity: "recoverable" });
simulateProtocolMessageEvent(checkout, "ec.error", errorParams, {
source: mockCheckoutWindow,
});
await listenForEvent;

expect(checkout.error).toStrictEqual(errorPayload);
expect(checkout.error).toStrictEqual(errorParams.error);
expect(onErrorSpy).toHaveBeenCalledOnce();
});

it("ignores the old ec.error shape with ucp and messages directly in params", async () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could update here to support both formats

const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onErrorSpy = vi.fn();
checkout.addEventListener("checkout:error", onErrorSpy);

const errorPayload = makeErrorPayload();
window.dispatchEvent(
new MessageEvent("message", {
data: {
jsonrpc: "2.0",
method: "ec.error",
params: errorPayload,
},
origin: new URL(checkout.src).origin,
source: mockCheckoutWindow,
}),
);
await Promise.resolve();

expect(checkout.error).toBeUndefined();
expect(onErrorSpy).not.toHaveBeenCalled();
});

it("auto-closes when any message has severity 'unrecoverable'", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const errorOrder: string[] = [];
Expand All @@ -769,7 +792,7 @@ describe("<shopify-checkout>", () => {
simulateProtocolMessageEvent(
checkout,
"ec.error",
makeErrorPayload({ severity: "unrecoverable" }),
makeErrorParams({ severity: "unrecoverable" }),
{ source: mockCheckoutWindow },
);
await Promise.resolve();
Expand All @@ -789,7 +812,7 @@ describe("<shopify-checkout>", () => {
const closeSpy = vi.fn();
checkout.addEventListener("checkout:close", closeSpy);

simulateProtocolMessageEvent(checkout, "ec.error", makeErrorPayload({ severity }), {
simulateProtocolMessageEvent(checkout, "ec.error", makeErrorParams({ severity }), {
source: mockCheckoutWindow,
});
await Promise.resolve();
Expand Down Expand Up @@ -915,14 +938,14 @@ describe("<shopify-checkout>", () => {
const spy = vi.fn();
const wait = waitForEvent(checkout, "checkout:error", spy);

const errorPayload = makeErrorPayload();
simulateProtocolMessageEvent(checkout, "ec.error", errorPayload, {
const errorParams = makeErrorParams();
simulateProtocolMessageEvent(checkout, "ec.error", errorParams, {
source: mockCheckoutWindow,
});
await wait;

const event = spy.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toEqual({ error: errorPayload });
expect(event.detail).toEqual({ error: errorParams.error });
});

it("checkout:lineItemsChange carries {lineItems, checkout}", async () => {
Expand Down Expand Up @@ -1498,7 +1521,7 @@ describe("<shopify-checkout>", () => {
function simulateProtocolMessageEvent<Message extends keyof CheckoutProtocolMessageMap>(
checkout: ShopifyCheckout,
name: Message,
body: CheckoutProtocolMessageMap[Message],
params: CheckoutProtocolMessageMap[Message],
options?: {
id?: string;
source?: MessageEventSource | null;
Expand All @@ -1520,7 +1543,7 @@ function simulateProtocolMessageEvent<Message extends keyof CheckoutProtocolMess
data: {
jsonrpc: "2.0",
method: name,
params: body,
params,
...(options?.id && { id: options.id }),
},
origin,
Expand Down Expand Up @@ -1646,3 +1669,9 @@ function makeErrorPayload(overrides?: {
],
};
}

function makeErrorParams(overrides?: {
severity?: CheckoutMessageError["severity"];
}): CheckoutProtocolMessageMap["ec.error"] {
return { error: makeErrorPayload(overrides) };
}
9 changes: 7 additions & 2 deletions platforms/web/src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ export class ShopifyCheckout
break;
}
case "ec.error": {
const error = message.body as CheckoutProtocolMessageMap["ec.error"];
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 —
Expand Down Expand Up @@ -801,7 +801,7 @@ export interface ShopifyCheckoutCompleteEventDetail {
}

export interface ShopifyCheckoutErrorEventDetail {
/** Wire-shape error payload from the ECP `ec.error` notification. */
/** Error payload from the ECP `ec.error` notification. */
error: UcpErrorResponse;
}

Expand Down Expand Up @@ -924,6 +924,7 @@ class CheckoutProtocolMessage<
static parse(event: MessageEvent): CheckoutProtocolMessage | undefined {
const { data, source, origin } = event;
if (!isCheckoutProtocolMessage(data)) return;
if (data.method === "ec.error" && !isEcErrorParams(data.params)) return;
return new CheckoutProtocolMessage(data, { source, origin });
}

Expand All @@ -950,6 +951,10 @@ class CheckoutProtocolMessage<
}
}

function isEcErrorParams(params: unknown): params is CheckoutProtocolMessageMap["ec.error"] {
return params != null && typeof params === "object" && "error" in params;
}

function isCheckoutProtocolMessage(data: unknown): data is CheckoutProtocolMessageData {
return (
data != null &&
Expand Down
9 changes: 7 additions & 2 deletions platforms/web/src/checkout.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export interface CheckoutCloseEvent {
export interface CheckoutErrorEvent {
type: "checkout:error";
detail: {
/** Wire-shape error payload from the ECP `ec.error` notification. */
/** Error payload from the ECP `ec.error` notification. */
error: UcpErrorResponse;
};
}
Expand Down Expand Up @@ -236,6 +236,11 @@ interface CheckoutPayload {
shop_cash?: ShopCash;
}

/** `ec.error` wraps the generated error response in the JSON-RPC `params.error` field. */
export interface EcErrorParams {
error: UcpErrorResponse;
}

/**
* Mapping of the 2026-04-08 ECP messages this component handles to their
* wire-format payloads. Delegation methods (fulfillment.address_change_request,
Expand All @@ -247,7 +252,7 @@ export interface CheckoutProtocolMessageMap {
"ec.ready": EcReadyParams;
"ec.start": CheckoutPayload;
"ec.complete": CheckoutPayload;
"ec.error": UcpErrorResponse;
"ec.error": EcErrorParams;
"ec.line_items.change": CheckoutPayload;
"ec.buyer.change": CheckoutPayload;
"ec.totals.change": CheckoutPayload;
Expand Down
Loading