From 9917880b3c749a5d50bbab14e8b8ea1a389cb9c0 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Apr 2026 22:01:09 +0400 Subject: [PATCH 01/15] fix(stripe): include productId in webhook-normalized providerProduct normalizeStripeSubscription emitted { priceId } while syncProducts stored { productId, priceId }. The symmetric key-set check in prepareSubscribeCheckoutCompleted rejected the checkout.completed webhook with PROVIDER_WEBHOOK_INVALID for any hosted-checkout flow. Pull productId off the Price object so both paths emit the same shape. --- packages/stripe/src/stripe-provider.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index b7a4318b..763373cd 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -119,8 +119,14 @@ function normalizeStripeInvoice(invoice: StripeInvoiceWithExtras) { function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) { const firstItem = subscription.items.data[0]; - const providerPriceId = - typeof firstItem?.price === "string" ? firstItem.price : firstItem?.price.id; + const price = firstItem?.price; + const providerPriceId = typeof price === "string" ? price : price?.id; + const providerProductId = + price && typeof price !== "string" + ? typeof price.product === "string" + ? price.product + : (price.product?.id ?? null) + : null; const periodStart = getEarliestPeriodStart(subscription); const periodEnd = getLatestPeriodEnd(subscription); @@ -131,7 +137,12 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) currentPeriodEndAt: toDate(periodEnd), currentPeriodStartAt: toDate(periodStart), endedAt: toDate(subscription.ended_at), - providerProduct: providerPriceId ? { priceId: providerPriceId } : null, + providerProduct: + providerPriceId && providerProductId + ? { priceId: providerPriceId, productId: providerProductId } + : providerPriceId + ? { priceId: providerPriceId } + : null, providerSubscriptionId: subscription.id, providerSubscriptionScheduleId: (typeof subscription.schedule === "string" From d20d1717ffcbbd402d0c238acad4acd8117c40ef Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Apr 2026 22:01:19 +0400 Subject: [PATCH 02/15] test(e2e): automate Stripe checkout, consolidate test configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement completeCheckout in the Stripe harness with Playwright: fills Stripe's hosted checkout form (card, expiry, CVC, name) via pressSequentially and submits. Replaces the console.log prompt in the 3 previously-manual tests (subscribe-paid-checkout, resubscribe-after-cancel, lifecycle/subscription). Drop vitest.automated.config.ts and vitest.manual.config.ts — with every test automated, the split is obsolete. Collapse scripts to a single "pnpm test" over smoke/vitest.config.ts. --- e2e/package.json | 10 ++--- .../checkout/resubscribe-after-cancel.test.ts | 3 +- .../checkout/subscribe-paid-checkout.test.ts | 7 ++- e2e/smoke/harness/stripe.ts | 43 ++++++++++++++++++- e2e/smoke/lifecycle/subscription.test.ts | 6 +-- e2e/smoke/vitest.automated.config.ts | 15 ------- e2e/smoke/vitest.manual.config.ts | 14 ------ 7 files changed, 51 insertions(+), 47 deletions(-) delete mode 100644 e2e/smoke/vitest.automated.config.ts delete mode 100644 e2e/smoke/vitest.manual.config.ts diff --git a/e2e/package.json b/e2e/package.json index aa2a95c7..948cfc82 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,12 +3,10 @@ "private": true, "type": "module", "scripts": { - "test": "vitest run --config smoke/vitest.automated.config.ts", - "test:stripe": "PROVIDER=stripe vitest run --config smoke/vitest.automated.config.ts", - "test:polar": "PROVIDER=polar vitest run --config smoke/vitest.automated.config.ts", - "test:watch": "vitest --config smoke/vitest.automated.config.ts", - "test:manual": "vitest run --config smoke/vitest.manual.config.ts", - "test:all": "vitest run --config smoke/vitest.config.ts", + "test": "vitest run --config smoke/vitest.config.ts", + "test:watch": "vitest --config smoke/vitest.config.ts", + "test:stripe": "PROVIDER=stripe vitest run --config smoke/vitest.config.ts", + "test:polar": "PROVIDER=polar vitest run --config smoke/vitest.config.ts", "test:cli": "vitest run --config cli/vitest.config.ts", "test:cli:watch": "vitest --config cli/vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/e2e/smoke/checkout/resubscribe-after-cancel.test.ts b/e2e/smoke/checkout/resubscribe-after-cancel.test.ts index 548ef0d4..99afe10d 100644 --- a/e2e/smoke/checkout/resubscribe-after-cancel.test.ts +++ b/e2e/smoke/checkout/resubscribe-after-cancel.test.ts @@ -98,9 +98,8 @@ describe("resubscribe-after-cancel: checkout after full cancellation", () => { throw new Error("Expected checkout URL but got direct subscription"); } - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl); - // Wait for manual checkout completion await waitForWebhook({ database: t.database, eventType: "checkout.completed", diff --git a/e2e/smoke/checkout/subscribe-paid-checkout.test.ts b/e2e/smoke/checkout/subscribe-paid-checkout.test.ts index 77db99d9..7d5afd74 100644 --- a/e2e/smoke/checkout/subscribe-paid-checkout.test.ts +++ b/e2e/smoke/checkout/subscribe-paid-checkout.test.ts @@ -11,7 +11,7 @@ import { waitForWebhook, } from "../setup"; -describe("subscribe-paid-checkout: free → pro via checkout (manual)", () => { +describe("subscribe-paid-checkout: free → pro via checkout", () => { let t: TestPayKit; let customerId: string; @@ -33,7 +33,7 @@ describe("subscribe-paid-checkout: free → pro via checkout (manual)", () => { await t?.cleanup(); }); - it("subscribing without a payment method returns a checkout URL, completing it activates the plan", async () => { + it("subscribing without a payment method returns a checkout URL; completing it activates the plan", async () => { try { const beforeCheckout = new Date(); @@ -48,9 +48,8 @@ describe("subscribe-paid-checkout: free → pro via checkout (manual)", () => { throw new Error("Expected checkout URL but got direct subscription"); } - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl); - // Wait for checkout.completed webhook (manual completion required) await waitForWebhook({ database: t.database, eventType: "checkout.completed", diff --git a/e2e/smoke/harness/stripe.ts b/e2e/smoke/harness/stripe.ts index 0e830e23..41298ecb 100644 --- a/e2e/smoke/harness/stripe.ts +++ b/e2e/smoke/harness/stripe.ts @@ -1,4 +1,5 @@ import { stripe } from "@paykitjs/stripe"; +import { chromium } from "playwright"; import { default as Stripe } from "stripe"; import type { PayKitDatabase } from "../../../packages/paykit/src/database/index"; @@ -35,8 +36,46 @@ export function createStripeHarness(): ProviderHarness { }); }, - async completeCheckout(_url: string) { - throw new Error("Stripe direct-subscription tests should not need checkout completion"); + async completeCheckout(url: string) { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + try { + await page.goto(url, { waitUntil: "domcontentloaded" }); + + // Stripe's hosted checkout uses custom inputs that require per-key events; + // fill() does not dispatch them correctly, so use pressSequentially. + const cardNumber = page.locator("#cardNumber"); + await cardNumber.waitFor({ timeout: 60_000 }); + await cardNumber.pressSequentially("4242424242424242"); + + const cardExpiry = page.locator("#cardExpiry"); + await cardExpiry.waitFor({ timeout: 30_000 }); + await cardExpiry.pressSequentially("1234"); + + const cardCvc = page.locator("#cardCvc"); + await cardCvc.waitFor({ timeout: 30_000 }); + await cardCvc.pressSequentially("123"); + + const billingName = page.locator("#billingName"); + if (await billingName.isVisible().catch(() => false)) { + await billingName.pressSequentially("Test Customer"); + } + + const submitBtn = page.locator(".SubmitButton-TextContainer").first(); + await submitBtn.evaluate((el) => (el as HTMLElement).click()); + + // Wait for Stripe to navigate away from the checkout page (success redirect + // or embedded confirmation). Don't fail the test if this times out — the + // webhook poll downstream is the real signal. + await page + .waitForURL((u) => !u.toString().includes("checkout.stripe.com"), { + timeout: 60_000, + }) + .catch(() => {}); + } finally { + await browser.close(); + } }, async cleanup(ctx) { diff --git a/e2e/smoke/lifecycle/subscription.test.ts b/e2e/smoke/lifecycle/subscription.test.ts index 2a521409..f5bc7654 100644 --- a/e2e/smoke/lifecycle/subscription.test.ts +++ b/e2e/smoke/lifecycle/subscription.test.ts @@ -169,10 +169,8 @@ describe("subscription lifecycle", () => { // No payment method → should return checkout URL expect(result.paymentUrl).not.toBeNull(); - // Log the checkout URL for manual completion - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl!); - // Wait for checkout.completed webhook after manual completion await waitForWebhook({ database: t.database, eventType: "checkout.completed", @@ -386,7 +384,7 @@ describe("subscription lifecycle", () => { // After full cancellation, Stripe clears the payment method. // Customer must go through checkout again. expect(result.paymentUrl).not.toBeNull(); - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl!); await waitForWebhook({ database: t.database, eventType: "checkout.completed", diff --git a/e2e/smoke/vitest.automated.config.ts b/e2e/smoke/vitest.automated.config.ts deleted file mode 100644 index 9a423132..00000000 --- a/e2e/smoke/vitest.automated.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vitest/config"; - -import { smokeVitestTestConfig } from "./vitest.shared"; - -export default defineConfig({ - test: { - ...smokeVitestTestConfig, - include: ["smoke/**/*.test.ts"], - exclude: [ - "**/smoke/checkout/resubscribe-after-cancel.test.ts", - "**/smoke/checkout/subscribe-paid-checkout.test.ts", - "**/smoke/lifecycle/subscription.test.ts", - ], - }, -}); diff --git a/e2e/smoke/vitest.manual.config.ts b/e2e/smoke/vitest.manual.config.ts deleted file mode 100644 index 2f9a51a9..00000000 --- a/e2e/smoke/vitest.manual.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "vitest/config"; - -import { smokeVitestTestConfig } from "./vitest.shared"; - -export default defineConfig({ - test: { - ...smokeVitestTestConfig, - include: [ - "smoke/checkout/resubscribe-after-cancel.test.ts", - "smoke/checkout/subscribe-paid-checkout.test.ts", - "smoke/lifecycle/subscription.test.ts", - ], - }, -}); From 66d43d34f041d64bff78a05961ed8720ef82812a Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Apr 2026 22:54:27 +0400 Subject: [PATCH 03/15] test(e2e): parallelize smoke tests via webhook hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the smoke suite from single-threaded to parallel by replacing per-test binding on :4567 with a shared webhook hub: - hub.ts: one long-lived HTTP server started in globalSetup. Routes Stripe events to per-test workers by provider customer ID, buffers briefly for customers not yet registered. - Each test's webhook server now binds :0 (random free port) and registers its provider customer IDs with the hub on creation, unregisters on cleanup. - DB name suffix adds randomness — Date.now() collided under parallelism. Stripe test mode caps at 25 ops/sec. To keep parallel setup from tripping the limit: - maxNetworkRetries: 3 on Stripe SDK clients (both paykit's internal client and all e2e harness clients). SDK retries 429s with backoff that respects Retry-After — strict production improvement. - maxWorkers: 6 caps concurrent syncProducts bursts. Full suite: 21/21 pass, ~100s wall-clock (down from ~525s sequential). --- e2e/cli/setup.ts | 2 +- e2e/smoke/global-setup.ts | 20 +++ e2e/smoke/harness/stripe.ts | 2 +- e2e/smoke/hub.ts | 154 ++++++++++++++++++ e2e/smoke/setup.ts | 43 +++-- e2e/smoke/vitest.shared.ts | 9 +- .../webhook/subscription-deleted.test.ts | 2 +- packages/stripe/src/stripe-provider.ts | 15 +- 8 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 e2e/smoke/global-setup.ts create mode 100644 e2e/smoke/hub.ts diff --git a/e2e/cli/setup.ts b/e2e/cli/setup.ts index 8b74b8fe..8ee5be2c 100644 --- a/e2e/cli/setup.ts +++ b/e2e/cli/setup.ts @@ -32,7 +32,7 @@ export async function createCliFixture(_globalKey: string): Promise Promise> { + let server: Server; + try { + server = await startHub(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") { + throw new Error( + "Hub port 4567 already in use. Kill any stale webhook server before running tests.", { cause: error }, + ); + } + throw error; + } + return async () => { + await new Promise((resolve) => server.close(() => resolve())); + }; +} diff --git a/e2e/smoke/harness/stripe.ts b/e2e/smoke/harness/stripe.ts index 41298ecb..8e218229 100644 --- a/e2e/smoke/harness/stripe.ts +++ b/e2e/smoke/harness/stripe.ts @@ -14,7 +14,7 @@ export function createStripeHarness(): ProviderHarness { throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); } - const stripeClient = new Stripe(secretKey); + const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); return { id: "stripe", diff --git a/e2e/smoke/hub.ts b/e2e/smoke/hub.ts new file mode 100644 index 00000000..ac7915d2 --- /dev/null +++ b/e2e/smoke/hub.ts @@ -0,0 +1,154 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +export const HUB_PORT = 4567; +export const HUB_REGISTER_URL = `http://127.0.0.1:${String(HUB_PORT)}/_hub/register`; +export const HUB_UNREGISTER_URL = `http://127.0.0.1:${String(HUB_PORT)}/_hub/unregister`; + +/** Events that arrive for an unknown customer are buffered for this long. */ +const BUFFER_TTL_MS = 60_000; + +interface BufferedEvent { + body: string; + headers: Record; + path: string; + receivedAt: number; +} + +/** + * Extract the provider customer ID from a Stripe event body. + * Returns null for events that aren't keyed by customer (e.g. product.created). + */ +function extractStripeCustomerId(body: string): string | null { + try { + const parsed = JSON.parse(body) as { + data?: { object?: { id?: string; customer?: string | null; object?: string } }; + }; + const obj = parsed.data?.object; + if (!obj) return null; + if (obj.object === "customer" && typeof obj.id === "string") return obj.id; + if (typeof obj.customer === "string") return obj.customer; + return null; + } catch { + return null; + } +} + +export function startHub(): Promise { + const registry = new Map(); + const buffers = new Map(); + + function dropExpired(customerId: string): void { + const buf = buffers.get(customerId); + if (!buf) return; + const now = Date.now(); + const kept = buf.filter((e) => now - e.receivedAt < BUFFER_TTL_MS); + if (kept.length === 0) buffers.delete(customerId); + else buffers.set(customerId, kept); + } + + async function forwardEvent(workerUrl: string, event: BufferedEvent): Promise { + const url = new URL(event.path, workerUrl); + return fetch(url, { method: "POST", headers: event.headers, body: event.body }); + } + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = Buffer.concat(chunks).toString(); + const path = req.url ?? "/"; + + if (path === "/_hub/register") { + const { providerCustomerId, workerUrl } = JSON.parse(body) as { + providerCustomerId: string; + workerUrl: string; + }; + registry.set(providerCustomerId, workerUrl); + // Drain any buffered events for this customer + dropExpired(providerCustomerId); + const pending = buffers.get(providerCustomerId) ?? []; + buffers.delete(providerCustomerId); + for (const event of pending) { + await forwardEvent(workerUrl, event).catch(() => {}); + } + res.writeHead(204); + res.end(); + return; + } + + if (path === "/_hub/unregister") { + const { providerCustomerIds } = JSON.parse(body) as { providerCustomerIds: string[] }; + for (const id of providerCustomerIds) { + registry.delete(id); + buffers.delete(id); + } + res.writeHead(204); + res.end(); + return; + } + + // Otherwise: route webhook by customer ID + const customerId = extractStripeCustomerId(body); + if (!customerId) { + // No customer → setup artifacts (product.created, price.created). Drop. + res.writeHead(204); + res.end(); + return; + } + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers[key] = value; + } + + const workerUrl = registry.get(customerId); + if (!workerUrl) { + // Unknown customer — buffer briefly in case a worker is about to register. + const buf = buffers.get(customerId) ?? []; + buf.push({ body, headers, path, receivedAt: Date.now() }); + buffers.set(customerId, buf); + res.writeHead(204); + res.end(); + return; + } + + try { + const response = await forwardEvent(workerUrl, { + body, + headers, + path, + receivedAt: Date.now(), + }); + res.writeHead(response.status); + res.end(await response.text()); + } catch (error) { + res.writeHead(500); + res.end(error instanceof Error ? error.message : "hub forward error"); + } + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(HUB_PORT, "127.0.0.1", () => resolve(server)); + }); +} + +export async function registerCustomer( + providerCustomerId: string, + workerUrl: string, +): Promise { + const response = await fetch(HUB_REGISTER_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerCustomerId, workerUrl }), + }); + if (!response.ok) throw new Error(`hub register failed: ${String(response.status)}`); +} + +export async function unregisterCustomers(providerCustomerIds: string[]): Promise { + if (providerCustomerIds.length === 0) return; + await fetch(HUB_UNREGISTER_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerCustomerIds }), + }).catch(() => {}); +} diff --git a/e2e/smoke/setup.ts b/e2e/smoke/setup.ts index 7c403411..4adceb40 100644 --- a/e2e/smoke/setup.ts +++ b/e2e/smoke/setup.ts @@ -20,8 +20,7 @@ import { syncProducts } from "../../packages/paykit/src/product/product-sync.ser import { env } from "../env"; import { loadHarness } from "./harness/index"; import type { ProviderHarness } from "./harness/types"; - -const WEBHOOK_PORT = 4567; +import { HUB_PORT, registerCustomer, unregisterCustomers } from "./hub"; // Provider harness — loaded once at module init based on PROVIDER env var export const harness: ProviderHarness = loadHarness(); @@ -92,6 +91,7 @@ export interface TestPayKit { harness: ProviderHarness; dbPath: string; server: Server; + workerUrl: string; webhookRequests: CapturedWebhookRequest[]; cleanup: () => Promise; } @@ -110,7 +110,7 @@ export async function createTestPayKit(): Promise { harness.validateEnv(); // 1. Create a fresh test database - const dbName = `paykit_smoke_${String(Date.now())}`; + const dbName = `paykit_smoke_${String(Date.now())}_${Math.random().toString(36).slice(2, 8)}`; const adminPool = new Pool({ connectionString: env.TEST_DATABASE_URL, }); @@ -138,7 +138,7 @@ export async function createTestPayKit(): Promise { // This allows direct subscription without client-side payment confirmation. if (harness.id === "stripe") { const secretKey = env.E2E_STRIPE_SK!; - const stripeClient = new Stripe(secretKey); + const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); (ctx.provider as unknown as Record).createSubscription = async (data: { providerCustomerId: string; @@ -189,12 +189,14 @@ export async function createTestPayKit(): Promise { }; } - // 4. Start webhook server BEFORE syncing products — product sync - // creates provider products which fires webhooks immediately + // 4. Start per-test webhook server on a random free port. A shared hub on 4567 + // (started in globalSetup) forwards webhooks to this worker by provider customer ID. const webhookRequests: CapturedWebhookRequest[] = []; - const server = await startWebhookServer(paykit, webhookRequests); + const { server, workerUrl } = await startWebhookServer(paykit, webhookRequests); - // 5. Sync products to provider + // 5. Sync products to provider. These fire product.created/price.created events + // which the hub drops (they don't carry a customer ID) — safe to run before + // any customer is registered. await syncProducts(ctx); return { @@ -204,6 +206,7 @@ export async function createTestPayKit(): Promise { harness, dbPath: dbUrl, server, + workerUrl, webhookRequests, cleanup: async () => { const customerRows = await ctx.database.query.customer.findMany(); @@ -218,6 +221,7 @@ export async function createTestPayKit(): Promise { // Wait for cleanup webhooks to arrive and be processed await new Promise((resolve) => setTimeout(resolve, 10_000)); + await unregisterCustomers([...idSet]); await new Promise((resolve) => server.close(() => resolve())); await pool.end(); // Drop the test database @@ -261,6 +265,9 @@ export async function createTestCustomer(input: { ); } + // Route this customer's webhooks to this test's worker + await registerCustomer(providerCustomerId, input.t.workerUrl); + return { customerId: uniqueId, providerCustomerId }; } @@ -280,7 +287,7 @@ export async function createTestCustomerWithPM(input: { // For Stripe, sync the payment method into PayKit DB if (input.t.harness.id === "stripe") { const secretKey = env.E2E_STRIPE_SK!; - const stripeClient = new Stripe(secretKey); + const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); const pm = await stripeClient.paymentMethods.list({ customer: providerCustomerId, type: "card", @@ -607,7 +614,7 @@ export async function expectExactMeteredBalance(input: { async function startWebhookServer( paykit: Pick, webhookRequests: CapturedWebhookRequest[], -): Promise { +): Promise<{ server: Server; workerUrl: string }> { const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const chunks: Buffer[] = []; for await (const chunk of req) { @@ -615,7 +622,9 @@ async function startWebhookServer( } const body = Buffer.concat(chunks).toString(); - const url = new URL(req.url ?? "/", `http://localhost:${String(WEBHOOK_PORT)}`); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const url = new URL(req.url ?? "/", `http://127.0.0.1:${String(port)}`); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (typeof value === "string") headers.set(key, value); @@ -646,9 +655,14 @@ async function startWebhookServer( await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(WEBHOOK_PORT, () => resolve()); + server.listen(0, "127.0.0.1", () => resolve()); }); - return server; + const address = server.address(); + if (typeof address !== "object" || !address) { + throw new Error("Failed to get webhook server address"); + } + const workerUrl = `http://127.0.0.1:${String(address.port)}/`; + return { server, workerUrl }; } export async function advanceTestClock(input: { @@ -737,7 +751,8 @@ export async function waitForForwardedWebhookRequest(input: { export async function replayWebhookRequest(input: { request: CapturedWebhookRequest; }): Promise { - const response = await fetch(`http://localhost:${String(WEBHOOK_PORT)}${input.request.path}`, { + // Replay goes through the hub, which routes to the owning worker by customer ID. + const response = await fetch(`http://127.0.0.1:${String(HUB_PORT)}${input.request.path}`, { body: input.request.body, headers: input.request.headers, method: "POST", diff --git a/e2e/smoke/vitest.shared.ts b/e2e/smoke/vitest.shared.ts index 541117b3..9794668e 100644 --- a/e2e/smoke/vitest.shared.ts +++ b/e2e/smoke/vitest.shared.ts @@ -1,9 +1,10 @@ export const smokeVitestTestConfig = { env: { NODE_ENV: "production" }, - fileParallelism: false, + globalSetup: ["smoke/global-setup.ts"] as string[], hookTimeout: 180_000, - maxWorkers: 1, - minWorkers: 1, - sequence: { concurrent: false }, + // Cap parallel workers — Stripe test mode rate-limits at 25 ops/sec; too many + // workers starting syncProducts simultaneously trips it. Pair with Stripe + // SDK maxNetworkRetries for headroom. + maxWorkers: 6, testTimeout: 600_000, } as const; diff --git a/e2e/smoke/webhook/subscription-deleted.test.ts b/e2e/smoke/webhook/subscription-deleted.test.ts index b78b9a61..a5b5f3e5 100644 --- a/e2e/smoke/webhook/subscription-deleted.test.ts +++ b/e2e/smoke/webhook/subscription-deleted.test.ts @@ -22,7 +22,7 @@ describe.skipIf(harness.id !== "stripe")( let t: TestPayKit; let customerId: string; let providerSubscriptionId: string; - const stripeClient = new Stripe(env.E2E_STRIPE_SK!); + const stripeClient = new Stripe(env.E2E_STRIPE_SK!, { maxNetworkRetries: 3 }); beforeAll(async () => { t = await createTestPayKit(); diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index 763373cd..dd907416 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -130,6 +130,13 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) const periodStart = getEarliestPeriodStart(subscription); const periodEnd = getLatestPeriodEnd(subscription); + let providerProduct: Record | null = null; + if (providerPriceId && providerProductId) { + providerProduct = { priceId: providerPriceId, productId: providerProductId }; + } else if (providerPriceId) { + providerProduct = { priceId: providerPriceId }; + } + const cancelAt = (subscription as { cancel_at?: number | null }).cancel_at; return { cancelAtPeriodEnd: subscription.cancel_at_period_end || (cancelAt != null && cancelAt > 0), @@ -137,12 +144,7 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) currentPeriodEndAt: toDate(periodEnd), currentPeriodStartAt: toDate(periodStart), endedAt: toDate(subscription.ended_at), - providerProduct: - providerPriceId && providerProductId - ? { priceId: providerPriceId, productId: providerProductId } - : providerPriceId - ? { priceId: providerPriceId } - : null, + providerProduct, providerSubscriptionId: subscription.id, providerSubscriptionScheduleId: (typeof subscription.schedule === "string" @@ -977,6 +979,7 @@ export function stripe(options: StripeOptions): PayKitProviderConfig { } const client = new StripeSdk(options.secretKey, { apiVersion: apiVersion as StripeSdk.LatestApiVersion, + maxNetworkRetries: 3, }); return { From c517b0c1617d86ed5d1b4aad11cebfaaf412c59e Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Apr 2026 22:54:58 +0400 Subject: [PATCH 04/15] chore(e2e): drop ambiguous "test" script, per-provider watch variants pnpm test and pnpm test:stripe pointed at the same command, which hid the fact that the default run was Stripe-only. Drop the generic test and test:watch; every invocation is now explicit about provider. --- e2e/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/package.json b/e2e/package.json index 948cfc82..cc4f6e2d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,10 +3,10 @@ "private": true, "type": "module", "scripts": { - "test": "vitest run --config smoke/vitest.config.ts", - "test:watch": "vitest --config smoke/vitest.config.ts", "test:stripe": "PROVIDER=stripe vitest run --config smoke/vitest.config.ts", + "test:stripe:watch": "PROVIDER=stripe vitest --config smoke/vitest.config.ts", "test:polar": "PROVIDER=polar vitest run --config smoke/vitest.config.ts", + "test:polar:watch": "PROVIDER=polar vitest --config smoke/vitest.config.ts", "test:cli": "vitest run --config cli/vitest.config.ts", "test:cli:watch": "vitest --config cli/vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" From 58d317f2e87644c43867649e530c607ca7de1a99 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 25 Apr 2026 00:31:31 +0400 Subject: [PATCH 05/15] test(e2e): reorganize test structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename smoke/ → core/. "smoke" already has a specific meaning in other suites (platform/runtime sanity checks); our tests are full end-to-end provider flows, so the old name was misleading. - Pull plumbing into test-utils/: env, harness, hub, setup, products. - Extract test plans/features out of setup.ts into products.ts. - Move Stripe's allow_incomplete override (test-only) out of the generic setup.ts into harness/stripe.ts as a new applyTestingOverrides hook on the ProviderHarness interface. - Merge global-setup.ts into hub.ts: the Vitest globalSetup default export lives alongside the hub it starts. - Collapse three per-folder vitest configs into one root vitest.config.ts using Vitest projects (core, cli). - Scripts use --project=core / --project=cli. 21/21 tests pass. --- e2e/cli/setup.ts | 2 +- e2e/cli/vitest.config.ts | 11 -- .../cancel/cancel-then-upgrade.test.ts | 2 +- .../cancel/downgrade-change-target.test.ts | 2 +- .../checkout/resubscribe-after-cancel.test.ts | 2 +- .../checkout/subscribe-paid-checkout.test.ts | 2 +- .../customer/default-free.test.ts | 2 +- .../entitlements/check-boolean.test.ts | 2 +- .../entitlements/check-metered.test.ts | 2 +- .../entitlements/stacked-metered.test.ts | 2 +- .../lifecycle/subscription.test.ts | 2 +- .../subscribe/cancel-end-of-cycle.test.ts | 2 +- .../subscribe/cancel-resume.test.ts | 2 +- .../subscribe/downgrade-scheduled.test.ts | 2 +- .../subscribe/downgrade-to-free.test.ts | 2 +- e2e/{smoke => core}/subscribe/renewal.test.ts | 2 +- .../subscribe/same-plan-noop.test.ts | 2 +- .../subscribe/subscribe-paid.test.ts | 2 +- .../subscribe/upgrade-immediate.test.ts | 2 +- .../webhook/duplicate-webhook.test.ts | 2 +- .../webhook/subscription-deleted.test.ts | 4 +- e2e/package.json | 12 +- e2e/smoke/global-setup.ts | 20 --- e2e/smoke/vitest.config.ts | 10 -- e2e/smoke/vitest.shared.ts | 10 -- e2e/{ => test-utils}/env.ts | 8 +- e2e/{smoke => test-utils}/harness/index.ts | 2 +- e2e/{smoke => test-utils}/harness/polar.ts | 2 +- e2e/{smoke => test-utils}/harness/stripe.ts | 55 +++++++- e2e/{smoke => test-utils}/harness/types.ts | 9 ++ e2e/{smoke => test-utils}/hub.ts | 19 +++ e2e/test-utils/index.ts | 2 + e2e/test-utils/products.ts | 51 +++++++ e2e/{smoke => test-utils}/setup.ts | 129 ++---------------- e2e/vitest.config.ts | 32 +++++ 35 files changed, 216 insertions(+), 198 deletions(-) delete mode 100644 e2e/cli/vitest.config.ts rename e2e/{smoke => core}/cancel/cancel-then-upgrade.test.ts (98%) rename e2e/{smoke => core}/cancel/downgrade-change-target.test.ts (98%) rename e2e/{smoke => core}/checkout/resubscribe-after-cancel.test.ts (99%) rename e2e/{smoke => core}/checkout/subscribe-paid-checkout.test.ts (98%) rename e2e/{smoke => core}/customer/default-free.test.ts (98%) rename e2e/{smoke => core}/entitlements/check-boolean.test.ts (98%) rename e2e/{smoke => core}/entitlements/check-metered.test.ts (98%) rename e2e/{smoke => core}/entitlements/stacked-metered.test.ts (98%) rename e2e/{smoke => core}/lifecycle/subscription.test.ts (99%) rename e2e/{smoke => core}/subscribe/cancel-end-of-cycle.test.ts (99%) rename e2e/{smoke => core}/subscribe/cancel-resume.test.ts (98%) rename e2e/{smoke => core}/subscribe/downgrade-scheduled.test.ts (98%) rename e2e/{smoke => core}/subscribe/downgrade-to-free.test.ts (98%) rename e2e/{smoke => core}/subscribe/renewal.test.ts (99%) rename e2e/{smoke => core}/subscribe/same-plan-noop.test.ts (99%) rename e2e/{smoke => core}/subscribe/subscribe-paid.test.ts (98%) rename e2e/{smoke => core}/subscribe/upgrade-immediate.test.ts (98%) rename e2e/{smoke => core}/webhook/duplicate-webhook.test.ts (99%) rename e2e/{smoke => core}/webhook/subscription-deleted.test.ts (97%) delete mode 100644 e2e/smoke/global-setup.ts delete mode 100644 e2e/smoke/vitest.config.ts delete mode 100644 e2e/smoke/vitest.shared.ts rename e2e/{ => test-utils}/env.ts (76%) rename e2e/{smoke => test-utils}/harness/index.ts (94%) rename e2e/{smoke => test-utils}/harness/polar.ts (98%) rename e2e/{smoke => test-utils}/harness/stripe.ts (65%) rename e2e/{smoke => test-utils}/harness/types.ts (70%) rename e2e/{smoke => test-utils}/hub.ts (89%) create mode 100644 e2e/test-utils/index.ts create mode 100644 e2e/test-utils/products.ts rename e2e/{smoke => test-utils}/setup.ts (84%) create mode 100644 e2e/vitest.config.ts diff --git a/e2e/cli/setup.ts b/e2e/cli/setup.ts index 8ee5be2c..632ab478 100644 --- a/e2e/cli/setup.ts +++ b/e2e/cli/setup.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { Pool } from "pg"; import { default as Stripe } from "stripe"; -import { env } from "../env"; +import { env } from "../test-utils/env"; process.env.PAYKIT_CLI = "1"; diff --git a/e2e/cli/vitest.config.ts b/e2e/cli/vitest.config.ts deleted file mode 100644 index 28879c93..00000000 --- a/e2e/cli/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - env: { NODE_ENV: "production" }, - testTimeout: 120_000, - hookTimeout: 60_000, - sequence: { concurrent: false }, - include: ["cli/**/*.test.ts"], - }, -}); diff --git a/e2e/smoke/cancel/cancel-then-upgrade.test.ts b/e2e/core/cancel/cancel-then-upgrade.test.ts similarity index 98% rename from e2e/smoke/cancel/cancel-then-upgrade.test.ts rename to e2e/core/cancel/cancel-then-upgrade.test.ts index 4f081d95..b429c66c 100644 --- a/e2e/smoke/cancel/cancel-then-upgrade.test.ts +++ b/e2e/core/cancel/cancel-then-upgrade.test.ts @@ -9,7 +9,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("cancel-then-upgrade: pro → free (scheduled) → ultra (upgrade)", () => { let t: TestPayKit; diff --git a/e2e/smoke/cancel/downgrade-change-target.test.ts b/e2e/core/cancel/downgrade-change-target.test.ts similarity index 98% rename from e2e/smoke/cancel/downgrade-change-target.test.ts rename to e2e/core/cancel/downgrade-change-target.test.ts index 5f047a10..261b8fb4 100644 --- a/e2e/smoke/cancel/downgrade-change-target.test.ts +++ b/e2e/core/cancel/downgrade-change-target.test.ts @@ -10,7 +10,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("downgrade-change-target: ultra → pro (scheduled) → free (change target)", () => { let t: TestPayKit; diff --git a/e2e/smoke/checkout/resubscribe-after-cancel.test.ts b/e2e/core/checkout/resubscribe-after-cancel.test.ts similarity index 99% rename from e2e/smoke/checkout/resubscribe-after-cancel.test.ts rename to e2e/core/checkout/resubscribe-after-cancel.test.ts index 99afe10d..05f497e6 100644 --- a/e2e/smoke/checkout/resubscribe-after-cancel.test.ts +++ b/e2e/core/checkout/resubscribe-after-cancel.test.ts @@ -11,7 +11,7 @@ import { expectSingleActivePlanInGroup, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("resubscribe-after-cancel: checkout after full cancellation", () => { let t: TestPayKit; diff --git a/e2e/smoke/checkout/subscribe-paid-checkout.test.ts b/e2e/core/checkout/subscribe-paid-checkout.test.ts similarity index 98% rename from e2e/smoke/checkout/subscribe-paid-checkout.test.ts rename to e2e/core/checkout/subscribe-paid-checkout.test.ts index 7d5afd74..17ea1d66 100644 --- a/e2e/smoke/checkout/subscribe-paid-checkout.test.ts +++ b/e2e/core/checkout/subscribe-paid-checkout.test.ts @@ -9,7 +9,7 @@ import { expectSubscription, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("subscribe-paid-checkout: free → pro via checkout", () => { let t: TestPayKit; diff --git a/e2e/smoke/customer/default-free.test.ts b/e2e/core/customer/default-free.test.ts similarity index 98% rename from e2e/smoke/customer/default-free.test.ts rename to e2e/core/customer/default-free.test.ts index defd3023..b3555106 100644 --- a/e2e/smoke/customer/default-free.test.ts +++ b/e2e/core/customer/default-free.test.ts @@ -9,7 +9,7 @@ import { expectProduct, expectSingleActivePlanInGroup, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("default-free: customer creation", () => { let t: TestPayKit; diff --git a/e2e/smoke/entitlements/check-boolean.test.ts b/e2e/core/entitlements/check-boolean.test.ts similarity index 98% rename from e2e/smoke/entitlements/check-boolean.test.ts rename to e2e/core/entitlements/check-boolean.test.ts index 9eb3ddd6..75a8a85b 100644 --- a/e2e/smoke/entitlements/check-boolean.test.ts +++ b/e2e/core/entitlements/check-boolean.test.ts @@ -6,7 +6,7 @@ import { dumpStateOnFailure, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("check-boolean: boolean feature access", () => { let t: TestPayKit; diff --git a/e2e/smoke/entitlements/check-metered.test.ts b/e2e/core/entitlements/check-metered.test.ts similarity index 98% rename from e2e/smoke/entitlements/check-metered.test.ts rename to e2e/core/entitlements/check-metered.test.ts index 6ce5280a..ce4e8b47 100644 --- a/e2e/smoke/entitlements/check-metered.test.ts +++ b/e2e/core/entitlements/check-metered.test.ts @@ -7,7 +7,7 @@ import { expectExactMeteredBalance, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("check-metered: metered feature balance and usage reporting", () => { let t: TestPayKit; diff --git a/e2e/smoke/entitlements/stacked-metered.test.ts b/e2e/core/entitlements/stacked-metered.test.ts similarity index 98% rename from e2e/smoke/entitlements/stacked-metered.test.ts rename to e2e/core/entitlements/stacked-metered.test.ts index 58504160..3279a1e4 100644 --- a/e2e/smoke/entitlements/stacked-metered.test.ts +++ b/e2e/core/entitlements/stacked-metered.test.ts @@ -7,7 +7,7 @@ import { expectExactMeteredBalance, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("stacked-metered: cross-group entitlement aggregation", () => { let t: TestPayKit; diff --git a/e2e/smoke/lifecycle/subscription.test.ts b/e2e/core/lifecycle/subscription.test.ts similarity index 99% rename from e2e/smoke/lifecycle/subscription.test.ts rename to e2e/core/lifecycle/subscription.test.ts index f5bc7654..e66b4d40 100644 --- a/e2e/smoke/lifecycle/subscription.test.ts +++ b/e2e/core/lifecycle/subscription.test.ts @@ -18,7 +18,7 @@ import { expectSingleScheduledPlanInGroup, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("subscription lifecycle", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/cancel-end-of-cycle.test.ts b/e2e/core/subscribe/cancel-end-of-cycle.test.ts similarity index 99% rename from e2e/smoke/subscribe/cancel-end-of-cycle.test.ts rename to e2e/core/subscribe/cancel-end-of-cycle.test.ts index 3745a781..4bedf016 100644 --- a/e2e/smoke/subscribe/cancel-end-of-cycle.test.ts +++ b/e2e/core/subscribe/cancel-end-of-cycle.test.ts @@ -15,7 +15,7 @@ import { subscribeCustomer, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe.skipIf(!harness.capabilities.testClocks)( "cancel-end-of-cycle: pro → free + clock advance", diff --git a/e2e/smoke/subscribe/cancel-resume.test.ts b/e2e/core/subscribe/cancel-resume.test.ts similarity index 98% rename from e2e/smoke/subscribe/cancel-resume.test.ts rename to e2e/core/subscribe/cancel-resume.test.ts index 63b6c92a..d77fa1e2 100644 --- a/e2e/smoke/subscribe/cancel-resume.test.ts +++ b/e2e/core/subscribe/cancel-resume.test.ts @@ -11,7 +11,7 @@ import { expectSubscription, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("cancel-resume: pro → free → pro (resume)", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/downgrade-scheduled.test.ts b/e2e/core/subscribe/downgrade-scheduled.test.ts similarity index 98% rename from e2e/smoke/subscribe/downgrade-scheduled.test.ts rename to e2e/core/subscribe/downgrade-scheduled.test.ts index a1c12b2f..2b8a8c12 100644 --- a/e2e/smoke/subscribe/downgrade-scheduled.test.ts +++ b/e2e/core/subscribe/downgrade-scheduled.test.ts @@ -9,7 +9,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("downgrade-scheduled: ultra → pro", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/downgrade-to-free.test.ts b/e2e/core/subscribe/downgrade-to-free.test.ts similarity index 98% rename from e2e/smoke/subscribe/downgrade-to-free.test.ts rename to e2e/core/subscribe/downgrade-to-free.test.ts index 17a9faf1..589e44ff 100644 --- a/e2e/smoke/subscribe/downgrade-to-free.test.ts +++ b/e2e/core/subscribe/downgrade-to-free.test.ts @@ -10,7 +10,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("downgrade-to-free: pro → free", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/renewal.test.ts b/e2e/core/subscribe/renewal.test.ts similarity index 99% rename from e2e/smoke/subscribe/renewal.test.ts rename to e2e/core/subscribe/renewal.test.ts index adb33a62..fd6af866 100644 --- a/e2e/smoke/subscribe/renewal.test.ts +++ b/e2e/core/subscribe/renewal.test.ts @@ -14,7 +14,7 @@ import { subscribeCustomer, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe.skipIf(!harness.capabilities.testClocks)( "renewal: pro subscription renews after 1 month", diff --git a/e2e/smoke/subscribe/same-plan-noop.test.ts b/e2e/core/subscribe/same-plan-noop.test.ts similarity index 99% rename from e2e/smoke/subscribe/same-plan-noop.test.ts rename to e2e/core/subscribe/same-plan-noop.test.ts index bd557450..1a21e5da 100644 --- a/e2e/smoke/subscribe/same-plan-noop.test.ts +++ b/e2e/core/subscribe/same-plan-noop.test.ts @@ -11,7 +11,7 @@ import { expectSingleActivePlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("same-plan-noop: pro → pro", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/subscribe-paid.test.ts b/e2e/core/subscribe/subscribe-paid.test.ts similarity index 98% rename from e2e/smoke/subscribe/subscribe-paid.test.ts rename to e2e/core/subscribe/subscribe-paid.test.ts index 3a310dbf..699047d9 100644 --- a/e2e/smoke/subscribe/subscribe-paid.test.ts +++ b/e2e/core/subscribe/subscribe-paid.test.ts @@ -11,7 +11,7 @@ import { expectSubscription, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("subscribe-paid: free → pro", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/upgrade-immediate.test.ts b/e2e/core/subscribe/upgrade-immediate.test.ts similarity index 98% rename from e2e/smoke/subscribe/upgrade-immediate.test.ts rename to e2e/core/subscribe/upgrade-immediate.test.ts index c6345682..07b10446 100644 --- a/e2e/smoke/subscribe/upgrade-immediate.test.ts +++ b/e2e/core/subscribe/upgrade-immediate.test.ts @@ -10,7 +10,7 @@ import { expectSingleActivePlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("upgrade-immediate: pro → ultra", () => { let t: TestPayKit; diff --git a/e2e/smoke/webhook/duplicate-webhook.test.ts b/e2e/core/webhook/duplicate-webhook.test.ts similarity index 99% rename from e2e/smoke/webhook/duplicate-webhook.test.ts rename to e2e/core/webhook/duplicate-webhook.test.ts index d919ee6c..a614bba3 100644 --- a/e2e/smoke/webhook/duplicate-webhook.test.ts +++ b/e2e/core/webhook/duplicate-webhook.test.ts @@ -15,7 +15,7 @@ import { type TestPayKit, waitForForwardedWebhookRequest, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("duplicate-webhook: same event delivered twice", () => { let t: TestPayKit; diff --git a/e2e/smoke/webhook/subscription-deleted.test.ts b/e2e/core/webhook/subscription-deleted.test.ts similarity index 97% rename from e2e/smoke/webhook/subscription-deleted.test.ts rename to e2e/core/webhook/subscription-deleted.test.ts index a5b5f3e5..fb1a7036 100644 --- a/e2e/smoke/webhook/subscription-deleted.test.ts +++ b/e2e/core/webhook/subscription-deleted.test.ts @@ -3,7 +3,6 @@ import { default as Stripe } from "stripe"; import { afterAll, beforeAll, describe, it } from "vitest"; import { subscription } from "../../../packages/paykit/src/database/schema"; -import { env } from "../../env"; import { createTestCustomerWithPM, createTestPayKit, @@ -14,7 +13,8 @@ import { subscribeCustomer, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; +import { env } from "../../test-utils/env"; describe.skipIf(harness.id !== "stripe")( "subscription-deleted: Stripe cancels subscription directly", diff --git a/e2e/package.json b/e2e/package.json index cc4f6e2d..df4ada7e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,12 +3,12 @@ "private": true, "type": "module", "scripts": { - "test:stripe": "PROVIDER=stripe vitest run --config smoke/vitest.config.ts", - "test:stripe:watch": "PROVIDER=stripe vitest --config smoke/vitest.config.ts", - "test:polar": "PROVIDER=polar vitest run --config smoke/vitest.config.ts", - "test:polar:watch": "PROVIDER=polar vitest --config smoke/vitest.config.ts", - "test:cli": "vitest run --config cli/vitest.config.ts", - "test:cli:watch": "vitest --config cli/vitest.config.ts", + "test:stripe": "PROVIDER=stripe vitest run --project=core", + "test:stripe:watch": "PROVIDER=stripe vitest --project=core", + "test:polar": "PROVIDER=polar vitest run --project=core", + "test:polar:watch": "PROVIDER=polar vitest --project=core", + "test:cli": "vitest run --project=cli", + "test:cli:watch": "vitest --project=cli", "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { diff --git a/e2e/smoke/global-setup.ts b/e2e/smoke/global-setup.ts deleted file mode 100644 index 5cfe391a..00000000 --- a/e2e/smoke/global-setup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Server } from "node:http"; - -import { startHub } from "./hub"; - -export default async function setup(): Promise<() => Promise> { - let server: Server; - try { - server = await startHub(); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") { - throw new Error( - "Hub port 4567 already in use. Kill any stale webhook server before running tests.", { cause: error }, - ); - } - throw error; - } - return async () => { - await new Promise((resolve) => server.close(() => resolve())); - }; -} diff --git a/e2e/smoke/vitest.config.ts b/e2e/smoke/vitest.config.ts deleted file mode 100644 index 54ddcf90..00000000 --- a/e2e/smoke/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -import { smokeVitestTestConfig } from "./vitest.shared"; - -export default defineConfig({ - test: { - ...smokeVitestTestConfig, - include: ["smoke/**/*.test.ts"], - }, -}); diff --git a/e2e/smoke/vitest.shared.ts b/e2e/smoke/vitest.shared.ts deleted file mode 100644 index 9794668e..00000000 --- a/e2e/smoke/vitest.shared.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const smokeVitestTestConfig = { - env: { NODE_ENV: "production" }, - globalSetup: ["smoke/global-setup.ts"] as string[], - hookTimeout: 180_000, - // Cap parallel workers — Stripe test mode rate-limits at 25 ops/sec; too many - // workers starting syncProducts simultaneously trips it. Pair with Stripe - // SDK maxNetworkRetries for headroom. - maxWorkers: 6, - testTimeout: 600_000, -} as const; diff --git a/e2e/env.ts b/e2e/test-utils/env.ts similarity index 76% rename from e2e/env.ts rename to e2e/test-utils/env.ts index 477395cd..bf644fec 100644 --- a/e2e/env.ts +++ b/e2e/test-utils/env.ts @@ -4,8 +4,12 @@ import { createEnv } from "@t3-oss/env-core"; import { config } from "dotenv"; import * as z from "zod"; -config({ path: path.resolve(import.meta.dirname, "../.env"), quiet: true }); -config({ path: path.resolve(import.meta.dirname, "../.env.local"), override: true, quiet: true }); +config({ path: path.resolve(import.meta.dirname, "../../.env"), quiet: true }); +config({ + path: path.resolve(import.meta.dirname, "../../.env.local"), + override: true, + quiet: true, +}); export const env = createEnv({ server: { diff --git a/e2e/smoke/harness/index.ts b/e2e/test-utils/harness/index.ts similarity index 94% rename from e2e/smoke/harness/index.ts rename to e2e/test-utils/harness/index.ts index 806313a4..9b3424e6 100644 --- a/e2e/smoke/harness/index.ts +++ b/e2e/test-utils/harness/index.ts @@ -1,4 +1,4 @@ -import { env } from "../../env"; +import { env } from "../env"; import { createPolarHarness } from "./polar"; import { createStripeHarness } from "./stripe"; import type { ProviderHarness } from "./types"; diff --git a/e2e/smoke/harness/polar.ts b/e2e/test-utils/harness/polar.ts similarity index 98% rename from e2e/smoke/harness/polar.ts rename to e2e/test-utils/harness/polar.ts index ceb3f483..86185cfa 100644 --- a/e2e/smoke/harness/polar.ts +++ b/e2e/test-utils/harness/polar.ts @@ -1,7 +1,7 @@ import { polar } from "@paykitjs/polar"; import { chromium } from "playwright"; -import { env } from "../../env"; +import { env } from "../env"; import type { ProviderHarness } from "./types"; export function createPolarHarness(): ProviderHarness { diff --git a/e2e/smoke/harness/stripe.ts b/e2e/test-utils/harness/stripe.ts similarity index 65% rename from e2e/smoke/harness/stripe.ts rename to e2e/test-utils/harness/stripe.ts index 8e218229..556475d6 100644 --- a/e2e/smoke/harness/stripe.ts +++ b/e2e/test-utils/harness/stripe.ts @@ -4,7 +4,7 @@ import { default as Stripe } from "stripe"; import type { PayKitDatabase } from "../../../packages/paykit/src/database/index"; import { syncPaymentMethodByProviderCustomer } from "../../../packages/paykit/src/payment-method/payment-method.service"; -import { env } from "../../env"; +import { env } from "../env"; import type { ProviderHarness } from "./types"; export function createStripeHarness(): ProviderHarness { @@ -27,6 +27,59 @@ export function createStripeHarness(): ProviderHarness { return stripe({ secretKey, webhookSecret }); }, + applyTestingOverrides(ctx) { + // Stripe's real createSubscription uses payment_behavior: "default_incomplete", + // which requires client-side confirmation via Stripe.js. In tests we want the + // subscription to activate straight away from the server after a PM is attached. + (ctx.provider as unknown as Record).createSubscription = async (data: { + providerCustomerId: string; + providerProduct: Record; + }) => { + const sub = await stripeClient.subscriptions.create({ + customer: data.providerCustomerId, + items: [{ price: data.providerProduct.priceId }], + payment_behavior: "allow_incomplete", + expand: ["latest_invoice"], + }); + + const firstItem = sub.items.data[0]; + const periodStart = firstItem?.current_period_start ?? null; + const periodEnd = firstItem?.current_period_end ?? null; + const latestInvoice = sub.latest_invoice; + const inv = + latestInvoice && typeof latestInvoice !== "string" + ? { + currency: latestInvoice.currency, + hostedUrl: latestInvoice.hosted_invoice_url ?? null, + periodEndAt: latestInvoice.period_end + ? new Date(latestInvoice.period_end * 1000) + : null, + periodStartAt: latestInvoice.period_start + ? new Date(latestInvoice.period_start * 1000) + : null, + providerInvoiceId: latestInvoice.id, + status: latestInvoice.status, + totalAmount: latestInvoice.total, + } + : null; + + return { + invoice: inv, + paymentUrl: null, + subscription: { + cancelAtPeriodEnd: sub.cancel_at_period_end, + canceledAt: sub.canceled_at != null ? new Date(sub.canceled_at * 1000) : null, + currentPeriodEndAt: periodEnd != null ? new Date(periodEnd * 1000) : null, + currentPeriodStartAt: periodStart != null ? new Date(periodStart * 1000) : null, + endedAt: sub.ended_at != null ? new Date(sub.ended_at * 1000) : null, + providerSubscriptionId: sub.id, + providerSubscriptionScheduleId: null, + status: sub.status, + }, + }; + }; + }, + async setupCustomerForDirectSubscription(providerCustomerId: string) { const pm = await stripeClient.paymentMethods.attach("pm_card_visa", { customer: providerCustomerId, diff --git a/e2e/smoke/harness/types.ts b/e2e/test-utils/harness/types.ts similarity index 70% rename from e2e/smoke/harness/types.ts rename to e2e/test-utils/harness/types.ts index 68a94814..e249eef2 100644 --- a/e2e/smoke/harness/types.ts +++ b/e2e/test-utils/harness/types.ts @@ -1,5 +1,7 @@ import type { PayKitProviderConfig } from "paykitjs"; +import type { PayKitContext } from "../../../packages/paykit/src/core/context"; + export interface ProviderCapabilities { testClocks: boolean; directSubscription: boolean; @@ -11,6 +13,13 @@ export interface ProviderHarness { createProviderConfig(): PayKitProviderConfig; + /** + * Apply testing-only overrides to the PayKit provider (e.g., Stripe's + * allow_incomplete to bypass client-side confirmation). No-op if the + * provider doesn't need any test-mode tweaks. + */ + applyTestingOverrides?(ctx: PayKitContext): void; + /** * Make the customer ready to subscribe without checkout (e.g., attach PM for Stripe). * For providers that only support checkout, this is a no-op. diff --git a/e2e/smoke/hub.ts b/e2e/test-utils/hub.ts similarity index 89% rename from e2e/smoke/hub.ts rename to e2e/test-utils/hub.ts index ac7915d2..f6271f04 100644 --- a/e2e/smoke/hub.ts +++ b/e2e/test-utils/hub.ts @@ -152,3 +152,22 @@ export async function unregisterCustomers(providerCustomerIds: string[]): Promis body: JSON.stringify({ providerCustomerIds }), }).catch(() => {}); } + +/** Vitest globalSetup entry — starts the hub once and returns a teardown. */ +export default async function globalSetup(): Promise<() => Promise> { + let server: Server; + try { + server = await startHub(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") { + throw new Error( + `Hub port ${String(HUB_PORT)} already in use. Kill any stale webhook server before running tests.`, + { cause: error }, + ); + } + throw error; + } + return async () => { + await new Promise((resolve) => server.close(() => resolve())); + }; +} diff --git a/e2e/test-utils/index.ts b/e2e/test-utils/index.ts new file mode 100644 index 00000000..50cab521 --- /dev/null +++ b/e2e/test-utils/index.ts @@ -0,0 +1,2 @@ +export * from "./products"; +export * from "./setup"; diff --git a/e2e/test-utils/products.ts b/e2e/test-utils/products.ts new file mode 100644 index 00000000..568e3c1d --- /dev/null +++ b/e2e/test-utils/products.ts @@ -0,0 +1,51 @@ +import { feature, plan } from "paykitjs"; + +const messagesFeature = feature({ id: "messages", type: "metered" }); +const dashboardFeature = feature({ id: "dashboard", type: "boolean" }); +const adminFeature = feature({ id: "admin", type: "boolean" }); + +export const freePlan = plan({ + default: true, + group: "base", + id: "free", + name: "Free", + includes: [messagesFeature({ limit: 100, reset: "month" })], +}); + +export const proPlan = plan({ + group: "base", + id: "pro", + name: "Pro", + includes: [messagesFeature({ limit: 500, reset: "month" }), dashboardFeature()], + price: { amount: 20, interval: "month" }, +}); + +export const premiumPlan = plan({ + group: "base", + id: "premium", + name: "Premium", + includes: [messagesFeature({ limit: 1_000, reset: "month" }), dashboardFeature(), adminFeature()], + price: { amount: 50, interval: "month" }, +}); + +export const ultraPlan = plan({ + group: "base", + id: "ultra", + name: "Ultra", + includes: [ + messagesFeature({ limit: 10_000, reset: "month" }), + dashboardFeature(), + adminFeature(), + ], + price: { amount: 200, interval: "month" }, +}); + +export const extraMessagesPlan = plan({ + group: "addons", + id: "extra_messages", + name: "Extra Messages", + includes: [messagesFeature({ limit: 200, reset: "month" })], + price: { amount: 5, interval: "month" }, +}); + +export const allPlans = [freePlan, proPlan, premiumPlan, ultraPlan, extraMessagesPlan] as const; diff --git a/e2e/smoke/setup.ts b/e2e/test-utils/setup.ts similarity index 84% rename from e2e/smoke/setup.ts rename to e2e/test-utils/setup.ts index 4adceb40..dd52dfd8 100644 --- a/e2e/smoke/setup.ts +++ b/e2e/test-utils/setup.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { and, count, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; -import { createPayKit, feature, plan } from "paykitjs"; +import { createPayKit } from "paykitjs"; import { Pool } from "pg"; import { default as Stripe } from "stripe"; @@ -17,75 +17,26 @@ import { } from "../../packages/paykit/src/database/schema"; import { syncPaymentMethodByProviderCustomer } from "../../packages/paykit/src/payment-method/payment-method.service"; import { syncProducts } from "../../packages/paykit/src/product/product-sync.service"; -import { env } from "../env"; +import { env } from "./env"; import { loadHarness } from "./harness/index"; import type { ProviderHarness } from "./harness/types"; import { HUB_PORT, registerCustomer, unregisterCustomers } from "./hub"; +import { allPlans } from "./products"; // Provider harness — loaded once at module init based on PROVIDER env var export const harness: ProviderHarness = loadHarness(); -const messagesFeature = feature({ id: "messages", type: "metered" }); -const dashboardFeature = feature({ id: "dashboard", type: "boolean" }); -const adminFeature = feature({ id: "admin", type: "boolean" }); - -export const freePlan = plan({ - default: true, - group: "base", - id: "free", - name: "Free", - includes: [messagesFeature({ limit: 100, reset: "month" })], -}); - -export const proPlan = plan({ - group: "base", - id: "pro", - name: "Pro", - includes: [messagesFeature({ limit: 500, reset: "month" }), dashboardFeature()], - price: { amount: 20, interval: "month" }, -}); - -export const premiumPlan = plan({ - group: "base", - id: "premium", - name: "Premium", - includes: [messagesFeature({ limit: 1_000, reset: "month" }), dashboardFeature(), adminFeature()], - price: { amount: 50, interval: "month" }, -}); - -export const ultraPlan = plan({ - group: "base", - id: "ultra", - name: "Ultra", - includes: [ - messagesFeature({ limit: 10_000, reset: "month" }), - dashboardFeature(), - adminFeature(), - ], - price: { amount: 200, interval: "month" }, -}); - -export const extraMessagesPlan = plan({ - group: "addons", - id: "extra_messages", - name: "Extra Messages", - includes: [messagesFeature({ limit: 200, reset: "month" })], - price: { amount: 5, interval: "month" }, -}); - -const smokePlans = [freePlan, proPlan, premiumPlan, ultraPlan, extraMessagesPlan] as const; - -type SmokePayKit = ReturnType< +type TestPayKitInstance = ReturnType< typeof createPayKit<{ database: Pool; - plans: typeof smokePlans; + plans: typeof allPlans; provider: ReturnType; testing: { enabled: true }; }> >; export interface TestPayKit { - paykit: SmokePayKit; + paykit: TestPayKitInstance; database: PayKitDatabase; ctx: PayKitContext; harness: ProviderHarness; @@ -110,7 +61,7 @@ export async function createTestPayKit(): Promise { harness.validateEnv(); // 1. Create a fresh test database - const dbName = `paykit_smoke_${String(Date.now())}_${Math.random().toString(36).slice(2, 8)}`; + const dbName = `paykit_test_${String(Date.now())}_${Math.random().toString(36).slice(2, 8)}`; const adminPool = new Pool({ connectionString: env.TEST_DATABASE_URL, }); @@ -127,67 +78,15 @@ export async function createTestPayKit(): Promise { const providerConfig = harness.createProviderConfig(); const paykit = createPayKit({ database: pool, - plans: smokePlans, + plans: allPlans, provider: providerConfig, testing: { enabled: true }, }); const ctx = await paykit.$context; - // Stripe-specific: Override createSubscription to use allow_incomplete. - // This allows direct subscription without client-side payment confirmation. - if (harness.id === "stripe") { - const secretKey = env.E2E_STRIPE_SK!; - const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); - - (ctx.provider as unknown as Record).createSubscription = async (data: { - providerCustomerId: string; - providerProduct: Record; - }) => { - const sub = await stripeClient.subscriptions.create({ - customer: data.providerCustomerId, - items: [{ price: data.providerProduct.priceId }], - payment_behavior: "allow_incomplete", - expand: ["latest_invoice"], - }); - - const firstItem = sub.items.data[0]; - const periodStart = firstItem?.current_period_start ?? null; - const periodEnd = firstItem?.current_period_end ?? null; - const latestInvoice = sub.latest_invoice; - const inv = - latestInvoice && typeof latestInvoice !== "string" - ? { - currency: latestInvoice.currency, - hostedUrl: latestInvoice.hosted_invoice_url ?? null, - periodEndAt: latestInvoice.period_end - ? new Date(latestInvoice.period_end * 1000) - : null, - periodStartAt: latestInvoice.period_start - ? new Date(latestInvoice.period_start * 1000) - : null, - providerInvoiceId: latestInvoice.id, - status: latestInvoice.status, - totalAmount: latestInvoice.total, - } - : null; - - return { - invoice: inv, - paymentUrl: null, - subscription: { - cancelAtPeriodEnd: sub.cancel_at_period_end, - canceledAt: sub.canceled_at != null ? new Date(sub.canceled_at * 1000) : null, - currentPeriodEndAt: periodEnd != null ? new Date(periodEnd * 1000) : null, - currentPeriodStartAt: periodStart != null ? new Date(periodStart * 1000) : null, - endedAt: sub.ended_at != null ? new Date(sub.ended_at * 1000) : null, - providerSubscriptionId: sub.id, - providerSubscriptionScheduleId: null, - status: sub.status, - }, - }; - }; - } + // Provider-specific testing overrides (e.g., Stripe allow_incomplete) + harness.applyTestingOverrides?.(ctx); // 4. Start per-test webhook server on a random free port. A shared hub on 4567 // (started in globalSetup) forwards webhooks to this worker by provider customer ID. @@ -321,7 +220,7 @@ export async function createTestCustomerWithPM(input: { export async function subscribeCustomer(input: { t: TestPayKit; customerId: string; - planId: Parameters[0]["planId"]; + planId: Parameters[0]["planId"]; }): Promise { const beforeSubscribe = new Date(); @@ -576,10 +475,10 @@ export async function expectNoScheduledPlanInGroup(input: { export async function expectExactMeteredBalance(input: { customerId: string; - featureId: Parameters[0]["featureId"]; + featureId: Parameters[0]["featureId"]; limit: number; remaining: number; - paykit: SmokePayKit; + paykit: TestPayKitInstance; }): Promise { const result = await input.paykit.check({ customerId: input.customerId, @@ -612,7 +511,7 @@ export async function expectExactMeteredBalance(input: { } async function startWebhookServer( - paykit: Pick, + paykit: Pick, webhookRequests: CapturedWebhookRequest[], ): Promise<{ server: Server; workerUrl: string }> { const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 00000000..a51df0dd --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Cap parallel workers — Stripe test mode rate-limits at 25 ops/sec; too many + // workers starting syncProducts simultaneously trips it. Paired with Stripe + // SDK maxNetworkRetries for headroom. + maxWorkers: 6, + projects: [ + { + test: { + name: "core", + env: { NODE_ENV: "production" }, + globalSetup: ["./test-utils/hub.ts"], + hookTimeout: 180_000, + include: ["core/**/*.test.ts"], + testTimeout: 600_000, + }, + }, + { + test: { + name: "cli", + env: { NODE_ENV: "production" }, + hookTimeout: 60_000, + include: ["cli/**/*.test.ts"], + sequence: { concurrent: false }, + testTimeout: 120_000, + }, + }, + ], + }, +}); From 157f6f908fb51bf62882b67d598b8ac7c26360af Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 02:15:38 +0400 Subject: [PATCH 06/15] chore(ci): add base pull request checks --- .github/workflows/ci.yml | 101 ++++++++++++++++++ bun.lock | 1 + e2e/test-utils/hub.ts | 10 +- landing/src/lib/source.ts | 4 +- package.json | 4 +- .../__tests__/customer.service.test.ts | 5 + vitest.unit.config.ts | 9 ++ 7 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 vitest.unit.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..bd9b2fc2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + merge_group: + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: ${{ github.workflow }}-lint-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install + run: bun install --frozen-lockfile + + - name: Lint + run: bun lint + + - name: Format + run: bun format:check + + typecheck: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: ${{ github.workflow }}-typecheck-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun typecheck + + unit: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-unit-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install + run: bun install --frozen-lockfile + + - name: Unit tests + run: bun test:unit diff --git a/bun.lock b/bun.lock index bd29c466..c8dddbb6 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "tsx": "^4.21.0", "turbo": "^2.8.10", "typescript": "catalog:", + "vitest": "^4.0.18", }, }, "apps/demo": { diff --git a/e2e/test-utils/hub.ts b/e2e/test-utils/hub.ts index f6271f04..05d68c3d 100644 --- a/e2e/test-utils/hub.ts +++ b/e2e/test-utils/hub.ts @@ -33,6 +33,11 @@ function extractStripeCustomerId(body: string): string | null { } } +async function forwardEvent(workerUrl: string, event: BufferedEvent): Promise { + const url = new URL(event.path, workerUrl); + return fetch(url, { method: "POST", headers: event.headers, body: event.body }); +} + export function startHub(): Promise { const registry = new Map(); const buffers = new Map(); @@ -46,11 +51,6 @@ export function startHub(): Promise { else buffers.set(customerId, kept); } - async function forwardEvent(workerUrl: string, event: BufferedEvent): Promise { - const url = new URL(event.path, workerUrl); - return fetch(url, { method: "POST", headers: event.headers, body: event.body }); - } - const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); diff --git a/landing/src/lib/source.ts b/landing/src/lib/source.ts index 6613a08a..f563c77f 100644 --- a/landing/src/lib/source.ts +++ b/landing/src/lib/source.ts @@ -9,7 +9,5 @@ export const source = loader({ }); export type SourcePage = InferPageType & { - data: InferPageType["data"] & - DocData & - DocMethods & { full?: boolean }; + data: InferPageType["data"] & DocData & DocMethods & { full?: boolean }; }; diff --git a/package.json b/package.json index 522b2d3a..0055eaeb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dev": "turbo run dev", "dev:demo": "bun --filter demo dev", "dev:web": "bun --filter landing dev", + "test:unit": "node ./node_modules/vitest/vitest.mjs run --config vitest.unit.config.ts", "typecheck": "turbo run typecheck", "format": "oxfmt --write '**/*.{ts,tsx,js,jsx}'", "format:check": "oxfmt --check '**/*.{ts,tsx,js,jsx}'", @@ -40,7 +41,8 @@ "tinyglobby": "^0.2.16", "tsx": "^4.21.0", "turbo": "^2.8.10", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.0.18" }, "simple-git-hooks": { "pre-commit": "bun lint-staged" diff --git a/packages/paykit/src/customer/__tests__/customer.service.test.ts b/packages/paykit/src/customer/__tests__/customer.service.test.ts index 4ae3d942..b96971c0 100644 --- a/packages/paykit/src/customer/__tests__/customer.service.test.ts +++ b/packages/paykit/src/customer/__tests__/customer.service.test.ts @@ -121,6 +121,7 @@ describe("customer/service", () => { const customer = await upsertCustomer(ctx, { email: "test@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(customer).toEqual(syncedCustomer); @@ -215,6 +216,7 @@ describe("customer/service", () => { await upsertCustomer(ctx, { email: "prod@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(stripe.createCustomer).toHaveBeenCalledWith({ @@ -385,6 +387,7 @@ describe("customer/service", () => { const result = await upsertCustomer(ctx, { email: "same@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(providerMock.createCustomer).not.toHaveBeenCalled(); @@ -437,6 +440,7 @@ describe("customer/service", () => { await upsertCustomer(ctx, { email: "new@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(providerMock.updateCustomer).toHaveBeenCalledWith( @@ -483,6 +487,7 @@ describe("customer/service", () => { await upsertCustomer(ctx, { email: "test@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(providerMock.updateCustomer).toHaveBeenCalled(); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts new file mode 100644 index 00000000..7485b6a4 --- /dev/null +++ b/vitest.unit.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/dist/**", "**/node_modules/**", "apps/**", "e2e/**", "landing/**"], + include: ["packages/**/__tests__/**/*.test.ts"], + }, +}); From 34db731ed994ff7450ca73054176733c72c618eb Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 02:19:59 +0400 Subject: [PATCH 07/15] fix(ci): skip git hook install in automation --- .github/workflows/ci.yml | 6 +++--- package.json | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd9b2fc2..148210c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: bun-version: 1.3.13 - name: Install - run: bun install --frozen-lockfile + run: SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile - name: Lint run: bun lint @@ -67,7 +67,7 @@ jobs: bun-version: 1.3.13 - name: Install - run: bun install --frozen-lockfile + run: SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile - name: Typecheck run: bun typecheck @@ -95,7 +95,7 @@ jobs: bun-version: 1.3.13 - name: Install - run: bun install --frozen-lockfile + run: SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile - name: Unit tests run: bun test:unit diff --git a/package.json b/package.json index 0055eaeb..1cc8a3e5 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,10 @@ "pre-commit": "bun lint-staged" }, "lint-staged": { - "!(**/migrations/**)": "oxlint --fix", - "*.{ts,tsx,js,jsx}": "oxfmt --write" + "*.{ts,tsx,js,jsx}": [ + "oxlint --fix", + "oxfmt --write" + ] }, "engines": { "node": ">=22" From da99bde7e9ced75a5d70d079a9d2423ca22d8d8f Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 02:37:19 +0400 Subject: [PATCH 08/15] chore: install git hooks via prepare --- .github/workflows/ci.yml | 6 +++--- bun.lock | 3 --- landing/vercel.json | 2 +- package.json | 3 +-- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 148210c4..bd9b2fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: bun-version: 1.3.13 - name: Install - run: SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile + run: bun install --frozen-lockfile - name: Lint run: bun lint @@ -67,7 +67,7 @@ jobs: bun-version: 1.3.13 - name: Install - run: SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile + run: bun install --frozen-lockfile - name: Typecheck run: bun typecheck @@ -95,7 +95,7 @@ jobs: bun-version: 1.3.13 - name: Install - run: SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile + run: bun install --frozen-lockfile - name: Unit tests run: bun test:unit diff --git a/bun.lock b/bun.lock index c8dddbb6..1945ef97 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,6 @@ "lint-staged": "^16.2.7", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", - "simple-git-hooks": "^2.13.1", "tinyglobby": "^0.2.16", "tsx": "^4.21.0", "turbo": "^2.8.10", @@ -2215,8 +2214,6 @@ "signal-exit": ["signal-exit@4.1.0", "https://registry.better-npm.dev/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "simple-git-hooks": ["simple-git-hooks@2.13.1", "https://registry.better-npm.dev/simple-git-hooks/-/simple-git-hooks-2.13.1.tgz", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="], - "sisteransi": ["sisteransi@1.0.5", "https://registry.better-npm.dev/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slice-ansi": ["slice-ansi@8.0.0", "https://registry.better-npm.dev/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], diff --git a/landing/vercel.json b/landing/vercel.json index b216b298..e8b679aa 100644 --- a/landing/vercel.json +++ b/landing/vercel.json @@ -1,4 +1,4 @@ { "ignoreCommand": "npx turbo-ignore landing", - "installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install" + "installCommand": "bun install" } diff --git a/package.json b/package.json index 1cc8a3e5..c8008240 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint:fix": "oxlint --fix", "release": "turbo build --filter=./packages/* && bumpp", "release:canary": "turbo build --filter=./packages/* && bumpp --tag canary", - "prepare": "simple-git-hooks", + "prepare": "bunx simple-git-hooks || true", "worktree:setup": "bash scripts/worktree-setup.sh" }, "devDependencies": { @@ -37,7 +37,6 @@ "lint-staged": "^16.2.7", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", - "simple-git-hooks": "^2.13.1", "tinyglobby": "^0.2.16", "tsx": "^4.21.0", "turbo": "^2.8.10", From 25416ca4f72feba5d222ab42c2e93495fa9a4e2a Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 02:38:12 +0400 Subject: [PATCH 09/15] chore(landing): use bunx in vercel config --- landing/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/vercel.json b/landing/vercel.json index e8b679aa..f15e70c5 100644 --- a/landing/vercel.json +++ b/landing/vercel.json @@ -1,4 +1,4 @@ { - "ignoreCommand": "npx turbo-ignore landing", + "ignoreCommand": "bunx turbo-ignore landing", "installCommand": "bun install" } From 51da3d5fea6290af099007087a685ae5fbb392ca Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 03:40:19 +0400 Subject: [PATCH 10/15] test(e2e): add stripe GitHub Actions workflow --- .github/workflows/e2e.yml | 127 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..4d6c427a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,127 @@ +name: E2E + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * *" + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - labeled + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + stripe: + name: Stripe E2E + if: | + github.event_name != 'pull_request' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + !github.event.pull_request.draft && + contains(github.event.pull_request.labels.*.name, 'run-e2e') + ) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + CF_TUNNEL_HOST: ${{ vars.CF_TUNNEL_HOST_STRIPE || 't1.paykit.sh' }} + E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} + E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install workspace dependencies + run: bun install --frozen-lockfile + + - name: Validate Stripe E2E configuration + env: + CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_STRIPE }} + run: | + set -euo pipefail + for name in CF_TUNNEL_TOKEN E2E_STRIPE_SK E2E_STRIPE_WHSEC TEST_DATABASE_URL; do + if [ -z "${!name:-}" ]; then + echo "::error::Missing required configuration: $name" + exit 1 + fi + done + + - name: Setup cloudflared + uses: AnimMouse/setup-cloudflared@v2 + + - name: Check cloudflared version + run: cloudflared --version + + - name: Install Playwright Chromium + run: bunx playwright install --with-deps chromium + + - name: Run Stripe E2E suite + env: + CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_STRIPE }} + run: | + set -euo pipefail + cloudflared tunnel --url http://127.0.0.1:4567 run --token "$CF_TUNNEL_TOKEN" > cloudflared.log 2>&1 & + cloudflared_pid=$! + + cleanup() { + if kill -0 "$cloudflared_pid" 2>/dev/null; then + kill "$cloudflared_pid" || true + wait "$cloudflared_pid" || true + fi + } + + trap cleanup EXIT + + sleep 5 + + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "::error::cloudflared exited before tests started" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + bun --filter e2e test:stripe + + - name: Upload cloudflared log + if: failure() + uses: actions/upload-artifact@v4 + with: + if-no-files-found: ignore + name: stripe-e2e-cloudflared-log + path: cloudflared.log From 081e379a295ebd9c6f768e49176e70192da6e249 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 03:46:58 +0400 Subject: [PATCH 11/15] test(e2e): make workflow manual and serialize stripe --- .github/workflows/e2e.yml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4d6c427a..5e51c5cb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -4,31 +4,16 @@ on: workflow_dispatch: schedule: - cron: "0 6 * * *" - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - - labeled permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: stripe: name: Stripe E2E - if: | - github.event_name != 'pull_request' || - ( - github.event.pull_request.head.repo.full_name == github.repository && - !github.event.pull_request.draft && - contains(github.event.pull_request.labels.*.name, 'run-e2e') - ) runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-stripe + cancel-in-progress: false timeout-minutes: 60 permissions: contents: read From f16aa2408bc4a036ad20ca0fb76eea814bee1ad3 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 04:22:51 +0400 Subject: [PATCH 12/15] test(e2e): harden Stripe checkout automation --- e2e/test-utils/harness/stripe.ts | 122 ++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/e2e/test-utils/harness/stripe.ts b/e2e/test-utils/harness/stripe.ts index 556475d6..e8f9c5a4 100644 --- a/e2e/test-utils/harness/stripe.ts +++ b/e2e/test-utils/harness/stripe.ts @@ -1,5 +1,5 @@ import { stripe } from "@paykitjs/stripe"; -import { chromium } from "playwright"; +import { chromium, type Locator, type Page } from "playwright"; import { default as Stripe } from "stripe"; import type { PayKitDatabase } from "../../../packages/paykit/src/database/index"; @@ -7,6 +7,98 @@ import { syncPaymentMethodByProviderCustomer } from "../../../packages/paykit/sr import { env } from "../env"; import type { ProviderHarness } from "./types"; +const stripeCardNumberSelectors = [ + "#cardNumber", + 'input[name="cardnumber"]', + 'input[autocomplete="cc-number"]', + 'input[placeholder*="card number" i]', + '[data-testid="card-number"]', +]; + +const stripeCardExpirySelectors = [ + "#cardExpiry", + 'input[name="exp-date"]', + 'input[autocomplete="cc-exp"]', + 'input[placeholder*="MM / YY" i]', + '[data-testid="card-expiry"]', +]; + +const stripeCardCvcSelectors = [ + "#cardCvc", + 'input[name="cvc"]', + 'input[autocomplete="cc-csc"]', + 'input[placeholder*="CVC" i]', + '[data-testid="card-cvc"]', +]; + +const stripeBillingNameSelectors = [ + "#billingName", + 'input[name="name"]', + 'input[autocomplete="cc-name"]', + '[data-testid="billing-name"]', +]; + +const stripeSubmitSelectors = [ + 'button[type="submit"]', + 'button:has-text("Subscribe")', + 'button:has-text("Pay")', + 'button:has-text("Start trial")', + ".SubmitButton-TextContainer", +]; + +async function waitForVisibleLocator( + page: Page, + selectors: string[], + timeout: number, +): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + for (const context of [page, ...page.frames()]) { + for (const selector of selectors) { + const locator = context.locator(selector).first(); + if (await locator.isVisible().catch(() => false)) { + return locator; + } + } + } + + await page.waitForTimeout(500); + } + + const frameUrls = page + .frames() + .map((frame) => frame.url()) + .filter(Boolean) + .join("\n- "); + + throw new Error( + [ + `Timed out waiting for Stripe checkout selectors: ${selectors.join(", ")}`, + `Page URL: ${page.url()}`, + frameUrls ? `Frames:\n- ${frameUrls}` : "Frames: (none)", + ].join("\n"), + ); +} + +async function maybeGetVisibleLocator( + page: Page, + selectors: string[], + timeout: number, +): Promise { + try { + return await waitForVisibleLocator(page, selectors, timeout); + } catch { + return null; + } +} + +async function fillStripeField(page: Page, selectors: string[], value: string, timeout: number) { + const locator = await waitForVisibleLocator(page, selectors, timeout); + await locator.click(); + await locator.pressSequentially(value); +} + export function createStripeHarness(): ProviderHarness { const secretKey = env.E2E_STRIPE_SK; const webhookSecret = env.E2E_STRIPE_WHSEC; @@ -95,28 +187,24 @@ export function createStripeHarness(): ProviderHarness { try { await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("load").catch(() => {}); // Stripe's hosted checkout uses custom inputs that require per-key events; - // fill() does not dispatch them correctly, so use pressSequentially. - const cardNumber = page.locator("#cardNumber"); - await cardNumber.waitFor({ timeout: 60_000 }); - await cardNumber.pressSequentially("4242424242424242"); - - const cardExpiry = page.locator("#cardExpiry"); - await cardExpiry.waitFor({ timeout: 30_000 }); - await cardExpiry.pressSequentially("1234"); - - const cardCvc = page.locator("#cardCvc"); - await cardCvc.waitFor({ timeout: 30_000 }); - await cardCvc.pressSequentially("123"); + // fill() does not dispatch them correctly, so use pressSequentially. In + // CI Stripe sometimes renders these fields inside iframes rather than as + // top-level inputs, so search both the page and all frames. + await fillStripeField(page, stripeCardNumberSelectors, "4242424242424242", 60_000); + await fillStripeField(page, stripeCardExpirySelectors, "1234", 30_000); + await fillStripeField(page, stripeCardCvcSelectors, "123", 30_000); - const billingName = page.locator("#billingName"); - if (await billingName.isVisible().catch(() => false)) { + const billingName = await maybeGetVisibleLocator(page, stripeBillingNameSelectors, 5_000); + if (billingName) { + await billingName.click(); await billingName.pressSequentially("Test Customer"); } - const submitBtn = page.locator(".SubmitButton-TextContainer").first(); - await submitBtn.evaluate((el) => (el as HTMLElement).click()); + const submitBtn = await waitForVisibleLocator(page, stripeSubmitSelectors, 30_000); + await submitBtn.click({ force: true }); // Wait for Stripe to navigate away from the checkout page (success redirect // or embedded confirmation). Don't fail the test if this times out — the From d0223edf6015bbd49027108563f2b32f668e9f47 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 04:32:48 +0400 Subject: [PATCH 13/15] Revert "test(e2e): harden Stripe checkout automation" This reverts commit f16aa2408bc4a036ad20ca0fb76eea814bee1ad3. --- e2e/test-utils/harness/stripe.ts | 122 +++++-------------------------- 1 file changed, 17 insertions(+), 105 deletions(-) diff --git a/e2e/test-utils/harness/stripe.ts b/e2e/test-utils/harness/stripe.ts index e8f9c5a4..556475d6 100644 --- a/e2e/test-utils/harness/stripe.ts +++ b/e2e/test-utils/harness/stripe.ts @@ -1,5 +1,5 @@ import { stripe } from "@paykitjs/stripe"; -import { chromium, type Locator, type Page } from "playwright"; +import { chromium } from "playwright"; import { default as Stripe } from "stripe"; import type { PayKitDatabase } from "../../../packages/paykit/src/database/index"; @@ -7,98 +7,6 @@ import { syncPaymentMethodByProviderCustomer } from "../../../packages/paykit/sr import { env } from "../env"; import type { ProviderHarness } from "./types"; -const stripeCardNumberSelectors = [ - "#cardNumber", - 'input[name="cardnumber"]', - 'input[autocomplete="cc-number"]', - 'input[placeholder*="card number" i]', - '[data-testid="card-number"]', -]; - -const stripeCardExpirySelectors = [ - "#cardExpiry", - 'input[name="exp-date"]', - 'input[autocomplete="cc-exp"]', - 'input[placeholder*="MM / YY" i]', - '[data-testid="card-expiry"]', -]; - -const stripeCardCvcSelectors = [ - "#cardCvc", - 'input[name="cvc"]', - 'input[autocomplete="cc-csc"]', - 'input[placeholder*="CVC" i]', - '[data-testid="card-cvc"]', -]; - -const stripeBillingNameSelectors = [ - "#billingName", - 'input[name="name"]', - 'input[autocomplete="cc-name"]', - '[data-testid="billing-name"]', -]; - -const stripeSubmitSelectors = [ - 'button[type="submit"]', - 'button:has-text("Subscribe")', - 'button:has-text("Pay")', - 'button:has-text("Start trial")', - ".SubmitButton-TextContainer", -]; - -async function waitForVisibleLocator( - page: Page, - selectors: string[], - timeout: number, -): Promise { - const deadline = Date.now() + timeout; - - while (Date.now() < deadline) { - for (const context of [page, ...page.frames()]) { - for (const selector of selectors) { - const locator = context.locator(selector).first(); - if (await locator.isVisible().catch(() => false)) { - return locator; - } - } - } - - await page.waitForTimeout(500); - } - - const frameUrls = page - .frames() - .map((frame) => frame.url()) - .filter(Boolean) - .join("\n- "); - - throw new Error( - [ - `Timed out waiting for Stripe checkout selectors: ${selectors.join(", ")}`, - `Page URL: ${page.url()}`, - frameUrls ? `Frames:\n- ${frameUrls}` : "Frames: (none)", - ].join("\n"), - ); -} - -async function maybeGetVisibleLocator( - page: Page, - selectors: string[], - timeout: number, -): Promise { - try { - return await waitForVisibleLocator(page, selectors, timeout); - } catch { - return null; - } -} - -async function fillStripeField(page: Page, selectors: string[], value: string, timeout: number) { - const locator = await waitForVisibleLocator(page, selectors, timeout); - await locator.click(); - await locator.pressSequentially(value); -} - export function createStripeHarness(): ProviderHarness { const secretKey = env.E2E_STRIPE_SK; const webhookSecret = env.E2E_STRIPE_WHSEC; @@ -187,24 +95,28 @@ export function createStripeHarness(): ProviderHarness { try { await page.goto(url, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("load").catch(() => {}); // Stripe's hosted checkout uses custom inputs that require per-key events; - // fill() does not dispatch them correctly, so use pressSequentially. In - // CI Stripe sometimes renders these fields inside iframes rather than as - // top-level inputs, so search both the page and all frames. - await fillStripeField(page, stripeCardNumberSelectors, "4242424242424242", 60_000); - await fillStripeField(page, stripeCardExpirySelectors, "1234", 30_000); - await fillStripeField(page, stripeCardCvcSelectors, "123", 30_000); + // fill() does not dispatch them correctly, so use pressSequentially. + const cardNumber = page.locator("#cardNumber"); + await cardNumber.waitFor({ timeout: 60_000 }); + await cardNumber.pressSequentially("4242424242424242"); + + const cardExpiry = page.locator("#cardExpiry"); + await cardExpiry.waitFor({ timeout: 30_000 }); + await cardExpiry.pressSequentially("1234"); + + const cardCvc = page.locator("#cardCvc"); + await cardCvc.waitFor({ timeout: 30_000 }); + await cardCvc.pressSequentially("123"); - const billingName = await maybeGetVisibleLocator(page, stripeBillingNameSelectors, 5_000); - if (billingName) { - await billingName.click(); + const billingName = page.locator("#billingName"); + if (await billingName.isVisible().catch(() => false)) { await billingName.pressSequentially("Test Customer"); } - const submitBtn = await waitForVisibleLocator(page, stripeSubmitSelectors, 30_000); - await submitBtn.click({ force: true }); + const submitBtn = page.locator(".SubmitButton-TextContainer").first(); + await submitBtn.evaluate((el) => (el as HTMLElement).click()); // Wait for Stripe to navigate away from the checkout page (success redirect // or embedded confirmation). Don't fail the test if this times out — the From f72eb00ea765c9a1c75db501947a26bf4e2f6b43 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 21:56:47 +0400 Subject: [PATCH 14/15] test(e2e): expand CI coverage and release gate --- .github/workflows/e2e.yml | 174 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 19 ++++ 2 files changed, 193 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5e51c5cb..95925a0a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,14 +2,84 @@ name: E2E on: workflow_dispatch: + inputs: + target: + description: E2E target to run + required: true + type: choice + default: all + options: + - all + - cli + - stripe + - polar schedule: - cron: "0 6 * * *" permissions: {} jobs: + cli: + name: CLI E2E + if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'cli' }} + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-stripe + cancel-in-progress: false + timeout-minutes: 30 + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} + E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install workspace dependencies + run: bun install --frozen-lockfile + + - name: Validate CLI E2E configuration + run: | + set -euo pipefail + for name in E2E_STRIPE_SK E2E_STRIPE_WHSEC TEST_DATABASE_URL; do + if [ -z "${!name:-}" ]; then + echo "::error::Missing required configuration: $name" + exit 1 + fi + done + + - name: Run CLI E2E suite + run: bun --filter e2e test:cli + stripe: name: Stripe E2E + if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'stripe' }} runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-stripe @@ -110,3 +180,107 @@ jobs: if-no-files-found: ignore name: stripe-e2e-cloudflared-log path: cloudflared.log + + polar: + name: Polar E2E + if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'polar' }} + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-polar + cancel-in-progress: false + timeout-minutes: 60 + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + CF_TUNNEL_HOST: ${{ vars.CF_TUNNEL_HOST_POLAR || 't2.paykit.sh' }} + E2E_POLAR_ACCESS_TOKEN: ${{ secrets.E2E_POLAR_ACCESS_TOKEN }} + E2E_POLAR_WHSEC: ${{ secrets.E2E_POLAR_WHSEC }} + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install workspace dependencies + run: bun install --frozen-lockfile + + - name: Validate Polar E2E configuration + env: + CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_POLAR }} + run: | + set -euo pipefail + for name in CF_TUNNEL_TOKEN E2E_POLAR_ACCESS_TOKEN E2E_POLAR_WHSEC TEST_DATABASE_URL; do + if [ -z "${!name:-}" ]; then + echo "::error::Missing required configuration: $name" + exit 1 + fi + done + + - name: Setup cloudflared + uses: AnimMouse/setup-cloudflared@v2 + + - name: Check cloudflared version + run: cloudflared --version + + - name: Install Playwright Chromium + run: bunx playwright install --with-deps chromium + + - name: Run Polar E2E suite + env: + CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_POLAR }} + run: | + set -euo pipefail + cloudflared tunnel --url http://127.0.0.1:4567 run --token "$CF_TUNNEL_TOKEN" > cloudflared.log 2>&1 & + cloudflared_pid=$! + + cleanup() { + if kill -0 "$cloudflared_pid" 2>/dev/null; then + kill "$cloudflared_pid" || true + wait "$cloudflared_pid" || true + fi + } + + trap cleanup EXIT + + sleep 5 + + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "::error::cloudflared exited before tests started" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + bun --filter e2e test:polar + + - name: Upload cloudflared log + if: failure() + uses: actions/upload-artifact@v4 + with: + if-no-files-found: ignore + name: polar-e2e-cloudflared-log + path: cloudflared.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd98a166..ac9539cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ jobs: release: runs-on: ubuntu-latest permissions: + actions: read contents: write id-token: write steps: @@ -19,6 +20,24 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Verify E2E gate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + required_checks=("CLI E2E" "Stripe E2E" "Polar E2E") + + for check_name in "${required_checks[@]}"; do + conclusion=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs" --jq ".check_runs | map(select(.app.slug == \"github-actions\" and .name == \"${check_name}\")) | sort_by(.completed_at) | last | .conclusion // \"missing\"") + + if [ "$conclusion" != "success" ]; then + echo "::error::Required E2E check '$check_name' is '$conclusion' for commit ${GITHUB_SHA}. Run the full E2E workflow on this exact SHA before releasing." + exit 1 + fi + done + + echo "Found successful CLI, Stripe, and Polar E2E checks for ${GITHUB_SHA}" + - uses: oven-sh/setup-bun@v2 - uses: actions/setup-node@v4 From cf293e228014d6a4ec5d9124ec2fd448514022f4 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Mon, 27 Apr 2026 22:38:04 +0400 Subject: [PATCH 15/15] test(e2e): fix workflow gating and harness cleanup --- .github/workflows/e2e.yml | 64 ++++++++++++++++++++++++-------- .github/workflows/release.yml | 3 +- e2e/test-utils/harness/stripe.ts | 54 +++++++-------------------- 3 files changed, 63 insertions(+), 58 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 95925a0a..bb65d30c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -102,7 +102,6 @@ jobs: --health-timeout 5s --health-retries 5 env: - CF_TUNNEL_HOST: ${{ vars.CF_TUNNEL_HOST_STRIPE || 't1.paykit.sh' }} E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres @@ -161,15 +160,32 @@ jobs: trap cleanup EXIT - sleep 5 + readiness_timeout_s=30 + readiness_deadline=$((SECONDS + readiness_timeout_s)) - if ! kill -0 "$cloudflared_pid" 2>/dev/null; then - echo "::error::cloudflared exited before tests started" - if [ -f cloudflared.log ]; then - cat cloudflared.log + while true; do + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "::error::cloudflared exited before tests started" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + if [ -f cloudflared.log ] && grep -Eq 'Registered tunnel|Connection [A-Za-z0-9]+ registered|INF.*Registered tunnel connection' cloudflared.log; then + break + fi + + if [ "$SECONDS" -ge "$readiness_deadline" ]; then + echo "::error::Timed out waiting for cloudflared readiness" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 fi - exit 1 - fi + + sleep 0.5 + done bun --filter e2e test:stripe @@ -206,7 +222,6 @@ jobs: --health-timeout 5s --health-retries 5 env: - CF_TUNNEL_HOST: ${{ vars.CF_TUNNEL_HOST_POLAR || 't2.paykit.sh' }} E2E_POLAR_ACCESS_TOKEN: ${{ secrets.E2E_POLAR_ACCESS_TOKEN }} E2E_POLAR_WHSEC: ${{ secrets.E2E_POLAR_WHSEC }} TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres @@ -265,15 +280,32 @@ jobs: trap cleanup EXIT - sleep 5 + readiness_timeout_s=30 + readiness_deadline=$((SECONDS + readiness_timeout_s)) - if ! kill -0 "$cloudflared_pid" 2>/dev/null; then - echo "::error::cloudflared exited before tests started" - if [ -f cloudflared.log ]; then - cat cloudflared.log + while true; do + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "::error::cloudflared exited before tests started" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + if [ -f cloudflared.log ] && grep -Eq 'Registered tunnel|Connection [A-Za-z0-9]+ registered|INF.*Registered tunnel connection' cloudflared.log; then + break + fi + + if [ "$SECONDS" -ge "$readiness_deadline" ]; then + echo "::error::Timed out waiting for cloudflared readiness" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 fi - exit 1 - fi + + sleep 0.5 + done bun --filter e2e test:polar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac9539cd..75fde211 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest permissions: actions: read + checks: read contents: write id-token: write steps: @@ -28,7 +29,7 @@ jobs: required_checks=("CLI E2E" "Stripe E2E" "Polar E2E") for check_name in "${required_checks[@]}"; do - conclusion=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs" --jq ".check_runs | map(select(.app.slug == \"github-actions\" and .name == \"${check_name}\")) | sort_by(.completed_at) | last | .conclusion // \"missing\"") + conclusion=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --jq ".check_runs | map(select(.app.slug == \"github-actions\" and .name == \"${check_name}\")) | sort_by(.completed_at) | last | .conclusion // \"missing\"") if [ "$conclusion" != "success" ]; then echo "::error::Required E2E check '$check_name' is '$conclusion' for commit ${GITHUB_SHA}. Run the full E2E workflow on this exact SHA before releasing." diff --git a/e2e/test-utils/harness/stripe.ts b/e2e/test-utils/harness/stripe.ts index 556475d6..2c0c5ff6 100644 --- a/e2e/test-utils/harness/stripe.ts +++ b/e2e/test-utils/harness/stripe.ts @@ -2,17 +2,14 @@ import { stripe } from "@paykitjs/stripe"; import { chromium } from "playwright"; import { default as Stripe } from "stripe"; -import type { PayKitDatabase } from "../../../packages/paykit/src/database/index"; -import { syncPaymentMethodByProviderCustomer } from "../../../packages/paykit/src/payment-method/payment-method.service"; +import type { PaymentProvider } from "../../../packages/paykit/src/providers/provider"; import { env } from "../env"; import type { ProviderHarness } from "./types"; export function createStripeHarness(): ProviderHarness { + validateStripeEnv(); const secretKey = env.E2E_STRIPE_SK; const webhookSecret = env.E2E_STRIPE_WHSEC; - if (!secretKey || !webhookSecret) { - throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); - } const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); @@ -31,10 +28,10 @@ export function createStripeHarness(): ProviderHarness { // Stripe's real createSubscription uses payment_behavior: "default_incomplete", // which requires client-side confirmation via Stripe.js. In tests we want the // subscription to activate straight away from the server after a PM is attached. - (ctx.provider as unknown as Record).createSubscription = async (data: { - providerCustomerId: string; - providerProduct: Record; - }) => { + const provider = ctx.provider as PaymentProvider; + provider.createSubscription = async ( + data: Parameters[0], + ) => { const sub = await stripeClient.subscriptions.create({ customer: data.providerCustomerId, items: [{ price: data.providerProduct.priceId }], @@ -91,9 +88,9 @@ export function createStripeHarness(): ProviderHarness { async completeCheckout(url: string) { const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); try { + const page = await browser.newPage(); await page.goto(url, { waitUntil: "domcontentloaded" }); // Stripe's hosted checkout uses custom inputs that require per-key events; @@ -116,7 +113,7 @@ export function createStripeHarness(): ProviderHarness { } const submitBtn = page.locator(".SubmitButton-TextContainer").first(); - await submitBtn.evaluate((el) => (el as HTMLElement).click()); + await submitBtn.click(); // Wait for Stripe to navigate away from the checkout page (success redirect // or embedded confirmation). Don't fail the test if this times out — the @@ -148,38 +145,13 @@ export function createStripeHarness(): ProviderHarness { }, validateEnv() { - if (!env.E2E_STRIPE_SK || !env.E2E_STRIPE_WHSEC) { - throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); - } + validateStripeEnv(); }, }; } -/** Sync a Stripe payment method into the PayKit database. */ -export async function syncStripePaymentMethod(input: { - database: PayKitDatabase; - providerCustomerId: string; - providerId: string; - stripeClient: Stripe; -}): Promise { - const pm = await input.stripeClient.paymentMethods.list({ - customer: input.providerCustomerId, - type: "card", - limit: 1, - }); - const method = pm.data[0]; - if (!method) return; - - await syncPaymentMethodByProviderCustomer(input.database, { - paymentMethod: { - providerMethodId: method.id, - type: method.type, - last4: method.card?.last4, - expiryMonth: method.card?.exp_month, - expiryYear: method.card?.exp_year, - isDefault: true, - }, - providerCustomerId: input.providerCustomerId, - providerId: input.providerId, - }); +function validateStripeEnv(): void { + if (!env.E2E_STRIPE_SK || !env.E2E_STRIPE_WHSEC) { + throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); + } }