Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,67 @@ jobs:
- run: npm run type-check
- run: npm run build
- run: npm run test

e2e:
runs-on: ubuntu-latest
needs: build

services:
postgres:
image: postgres:16
env:
POSTGRES_USER: pacto
POSTGRES_PASSWORD: pacto
POSTGRES_DB: pacto_e2e
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 3s
--health-retries 10

env:
DATABASE_URL: postgresql://pacto:pacto@localhost:5432/pacto_e2e
DIRECT_URL: postgresql://pacto:pacto@localhost:5432/pacto_e2e
GATEWAY_ADMIN_TOKEN: ${{ secrets.E2E_ADMIN_TOKEN }}
GATEWAY_SIGNING_SECRET: ${{ secrets.E2E_SIGNING_SECRET }}
NODE_ENV: test
TESTMODE_RELEASE_DELAY_MS: "500"
WEBHOOK_BACKOFF_BASE_MS: "100"
PORT: "8788"

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Build all packages (required before E2E)
run: npm run build

- name: Run database migrations
working-directory: services/connect-gateway
run: npx prisma migrate deploy

- name: Install Playwright browsers
working-directory: apps/e2e
run: npx playwright install --with-deps chromium

- name: Run E2E tests
run: npm run test:e2e
env:
CI: "true"

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7
29 changes: 29 additions & 0 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@pacto-connect/e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:e2e": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"clean": "rm -rf .playwright test-results playwright-report"
},
"dependencies": {
"@pacto-connect/core": "*",
"@pacto-connect/elements": "*",
"@pacto-connect/react": "*"
},
"devDependencies": {
"@playwright/test": "^1.46.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.3",
"vite": "^5.4.0"
}
}
77 changes: 77 additions & 0 deletions apps/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const GATEWAY_URL = process.env.E2E_GATEWAY_URL ?? 'http://localhost:8788';

export default defineConfig({
testDir: './src/tests',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'github' : 'list',

// globalSetup provisions the test API key before tests run.
// webServer starts FIRST (Playwright ensures gateway is healthy), then globalSetup runs.
globalSetup: './src/server/gateway-bootstrap.ts',

use: {
trace: 'on-first-retry',
video: 'on-first-retry',
},

webServer: [
{
// Gateway — must be built before running (turbo build handles this)
command: 'node dist/index.js',
cwd: path.resolve(__dirname, '../../services/connect-gateway'),
url: `${GATEWAY_URL}/health`,
reuseExistingServer: !process.env.CI,
timeout: 20_000,
env: {
PORT: '8788',
NODE_ENV: 'test',
// In CI these come from the job env. Locally, set them in your shell
// or create services/connect-gateway/.env.test and source it before running.
DATABASE_URL:
process.env.DATABASE_URL ?? 'postgresql://pacto:pacto@localhost:5432/pacto_e2e',
DIRECT_URL:
process.env.DIRECT_URL ?? 'postgresql://pacto:pacto@localhost:5432/pacto_e2e',
GATEWAY_ADMIN_TOKEN: process.env.GATEWAY_ADMIN_TOKEN ?? 'e2e-local-admin-secret',
GATEWAY_SIGNING_SECRET:
process.env.GATEWAY_SIGNING_SECRET ?? 'e2e-local-signing-secret-32chars!',
TESTMODE_RELEASE_DELAY_MS: '500',
WEBHOOK_BACKOFF_BASE_MS: '100',
},
},
{
// React dev server for connect-react tests
command: 'npx vite --port 5174',
cwd: path.join(__dirname, 'src/pages/react-checkout'),
url: 'http://localhost:5174',
reuseExistingServer: !process.env.CI,
timeout: 20_000,
},
{
// Vite dev server for connect-elements tests (web component, no iframe)
command: 'npx vite --port 5175',
cwd: path.join(__dirname, 'src/pages/elements-checkout'),
url: 'http://localhost:5175',
reuseExistingServer: !process.env.CI,
timeout: 20_000,
},
],

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});
39 changes: 39 additions & 0 deletions apps/e2e/src/fixtures/gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test as base } from '@playwright/test';
import { readFileSync } from 'node:fs';

interface E2EConfig {
gatewayUrl: string;
publishableKey: string;
apiKeyId: string;
adminToken: string;
}

function readConfig(): E2EConfig {
const configPath = process.env.E2E_CONFIG_PATH;
if (!configPath) {
throw new Error('[e2e] E2E_CONFIG_PATH is not set. Did globalSetup run?');
}
return JSON.parse(readFileSync(configPath, 'utf-8')) as E2EConfig;
}

export interface GatewayFixtures {
gatewayUrl: string;
publishableKey: string;
apiKeyId: string;
adminToken: string;
}

export const test = base.extend<GatewayFixtures>({
gatewayUrl: async ({}, use) => {
await use(readConfig().gatewayUrl);
},
publishableKey: async ({}, use) => {
await use(readConfig().publishableKey);
},
apiKeyId: async ({}, use) => {
await use(readConfig().apiKeyId);
},
adminToken: async ({}, use) => {
await use(readConfig().adminToken);
},
});
5 changes: 5 additions & 0 deletions apps/e2e/src/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { test } from './session-client.js';
export { expect } from '@playwright/test';
export type { CreatedSession, SessionClientFixture } from './session-client.js';
export type { WebhookCapture, WebhookPayload } from './webhook-capture.js';
export type { GatewayFixtures } from './gateway.js';
50 changes: 50 additions & 0 deletions apps/e2e/src/fixtures/session-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Pacto, type PactoApiClient, type PactoSession } from '@pacto-connect/core';
import { test as webhookTest } from './webhook-capture.js';

export interface CreatedSession {
session: PactoSession;
api: PactoApiClient;
}

export interface SessionClientFixture {
/**
* Creates a checkout session using the test API key. The returned session
* and API client are ready to use against the sandbox gateway.
*
* Uses the synthetic origin http://localhost:5176, which is whitelisted in
* the test API key created by globalSetup.
*/
createSession(mode?: 'buy' | 'sell'): Promise<CreatedSession>;
}

export const test = webhookTest.extend<{ sessionClient: SessionClientFixture }>({
sessionClient: async ({ gatewayUrl, publishableKey }, use) => {
const sessions: PactoSession[] = [];

const fixture: SessionClientFixture = {
async createSession(mode = 'buy'): Promise<CreatedSession> {
const client = Pacto.init({
publishableKey,
gatewayUrl,
// Synthetic origin whitelisted in the test key's allowedOrigins
origin: 'http://localhost:5176',
});

// Use browse mode so we don't need a listingId.
// The CheckoutFlowController would call GET /v1/listings (not in gateway),
// so we use client.createCheckoutSession directly instead of the controller.
const session = await client.createCheckoutSession({ quote: { browse: true }, mode });
sessions.push(session);

return { session, api: client.api(session) };
},
};

await use(fixture);

// Cleanup: close all SSE event streams
for (const s of sessions) {
s.closeEvents();
}
},
});
Loading
Loading