Files
cfw-autumn/server/tests/utils/browserPool/playwright/invoiceCheckout.ts
2026-02-22 18:20:30 +00:00

295 lines
9.3 KiB
TypeScript

import type { Page } from "playwright-core";
/**
* Self-contained Playwright function for completing a Stripe Invoice Checkout.
* NO external imports — this function must be serializable via fn.toString()
* for Kernel Playwright Execution. (type imports are fine — Bun strips them)
*
* Handles the Stripe hosted invoice page which has:
* 1. A payment method accordion (Card / Cash App Pay / Klarna) on the main page
* 2. Card form inputs inside a nested Stripe iframe (Payment Element)
* 3. A submit/pay button on the main page
*/
export const invoiceCheckout = async ({
page,
url,
}: {
page: Page;
url: string;
}) => {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
console.log("[invoiceCheckout] Page loaded");
// Wait for the page to fully render — Stripe hosted invoice pages load JS dynamically
await page.waitForTimeout(5000);
// Step 1: Find the Payment Element iframe.
// On the hosted invoice page, the payment element is rendered inside an iframe.
// We need to wait for it to appear — it may take a few seconds after page load.
let paymentFrame = null;
// Try to find the iframe via #payment-element first (embedded Payment Element)
// Use a specific selector to avoid matching the bank search results iframe
try {
await page.waitForSelector('#payment-element iframe[title="Secure payment input frame"]', { timeout: 10000 });
paymentFrame = page.frameLocator('#payment-element iframe[title="Secure payment input frame"]');
console.log("[invoiceCheckout] Found #payment-element iframe");
} catch {
// Fallback: try first iframe in #payment-element
try {
await page.waitForSelector("#payment-element iframe", { timeout: 5000 });
paymentFrame = page.frameLocator("#payment-element iframe").first();
console.log("[invoiceCheckout] Found #payment-element iframe (first)");
} catch {
console.log(
"[invoiceCheckout] No #payment-element iframe, searching all frames...",
);
}
}
// Fallback: search all frames for one containing Stripe payment fields
if (!paymentFrame) {
// On hosted invoice pages, Stripe renders the payment form in iframes
// Look for any iframe that contains card input fields
const frames = page.frames();
for (const frame of frames) {
try {
const cardInput = frame.locator(
'input[name="number"], input[data-elements-stable-field-name="cardNumber"]',
);
if ((await cardInput.count()) > 0) {
paymentFrame = frame;
console.log(
`[invoiceCheckout] Found payment frame: ${frame.url().substring(0, 80)}`,
);
break;
}
} catch {
// Frame not ready
}
}
}
// If still no payment frame, the card accordion might need clicking first.
// On hosted invoice pages, the "Card" option is an accordion on the main page.
if (!paymentFrame) {
console.log(
"[invoiceCheckout] No payment frame found yet, trying to click Card accordion...",
);
// Try clicking the Card payment method option
const cardOptionSelectors = [
'[data-testid="CARD-tab"]',
"text=Card",
'button:has-text("Card")',
'[role="tab"]:has-text("Card")',
'[role="button"]:has-text("Card")',
'.TabLabel:has-text("Card")',
];
for (const sel of cardOptionSelectors) {
try {
const el = page.locator(sel).first();
if ((await el.count()) > 0) {
await el.click();
console.log(
`[invoiceCheckout] Clicked card option with selector: ${sel}`,
);
await page.waitForTimeout(3000);
break;
}
} catch {
// Try next selector
}
}
// Now try to find the payment frame again
try {
await page.waitForSelector('#payment-element iframe[title="Secure payment input frame"]', { timeout: 10000 });
paymentFrame = page.frameLocator('#payment-element iframe[title="Secure payment input frame"]');
console.log(
"[invoiceCheckout] Found #payment-element iframe after card click",
);
} catch {
// Fallback: first iframe
try {
await page.waitForSelector("#payment-element iframe", { timeout: 5000 });
paymentFrame = page.frameLocator("#payment-element iframe").first();
console.log(
"[invoiceCheckout] Found #payment-element iframe after card click (first)",
);
} catch {
// Still try frame search
}
}
if (!paymentFrame) {
const frames = page.frames();
for (const frame of frames) {
try {
const cardInput = frame.locator(
'input[name="number"], input[data-elements-stable-field-name="cardNumber"]',
);
if ((await cardInput.count()) > 0) {
paymentFrame = frame;
console.log(
`[invoiceCheckout] Found payment frame after click: ${frame.url().substring(0, 80)}`,
);
break;
}
} catch {
// Frame not ready
}
}
}
}
if (!paymentFrame) {
// Last resort: dump page info for debugging
const frameUrls = page
.frames()
.map((f: { url: () => string }) => f.url())
.join("\n");
throw new Error(
`Could not find Stripe payment frame. Page frames:\n${frameUrls}`,
);
}
// Step 2: Expand the Card accordion inside the payment frame if collapsed
try {
const cardAccordion = paymentFrame.locator(
'[role="button"][data-value="card"]',
);
if ((await cardAccordion.count()) > 0) {
const isExpanded = await cardAccordion.getAttribute("aria-expanded");
if (isExpanded === "false") {
await cardAccordion.click();
console.log("[invoiceCheckout] Expanded card accordion");
await page.waitForTimeout(1000);
}
}
} catch {
// Accordion may not exist — card might be the only option or already expanded
}
// Step 3: Fill card details
const cardInput = paymentFrame
.locator(
'input[name="number"], input[data-elements-stable-field-name="cardNumber"], input[placeholder*="1234"], input[aria-label*="Card number"]',
)
.first();
await cardInput.waitFor({ timeout: 15000 });
await cardInput.click();
await cardInput.fill("4242424242424242");
console.log("[invoiceCheckout] Card number filled");
const expiryInput = paymentFrame
.locator(
'input[name="expiry"], input[data-elements-stable-field-name="cardExpiry"], input[placeholder*="MM"], input[aria-label*="expir"]',
)
.first();
await expiryInput.waitFor({ timeout: 10000 });
await expiryInput.click();
await expiryInput.fill("1228");
console.log("[invoiceCheckout] Expiry filled");
const cvcInput = paymentFrame
.locator(
'input[name="cvc"], input[data-elements-stable-field-name="cardCvc"], input[placeholder*="CVC"], input[aria-label*="CVC"]',
)
.first();
await cvcInput.waitFor({ timeout: 10000 });
await cvcInput.click();
await cvcInput.fill("123");
console.log("[invoiceCheckout] CVC filled");
// Select "United States" as country so we can use a known US zip code.
// This avoids flaky postal code detection across different locales.
try {
const countrySelect = paymentFrame
.locator('select[name="country"]')
.first();
if ((await countrySelect.count()) > 0) {
await countrySelect.selectOption("US");
console.log("[invoiceCheckout] Country set to US");
await page.waitForTimeout(500);
}
} catch {
// Country selector may not exist
}
// Fill ZIP code (best-effort — not all locales require it)
try {
const postalInput = paymentFrame
.locator(
'input[name="postalCode"], input[data-elements-stable-field-name="postalCode"], input[placeholder*="12345"], input[aria-label*="ZIP"], input[aria-label*="Postal"]',
)
.first();
await postalInput.waitFor({ timeout: 5000 });
await postalInput.click();
await postalInput.fill("10001");
console.log("[invoiceCheckout] ZIP code filled: 10001");
} catch {
console.log("[invoiceCheckout] No postal code field, skipping");
}
// Step 4: Wait for Stripe to process inputs, then click submit
await page.waitForTimeout(2000);
// The Pay button is on the main page (not in the iframe).
// On hosted invoice pages it's a simple <button> with text "Pay".
// Try multiple strategies, scrolling into view before clicking.
let clicked = false;
// Strategy 1: Known Stripe hosted invoice selectors
const submitSelectors = [
".SubmitButton-IconContainer",
".SubmitButton",
"button[type=submit]",
"[data-testid=hosted-payment-submit-button]",
];
for (const sel of submitSelectors) {
const btn = page.locator(sel).first();
if ((await btn.count()) > 0) {
await btn.scrollIntoViewIfNeeded();
await btn.click();
clicked = true;
console.log(`[invoiceCheckout] Clicked submit via: ${sel}`);
break;
}
}
// Strategy 2: Find button by text "Pay" on the main page
if (!clicked) {
const payBtn = page.locator("button").filter({ hasText: /^Pay$/i }).first();
if ((await payBtn.count()) > 0) {
await payBtn.scrollIntoViewIfNeeded();
await payBtn.click();
clicked = true;
console.log("[invoiceCheckout] Clicked submit via exact 'Pay' text");
}
}
// Strategy 3: Broader text match
if (!clicked) {
const fallbackBtn = page
.locator("button")
.filter({ hasText: /pay|pay now|submit|complete/i })
.first();
if ((await fallbackBtn.count()) > 0) {
await fallbackBtn.scrollIntoViewIfNeeded();
await fallbackBtn.click();
clicked = true;
console.log("[invoiceCheckout] Clicked submit via broad text match");
}
}
if (!clicked) {
throw new Error("Could not find or click any submit/pay button");
}
// Step 5: Wait for payment processing + webhook delivery
await page.waitForTimeout(20000);
console.log("[invoiceCheckout] Checkout complete");
};