From f754a7dd76fba2947f45f3298218297fc7918e15 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 8 Jul 2026 09:52:40 +0100 Subject: [PATCH 1/2] [Web] Playwright E2E tests --- Package.resolved | 12 +- dev.yml | 29 +++++ e2e/README.md | 65 ++++++++-- e2e/web/.gitignore | 4 + e2e/web/fixtures/host.html | 60 +++++++++ e2e/web/fixtures/synthetic-checkout.html | 124 ++++++++++++++++++ e2e/web/package.json | 21 ++++ e2e/web/playwright.config.ts | 25 ++++ e2e/web/pnpm-lock.yaml | 77 ++++++++++++ e2e/web/server.mjs | 65 ++++++++++ e2e/web/support/checkout-fixture.ts | 21 ++++ e2e/web/support/checkout-host-page.ts | 87 +++++++++++++ e2e/web/support/embedded-checkout-stub.ts | 29 +++++ e2e/web/support/global.d.ts | 16 +++ e2e/web/support/index.ts | 13 ++ e2e/web/support/shopify-checkout-popup.ts | 72 +++++++++++ e2e/web/support/types.ts | 36 ++++++ e2e/web/tests/synthetic/presentation.spec.ts | 98 +++++++++++++++ e2e/web/tests/synthetic/protocol.spec.ts | 125 +++++++++++++++++++ e2e/web/tsconfig.json | 15 +++ 20 files changed, 975 insertions(+), 19 deletions(-) create mode 100644 e2e/web/.gitignore create mode 100644 e2e/web/fixtures/host.html create mode 100644 e2e/web/fixtures/synthetic-checkout.html create mode 100644 e2e/web/package.json create mode 100644 e2e/web/playwright.config.ts create mode 100644 e2e/web/pnpm-lock.yaml create mode 100644 e2e/web/server.mjs create mode 100644 e2e/web/support/checkout-fixture.ts create mode 100644 e2e/web/support/checkout-host-page.ts create mode 100644 e2e/web/support/embedded-checkout-stub.ts create mode 100644 e2e/web/support/global.d.ts create mode 100644 e2e/web/support/index.ts create mode 100644 e2e/web/support/shopify-checkout-popup.ts create mode 100644 e2e/web/support/types.ts create mode 100644 e2e/web/tests/synthetic/presentation.spec.ts create mode 100644 e2e/web/tests/synthetic/protocol.spec.ts create mode 100644 e2e/web/tsconfig.json diff --git a/Package.resolved b/Package.resolved index 0cf6bfc48..e5d0861be 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,5 @@ { - "originHash" : "26dbcafccf3b59eed474185fd06890aec2eacb608722f803008729da6a0bfe0d", "pins" : [ - { - "identity" : "apollo-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apollographql/apollo-ios.git", - "state" : { - "revision" : "5a8d18cc239dd3c4b31c9e13e671dbcef5e4072a", - "version" : "2.2.0" - } - }, { "identity" : "viewinspector", "kind" : "remoteSourceControl", @@ -20,5 +10,5 @@ } } ], - "version" : 3 + "version" : 2 } diff --git a/dev.yml b/dev.yml index f8408ae89..58cd062a4 100644 --- a/dev.yml +++ b/dev.yml @@ -23,6 +23,12 @@ up: - protocol - platforms/react-native - platforms/web + - e2e/web + - custom: + name: Install Playwright Chromium for web e2e + met?: | + cd e2e/web && pnpm exec node -e 'const fs = require("fs"); const {chromium} = require("@playwright/test"); if (!fs.existsSync(chromium.executablePath())) process.exit(1)' + meet: cd e2e/web && pnpm exec playwright install chromium - custom: name: Copy root env into worktree met?: ./scripts/copy_worktree_env --check @@ -688,6 +694,29 @@ commands: desc: Run Web package verification run: cd platforms/web && pnpm verify + e2e: + desc: Run Playwright end-to-end tests against a built copy of the package + long_desc: | + Builds the @shopify/checkout-kit package, then runs the Playwright + suite under e2e/web against the built dist/index.js. Any extra + arguments are forwarded to `playwright test`. + + dev web e2e Run the suite headless + dev web e2e --headed Run in a headed browser + dev web e2e --ui Open the Playwright UI runner + dev web e2e --grep "happy" Run only specs matching a pattern + dev web e2e report Open the last HTML report + syntax: + optional: "[playwright test args]" + run: | + set -e + /opt/dev/bin/dev web build + cd e2e/web && pnpm exec playwright test "$@" + subcommands: + report: + desc: Open the last Playwright HTML report + run: cd e2e/web && pnpm report + snapshot: desc: Manage snapshots for packaged contents run: | diff --git a/e2e/README.md b/e2e/README.md index 45b8ad3ed..3bb62d7f8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,13 +1,17 @@ # Checkout Kit End-to-End Tests -This directory contains Maestro end-to-end smoke flows for Checkout Kit sample -apps. +This directory contains cross-platform end-to-end tests for Checkout Kit: -The current runnable suite starts with React Native. It verifies a full guest -checkout from a seeded cart in the sample app, through Shopify checkout, and -back to the app after completion. +- **React Native** — Maestro smoke flows against the sample app. +- **Web** — Playwright tests against a built copy of the `@shopify/checkout-kit` + package. See [`web/`](web/). -## Run locally +## React Native (Maestro) + +The React Native suite verifies a full guest checkout from a seeded cart in the +sample app, through Shopify checkout, and back to the app after completion. + +### Run locally Run the matching command from the repo root. @@ -31,17 +35,62 @@ sample app, then run Maestro. They require the standard storefront `.env` setup, but the E2E flow seeds its own cart through the bootstrap deep link; no manual sample cart setup is required. -## Files +### Files - `config.yaml` configures Maestro for shared platform behavior. - `flows/` contains reusable Maestro subflows for app setup and checkout steps. - `tests/react-native/full-guest-checkout.yaml` composes the React Native guest checkout smoke test from those subflows. -## Scope +### Scope This smoke flow is intended to catch regressions in the React Native sample app integration surface: cart bootstrap, checkout presentation, checkout completion, and return to the sample app. It is not a replacement for checkout-web's browser-based coverage or for future native Swift and Android sample-app E2E coverage. + +## Web (Playwright) + +The web suite loads the built `dist/index.js` in a real Chromium browser and +drives the `` custom element end to end. It is hermetic — the +embedded checkout is stubbed with Playwright network routing, so no storefront +`.env` or network access is required. + +### Run locally + +From the repo root: + +```bash +dev web e2e # build the package, then run headless +dev web e2e --headed # run in a headed browser +dev web e2e --ui # open the Playwright UI runner +dev web e2e --grep "happy" # run only specs matching a pattern +dev web e2e report # open the last HTML report +``` + +`dev web e2e` rebuilds the package first, then forwards any extra arguments to +`playwright test`, so any Playwright flag works. `report` is the one exception: +it opens the last HTML report instead. `dev up` installs the `e2e/web` +dependencies and the Playwright Chromium browser. + +### Layout + +The web tests are a standalone package under [`web/`](web/) so they can pin +Playwright independently of the platform packages: + +- `web/server.mjs` — zero-dependency static server that serves the test + fixtures and mounts the built `platforms/web/dist/` at `/dist/`. +- `web/fixtures/` — the host page that mounts the component and a synthetic + checkout page used to exercise the protocol handshake. +- `web/support/` — shared TypeScript types and checkout payload constants. +- `web/tests/synthetic/` — hermetic Playwright specs that use local host and + embedded-checkout fixtures (`presentation` + protocol handshake). + +### Scope + +These tests cover the web component's public surface against its built output: +custom-element registration, `src` handling and the overlay link, popup +open/close, and the embedded checkout protocol handshake (`ec.ready` → +`ec.start`/`ec.complete` and the unrecoverable `ec.error` path). They +complement, and do not replace, the unit tests in `platforms/web`. diff --git a/e2e/web/.gitignore b/e2e/web/.gitignore new file mode 100644 index 000000000..e16c8f020 --- /dev/null +++ b/e2e/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/e2e/web/fixtures/host.html b/e2e/web/fixtures/host.html new file mode 100644 index 000000000..9ceb49766 --- /dev/null +++ b/e2e/web/fixtures/host.html @@ -0,0 +1,60 @@ + + + + + + Checkout Kit web e2e host + + + +

Checkout Kit web e2e host

+ + + + + + diff --git a/e2e/web/fixtures/synthetic-checkout.html b/e2e/web/fixtures/synthetic-checkout.html new file mode 100644 index 000000000..cca2c966c --- /dev/null +++ b/e2e/web/fixtures/synthetic-checkout.html @@ -0,0 +1,124 @@ + + + + + Synthetic checkout + + + + + diff --git a/e2e/web/package.json b/e2e/web/package.json new file mode 100644 index 000000000..3c1dacba7 --- /dev/null +++ b/e2e/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "@shopify/checkout-kit-web-e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Playwright end-to-end tests for the @shopify/checkout-kit web component, run against a built copy of the package.", + "scripts": { + "test": "playwright test", + "typecheck": "tsc --noEmit", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@types/node": "^22.10.0", + "typescript": "^5.9.0" + }, + "packageManager": "pnpm@10.33.1", + "engines": { + "node": ">=22.14.0" + } +} diff --git a/e2e/web/playwright.config.ts b/e2e/web/playwright.config.ts new file mode 100644 index 000000000..cd8477efa --- /dev/null +++ b/e2e/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.PORT ?? 4321); +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "line" : [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "node server.mjs", + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + stderr: "pipe", + env: { PORT: String(PORT) }, + }, +}); diff --git a/e2e/web/pnpm-lock.yaml b/e2e/web/pnpm-lock.yaml new file mode 100644 index 000000000..354383ce7 --- /dev/null +++ b/e2e/web/pnpm-lock.yaml @@ -0,0 +1,77 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.61.1 + '@types/node': + specifier: ^22.10.0 + version: 22.20.0 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + +packages: + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==, tarball: https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz} + engines: {node: '>=18'} + hasBin: true + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==, tarball: https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==, tarball: https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==, tarball: https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz} + engines: {node: '>=18'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz} + +snapshots: + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + fsevents@2.3.2: + optional: true + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/e2e/web/server.mjs b/e2e/web/server.mjs new file mode 100644 index 000000000..465c34033 --- /dev/null +++ b/e2e/web/server.mjs @@ -0,0 +1,65 @@ +import { createServer } from "node:http"; +import { existsSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { dirname, extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(here, "fixtures"); +const distDir = join(here, "..", "..", "platforms", "web", "dist"); +const distEntry = join(distDir, "index.js"); + +if (!existsSync(distEntry)) { + console.error( + `[web-e2e] Built web component not found at ${distEntry}\n` + + "Build it first: `dev web build` (or `pnpm --dir platforms/web build`).", + ); + process.exit(1); +} + +const PORT = Number(process.env.PORT ?? 4321); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +function resolveRequestPath(rawUrl) { + const clean = normalize(decodeURIComponent(rawUrl.split("?")[0])); + if (clean.startsWith("/dist/")) { + const full = join(distDir, clean.slice("/dist/".length)); + return full.startsWith(distDir) ? full : null; + } + const full = join(fixturesDir, clean === "/" ? "host.html" : clean); + return full.startsWith(fixturesDir) ? full : null; +} + +const server = createServer(async (req, res) => { + const filePath = resolveRequestPath(req.url ?? "/"); + if (!filePath) { + res.writeHead(403).end("Forbidden"); + return; + } + try { + const info = await stat(filePath); + if (!info.isFile()) throw new Error("not a file"); + const body = await readFile(filePath); + res.writeHead(200, { + "content-type": MIME[extname(filePath)] ?? "application/octet-stream", + "cache-control": "no-store", + }); + res.end(body); + } catch { + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found"); + } +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`[web-e2e] serving fixtures + built dist on http://127.0.0.1:${PORT}`); +}); diff --git a/e2e/web/support/checkout-fixture.ts b/e2e/web/support/checkout-fixture.ts new file mode 100644 index 000000000..749b88bf6 --- /dev/null +++ b/e2e/web/support/checkout-fixture.ts @@ -0,0 +1,21 @@ +export class CheckoutFixture { + static readonly ORIGIN = "https://checkout.example.test"; + static readonly CHECKOUT_ID = "checkout_test_1"; + static readonly ORDER_ID = "order_test_1"; + static readonly ERROR_CODE = "checkout_failed"; + + static get glob(): string { + return `${this.ORIGIN}/**`; + } + + static url(path = ""): string { + return `${this.ORIGIN}${path}`; + } + + static src(params: Record = {}): string { + const searchParams = new URLSearchParams(params); + return `${this.ORIGIN}/checkout/${this.CHECKOUT_ID}${ + searchParams.size > 0 ? `?${searchParams}` : "" + }`; + } +} diff --git a/e2e/web/support/checkout-host-page.ts b/e2e/web/support/checkout-host-page.ts new file mode 100644 index 000000000..e7f670c9b --- /dev/null +++ b/e2e/web/support/checkout-host-page.ts @@ -0,0 +1,87 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +import type { CheckoutError, CheckoutEventRecord, CheckoutResource, ConfigureOptions } from "./types"; + +export class CheckoutHostPage { + readonly page: Page; + readonly overlay: Locator; + readonly overlayLink: Locator; + readonly overlayCloseButton: Locator; + readonly buyButton: Locator; + + constructor(page: Page) { + this.page = page; + this.overlay = page.locator("#overlay"); + this.overlayLink = page.locator("#overlay-link"); + this.overlayCloseButton = page.locator("#overlay-close-button"); + this.buyButton = page.locator("#buy"); + } + + async goto(): Promise { + await this.page.goto("/"); + await this.page.evaluate(() => customElements.whenDefined("shopify-checkout")); + } + + async configure(options: ConfigureOptions): Promise { + await this.page.evaluate((opts) => window.__ck.configure(opts), options); + } + + async open(): Promise { + await this.page.evaluate(() => window.__ck.open()); + } + + async close(): Promise { + await this.page.evaluate(() => window.__ck.close()); + } + + async clickBuy(): Promise { + await this.buyButton.click(); + } + + async openPopup(): Promise { + const [popup] = await Promise.all([this.page.waitForEvent("popup"), this.buyButton.click()]); + return popup; + } + + async startCheckout(options: ConfigureOptions): Promise { + await this.goto(); + await this.configure(options); + return this.openPopup(); + } + + async hasShadowWrapper(): Promise { + return this.page.evaluate(() => + Boolean( + document.querySelector("shopify-checkout")?.shadowRoot?.querySelector( + "#shopify-element-wrapper", + ), + ), + ); + } + + async receivedEvents(): Promise { + return this.page.evaluate(() => window.__ck.events); + } + + async eventDetail(type: string): Promise { + const detail = await this.page.evaluate( + (eventType) => window.__ck.events.find((event) => event.type === eventType)?.detail, + type, + ); + return detail as T | undefined; + } + + async checkout(): Promise { + return this.page.evaluate(() => window.__ck.checkout); + } + + async error(): Promise { + return this.page.evaluate(() => window.__ck.error); + } + + async expectEvent(type: string): Promise { + await expect + .poll(async () => (await this.receivedEvents()).map((event) => event.type)) + .toContain(type); + } +} diff --git a/e2e/web/support/embedded-checkout-stub.ts b/e2e/web/support/embedded-checkout-stub.ts new file mode 100644 index 000000000..d59b955ea --- /dev/null +++ b/e2e/web/support/embedded-checkout-stub.ts @@ -0,0 +1,29 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { BrowserContext } from "@playwright/test"; + +import { CheckoutFixture } from "./checkout-fixture"; + +const SYNTHETIC_CHECKOUT_HTML = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "fixtures", + "synthetic-checkout.html", +); + +const HTML = "text/html; charset=utf-8"; + +export class EmbeddedCheckoutStub { + static async blank(context: BrowserContext): Promise { + await context.route(CheckoutFixture.glob, (route) => + route.fulfill({ status: 200, contentType: HTML, body: "popup" }), + ); + } + + static async handshake(context: BrowserContext): Promise { + await context.route(CheckoutFixture.glob, (route) => + route.fulfill({ path: SYNTHETIC_CHECKOUT_HTML, contentType: HTML }), + ); + } +} diff --git a/e2e/web/support/global.d.ts b/e2e/web/support/global.d.ts new file mode 100644 index 000000000..4ace6fc11 --- /dev/null +++ b/e2e/web/support/global.d.ts @@ -0,0 +1,16 @@ +import type { CheckoutError, CheckoutEventRecord, CheckoutResource, ConfigureOptions } from "./types"; + +export {}; + +declare global { + interface Window { + __ck: { + readonly events: CheckoutEventRecord[]; + readonly checkout: CheckoutResource | undefined; + readonly error: CheckoutError | undefined; + configure(options?: ConfigureOptions): void; + open(): void; + close(): void; + }; + } +} diff --git a/e2e/web/support/index.ts b/e2e/web/support/index.ts new file mode 100644 index 000000000..c78a4898c --- /dev/null +++ b/e2e/web/support/index.ts @@ -0,0 +1,13 @@ +export { CheckoutFixture } from "./checkout-fixture"; +export { CheckoutHostPage } from "./checkout-host-page"; +export { EmbeddedCheckoutStub } from "./embedded-checkout-stub"; +export { ShopifyCheckoutPopup } from "./shopify-checkout-popup"; +export type { + CheckoutError, + CheckoutErrorMessage, + CheckoutEventRecord, + CheckoutResource, + CompleteEventDetail, + ConfigureOptions, + OrderConfirmation, +} from "./types"; diff --git a/e2e/web/support/shopify-checkout-popup.ts b/e2e/web/support/shopify-checkout-popup.ts new file mode 100644 index 000000000..c3452ea52 --- /dev/null +++ b/e2e/web/support/shopify-checkout-popup.ts @@ -0,0 +1,72 @@ +import type { Page } from "@playwright/test"; + +interface SyntheticCheckoutDriver { + readyResponse: Promise; + start(): void; + complete(): void; + lineItemsChange(): void; + totalsChange(): void; + messagesChange(): void; + openRequest(url: string): void; + error(): void; +} + +export class ShopifyCheckoutPopup { + constructor(private readonly page: Page) {} + + async waitForReadyResponse(): Promise { + await this.page.evaluate(() => { + const checkout = ( + window as unknown as { + __syntheticCheckout: Pick; + } + ).__syntheticCheckout; + return checkout.readyResponse; + }); + } + + async start(): Promise { + await this.call("start"); + } + + async complete(): Promise { + await this.call("complete"); + } + + async lineItemsChange(): Promise { + await this.call("lineItemsChange"); + } + + async totalsChange(): Promise { + await this.call("totalsChange"); + } + + async messagesChange(): Promise { + await this.call("messagesChange"); + } + + async openRequest(url: string): Promise { + await this.call("openRequest", url); + } + + async error(): Promise { + await this.call("error"); + } + + private async call( + method: Exclude, + ...args: string[] + ): Promise { + await this.page.evaluate( + ({ methodName, methodArgs }) => { + const checkout = ( + window as unknown as { + __syntheticCheckout: Record void>; + } + ).__syntheticCheckout; + checkout[methodName]?.(...methodArgs); + }, + { methodName: method, methodArgs: args }, + ); + } +} diff --git a/e2e/web/support/types.ts b/e2e/web/support/types.ts new file mode 100644 index 000000000..553e88522 --- /dev/null +++ b/e2e/web/support/types.ts @@ -0,0 +1,36 @@ +export interface ConfigureOptions { + src?: string; + target?: string; +} + +export interface OrderConfirmation { + id: string; + permalink_url: string; +} + +export interface CheckoutResource { + id: string; + currency?: string; + status?: string; + order?: OrderConfirmation; +} + +export interface CompleteEventDetail { + order?: OrderConfirmation; +} + +export interface CheckoutErrorMessage { + type: string; + code: string; + content: string; + severity: string; +} + +export interface CheckoutError { + messages: CheckoutErrorMessage[]; +} + +export interface CheckoutEventRecord { + type: string; + detail: unknown; +} diff --git a/e2e/web/tests/synthetic/presentation.spec.ts b/e2e/web/tests/synthetic/presentation.spec.ts new file mode 100644 index 000000000..291547f28 --- /dev/null +++ b/e2e/web/tests/synthetic/presentation.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "@playwright/test"; + +import { CheckoutFixture, CheckoutHostPage, EmbeddedCheckoutStub } from "../../support"; + +const HTTPS_SRC = CheckoutFixture.src(); + +test.describe("component registration", () => { + test("upgrades and attaches an open shadow root", async ({ page }) => { + const host = new CheckoutHostPage(page); + await host.goto(); + + expect(await host.hasShadowWrapper()).toBe(true); + }); + + test("renders the overlay scrim structure in the shadow root", async ({ page }) => { + const host = new CheckoutHostPage(page); + await host.goto(); + + await expect(host.overlay).toHaveCount(1); + await expect(host.overlayLink).toHaveCount(1); + await expect(host.overlayCloseButton).toHaveCount(1); + }); +}); + +test.describe("src reflection and overlay link", () => { + test("reflects src and points the overlay link at the parametrised checkout URL", async ({ + page, + }) => { + const host = new CheckoutHostPage(page); + await host.goto(); + await host.configure({ src: HTTPS_SRC }); + + const href = await host.overlayLink.getAttribute("href"); + expect(href).not.toBeNull(); + + const url = new URL(href!); + expect(url.origin).toBe(CheckoutFixture.ORIGIN); + expect(url.searchParams.get("ec_version")).toBe("2026-04-08"); + expect(url.searchParams.get("ec_delegate")).toBe("window.open"); + expect(url.searchParams.get("ck_version")).toBe("4.0.0"); + }); + + test("leaves the overlay link without href for a non-https src", async ({ page }) => { + const host = new CheckoutHostPage(page); + await host.goto(); + await host.configure({ src: "http://shop.example.com/checkout" }); + + await expect(host.overlayLink).not.toHaveAttribute("href", /.*/); + }); +}); + +test.describe("open()", () => { + test("warns and opens no popup when src is empty", async ({ page }) => { + const warnings: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "warning") warnings.push(msg.text()); + }); + + let popped = false; + page.on("popup", () => { + popped = true; + }); + + const host = new CheckoutHostPage(page); + await host.goto(); + await host.clickBuy(); + await page.waitForTimeout(200); + + expect(popped).toBe(false); + expect(warnings.join("\n")).toContain("src property is empty or invalid"); + }); + + test("opens a popup and shows the modal scrim for an https popup target", async ({ + page, + context, + }) => { + await EmbeddedCheckoutStub.blank(context); + + const host = new CheckoutHostPage(page); + const popup = await host.startCheckout({ src: HTTPS_SRC, target: "popup" }); + expect(popup).toBeTruthy(); + + await expect(host.overlay).toHaveAttribute("open", ""); + }); + + test("close() dismisses the popup and dispatches ec.close", async ({ page, context }) => { + await EmbeddedCheckoutStub.blank(context); + + const host = new CheckoutHostPage(page); + const popup = await host.startCheckout({ src: HTTPS_SRC, target: "popup" }); + await expect(host.overlay).toHaveAttribute("open", ""); + + await host.close(); + + await host.expectEvent("ec.close"); + await expect.poll(() => popup.isClosed()).toBe(true); + }); +}); diff --git a/e2e/web/tests/synthetic/protocol.spec.ts b/e2e/web/tests/synthetic/protocol.spec.ts new file mode 100644 index 000000000..3310116e2 --- /dev/null +++ b/e2e/web/tests/synthetic/protocol.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from "@playwright/test"; + +import { + CheckoutFixture, + CheckoutHostPage, + EmbeddedCheckoutStub, + ShopifyCheckoutPopup, +} from "../../support"; + +test.beforeEach(async ({ context }) => { + await EmbeddedCheckoutStub.handshake(context); +}); + +test.describe("embedded checkout protocol", () => { + test.describe("completion flow", () => { + test("ec.ready handshake drives ec.start then ec.complete", async ({ page }) => { + const host = new CheckoutHostPage(page); + const popup = await host.startCheckout({ src: CheckoutFixture.src(), target: "popup" }); + const syntheticCheckout = new ShopifyCheckoutPopup(popup); + await syntheticCheckout.waitForReadyResponse(); + await syntheticCheckout.start(); + await syntheticCheckout.complete(); + + await host.expectEvent("ec.complete"); + + const types = (await host.receivedEvents()).map((event) => event.type); + expect(types).not.toContain("ec.ready"); + expect(types.indexOf("ec.start")).toBeGreaterThanOrEqual(0); + expect(types.indexOf("ec.start")).toBeLessThan(types.indexOf("ec.complete")); + + const checkout = await host.checkout(); + expect(checkout?.id).toBe(CheckoutFixture.CHECKOUT_ID); + + const complete = await host.eventDetail<{ order?: { id?: string } }>("ec.complete"); + expect(complete?.order?.id).toBe(CheckoutFixture.ORDER_ID); + }); + }); + + test.describe("change notifications", () => { + test("updates checkout and dispatches line item, total, and message changes", async ({ + page, + }) => { + const host = new CheckoutHostPage(page); + const popup = await host.startCheckout({ src: CheckoutFixture.src(), target: "popup" }); + const syntheticCheckout = new ShopifyCheckoutPopup(popup); + await syntheticCheckout.waitForReadyResponse(); + await syntheticCheckout.start(); + await syntheticCheckout.lineItemsChange(); + await syntheticCheckout.totalsChange(); + await syntheticCheckout.messagesChange(); + + await host.expectEvent("ec.messages.change"); + + const events = await host.receivedEvents(); + const types = events.map((event) => event.type); + expect(types).toEqual([ + "ec.start", + "ec.line_items.change", + "ec.totals.change", + "ec.messages.change", + ]); + + const lineItemsChange = await host.eventDetail<{ + lineItems?: Array<{ id?: string }>; + checkout?: { id?: string }; + }>("ec.line_items.change"); + expect(lineItemsChange?.lineItems?.[0]?.id).toBe("li_1"); + expect(lineItemsChange?.checkout?.id).toBe(CheckoutFixture.CHECKOUT_ID); + + const totalsChange = await host.eventDetail<{ + totals?: Array<{ type?: string; amount?: number }>; + }>("ec.totals.change"); + expect(totalsChange?.totals?.find((total) => total.type === "total")?.amount).toBe(2000); + + const messagesChange = await host.eventDetail<{ + messages?: Array<{ code?: string }>; + }>("ec.messages.change"); + expect(messagesChange?.messages?.[0]?.code).toBe("inventory_updated"); + }); + }); + + test.describe("window delegation", () => { + test("opens the URL from ec.window.open_request in a new window", async ({ + context, + page, + }) => { + const requestedUrl = "https://return.example.test/return?source=protocol-event"; + + await context.route("https://return.example.test/**", (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: "Return target", + }), + ); + + const host = new CheckoutHostPage(page); + const popup = await host.startCheckout({ src: CheckoutFixture.src(), target: "popup" }); + const syntheticCheckout = new ShopifyCheckoutPopup(popup); + await syntheticCheckout.waitForReadyResponse(); + await syntheticCheckout.openRequest(requestedUrl); + + const delegatedWindow = await page.waitForEvent("popup"); + await expect(delegatedWindow).toHaveURL(requestedUrl); + }); + }); + + test.describe("error flow", () => { + test("unrecoverable ec.error populates error and auto-closes", async ({ page }) => { + const host = new CheckoutHostPage(page); + const popup = await host.startCheckout({ src: CheckoutFixture.src(), target: "popup" }); + const syntheticCheckout = new ShopifyCheckoutPopup(popup); + await syntheticCheckout.waitForReadyResponse(); + await syntheticCheckout.error(); + + await host.expectEvent("ec.error"); + + const error = await host.error(); + expect(error?.messages[0]?.code).toBe(CheckoutFixture.ERROR_CODE); + + await host.expectEvent("ec.close"); + await expect.poll(() => popup.isClosed()).toBe(true); + }); + }); +}); diff --git a/e2e/web/tsconfig.json b/e2e/web/tsconfig.json new file mode 100644 index 000000000..e0f826269 --- /dev/null +++ b/e2e/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["tests", "support", "playwright.config.ts"] +} From 3fc550454f779386e8484e19fbab087051fc42d1 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 8 Jul 2026 09:55:19 +0100 Subject: [PATCH 2/2] Run E2e in CI --- .github/filters/platform-non-docs.yml | 4 +++ .github/workflows/ci.yml | 2 +- .github/workflows/web.yml | 20 ++++++++++- dev.yml | 4 +-- e2e/web/fixtures/synthetic-checkout.html | 12 ++++--- e2e/web/support/shopify-checkout-popup.ts | 8 +++++ e2e/web/tests/synthetic/presentation.spec.ts | 37 ++++++++++++-------- e2e/web/tests/synthetic/protocol.spec.ts | 6 ++-- 8 files changed, 68 insertions(+), 25 deletions(-) diff --git a/.github/filters/platform-non-docs.yml b/.github/filters/platform-non-docs.yml index f8104e1dc..2883b6df3 100644 --- a/.github/filters/platform-non-docs.yml +++ b/.github/filters/platform-non-docs.yml @@ -17,6 +17,10 @@ web: - 'platforms/web/**' - *exclude-markdown - *exclude-docs-directory +webE2e: + - 'e2e/web/**' + - *exclude-markdown + - *exclude-docs-directory protocol: - 'protocol/**' - *exclude-markdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a30a666c..5de426381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: reactNative: ${{ steps.platform.outputs.reactNative == 'true' || steps.platform.outputs.swift == 'true' || steps.platform.outputs.android == 'true' || steps.platform.outputs.protocol == 'true' || steps.infra.outputs.reactNativeCommon == 'true' || steps.infra.outputs.reactNativeIos == 'true' || steps.infra.outputs.reactNativeAndroid == 'true' }} reactNativeIos: ${{ steps.platform.outputs.reactNative == 'true' || steps.platform.outputs.swift == 'true' || steps.platform.outputs.protocol == 'true' || steps.infra.outputs.reactNativeCommon == 'true' || steps.infra.outputs.reactNativeIos == 'true' }} reactNativeAndroid: ${{ steps.platform.outputs.reactNative == 'true' || steps.platform.outputs.android == 'true' || steps.platform.outputs.protocol == 'true' || steps.infra.outputs.reactNativeCommon == 'true' || steps.infra.outputs.reactNativeAndroid == 'true' }} - web: ${{ steps.platform.outputs.web == 'true' || steps.infra.outputs.web == 'true' }} + web: ${{ steps.platform.outputs.web == 'true' || steps.platform.outputs.webE2e == 'true' || steps.infra.outputs.web == 'true' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 48f98cdb1..df009cb41 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -22,16 +22,30 @@ jobs: uses: ./.github/actions/setup with: node-version-file: platforms/web/package.json - cache-dependency-path: platforms/web/pnpm-lock.yaml + cache-dependency-path: | + platforms/web/pnpm-lock.yaml + e2e/web/pnpm-lock.yaml package-json-file: platforms/web/package.json working-directory: platforms/web + - name: Install web e2e dependencies + working-directory: e2e/web + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + working-directory: e2e/web + run: pnpm exec playwright install --with-deps chromium + - name: Lint (typecheck + oxlint + oxfmt --check) run: pnpm lint - name: Test run: pnpm test + - name: Typecheck web e2e tests + working-directory: e2e/web + run: pnpm typecheck + - name: Coverage comment if: github.event_name == 'pull_request' uses: MishaKav/jest-coverage-comment@fb83bcbaeb5ca467936175796f862a2992938833 # main @@ -43,6 +57,10 @@ jobs: - name: Build run: pnpm build + - name: Web end-to-end tests + working-directory: e2e/web + run: pnpm exec playwright test + - name: Verify package (publint) run: pnpm verify diff --git a/dev.yml b/dev.yml index 58cd062a4..18ce4923c 100644 --- a/dev.yml +++ b/dev.yml @@ -661,12 +661,12 @@ commands: pnpm run format check: - desc: Run Web lint, unit tests, build, package verification, and sample build checks + desc: Run Web lint, unit tests, end-to-end tests, package verification, and sample build checks run: | set -e /opt/dev/bin/dev web lint /opt/dev/bin/dev web test - /opt/dev/bin/dev web build + /opt/dev/bin/dev web e2e /opt/dev/bin/dev web verify /opt/dev/bin/dev web sample build diff --git a/e2e/web/fixtures/synthetic-checkout.html b/e2e/web/fixtures/synthetic-checkout.html index cca2c966c..793597cd2 100644 --- a/e2e/web/fixtures/synthetic-checkout.html +++ b/e2e/web/fixtures/synthetic-checkout.html @@ -7,6 +7,8 @@