test(e2e): make donation payment widgets run on every PR (#69477)

This commit is contained in:
Sem Bauke
2026-08-12 09:30:35 +02:00
committed by GitHub
parent 674c6f132d
commit 0febf22779
8 changed files with 356 additions and 290 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ jobs:
run: pnpm run seed:certified-user
- name: Run playwright tests
run: pnpm run playwright:run --project=${{ matrix.browsers }} --grep-invert 'third-party-donation.spec.ts'
run: pnpm run playwright:run --project=${{ matrix.browsers }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: ${{ !cancelled() }}
-166
View File
@@ -1,166 +0,0 @@
name: CI - E2E - 3rd party donation tests
on:
workflow_dispatch:
push:
branches:
- 'prod-**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
permissions:
contents: read
jobs:
build-client:
name: Build Client
runs-on: ubuntu-24.04
strategy:
matrix:
node-version: [24]
steps:
- name: Checkout Source Files
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
submodules: 'recursive'
- name: Checkout client-config
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
repository: freeCodeCamp/client-config
path: client-config
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: ${{ matrix.node-version }}
- name: Set freeCodeCamp Environment Variables
run: |
sed '/STRIPE/d; /PAYPAL/d; /PATREON/d;' sample.env > .env
echo 'STRIPE_PUBLIC_KEY=${{ secrets.STRIPE_PUBLIC_KEY }}' >> .env
echo 'PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }}' >> .env
echo 'PATREON_CLIENT_ID=${{ secrets.PATREON_CLIENT_ID }}' >> .env
- name: Install and Build
run: |
pnpm install
pnpm run build
- name: Move serve.json to Public Folder
run: cp client-config/serve.json client/public/serve.json
- name: Tar Files
run: tar -cf client-artifact.tar client/public
- name: Upload Client Artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: client-artifact
path: client-artifact.tar
build-api:
name: Build API (Container)
runs-on: ubuntu-24.04
steps:
- name: Checkout Source Files
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
submodules: 'recursive'
- name: Create Image
run: |
docker build \
-t fcc-api \
-f docker/api/Dockerfile .
- name: Save Image
run: docker save fcc-api > api-artifact.tar
- name: Upload API Artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: api-artifact
path: api-artifact.tar
playwright-run-api:
name: Run Playwright 3rd Party Donation Tests
runs-on: ubuntu-24.04
needs: [build-client, build-api]
strategy:
fail-fast: false
matrix:
browsers: [chromium]
node-version: [24]
steps:
- name: Set Action Environment Variables
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
- name: Checkout Source Files
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- name: Unpack Client Artifact
run: |
tar -xf client-artifact/client-artifact.tar
rm client-artifact/client-artifact.tar
- name: Load API Image
run: |
docker load < api-artifact/api-artifact.tar
rm api-artifact/api-artifact.tar
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: pnpm install
- name: Set freeCodeCamp Environment Variables (needed by api)
run: |
sed '/STRIPE/d; /PAYPAL/d; /PATREON/d;' sample.env > .env
echo 'STRIPE_PUBLIC_KEY=${{ secrets.STRIPE_PUBLIC_KEY }}' >> .env
echo 'PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }}' >> .env
echo 'PATREON_CLIENT_ID=${{ secrets.PATREON_CLIENT_ID }}' >> .env
- name: Install playwright dependencies
run: npx playwright install --with-deps
- name: Install
run: pnpm install
- name: Start apps
run: |
docker compose -f docker/docker-compose.yml -f docker/docker-compose.e2e.yml up -d
pnpm run serve:client-ci &
sleep 10
- name: Seed Database with Certified User
run: pnpm run seed:certified-user
- name: Run playwright tests
run: pnpm run playwright:run third-party-donation.spec.ts --project=${{ matrix.browsers }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.browsers }}
path: e2e/playwright/reporter
retention-days: 7
-48
View File
@@ -1,48 +0,0 @@
{
"id": "pm_1PRWUAFI1KEgscdTFyjUCbcd",
"object": "payment_method",
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": null,
"country": null,
"line1": null,
"line2": null,
"postal_code": null,
"state": null
},
"email": null,
"name": null,
"phone": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": null,
"address_postal_code_check": null,
"cvc_check": null
},
"country": "US",
"display_brand": "visa",
"exp_month": 10,
"exp_year": 2025,
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1718357514,
"customer": null,
"livemode": false,
"radar_options": {},
"type": "card"
}
+44
View File
@@ -0,0 +1,44 @@
import { test, expect, type Page } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
// Unlike PayPal and Stripe, Patreon isn't a third-party SDK integration - it's
// a plain link built from static config, so it can't crash the page the way
// a broken third-party script can. Still worth a basic presence/href check so
// a regression in the URL-building logic itself doesn't go unnoticed.
async function expectPatreonButtonToBeVisible(page: Page) {
await page
.getByRole('button', { name: translations.buttons.donate, exact: true })
.click();
const patreonButton = page.locator('.patreon-button');
await expect(patreonButton).toBeVisible();
await expect(patreonButton).toHaveAttribute(
'href',
/^https:\/\/www\.patreon\.com\/oauth2\/become-patron\?/
);
}
test.describe('Patreon donation button', () => {
test.describe('Authenticated User', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/donate');
});
test('renders the Patreon button', async ({ page }) => {
await expectPatreonButtonToBeVisible(page);
});
});
test.describe('Unauthenticated User', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await page.goto('/donate');
});
test('renders the Patreon button', async ({ page }) => {
await expectPatreonButtonToBeVisible(page);
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { test, expect, type Page } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
async function clickDonate(page: Page) {
await page
.getByRole('button', { name: translations.buttons.donate, exact: true })
.click();
}
async function expectRealPaypalButtonToBeVisible(page: Page) {
await clickDonate(page);
// The widget can also render a "Debit or Credit Card" funding option
// alongside the PayPal one, so the PayPal link must be targeted by name.
const paypalButtonIframe = page.frameLocator('.component-frame');
const paypalButton = paypalButtonIframe.getByRole('link', {
name: 'PayPal'
});
await expect(paypalButton).toBeVisible();
await expect(paypalButton).toHaveAttribute('aria-label', 'PayPal');
}
// PayPal's SDK is consumed two different ways across the codebase's history:
// the legacy \`Buttons.driver('react', { React, ReactDOM })\` API (which
// returns a React component, and is what production currently uses), and
// the plain \`Buttons({...}).render(container)\` API (imperative, no React
// involved). This fake supports both call shapes so the test keeps working
// across that implementation detail.
const FAKE_APPROVE_DATA = `{ orderID: 'FAKE_ORDER_ID', subscriptionID: 'FAKE_SUBSCRIPTION_ID' }`;
const FAKE_APPROVE_ACTIONS = `{ order: { capture: function () { return Promise.resolve({}); } } }`;
const FAKE_PAYPAL_SDK = `
function FakeButtons(options) {
return {
render: function (container) {
var btn = document.createElement('button');
btn.textContent = 'Fake PayPal Approve';
// This project configures a custom testIdAttribute in
// playwright.config.ts, so getByTestId() looks for this attribute,
// not the Playwright default "data-testid".
btn.setAttribute('data-playwright-test-label', 'fake-paypal-approve');
btn.onclick = function () {
options.onApprove(${FAKE_APPROVE_DATA}, ${FAKE_APPROVE_ACTIONS});
};
container.appendChild(btn);
},
close: function () { return Promise.resolve(); }
};
}
FakeButtons.driver = function (reactString, deps) {
return function FakeDriverButton(props) {
return deps.React.createElement('button', {
'data-playwright-test-label': 'fake-paypal-approve',
onClick: function () {
props.onApprove(${FAKE_APPROVE_DATA}, ${FAKE_APPROVE_ACTIONS});
}
}, 'Fake PayPal Approve');
};
};
window.paypal = {
Buttons: FakeButtons
};
`;
async function mockPaypalSdk(page: Page) {
await page.route('https://www.paypal.com/sdk/js**', route =>
route.fulfill({
contentType: 'application/javascript',
body: FAKE_PAYPAL_SDK
})
);
}
async function expectDonationToComplete(page: Page) {
await clickDonate(page);
await page.getByTestId('fake-paypal-approve').click();
await expect(page.getByText(translations.donate['thank-you'])).toBeVisible();
await expect(page.getByText(translations.donate['free-tech'])).toBeVisible();
}
test.describe('PayPal donation button', () => {
test.describe('Authenticated User', () => {
test('renders the real PayPal button', async ({ page }) => {
await page.goto('/donate');
await expectRealPaypalButtonToBeVisible(page);
});
test('completes the donation flow once PayPal approves the payment', async ({
page
}) => {
// Register the mock before navigating, so PayPal's real script is
// never requested at all.
let addDonationCalled = false;
await page.route('**/donate/add-donation', route => {
addDonationCalled = true;
return route.fulfill({ json: {} });
});
await mockPaypalSdk(page);
await page.goto('/donate');
await expectDonationToComplete(page);
expect(addDonationCalled).toBe(true);
});
});
test.describe('Unauthenticated User', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('renders the real PayPal button', async ({ page }) => {
await page.goto('/donate');
await expectRealPaypalButtonToBeVisible(page);
});
test('completes the donation flow once PayPal approves the payment', async ({
page
}) => {
// Unauthenticated donors are never recorded via /donate/add-donation
// (see donation-saga.js), so completion here should not call our API
// at all - it should go straight to the success screen.
let addDonationCalled = false;
await page.route('**/donate/add-donation', route => {
addDonationCalled = true;
return route.fulfill({ json: {} });
});
await mockPaypalSdk(page);
await page.goto('/donate');
await expectDonationToComplete(page);
expect(addDonationCalled).toBe(false);
});
});
});
+173
View File
@@ -0,0 +1,173 @@
import { test, expect, type Page } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
import { alertToBeVisible } from './utils/alerts';
async function clickDonate(page: Page) {
await page
.getByRole('button', { name: translations.buttons.donate, exact: true })
.click();
}
async function fillTestCard(page: Page, cardNumber: string) {
const cardNumberIframe = page
.locator('iframe[src*="elements-inner-card"]')
.nth(0)
.contentFrame();
const cardExpiryIframe = page
.locator('iframe[src*="elements-inner-card"]')
.nth(1)
.contentFrame();
await cardNumberIframe
.locator('input[data-elements-stable-field-name="cardNumber"]')
.fill(cardNumber);
// Per Stripe's testing guide, any future expiry works with test cards.
// Tokenization only validates the card is well-formed; declines and other
// failure scenarios only surface once a charge is actually attempted, so
// the expiry itself is never the point being tested here - it just needs
// to stay in the future indefinitely.
await cardExpiryIframe
.locator('input[data-elements-stable-field-name="cardExpiry"]')
.fill('1239');
}
async function expectWalletsToMountWithoutError(page: Page) {
const pageErrors: string[] = [];
page.on('pageerror', err => pageErrors.push(err.message));
await clickDonate(page);
await expect(page.locator('.wallets-form')).toBeAttached();
await page.waitForTimeout(2000);
expect(pageErrors).toEqual([]);
}
test.describe('Stripe donation form', () => {
test.describe('Authenticated User', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/donate');
});
test('renders the real Stripe card element', async ({ page }) => {
await clickDonate(page);
await expect(
page.getByText(translations.donate['or-card'])
).toBeVisible();
const cardNumberIframe = page
.locator('iframe[src*="elements-inner-card"]')
.nth(0)
.contentFrame();
await expect(
cardNumberIframe.locator(
'input[data-elements-stable-field-name="cardNumber"]'
)
).toBeVisible();
const cardExpiryIframe = page
.locator('iframe[src*="elements-inner-card"]')
.nth(1)
.contentFrame();
await expect(
cardExpiryIframe.locator(
'input[data-elements-stable-field-name="cardExpiry"]'
)
).toBeVisible();
});
test('mounts the wallets (Apple/Google Pay) integration without erroring', async ({
page
}) => {
await expectWalletsToMountWithoutError(page);
});
test('completes a donation with a real (test-mode) card', async ({
page
}) => {
await clickDonate(page);
// 4242424242424242 is Stripe's well-known always-succeeds test card
// number. We let this hit Stripe's real test-mode API for
// tokenization (per Stripe's testing guide, this is what test mode is
// for) rather than mocking it - only our own backend, which we don't
// have test credentials for here, is mocked.
await fillTestCard(page, '4242424242424242');
await page.route(
new URL(
'donate/charge-stripe-card',
process.env.API_LOCATION
).toString(),
route => route.fulfill({ json: { isDonating: true } })
);
await clickDonate(page);
await expect(page.getByRole('alert')).toBeVisible();
await alertToBeVisible(page, translations.donate['free-tech']);
await alertToBeVisible(page, translations.donate['visit-supporters']);
});
test('shows an error when the charge is declined', async ({ page }) => {
await clickDonate(page);
// Per Stripe's testing guide, tokenization (createPaymentMethod)
// succeeds for any well-formed test card - card-specific decline
// behavior (e.g. 4000000000000002, a generic decline) only surfaces
// when a charge is actually attempted, which happens server-side in
// our own backend. So the decline itself has to be simulated via our
// backend's response, not by choosing a particular "decline" test
// card number here.
await fillTestCard(page, '4242424242424242');
await page.route(
new URL(
'donate/charge-stripe-card',
process.env.API_LOCATION
).toString(),
route =>
route.fulfill({
json: { error: { type: 'GenericDecline' } }
})
);
await clickDonate(page);
await expect(page.getByRole('alert')).toBeVisible();
await alertToBeVisible(page, translations.donate.error);
await alertToBeVisible(page, translations.donate['error-2']);
await expect(
page.getByRole('button', { name: translations.buttons['try-again'] })
).toBeVisible();
});
});
test.describe('Unauthenticated User', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await page.goto('/donate');
});
test('does not render the Stripe card element (PayPal handles card payment instead)', async ({
page
}) => {
await clickDonate(page);
await expect(
page.getByText(translations.donate['or-card'])
).not.toBeVisible();
await expect(
page.locator('iframe[src*="elements-inner-card"]')
).toHaveCount(0);
});
test('mounts the wallets (Apple/Google Pay) integration without erroring', async ({
page
}) => {
await expectWalletsToMountWithoutError(page);
});
});
});
-72
View File
@@ -1,72 +0,0 @@
import { test, expect } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
import stripeJson from './fixtures/donation/stripe.json';
import { alertToBeVisible } from './utils/alerts';
test.describe('third-party donation tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/donate');
});
test('All elements are present in the widget', async ({ page }) => {
await page.getByRole('button', { name: 'Donate', exact: true }).click();
// Paypal button should be present
const paypalButtonIframe = page.frameLocator('.component-frame');
const paypalButton = paypalButtonIframe.getByRole('link');
await expect(paypalButton).toBeVisible();
await expect(paypalButton).toHaveAttribute('aria-label', 'PayPal');
// Patreon button should be present
const patreonButton = page.locator('.patreon-button');
await expect(patreonButton).toBeVisible();
// "Or dontate with card" button should be present
await expect(page.getByText('Or donate with card')).toBeVisible();
});
test('It is possible to donate with a card', async ({ page }) => {
await page.getByRole('button', { name: 'Donate', exact: true }).click();
const cardNumberIframe = page
.frameLocator('iframe[src*="elements-inner-card"]')
.nth(0);
const cardExpiryIframe = page
.frameLocator('iframe[src*="elements-inner-card"]')
.nth(1);
await cardNumberIframe
.locator('input[data-elements-stable-field-name="cardNumber"]')
.fill('4242424242424242');
await cardExpiryIframe
.locator('input[data-elements-stable-field-name="cardExpiry"]')
.fill('1025');
await page.getByRole('button', { name: 'Donate', exact: true }).click();
await page.route(
'https://api.stripe.com/v1/payment_methods',
async route => {
await route.fulfill({ json: stripeJson });
}
);
await page.route(
new URL('donate/charge-stripe-card', process.env.API_LOCATION).toString(),
async route => {
await route.fulfill({ json: { isDonating: true } });
}
);
await expect(page.getByRole('alert')).toBeVisible();
await alertToBeVisible(page, translations.donate['free-tech']);
await alertToBeVisible(page, translations.donate['visit-supporters']);
});
});
+3 -3
View File
@@ -26,17 +26,17 @@ ALGOLIA_APP_ID=app_id_from_algolia_dashboard
ALGOLIA_API_KEY=api_key_from_algolia_dashboard
# Stripe
STRIPE_PUBLIC_KEY=pk_from_stripe_dashboard
STRIPE_PUBLIC_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
STRIPE_SECRET_KEY=sk_from_stripe_dashboard
# Third-party App API
TPA_API_BEARER_TOKEN=tpa_api_bearer_token_from_dashboard
# PayPal
PAYPAL_CLIENT_ID=id_from_paypal_dashboard
PAYPAL_CLIENT_ID=sb
# Patreon
PATREON_CLIENT_ID=id_from_patreon_dashboard
PATREON_CLIENT_ID=local-dev-patreon-client-id
# Analytics
GROWTHBOOK_URI=api_URI_from_Growthbook_dashboard