From 24a9cb2d903732cce2f7ceb2c075feffcc0207df Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Fri, 2 Oct 2026 11:13:55 +0100 Subject: [PATCH] [Web] Organize browser coverage around checkout page objects --- platforms/web/test/e2e/README.md | 18 +- .../test/e2e/fixtures/synthetic-checkout.html | 164 +++++++++++------- .../web/test/e2e/support/checkout-fixture.ts | 28 +++ .../test/e2e/support/checkout-host-page.ts | 93 ++++++++++ .../e2e/support/embedded-checkout-stub.ts | 24 +++ platforms/web/test/e2e/support/fixtures.ts | 41 ++--- platforms/web/test/e2e/support/global.d.ts | 8 + platforms/web/test/e2e/support/index.ts | 10 ++ .../e2e/support/shopify-checkout-popup.ts | 45 +++++ platforms/web/test/e2e/support/types.ts | 21 +++ platforms/web/test/e2e/tests/checkout.spec.ts | 77 -------- .../e2e/tests/synthetic/presentation.spec.ts | 96 ++++++++++ .../test/e2e/tests/synthetic/protocol.spec.ts | 137 +++++++++++++++ platforms/web/test/e2e/tsconfig.json | 1 + 14 files changed, 597 insertions(+), 166 deletions(-) create mode 100644 platforms/web/test/e2e/support/checkout-fixture.ts create mode 100644 platforms/web/test/e2e/support/checkout-host-page.ts create mode 100644 platforms/web/test/e2e/support/embedded-checkout-stub.ts create mode 100644 platforms/web/test/e2e/support/global.d.ts create mode 100644 platforms/web/test/e2e/support/index.ts create mode 100644 platforms/web/test/e2e/support/shopify-checkout-popup.ts create mode 100644 platforms/web/test/e2e/support/types.ts delete mode 100644 platforms/web/test/e2e/tests/checkout.spec.ts create mode 100644 platforms/web/test/e2e/tests/synthetic/presentation.spec.ts create mode 100644 platforms/web/test/e2e/tests/synthetic/protocol.spec.ts diff --git a/platforms/web/test/e2e/README.md b/platforms/web/test/e2e/README.md index c8132cdf3..9b314eb34 100644 --- a/platforms/web/test/e2e/README.md +++ b/platforms/web/test/e2e/README.md @@ -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. diff --git a/platforms/web/test/e2e/fixtures/synthetic-checkout.html b/platforms/web/test/e2e/fixtures/synthetic-checkout.html index c01bd4024..2174b5584 100644 --- a/platforms/web/test/e2e/fixtures/synthetic-checkout.html +++ b/platforms/web/test/e2e/fixtures/synthetic-checkout.html @@ -5,73 +5,119 @@ Synthetic checkout -

Waiting for handshake

- - diff --git a/platforms/web/test/e2e/support/checkout-fixture.ts b/platforms/web/test/e2e/support/checkout-fixture.ts new file mode 100644 index 000000000..cd9e73ba4 --- /dev/null +++ b/platforms/web/test/e2e/support/checkout-fixture.ts @@ -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 { + 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; diff --git a/platforms/web/test/e2e/support/checkout-host-page.ts b/platforms/web/test/e2e/support/checkout-host-page.ts new file mode 100644 index 000000000..0ba445a42 --- /dev/null +++ b/platforms/web/test/e2e/support/checkout-host-page.ts @@ -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 { + await this.page.goto("/"); + await this.page.evaluate(() => customElements.whenDefined("shopify-checkout")); + } + + async configure(options: ConfigureOptions): Promise { + 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 { + await this.component.evaluate((element) => (element as ShopifyCheckout).close()); + } + + async clickBuy(): Promise { + await this.buyButton.click(); + } + + async openPopup(): Promise { + const [popup] = await Promise.all([this.page.waitForEvent("popup"), this.clickBuy()]); + return new ShopifyCheckoutPopup(popup); + } + + async startCheckout(options: ConfigureOptions = {}): Promise { + 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 { + return this.component.evaluate((element) => + Boolean(element.shadowRoot?.querySelector("#shopify-element-wrapper")), + ); + } + + async receivedEvents(): Promise { + return this.page.evaluate(() => window.checkoutEvents); + } + + async eventTypes(): Promise { + return (await this.receivedEvents()).map(({ type }) => type); + } + + eventDetail( + type: K, + ): Promise; + 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 { + await expect.poll(() => this.eventTypes()).toContain(type); + } +} diff --git a/platforms/web/test/e2e/support/embedded-checkout-stub.ts b/platforms/web/test/e2e/support/embedded-checkout-stub.ts new file mode 100644 index 000000000..70d7682a9 --- /dev/null +++ b/platforms/web/test/e2e/support/embedded-checkout-stub.ts @@ -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 { + await context.route(`${checkoutOrigin}/**`, (route) => + route.fulfill({ + contentType: "text/html; charset=utf-8", + ...(mode === "blank" + ? { body: "Checkout popup" } + : { path: syntheticCheckout }), + }), + ); +} diff --git a/platforms/web/test/e2e/support/fixtures.ts b/platforms/web/test/e2e/support/fixtures.ts index 50bb072e6..289909647 100644 --- a/platforms/web/test/e2e/support/fixtures.ts +++ b/platforms/web/test/e2e/support/fixtures.ts @@ -1,39 +1,30 @@ -import { fileURLToPath } from "node:url"; import { test as base, expect } from "@playwright/test"; -import type { ShopifyCheckoutEventMap } from "@shopify/checkout-kit"; +import { CheckoutHostPage } from "./checkout-host-page"; +import { stubEmbeddedCheckout, type CheckoutStubMode } from "./embedded-checkout-stub"; -type CheckoutEventRecord = { - [K in keyof ShopifyCheckoutEventMap]: { - type: K; - detail: ShopifyCheckoutEventMap[K]["detail"] | null; - }; -}[keyof ShopifyCheckoutEventMap]; - -declare global { - interface Window { - checkoutEvents: CheckoutEventRecord[]; - } -} - -export const checkoutOrigin = "https://checkout.example.test"; -const syntheticCheckout = fileURLToPath( - new URL("../fixtures/synthetic-checkout.html", import.meta.url), -); - -export const test = base.extend<{ networkGuard: void }>({ +export const test = base.extend<{ + checkoutStub: CheckoutStubMode; + host: CheckoutHostPage; + networkGuard: void; +}>({ + checkoutStub: ["handshake", { option: true }], + host: async ({ page }, use) => { + await use(new CheckoutHostPage(page)); + }, networkGuard: [ - async ({ context, baseURL }, use) => { + async ({ context, baseURL, checkoutStub }, use) => { const unexpectedRequests: string[] = []; await context.route("**/*", async (route) => { const url = new URL(route.request().url()); if (url.origin === baseURL) return route.continue(); - if (url.origin === checkoutOrigin) { - return route.fulfill({ path: syntheticCheckout, contentType: "text/html" }); - } unexpectedRequests.push(url.origin); await route.abort(); }); + // Playwright tries the most recently registered route first. Checkout + // requests use the stub; everything else still passes through the guard. + await stubEmbeddedCheckout(context, checkoutStub); + await use(); expect(unexpectedRequests, "All requests should stay within the local fixtures").toEqual([]); }, diff --git a/platforms/web/test/e2e/support/global.d.ts b/platforms/web/test/e2e/support/global.d.ts new file mode 100644 index 000000000..c764d21e2 --- /dev/null +++ b/platforms/web/test/e2e/support/global.d.ts @@ -0,0 +1,8 @@ +import type { CheckoutEventRecord, SyntheticCheckoutDriver } from "./types"; + +declare global { + interface Window { + checkoutEvents: CheckoutEventRecord[]; + syntheticCheckout: SyntheticCheckoutDriver; + } +} diff --git a/platforms/web/test/e2e/support/index.ts b/platforms/web/test/e2e/support/index.ts new file mode 100644 index 000000000..a6bdef151 --- /dev/null +++ b/platforms/web/test/e2e/support/index.ts @@ -0,0 +1,10 @@ +export { + checkoutId, + checkoutOrigin, + checkoutSrc, + orderId, + startedCheckout, +} from "./checkout-fixture"; +export { CheckoutHostPage } from "./checkout-host-page"; +export { ShopifyCheckoutPopup } from "./shopify-checkout-popup"; +export { expect, test } from "./fixtures"; diff --git a/platforms/web/test/e2e/support/shopify-checkout-popup.ts b/platforms/web/test/e2e/support/shopify-checkout-popup.ts new file mode 100644 index 000000000..b2c7b4c1e --- /dev/null +++ b/platforms/web/test/e2e/support/shopify-checkout-popup.ts @@ -0,0 +1,45 @@ +import type { Page } from "@playwright/test"; + +export class ShopifyCheckoutPopup { + constructor(readonly page: Page) {} + + async waitForReadyResponse(): Promise { + await this.page.waitForFunction(() => Boolean(window.syntheticCheckout)); + await this.page.evaluate(() => window.syntheticCheckout.readyResponse); + } + + async start(): Promise { + await this.page.evaluate(() => window.syntheticCheckout.start()); + } + + async complete(): Promise { + await this.page.evaluate(() => window.syntheticCheckout.complete()); + } + + async lineItemsChange(): Promise { + await this.page.evaluate(() => window.syntheticCheckout.lineItemsChange()); + } + + async totalsChange(): Promise { + await this.page.evaluate(() => window.syntheticCheckout.totalsChange()); + } + + async messagesChange(): Promise { + await this.page.evaluate(() => window.syntheticCheckout.messagesChange()); + } + + async openRequest(url: string): Promise { + await this.page.evaluate( + (requestedUrl) => window.syntheticCheckout.openRequest(requestedUrl), + url, + ); + } + + async error(): Promise { + await this.page.evaluate(() => window.syntheticCheckout.error()); + } + + isClosed(): boolean { + return this.page.isClosed(); + } +} diff --git a/platforms/web/test/e2e/support/types.ts b/platforms/web/test/e2e/support/types.ts new file mode 100644 index 000000000..2bda662fe --- /dev/null +++ b/platforms/web/test/e2e/support/types.ts @@ -0,0 +1,21 @@ +import type { ShopifyCheckout, ShopifyCheckoutEventMap } from "@shopify/checkout-kit"; + +export type ConfigureOptions = Partial>; + +export type CheckoutEventRecord = { + [K in keyof ShopifyCheckoutEventMap]: { + type: K; + detail: ShopifyCheckoutEventMap[K]["detail"] | null; + }; +}[keyof ShopifyCheckoutEventMap]; + +export interface SyntheticCheckoutDriver { + readyResponse: Promise; + start(): void; + complete(): void; + lineItemsChange(): void; + totalsChange(): void; + messagesChange(): void; + openRequest(url: string): void; + error(): void; +} diff --git a/platforms/web/test/e2e/tests/checkout.spec.ts b/platforms/web/test/e2e/tests/checkout.spec.ts deleted file mode 100644 index 5dff3812f..000000000 --- a/platforms/web/test/e2e/tests/checkout.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ShopifyCheckout } from "@shopify/checkout-kit"; -import { checkoutOrigin, expect, test } from "../support/fixtures"; - -test("opens checkout, completes the handshake and purchase, then closes", async ({ page }) => { - await page.goto("/"); - await page.evaluate(() => customElements.whenDefined("shopify-checkout")); - await page.locator("shopify-checkout").evaluate((element, origin) => { - const src = new URL("/checkout/checkout_test_1", origin); - src.searchParams.set("host_origin", location.origin); - (element as ShopifyCheckout).src = src.href; - }, checkoutOrigin); - - const popupPromise = page.waitForEvent("popup"); - await page.getByRole("button", { name: "Buy", exact: true }).click(); - const popup = await popupPromise; - - await expect(page.locator("#overlay")).toBeVisible(); - await expect(popup.locator("#status")).toHaveText("Ready"); - await popup.getByRole("button", { name: "Start checkout" }).click(); - await expect - .poll(() => page.evaluate(() => window.checkoutEvents)) - .toEqual([ - { - type: "start", - detail: { - checkout: { - id: "checkout_test_1", - 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: [], - }, - }, - }, - ]); - - await popup.getByRole("button", { name: "Complete checkout" }).click(); - await expect - .poll(() => page.evaluate(() => window.checkoutEvents.map(({ type }) => type))) - .toEqual(["start", "complete"]); - - const complete = await page.evaluate(() => - window.checkoutEvents.find((event) => event.type === "complete"), - ); - expect(complete?.detail?.checkout).toMatchObject({ - id: "checkout_test_1", - status: "completed", - order: { - id: "order_test_1", - permalinkUrl: `${checkoutOrigin}/orders/order_test_1`, - }, - }); - expect(complete?.detail?.checkout).not.toHaveProperty("ucp"); - expect( - await page - .locator("shopify-checkout") - .evaluate((element) => (element as ShopifyCheckout).checkout), - ).toEqual(complete?.detail?.checkout); - - // Completion leaves presentation under the host's control. - await page - .locator("shopify-checkout") - .evaluate((element) => (element as ShopifyCheckout).close()); - await expect.poll(() => popup.isClosed()).toBe(true); - await expect(page.locator("#overlay")).not.toBeVisible(); - await expect - .poll(() => page.evaluate(() => window.checkoutEvents.map(({ type }) => type))) - .toEqual(["start", "complete", "close"]); -}); diff --git a/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts b/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts new file mode 100644 index 000000000..04463ad67 --- /dev/null +++ b/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts @@ -0,0 +1,96 @@ +import packageJson from "@shopify/checkout-kit/package.json" with { type: "json" }; +import { checkoutOrigin, checkoutSrc, expect, test } from "../../support"; + +test.use({ checkoutStub: "blank" }); + +test.describe("component registration", () => { + test("upgrades and attaches an open shadow root", async ({ host }) => { + await host.goto(); + + expect(await host.hasShadowWrapper()).toBe(true); + }); + + test("renders the overlay controls when checkout opens", async ({ host }) => { + await host.startCheckout(); + + await expect(host.overlay).toBeVisible(); + await expect(host.overlayFocusButton).toBeVisible(); + await expect(host.overlayCloseButton).toBeVisible(); + }); +}); + +test.describe("src reflection and popup URL", () => { + test("reflects src and adds negotiation parameters to the popup URL", async ({ host }) => { + const src = checkoutSrc({ source: "browser-test" }); + const popup = await host.startCheckout({ src }); + + await expect(host.component).toHaveAttribute("src", src); + await expect(popup.page).toHaveURL( + (url) => url.origin === checkoutOrigin && url.searchParams.get("ec_version") !== null, + ); + const url = new URL(popup.page.url()); + expect(url.pathname).toBe(new URL(src).pathname); + expect(url.searchParams.get("source")).toBe("browser-test"); + expect(url.searchParams.get("ec_version")).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(url.searchParams.get("ec_delegate")).toBe("window.open"); + expect(url.searchParams.get("ck_version")).toBe(packageJson.version); + }); +}); + +test.describe("open()", () => { + for (const { name, src } of [ + { name: "empty", src: "" }, + { name: "non-HTTPS", src: "http://checkout.example.test/checkout" }, + { name: "malformed", src: "not-a-url" }, + ]) { + test(`warns and opens no popup when src is ${name}`, async ({ host, page, context }) => { + await host.goto(); + await host.configure({ src, logLevel: "warn" }); + + const [warning] = await Promise.all([ + page.waitForEvent( + "console", + (message) => + message.type() === "warning" && + message.text().includes("src property is empty or invalid"), + ), + host.clickBuy(), + ]); + + expect(warning.text()).toContain("src property is empty or invalid"); + expect(context.pages()).toHaveLength(1); + await expect(host.overlay).not.toBeVisible(); + }); + } + + test("opens a popup and shows the modal scrim", async ({ host }) => { + const popup = await host.startCheckout(); + + expect(popup.isClosed()).toBe(false); + await expect(host.overlay).toBeVisible(); + await expect(host.overlay).toHaveAttribute("open", ""); + }); +}); + +test.describe("closing checkout", () => { + test("close() dismisses the popup and dispatches close", async ({ host }) => { + const popup = await host.startCheckout(); + await expect(host.overlay).toBeVisible(); + + await host.close(); + + await expect.poll(() => popup.isClosed()).toBe(true); + await expect(host.overlay).not.toBeVisible(); + await expect.poll(() => host.eventTypes()).toEqual(["close"]); + }); + + test("the overlay close button dismisses the popup and dispatches close", async ({ host }) => { + const popup = await host.startCheckout(); + + await host.overlayCloseButton.click(); + + await expect.poll(() => popup.isClosed()).toBe(true); + await expect(host.overlay).not.toBeVisible(); + await expect.poll(() => host.eventTypes()).toEqual(["close"]); + }); +}); diff --git a/platforms/web/test/e2e/tests/synthetic/protocol.spec.ts b/platforms/web/test/e2e/tests/synthetic/protocol.spec.ts new file mode 100644 index 000000000..b251f2acb --- /dev/null +++ b/platforms/web/test/e2e/tests/synthetic/protocol.spec.ts @@ -0,0 +1,137 @@ +import { checkoutId, checkoutOrigin, expect, orderId, startedCheckout, test } from "../../support"; + +test.describe("embedded checkout protocol", () => { + test.describe("completion flow", () => { + test("handshakes, starts, completes, and leaves closing to the host", async ({ host }) => { + const popup = await host.startCheckout(); + await expect(host.overlay).toBeVisible(); + await popup.waitForReadyResponse(); + expect(await host.receivedEvents()).toEqual([]); + + await popup.start(); + + await expect + .poll(() => host.receivedEvents()) + .toEqual([{ type: "start", detail: { checkout: startedCheckout } }]); + expect(await host.checkout()).toEqual(startedCheckout); + + await popup.complete(); + + await expect.poll(() => host.eventTypes()).toEqual(["start", "complete"]); + const complete = await host.eventDetail("complete"); + expect(complete?.checkout).toMatchObject({ + id: checkoutId, + status: "completed", + order: { + id: orderId, + permalinkUrl: `${checkoutOrigin}/orders/${orderId}`, + }, + }); + expect(complete?.checkout).not.toHaveProperty("ucp"); + expect(await host.checkout()).toEqual(complete?.checkout); + expect(popup.isClosed()).toBe(false); + await expect(host.overlay).toBeVisible(); + + await host.close(); + + await expect.poll(() => popup.isClosed()).toBe(true); + await expect(host.overlay).not.toBeVisible(); + await expect.poll(() => host.eventTypes()).toEqual(["start", "complete", "close"]); + }); + }); + + test.describe("change notifications", () => { + test("ec.line_items.change updates line items and dispatches update", async ({ host }) => { + const popup = await host.startCheckout(); + await popup.waitForReadyResponse(); + await popup.start(); + await host.expectEvent("start"); + + await popup.lineItemsChange(); + + await expect.poll(() => host.eventTypes()).toEqual(["start", "update"]); + const update = await host.eventDetail("update"); + expect(update?.checkout).toMatchObject({ + id: checkoutId, + lineItems: [{ id: "li_1", quantity: 2, totals: [{ type: "total", amount: 4000 }] }], + totals: [{ type: "total", amount: 4000 }], + }); + expect(update?.checkout).not.toHaveProperty("ucp"); + expect(await host.checkout()).toEqual(update?.checkout); + }); + + test("ec.totals.change updates totals and dispatches update", async ({ host }) => { + const popup = await host.startCheckout(); + await popup.waitForReadyResponse(); + await popup.start(); + await host.expectEvent("start"); + + await popup.totalsChange(); + + await expect.poll(() => host.eventTypes()).toEqual(["start", "update"]); + const update = await host.eventDetail("update"); + expect(update?.checkout).toMatchObject({ + id: checkoutId, + totals: [{ type: "total", amount: 2500 }], + }); + expect(await host.checkout()).toEqual(update?.checkout); + }); + + test("ec.messages.change updates messages and dispatches update", async ({ host }) => { + const popup = await host.startCheckout(); + await popup.waitForReadyResponse(); + await popup.start(); + await host.expectEvent("start"); + + await popup.messagesChange(); + + await expect.poll(() => host.eventTypes()).toEqual(["start", "update"]); + const update = await host.eventDetail("update"); + expect(update?.checkout).toMatchObject({ + id: checkoutId, + messages: [{ code: "inventory_updated", content: "Inventory changed." }], + }); + expect(await host.checkout()).toEqual(update?.checkout); + }); + }); + + test.describe("window delegation", () => { + test("opens the URL from ec.window.open_request in a new window", async ({ host, context }) => { + const requestedUrl = "https://return.example.test/return?source=protocol-event"; + await context.route(requestedUrl, (route) => + route.fulfill({ + contentType: "text/html", + body: "Return target", + }), + ); + const popup = await host.startCheckout(); + await popup.waitForReadyResponse(); + + // The SDK uses noopener, so the delegated window belongs to the context + // without being exposed as a popup of the host page. + const [delegatedWindow] = await Promise.all([ + context.waitForEvent("page"), + popup.openRequest(requestedUrl), + ]); + + await expect(delegatedWindow).toHaveURL(requestedUrl); + expect(popup.isClosed()).toBe(false); + }); + }); + + test.describe("error flow", () => { + test("terminal ec.error populates error and closes the popup", async ({ host }) => { + const popup = await host.startCheckout(); + await popup.waitForReadyResponse(); + + await popup.error(); + + await expect.poll(() => host.eventTypes()).toEqual(["error", "close"]); + const error = { code: "invalid_cart", message: "The cart is no longer valid." }; + expect(await host.error()).toEqual(error); + expect(await host.eventDetail("error")).toEqual({ error }); + await expect.poll(() => popup.isClosed()).toBe(true); + await expect(host.overlay).not.toBeVisible(); + }); + }); +}); diff --git a/platforms/web/test/e2e/tsconfig.json b/platforms/web/test/e2e/tsconfig.json index 10894fe00..88a810b3f 100644 --- a/platforms/web/test/e2e/tsconfig.json +++ b/platforms/web/test/e2e/tsconfig.json @@ -7,6 +7,7 @@ "types": ["node"], "strict": true, "skipLibCheck": true, + "resolveJsonModule": true, "noEmit": true }, "include": ["tests", "support", "playwright.config.ts"]