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
97 changes: 91 additions & 6 deletions platforms/web/src/checkout-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,76 @@ describe("<shopify-checkout>", () => {

expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled();
});

it("does not reply to a sender after its presentation is replaced", async () => {
const { checkout, mockCheckoutWindow: firstWindow } = openPopupCheckout();
const secondWindow = createMockWindow();

simulateProtocolMessageEvent(
checkout,
"ec.ready",
{ delegate: [] },
{
id: "stale-ready",
source: firstWindow,
},
);
vi.mocked(window.open).mockReturnValue(secondWindow);
checkout.open();
await flushProtocolDispatch();

expect(firstWindow.postMessage).not.toHaveBeenCalled();
expect(secondWindow.postMessage).not.toHaveBeenCalled();

simulateProtocolMessageEvent(
checkout,
"ec.ready",
{ delegate: [] },
{
id: "current-ready",
source: secondWindow,
},
);
await flushProtocolDispatch();

expect(secondWindow.postMessage).toHaveBeenCalledWith(
{
jsonrpc: "2.0",
id: "current-ready",
result: { ucp: { status: "success", version: EMBED_PROTOCOL_VERSION } },
},
new URL(checkout.src).origin,
);
});

it("keeps sender and response routing independent across elements", async () => {
const first = renderCheckout({ target: "popup", src: "https://first.example/checkout" });
const second = renderCheckout({ target: "popup", src: "https://second.example/checkout" });
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
vi.spyOn(window, "open").mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {});
first.open();
second.open();

simulateProtocolMessageEvent(
first,
"ec.ready",
{ delegate: [] },
{
id: "first-ready",
source: firstWindow,
},
);
await flushProtocolDispatch();

expect(firstWindow.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ id: "first-ready" }),
"https://first.example",
);
expect(secondWindow.postMessage).not.toHaveBeenCalled();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thinking out loud here. I tried removing the event.source check in isCheckoutMessageFromPresentation locally and this test still passes. I think it's because the two elements have different origins, so the origin allowlist drops the message anyway. And replies go back to the window that sent the message (always firstWindow here), so the secondWindow check can't fail.

The test at L1628 does catch it, so not a big deal. But if the goal here is the reply path, what if both use the same origin and we check firstWindow only got one reply? With the change below it fails with the source check removed and passes with it in. Alternatively, maybe we don't even need this test? 🤔

});
});

describe("unsupported protocol methods", () => {
Expand Down Expand Up @@ -909,11 +979,9 @@ describe("<shopify-checkout>", () => {
const targetOrigin = new URL(checkout.src).origin;

// The shared client can't decode a request without a valid `url`, so
// the handler never runs. The host preserves the diagnostic warning,
// logging the raw message data that failed to decode.
// the handler never runs. The host preserves a safe diagnostic warning.
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("ec.window.open_request received without a valid url"),
expect.objectContaining({ method: "ec.window.open_request" }),
);
expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
{
Expand Down Expand Up @@ -954,8 +1022,8 @@ describe("<shopify-checkout>", () => {

expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("ec.window.open_request received without a valid url"),
expect.objectContaining({ url: "not a real url" }),
);
expect(consoleWarnSpy.mock.calls.flat().join(" ")).not.toContain("not a real url");
expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
jsonrpc: "2.0",
Expand Down Expand Up @@ -984,8 +1052,8 @@ describe("<shopify-checkout>", () => {

expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("ec.window.open_request received without a valid url"),
expect.objectContaining({ url: "http://example.com/insecure" }),
);
expect(consoleWarnSpy.mock.calls.flat().join(" ")).not.toContain("example.com/insecure");
expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
jsonrpc: "2.0",
Expand Down Expand Up @@ -1020,7 +1088,6 @@ describe("<shopify-checkout>", () => {

expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining("ec.window.open_request received without a valid url"),
expect.anything(),
);
expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -1279,6 +1346,24 @@ describe("<shopify-checkout>", () => {
expect(checkout.checkout).toBeUndefined();
});

it("does not trust a null sender when the popup was blocked", async () => {
const checkout = renderCheckout({ target: "popup" });
vi.spyOn(window, "open").mockReturnValue(null);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {});
checkout.open();
const onStart = vi.fn();
checkout.addEventListener("start", onStart);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: null,
});
await flushProtocolDispatch();

expect(onStart).not.toHaveBeenCalled();
expect(checkout.checkout).toBeUndefined();
});

it("drops protocol messages when src is unset even if the event origin is HTTPS", async () => {
const checkout = document.createElement("shopify-checkout");
document.body.appendChild(checkout);
Expand Down
123 changes: 123 additions & 0 deletions platforms/web/src/checkout-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,32 @@ describe("<shopify-checkout>", () => {
});
});

describe("src", () => {
it("closes the active presentation when the URL changes", () => {
const checkout = renderCheckout({ target: "popup" });
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
const windowOpenSpy = vi
.spyOn(window, "open")
.mockReturnValueOnce(firstWindow)
.mockReturnValueOnce(secondWindow);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {});
const onClose = vi.fn();
checkout.addEventListener("close", onClose);

checkout.open();
checkout.src = "https://new.example/checkout";

expect(firstWindow.close).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();

checkout.open();
expect(new URL(windowOpenSpy.mock.calls[1]![0]!).origin).toBe("https://new.example");
expect(secondWindow.close).not.toHaveBeenCalled();
});
});

describe("methods", () => {
describe("open", () => {
describe("when target is not specified", () => {
Expand Down Expand Up @@ -296,6 +322,7 @@ describe("<shopify-checkout>", () => {
checkout.open();

const dialog = checkout.shadowRoot!.querySelector("dialog") as HTMLDialogElement;
dialog.removeAttribute("open");
dialog.dispatchEvent(new Event("close"));

expect(mockPopup.close).toHaveBeenCalled();
Expand Down Expand Up @@ -389,6 +416,54 @@ describe("<shopify-checkout>", () => {
expect(closeEventSpy).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledTimes(2);
});

it("ignores a queued dialog close event after opening the next session", () => {
vi.useFakeTimers();
try {
const checkout = renderCheckout({ target: "popup" });
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
vi.spyOn(window, "open")
.mockReturnValueOnce(firstWindow)
.mockReturnValueOnce(secondWindow);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(
function (this: HTMLDialogElement) {
this.setAttribute("open", "");
},
);
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(
function (this: HTMLDialogElement) {
if (!this.open) return;
this.removeAttribute("open");
setTimeout(() => this.dispatchEvent(new Event("close")), 0);
},
);

const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);
const dialog = checkout.shadowRoot!.querySelector<HTMLDialogElement>("#overlay")!;

checkout.open();
checkout.open();

expect(dialog.open).toBe(true);
expect(firstWindow.close).toHaveBeenCalledOnce();
expect(secondWindow.close).not.toHaveBeenCalled();

// Native dialog.close() queues the event after a new showModal() can run.
vi.advanceTimersByTime(0);
expect(dialog.open).toBe(true);
expect(secondWindow.close).not.toHaveBeenCalled();
expect(closeEventSpy).toHaveBeenCalledOnce();

dialog.close();
vi.advanceTimersByTime(0);
expect(secondWindow.close).toHaveBeenCalledOnce();
expect(closeEventSpy).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});

describe("overlay scrim", () => {
Expand Down Expand Up @@ -419,6 +494,30 @@ describe("<shopify-checkout>", () => {
expect(dialog).toBeTruthy();
});

it("keeps the overlay slot usable for custom focus and close controls", () => {
const checkout = renderCheckout({ target: "popup" });
const customOverlay = document.createElement("div");
customOverlay.slot = "overlay";
const focusButton = document.createElement("button");
const closeButton = document.createElement("button");
focusButton.addEventListener("click", () => checkout.focus());
closeButton.addEventListener("click", () => checkout.close());
customOverlay.append(focusButton, closeButton);
checkout.append(customOverlay);

const mockWindow = createMockWindow();
vi.spyOn(window, "open").mockReturnValue(mockWindow);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {});
checkout.open();

expect(checkout.shadowRoot!.querySelector('slot[name="overlay"]')).not.toBeNull();
focusButton.click();
closeButton.click();
expect(mockWindow.focus).toHaveBeenCalledOnce();
expect(mockWindow.close).toHaveBeenCalledOnce();
});

it("focuses the popup when the overlay button is clicked", () => {
const { checkout, mockWindow } = openWithRealOverlay();
const button = checkout.shadowRoot!.querySelector<HTMLButtonElement>("#overlay-link")!;
Expand Down Expand Up @@ -543,6 +642,30 @@ describe("<shopify-checkout>", () => {
});

describe("close", () => {
it("dispatches once per presentation across repeated close and disconnect", () => {
const checkout = renderCheckout({ target: "popup" });
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
vi.spyOn(window, "open").mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {});
const onClose = vi.fn();
checkout.addEventListener("close", onClose);

checkout.open();
checkout.close();
checkout.close();
checkout.remove();
expect(onClose).toHaveBeenCalledOnce();
expect(firstWindow.close).toHaveBeenCalledOnce();

document.body.append(checkout);
checkout.open();
checkout.remove();
expect(onClose).toHaveBeenCalledTimes(2);
expect(secondWindow.close).toHaveBeenCalledOnce();
});

describe('when target="popup", "auto", or undefined', () => {
it("dispatches close event when popup is closed", () => {
POPUP_TARGETS.forEach((target) => {
Expand Down
Loading
Loading