Skip to content
Merged
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
18 changes: 13 additions & 5 deletions platforms/web/test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,19 @@ Failures retain traces and screenshots in `test-results/`; the HTML report is in

## Scope and layout

The first test covers opening checkout, the `ec.ready` handshake, public `start`
and `complete` events and snapshots, and host-driven closing. It tests the built
SDK's browser integration; it does not place a real order or exercise checkout-web.
The presentation specs cover component registration, checkout URL negotiation,
invalid URLs, popup presentation, and host/overlay closing. The protocol specs
cover the `ec.ready` handshake, public `start`/`update`/`complete` events and
snapshots, window delegation, and terminal errors. They test the built SDK's
browser integration; they do not place a real order or exercise checkout-web.

- `server.mjs` serves the host page and built bundle.
- `fixtures/` contains the host page and synthetic checkout.
- `support/fixtures.ts` installs checkout routing and the network guard.
- `tests/checkout.spec.ts` drives the happy path through browser interactions.
- `support/fixtures.ts` provides a host page object and installs the network guard.
- `support/checkout-host-page.ts` owns host controls and typed public event/state reads.
- `support/shopify-checkout-popup.ts` drives the synthetic checkout's protocol actions.
- `support/embedded-checkout-stub.ts` routes checkout requests to a blank popup or
the synthetic protocol fixture, selected with `test.use({ checkoutStub: "blank" })`.
- `support/checkout-fixture.ts` contains synthetic URLs and expected public checkout data.
- `tests/synthetic/presentation.spec.ts` uses blank popups to isolate presentation behavior.
- `tests/synthetic/protocol.spec.ts` exercises cross-origin protocol exchanges and public events.
164 changes: 105 additions & 59 deletions platforms/web/test/e2e/fixtures/synthetic-checkout.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,73 +5,119 @@
<title>Synthetic checkout</title>
</head>
<body>
<p id="status">Waiting for handshake</p>
<button id="start" type="button" disabled>Start checkout</button>
<button id="complete" type="button" disabled>Complete checkout</button>
<script>
const hostOrigin = new URL(location.href).searchParams.get("host_origin");
const readyId = "ready-1";
let protocolVersion;
class SyntheticCheckout {
constructor() {
this.hostOrigin = new URL(location.href).searchParams.get("host_origin");
this.readyResponse = new Promise((resolve) => {
window.addEventListener("message", (event) => {
if (event.source !== window.opener || event.origin !== this.hostOrigin) return;
const response = event.data;
if (response?.jsonrpc !== "2.0" || response.id !== "ready-1") return;
if (response.result?.ucp?.status !== "success") return;
this.protocolVersion = response.result.ucp.version;
resolve();
});
});
this.request("ec.ready", { delegate: ["window.open"] }, "ready-1");
}

window.addEventListener("message", (event) => {
if (event.source !== window.opener || event.origin !== hostOrigin) return;
const response = event.data;
if (response?.jsonrpc !== "2.0" || response.id !== readyId) return;
if (response.result?.ucp?.status !== "success") return;
protocolVersion = response.result.ucp.version;
document.getElementById("status").textContent = "Ready";
document.getElementById("start").disabled = false;
});
notify(method, params) {
window.opener.postMessage({ jsonrpc: "2.0", method, params }, this.hostOrigin);
}

// Use the negotiated version rather than coupling the harness to a
// particular SDK release. The payload remains a synthetic wire fixture.
function checkout(status) {
return {
ucp: { version: protocolVersion, payment_handlers: {} },
id: "checkout_test_1",
currency: "USD",
status,
line_items: [
{
id: "li_1",
item: { id: "variant_1", title: "Test Product", price: 2000 },
quantity: 1,
totals: [{ type: "total", amount: 2000 }],
request(method, params, id) {
window.opener.postMessage({ jsonrpc: "2.0", id, method, params }, this.hostOrigin);
}

// Use the negotiated version so the wire fixture survives SDK releases.
checkoutResource(status = "incomplete") {
return {
ucp: { version: this.protocolVersion, payment_handlers: {} },
id: "checkout_test_1",
currency: "USD",
status,
line_items: [
{
id: "li_1",
item: { id: "variant_1", title: "Test Product", price: 2000 },
quantity: 1,
totals: [{ type: "total", amount: 2000 }],
},
],
totals: [{ type: "total", amount: 2000 }],
links: [],
};
}

start() {
this.notify("ec.start", { checkout: this.checkoutResource() });
}

complete() {
this.notify("ec.complete", {
checkout: {
...this.checkoutResource("completed"),
order: {
id: "order_test_1",
permalink_url: "https://checkout.example.test/orders/order_test_1",
},
},
],
totals: [{ type: "total", amount: 2000 }],
links: [],
};
}
});
}

function notify(method, resource) {
window.opener.postMessage(
{ jsonrpc: "2.0", method, params: { checkout: resource } },
hostOrigin,
);
}
lineItemsChange() {
const checkout = this.checkoutResource();
checkout.line_items[0].quantity = 2;
checkout.line_items[0].totals[0].amount = 4000;
checkout.totals[0].amount = 4000;
this.notify("ec.line_items.change", { checkout });
}

document.getElementById("start").addEventListener("click", () => {
notify("ec.start", checkout("incomplete"));
document.getElementById("start").disabled = true;
document.getElementById("complete").disabled = false;
});
totalsChange() {
const checkout = this.checkoutResource();
checkout.totals[0].amount = 2500;
this.notify("ec.totals.change", { checkout });
}

document.getElementById("complete").addEventListener("click", () => {
notify("ec.complete", {
...checkout("completed"),
order: {
id: "order_test_1",
permalink_url: "https://checkout.example.test/orders/order_test_1",
},
});
document.getElementById("complete").disabled = true;
});
messagesChange() {
this.notify("ec.messages.change", {
checkout: {
...this.checkoutResource(),
messages: [
{
type: "notice",
code: "inventory_updated",
content: "Inventory changed.",
severity: "informational",
},
],
},
});
}

openRequest(url) {
this.request("ec.window.open_request", { url }, "open-request-1");
}

error() {
this.notify("ec.error", {
error: {
ucp: { version: this.protocolVersion, status: "error" },
messages: [
{
type: "error",
code: "invalid_cart",
content: "The cart is no longer valid.",
severity: "unrecoverable",
},
],
},
});
}
}

window.opener.postMessage(
{ jsonrpc: "2.0", id: readyId, method: "ec.ready", params: { delegate: ["window.open"] } },
hostOrigin,
);
window.syntheticCheckout = new SyntheticCheckout();
</script>
</body>
</html>
28 changes: 28 additions & 0 deletions platforms/web/test/e2e/support/checkout-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Checkout } from "@shopify/checkout-kit";

export const checkoutOrigin = "https://checkout.example.test";
export const checkoutId = "checkout_test_1";
export const orderId = "order_test_1";

export function checkoutSrc(params: Record<string, string> = {}): string {
const url = new URL(`/checkout/${checkoutId}`, checkoutOrigin);
url.search = new URLSearchParams(params).toString();
return url.href;
}

// Public SDK data, kept separate from the synthetic checkout's wire payload.
export const startedCheckout = {
id: checkoutId,
currency: "USD",
status: "incomplete",
lineItems: [
{
id: "li_1",
item: { id: "variant_1", title: "Test Product", price: 2000 },
quantity: 1,
totals: [{ type: "total", amount: 2000 }],
},
],
totals: [{ type: "total", amount: 2000 }],
links: [],
} satisfies Checkout;
93 changes: 93 additions & 0 deletions platforms/web/test/e2e/support/checkout-host-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { expect, type Locator, type Page } from "@playwright/test";
import type { ShopifyCheckout, ShopifyCheckoutEventMap } from "@shopify/checkout-kit";

import { checkoutSrc } from "./checkout-fixture";
import { ShopifyCheckoutPopup } from "./shopify-checkout-popup";
import type { CheckoutEventRecord, ConfigureOptions } from "./types";

export class CheckoutHostPage {
readonly component: Locator;
readonly overlay: Locator;
readonly overlayFocusButton: Locator;
readonly overlayCloseButton: Locator;
readonly buyButton: Locator;

constructor(readonly page: Page) {
this.component = page.locator("shopify-checkout");
this.overlay = this.component.locator("#overlay");
this.overlayFocusButton = this.component.getByRole("button", { name: "checkout window" });
this.overlayCloseButton = this.component.getByRole("button", { name: "Close", exact: true });
this.buyButton = page.getByRole("button", { name: "Buy", exact: true });
}

async goto(): Promise<void> {
await this.page.goto("/");
await this.page.evaluate(() => customElements.whenDefined("shopify-checkout"));
}

async configure(options: ConfigureOptions): Promise<void> {
await this.component.evaluate((element, { src, target, logLevel }) => {
const checkout = element as ShopifyCheckout;
if (src !== undefined) checkout.src = src;
if (target !== undefined) checkout.target = target;
if (logLevel !== undefined) checkout.logLevel = logLevel;
}, options);
}

async close(): Promise<void> {
await this.component.evaluate((element) => (element as ShopifyCheckout).close());
}

async clickBuy(): Promise<void> {
await this.buyButton.click();
}

async openPopup(): Promise<ShopifyCheckoutPopup> {
const [popup] = await Promise.all([this.page.waitForEvent("popup"), this.clickBuy()]);
return new ShopifyCheckoutPopup(popup);
}

async startCheckout(options: ConfigureOptions = {}): Promise<ShopifyCheckoutPopup> {
await this.goto();
await this.configure({
src: checkoutSrc({ host_origin: new URL(this.page.url()).origin }),
target: "popup",
...options,
});
return this.openPopup();
}

async hasShadowWrapper(): Promise<boolean> {
return this.component.evaluate((element) =>
Boolean(element.shadowRoot?.querySelector("#shopify-element-wrapper")),
);
}

async receivedEvents(): Promise<CheckoutEventRecord[]> {
return this.page.evaluate(() => window.checkoutEvents);
}

async eventTypes(): Promise<CheckoutEventRecord["type"][]> {
return (await this.receivedEvents()).map(({ type }) => type);
}

eventDetail<K extends keyof ShopifyCheckoutEventMap>(
type: K,
): Promise<ShopifyCheckoutEventMap[K]["detail"] | null | undefined>;
async eventDetail(type: keyof ShopifyCheckoutEventMap) {
const events = await this.receivedEvents();
return events.find((record) => record.type === type)?.detail;
}

async checkout() {
return this.component.evaluate((element) => (element as ShopifyCheckout).checkout);
}

async error() {
return this.component.evaluate((element) => (element as ShopifyCheckout).error);
}

async expectEvent(type: CheckoutEventRecord["type"]): Promise<void> {
await expect.poll(() => this.eventTypes()).toContain(type);
}
}
24 changes: 24 additions & 0 deletions platforms/web/test/e2e/support/embedded-checkout-stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { fileURLToPath } from "node:url";
import type { BrowserContext } from "@playwright/test";

import { checkoutOrigin } from "./checkout-fixture";

export type CheckoutStubMode = "blank" | "handshake";

const syntheticCheckout = fileURLToPath(
new URL("../fixtures/synthetic-checkout.html", import.meta.url),
);

export async function stubEmbeddedCheckout(
context: BrowserContext,
mode: CheckoutStubMode,
): Promise<void> {
await context.route(`${checkoutOrigin}/**`, (route) =>
route.fulfill({
contentType: "text/html; charset=utf-8",
...(mode === "blank"
? { body: "<!doctype html><title>Checkout popup</title>" }
: { path: syntheticCheckout }),
}),
);
}
Loading
Loading