diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index af327d80dcb..e8853814c45 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -8,6 +8,8 @@ env:
OC_ENV: ci
NODE_ENV: test
WEBSITE_URL: http://localhost:3000
+ IMAGES_URL: http://localhost:3001
+ PDF_SERVICE_URL: http://localhost:3002
API_URL: http://localhost:3060
API_KEY: dvl-1510egmf4a23d80342403fb599qd
CI: true
@@ -15,12 +17,12 @@ env:
E2E_TEST: 1
PGHOST: localhost
PGUSER: postgres
- IMAGES_URL: http://localhost:3001
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
FRONTEND_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend
API_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend/opencollective-api
IMAGES_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend/opencollective-images
+ PDF_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend/opencollective-pdf
TERM: xterm
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
@@ -117,6 +119,12 @@ jobs:
repository: opencollective/opencollective-images
path: opencollective-images
+ - name: Checkout (PDF)
+ uses: actions/checkout@v4
+ with:
+ repository: opencollective/opencollective-pdf
+ path: opencollective-pdf
+
# Prepare API
- name: Restore node_modules (api)
diff --git a/components/contribution-flow/index.js b/components/contribution-flow/index.js
index 8535c9b4411..8916490f45c 100644
--- a/components/contribution-flow/index.js
+++ b/components/contribution-flow/index.js
@@ -887,6 +887,7 @@ class ContributionFlow extends React.Component {
const currency = tier?.amount.currency || collective.currency;
const currentStepName = this.getCurrentStepName();
+ console.log({ currentStepName });
if (currentStepName === STEPS.SUCCESS) {
return ;
}
diff --git a/playwright.config.ts b/playwright.config.ts
index d90e887caf2..710a30d2c8b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -23,8 +23,7 @@ export default defineConfig({
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
- /* Base URL to use in actions like `await page.goto('/')`. */
- // baseURL: 'http://127.0.0.1:3000',
+ baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
@@ -36,36 +35,6 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
-
- // {
- // name: 'firefox',
- // use: { ...devices['Desktop Firefox'] },
- // },
- //
- // {
- // name: 'webkit',
- // use: { ...devices['Desktop Safari'] },
- // },
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: { ...devices['Pixel 5'] },
- // },
- // {
- // name: 'Mobile Safari',
- // use: { ...devices['iPhone 12'] },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: { ...devices['Desktop Edge'], channel: 'msedge' },
- // },
- // {
- // name: 'Google Chrome',
- // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
- // },
],
/* Run your local dev server before starting the tests */
diff --git a/scripts/run_e2e_tests.sh b/scripts/run_e2e_tests.sh
index dcb1e6d785d..f3d89f6016f 100755
--- a/scripts/run_e2e_tests.sh
+++ b/scripts/run_e2e_tests.sh
@@ -87,7 +87,12 @@ wait_for_service PDF 127.0.0.1 3002
echo ""
if [ "$USE_PLAYWRIGHT" = "true" ]; then
echo "> Running playwright tests"
- npx playwright test test/playwright/*.spec.ts
+ pwd
+ ls
+ ls test
+ ls test/playwright
+ ls test/playwright/integration
+ npx playwright test test/playwright/integration/*.spec.ts
else
echo "> Running cypress tests"
npm run cypress:run -- ${CYPRESS_RECORD} --env OC_ENV=$OC_ENV --spec "test/cypress/integration/${CYPRESS_TEST_FILES}"
diff --git a/test/playwright/commands/authentication.ts b/test/playwright/commands/authentication.ts
new file mode 100644
index 00000000000..f237f8b3d54
--- /dev/null
+++ b/test/playwright/commands/authentication.ts
@@ -0,0 +1,70 @@
+import { Readable } from 'stream';
+
+import fetch from 'node-fetch';
+import { Page } from 'playwright/test';
+
+import { loggedInUserQuery } from '../../../lib/graphql/v1/queries';
+
+import { randomEmail } from '../../cypress/support/faker';
+
+const baseURL = 'http://localhost:3000'; // TODO: Get this to config
+
+const graphqlQueryV1 = (body: any, token: string) => {
+ return fetch(`${baseURL}/api/graphql/v1`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ authorization: !token ? undefined : `Bearer ${token}`,
+ },
+ body: Readable.from([JSON.stringify(body)]),
+ });
+};
+
+const signinRequest = (user, redirect: string, sendLink: boolean = false) => {
+ return fetch(`${baseURL}/api/users/signin`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: Readable.from([JSON.stringify({ user, redirect, createProfile: true, sendLink })]),
+ });
+};
+
+const getLoggedInUserFromToken = async (token: string): Promise => {
+ const response = await graphqlQueryV1(
+ { operationName: 'LoggedInUser', query: loggedInUserQuery.loc.source.body, variables: {} },
+ token,
+ );
+ const result = await response.json();
+ return result.data.LoggedInUser;
+};
+
+function getTokenFromRedirectUrl(url) {
+ const regex = /\/signin\/([^?]+)/;
+ return regex.exec(url)[1];
+}
+
+/**
+ * Signup with the given params and redirect to the provided URL
+ */
+export const signup = async (
+ page: Page,
+ {
+ user,
+ redirect = '/',
+ }: {
+ user?: { email?: string; name?: string };
+ redirect?: string;
+ } = {},
+) => {
+ const email = user?.email || randomEmail();
+ const relativeRedirect = redirect.startsWith(baseURL) ? redirect.replace(baseURL, '') : redirect;
+ const response = await signinRequest({ ...user, email }, relativeRedirect);
+ const result = await response.json();
+ const signInRedirectUrl = result.redirect;
+ const token = getTokenFromRedirectUrl(signInRedirectUrl);
+ await page.goto(signInRedirectUrl); // TODO: Rather than redirecting, we could exchange the token directly
+ return getLoggedInUserFromToken(token);
+};
diff --git a/test/playwright/commands/contribution-flow.ts b/test/playwright/commands/contribution-flow.ts
new file mode 100644
index 00000000000..dd01567073a
--- /dev/null
+++ b/test/playwright/commands/contribution-flow.ts
@@ -0,0 +1,20 @@
+import { Page } from 'playwright/test';
+
+export const checkStepsProgress = async (
+ page: Page,
+ {
+ enabled = [],
+ disabled = [],
+ }: {
+ enabled?: string | string[];
+ disabled?: string | string[];
+ },
+) => {
+ const isEnabled = step => page.waitForSelector(`[data-cy="progress-step-${step}"][data-disabled=false]`);
+ const isDisabled = step => page.waitForSelector(`[data-cy="progress-step-${step}"][data-disabled=true]`);
+
+ await Promise.all([
+ ...(Array.isArray(enabled) ? enabled.map(isEnabled) : [isEnabled(enabled)]),
+ ...(Array.isArray(disabled) ? disabled.map(isDisabled) : [isDisabled(disabled)]),
+ ]);
+};
diff --git a/test/playwright/commands/mock-now.ts b/test/playwright/commands/mock-now.ts
new file mode 100644
index 00000000000..6defc8728ad
--- /dev/null
+++ b/test/playwright/commands/mock-now.ts
@@ -0,0 +1,20 @@
+import { Page } from 'playwright/test';
+
+export const mockNow = async (page: Page, now: number) => {
+ await page.addInitScript(`{
+ // Extend Date constructor to default to now
+ Date = class extends Date {
+ constructor(...args) {
+ if (args.length === 0) {
+ super(${now});
+ } else {
+ super(...args);
+ }
+ }
+ }
+ // Override Date.now() to start from now
+ const __DateNowOffset = ${now} - Date.now();
+ const __DateNow = Date.now;
+ Date.now = () => __DateNow() + __DateNowOffset;
+ }`);
+};
diff --git a/test/playwright/commands/stripe.ts b/test/playwright/commands/stripe.ts
new file mode 100644
index 00000000000..5b90f577f16
--- /dev/null
+++ b/test/playwright/commands/stripe.ts
@@ -0,0 +1,21 @@
+import { Page } from 'playwright/test';
+
+import { CreditCards } from '../../stripe-helpers';
+
+type FillStripeInputOptions = {
+ card?: {
+ creditCardNumber?: string;
+ expirationDate?: string;
+ cvcCode?: string;
+ postalCode?: string;
+ };
+};
+
+export const fillStripeInput = async (page: Page, { card = CreditCards.CARD_DEFAULT }: FillStripeInputOptions = {}) => {
+ const stripeIframeSelector = '.__PrivateStripeElement iframe';
+ const stripeFrame = page.frameLocator(stripeIframeSelector).first();
+ card.creditCardNumber && (await stripeFrame.locator('[placeholder="Card number"]').fill(card.creditCardNumber));
+ card.expirationDate && (await stripeFrame.locator('[placeholder="MM / YY"]').fill(card.expirationDate));
+ card.cvcCode && (await stripeFrame.locator('[placeholder="CVC"]').fill(card.cvcCode));
+ card.postalCode && (await stripeFrame.locator('[placeholder="ZIP"]').fill(card.postalCode));
+};
diff --git a/test/playwright/integration/12-contributionFlow.donate.spec.ts b/test/playwright/integration/12-contributionFlow.donate.spec.ts
new file mode 100644
index 00000000000..5d8beb523de
--- /dev/null
+++ b/test/playwright/integration/12-contributionFlow.donate.spec.ts
@@ -0,0 +1,76 @@
+import { expect, test } from '@playwright/test';
+
+import { signup } from '../commands/authentication';
+import { checkStepsProgress } from '../commands/contribution-flow';
+import { mockNow } from '../commands/mock-now';
+import { fillStripeInput } from '../commands/stripe';
+
+const donateUrl = `/apex/donate`;
+
+test('Can donate as new user', async ({ page }) => {
+ // Mock clock so we can check next contribution date in a consistent way
+ await mockNow(page, Date.parse('2042/05/25').valueOf());
+
+ const userParams = { name: 'Donate Tester' };
+ const user = await signup(page, { redirect: donateUrl, user: userParams });
+
+ // General checks
+ await expect(page).toHaveTitle('Contribute to APEX - Open Collective');
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', donateUrl);
+
+ // ---- Step Details ----
+ // Has default amount selected
+ await page.waitForSelector('#amount button.selected');
+
+ // Change amount
+ await page.click('[data-cy="amount-picker-btn-other"]');
+ await page.fill('input[type=number][name=custom-amount]', '1337');
+ await page.waitForSelector('[data-cy="progress-step-details"] :text("1,337.00")');
+
+ // Change frequency - monthly
+ await page.click('text="Monthly"');
+ await page.waitForSelector('[data-cy="progress-step-details"] :text("1,337.00 USD / mo.")');
+ await page.waitForSelector(':has-text("the next charge will be on July 1, 2042")');
+
+ // Change frequency - yearly
+ await page.click('text="Yearly"');
+ await page.waitForSelector('[data-cy="progress-step-details"] :text("1,337.00 USD / yr.")');
+ await page.waitForSelector('text="Today\'s charge"');
+ await page.waitForSelector(':has-text("the next charge will be on May 1, 2043")');
+
+ // Click the button
+ await page.click('button[data-cy="cf-next-step"]');
+
+ // ---- Step profile ----
+ await checkStepsProgress(page, { enabled: ['profile', 'details'], disabled: 'payment' });
+
+ // Personal account must be the first entry, and it must be checked
+ await page.waitForSelector(`[data-cy="contribute-profile-picker"] :text("${user.collective.name}")`);
+ await page.waitForSelector('[data-cy="contribute-profile-picker"] :text("Personal")');
+ await page.click('[data-cy="contribute-profile-picker"]');
+ await page.waitForSelector(`[data-cy="select-option"]:first-of-type :text("${user.collective.name}")`);
+ await page.waitForSelector('[data-cy="select-option"]:first-of-type :text("Personal")');
+ await page.keyboard.press('Escape');
+
+ // User profile is shown on step, all other steps must be disabled
+ await page.waitForSelector(`[data-cy="progress-step-profile"] :text("${user.collective.name}")`);
+ await page.click('button[data-cy="cf-next-step"]');
+
+ // ---- Step Payment ----
+ await checkStepsProgress(page, { enabled: ['profile', 'details', 'payment'] });
+
+ // As this is a new account, no payment method is configured yet, so
+ // we should have the credit card form selected by default.
+ await page.waitForSelector('input[type=checkbox][name=save]:checked', { state: 'hidden' }); // Our checkbox has custom styles and the input is hidden
+
+ // Ensure we display errors
+ await fillStripeInput(page, { card: { creditCardNumber: '123' } });
+ await page.click('button:has-text("Contribute $1,337")');
+ await page.waitForSelector('text="Credit card ZIP code and CVC are required"');
+
+ // Submit with valid credit card
+ await fillStripeInput(page);
+ await page.click('button:has-text("Contribute $1,337")');
+ await page.waitForURL(`**/apex/donate/success**`, { timeout: 10000 });
+ await page.waitForSelector('text="You are now supporting APEX."');
+});
diff --git a/test/playwright/16-tiers-page.spec.ts b/test/playwright/integration/16-tiers-page.spec.ts
similarity index 67%
rename from test/playwright/16-tiers-page.spec.ts
rename to test/playwright/integration/16-tiers-page.spec.ts
index 2bd4d53e254..fe6c0cc770d 100644
--- a/test/playwright/16-tiers-page.spec.ts
+++ b/test/playwright/integration/16-tiers-page.spec.ts
@@ -1,15 +1,13 @@
import { expect, test } from '@playwright/test';
-const baseUrl: String = 'http://localhost:3000';
-
test('Can be accessed from "/collective/contribute" (default)', async ({ page }) => {
- await page.goto(`${baseUrl}/apex/contribute`);
+ await page.goto(`/apex/contribute`);
await expect(page).toHaveTitle('Contribute to APEX - Open Collective');
- await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${baseUrl}/apex/contribute`);
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `/apex/contribute`);
});
test('Can be accessed from "/collective/tiers"', async ({ page }) => {
- await page.goto(`${baseUrl}/apex/tiers`);
+ await page.goto(`/apex/tiers`);
await expect(page).toHaveTitle('Contribute to APEX - Open Collective');
- await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${baseUrl}/apex/contribute`);
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `/apex/contribute`);
});