diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index 35f82b2039..70bd7172b2 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '14.x' + node-version: '16.x' cache: yarn - name: Install dependencies diff --git a/ghost/core/playwright.config.js b/ghost/core/playwright.config.js index 5a6f672921..f1bd323c61 100644 --- a/ghost/core/playwright.config.js +++ b/ghost/core/playwright.config.js @@ -1,13 +1,17 @@ /** @type {import('@playwright/test').PlaywrightTestConfig} */ + const config = { timeout: 60 * 1000, workers: 1, use: { // Use a single browser since we can't simultaneously test multiple browsers browserName: 'chromium', - baseURL: 'http://localhost:2368' - }, - webServer: { + baseURL: process.env.TEST_URL ?? 'http://localhost:2368' + } +}; + +if (!process.env.TEST_URL) { + config.webServer = { command: 'yarn start', env: { // TODO: Use `testing` when starting a server @@ -15,7 +19,7 @@ const config = { }, reuseExistingServer: !process.env.CI, url: 'http://localhost:2368' - } -}; + }; +} module.exports = config; diff --git a/ghost/core/test/e2e-browser/admin.spec.js b/ghost/core/test/e2e-browser/admin.spec.js index 4178c0e4d0..f7f83b3e60 100644 --- a/ghost/core/test/e2e-browser/admin.spec.js +++ b/ghost/core/test/e2e-browser/admin.spec.js @@ -1,6 +1,5 @@ const {expect, test} = require('@playwright/test'); -const {setupGhost, setupStripe, createTier} = require('./utils'); -const ObjectID = require('bson-objectid').default; +const {setupGhost, setupStripe, createTier, createOffer} = require('./utils'); test.describe('Ghost Admin', () => { test.beforeEach(async ({page}) => { @@ -36,44 +35,12 @@ test.describe('Ghost Admin', () => { monthlyPrice: 5, yearlyPrice: 50 }); - - await page.goto('/ghost'); - await page.locator('[data-test-nav="offers"]').click(); - - // Keep offer names unique - let offerName = `Get 5% Off! (${new ObjectID()})`; - - if (await page.locator('.gh-offers-list').isVisible()) { - const listItem = page.locator('[data-test-list="offers-list-item"]').first(); - while (await page.locator('[data-test-list="offers-list-item"]').first().isVisible().then(() => true).catch(() => false)) { - await listItem.getByRole('link', {name: 'arrow-right'}).click(); - await page.getByRole('button', {name: 'Archive offer'}).click(); - await page - .locator('.modal-content') - .filter({hasText: 'Archive offer'}) - .first() - .getByRole('button', {name: 'Archive'}) - .click(); - - const statusDropdown = await page.getByRole('button', {name: 'Archived offers arrow-down-small'}); - if (await statusDropdown.isVisible()) { - await statusDropdown.click(); - await page.getByRole('option', {name: 'Active offers'}).click(); - } - } - } - - await page.getByRole('link', {name: 'New offer'}).click(); - await page.locator('[data-test-input="offer-name"]').fill(offerName); - await page.locator('input#amount').fill('5'); - const priceId = await page.locator(`.gh-select-product-cadence>select>option`).getByText(`${tierName} - Monthly`).getAttribute('value'); - await page.locator('.gh-select-product-cadence>select').selectOption(priceId); - await page.getByRole('button', {name: 'Save'}).click(); - await page.locator('[data-test-button="save"] [data-test-task-button-state="success"]').waitFor({ - state: 'visible', - timeout: 1000 + const offerName = await createOffer(page, { + name: 'Get 5% Off!', + tierName, + percentOff: 5 }); - // Click the "offers" link to go back + await page.locator('[data-test-nav="offers"]').click(); await page.locator('.gh-offers-list').waitFor({state: 'visible', timeout: 1000}); await expect(page.locator('.gh-offers-list')).toContainText(tierName); diff --git a/ghost/core/test/e2e-browser/frontend.spec.js b/ghost/core/test/e2e-browser/frontend.spec.js index 08de0c326e..4582ef45d5 100644 --- a/ghost/core/test/e2e-browser/frontend.spec.js +++ b/ghost/core/test/e2e-browser/frontend.spec.js @@ -1,5 +1,5 @@ const {expect, test} = require('@playwright/test'); -const {setupGhost} = require('./utils'); +const {setupGhost, setupStripe, createTier, createOffer, completeStripeSubscription} = require('./utils'); test.describe('Ghost Frontend', () => { test.beforeEach(async ({page}) => { @@ -14,11 +14,37 @@ test.describe('Ghost Frontend', () => { }); test.describe('Portal flows', () => { - test('Loads the homepage', async ({page}) => { - const response = await page.goto('/'); - expect(response.status()).toEqual(200); + test('Uses an offer successfully', async ({page}) => { + await setupStripe(page); + await createTier(page, { + name: 'Portal Tier', + monthlyPrice: 6, + yearlyPrice: 60 + }); + const offerName = await createOffer(page, { + name: 'Black Friday Special', + tierName: 'Portal Tier', + percentOff: 10 + }); - // TODO: Implement a real portal test + // TODO: Click on the offer, copy the link, goto the link + await page.locator('[data-test-list="offer-name"]').filter({hasText: offerName}).click(); + const portalUrl = await page.locator('input#url').inputValue(); + + await page.goto(portalUrl); + const portalFrame = page.frameLocator('#ghost-portal-root div iframe'); + await portalFrame.locator('#input-name').fill('Testy McTesterson'); + await portalFrame.locator('#input-email').fill('testy@example.com'); + await portalFrame.getByRole('button', {name: 'Continue'}).click(); + + await completeStripeSubscription(page); + + // Wait for success notification to say we have subscribed successfully + const gotNotification = await page.frameLocator('iframe >> nth=1').getByText('Success! Check your email for magic link').waitFor({ + state: 'visible', + timeout: 10000 + }).then(() => true).catch(() => false); + test.expect(gotNotification, 'Did not get portal success notification').toBeTruthy(); }); }); }); diff --git a/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js b/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js index 6be4ab0abf..7ed5a5d698 100644 --- a/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js +++ b/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js @@ -1,4 +1,5 @@ const DataGenerator = require('../../utils/fixtures/data-generator'); +const ObjectID = require('bson-objectid').default; /** * Setup Ghost Admin, or login if there's a login prompt @@ -28,10 +29,14 @@ const setupGhost = async (page) => { page.locator('.gh-nav').waitFor(options).then(() => actions.noAction).catch(() => {}) ]); - // TODO: Use env variables in CI // Add owner user data from usual fixture const ownerUser = DataGenerator.Content.users.find(user => user.id === '1'); + if (process.env.CI && process.env.TEST_URL) { + ownerUser.email = process.env.TEST_OWNER_EMAIL; + ownerUser.password = process.env.TEST_OWNER_PASSWORD; + } + if (action === actions.signin) { // Fill email + password await page.locator('#identification').fill(ownerUser.email); @@ -55,6 +60,7 @@ const setupGhost = async (page) => { } }; +// Only ever setup Stripe once, for performance reasons let isStripeSetup = false; /** @@ -130,8 +136,79 @@ const createTier = async (page, {name, monthlyPrice, yearlyPrice}) => { await page.waitForSelector('input[data-test-input="tier-name"]', {state: 'detached'}); }; +/** + * Create an offer on a tier + * @param {import('@playwright/test').Page} page + * @param {object} options + * @param {string} options.name + * @param {string} options.tierName + * @param {number} options.percentOff + * @returns {Promise} Unique offer name + */ +const createOffer = async (page, {name, tierName, percentOff}) => { + await page.goto('/ghost'); + await page.locator('[data-test-nav="offers"]').click(); + + // Keep offer names unique & <= 40 characters + let offerName = `${name} (${new ObjectID().toHexString().slice(0, 40 - name.length - 3)})`; + + // Archive other offers to keep the list tidy + // We only need 1 offer to be active at a time + // Either the list of active offers loads, or the CTA when no offers exist + while (await Promise.race([ + page.locator('.gh-offers-list .gh-list-header').filter({hasText: 'active'}).waitFor({state: 'visible', timeout: 1000}).then(() => true).catch(() => false), + page.locator('.gh-offers-list-cta').waitFor({state: 'visible', timeout: 1000}).then(() => false).catch(() => false) + ])) { + const listItem = page.locator('[data-test-list="offers-list-item"]').first(); + await listItem.getByRole('link', {name: 'arrow-right'}).click(); + await page.getByRole('button', {name: 'Archive offer'}).click(); + await page + .locator('.modal-content') + .filter({hasText: 'Archive offer'}) + .first() + .getByRole('button', {name: 'Archive'}) + .click(); + + // TODO: Use a more resilient selector + const statusDropdown = await page.getByRole('button', {name: 'Archived offers arrow-down-small'}); + await statusDropdown.waitFor({ + state: 'visible', + timeout: 1000 + }); + await statusDropdown.click(); + await page.getByRole('option', {name: 'Active offers'}).click(); + } + + await page.getByRole('link', {name: 'New offer'}).click(); + await page.locator('[data-test-input="offer-name"]').fill(offerName); + await page.locator('input#amount').fill(`${percentOff}`); + const priceId = await page.locator(`.gh-select-product-cadence>select>option`).getByText(`${tierName} - Monthly`).getAttribute('value'); + await page.locator('.gh-select-product-cadence>select').selectOption(priceId); + await page.getByRole('button', {name: 'Save'}).click(); + // Wait for the "Saved" button, ensures that next clicks don't trigger the unsaved work modal + await page.locator('[data-test-button="save"] [data-test-task-button-state="success"]').waitFor({ + state: 'visible', + timeout: 1000 + }); + await page.locator('[data-test-nav="offers"]').click(); + + return offerName; +}; + +const completeStripeSubscription = async (page) => { + await page.locator('#cardNumber').fill('4242 4242 4242 4242'); + await page.locator('#cardExpiry').fill('04 / 24'); + await page.locator('#cardCvc').fill('424'); + await page.locator('#billingName').fill('Testy McTesterson'); + await page.getByRole('combobox', {name: 'Country or region'}).selectOption('US'); + await page.locator('#billingPostalCode').fill('42424'); + await page.getByTestId('hosted-payment-submit-button').click(); +}; + module.exports = { setupGhost, setupStripe, - createTier + createTier, + createOffer, + completeStripeSubscription };