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`); });