From 668e523ab4738de136b27a1495ec42c7fbf0fc86 Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Thu, 1 Dec 2022 00:34:19 +0000 Subject: [PATCH] Allow playwright tests to run locally and remotely no issue This commit allows tests to run remotely by replacing selectors with production-suitable ones (no [data-test...]). It also allows running locally with Stripe webhooks by adding a new global setup function. --- ghost/core/playwright.config.js | 19 ++- ghost/core/test/e2e-browser/admin.spec.js | 15 +-- ghost/core/test/e2e-browser/frontend.spec.js | 30 ++--- .../e2e-browser/utils/e2e-browser-utils.js | 124 ++++++++++-------- .../test/e2e-browser/utils/global-setup.js | 66 ++++++++++ 5 files changed, 170 insertions(+), 84 deletions(-) create mode 100644 ghost/core/test/e2e-browser/utils/global-setup.js diff --git a/ghost/core/playwright.config.js b/ghost/core/playwright.config.js index f1bd323c61..b6e0054e26 100644 --- a/ghost/core/playwright.config.js +++ b/ghost/core/playwright.config.js @@ -1,3 +1,10 @@ +const {execSync} = require('child_process'); + +const getWebhookSecret = () => { + const webhookSecret = execSync('stripe listen --print-secret'); + return webhookSecret.toString().trim(); +}; + /** @type {import('@playwright/test').PlaywrightTestConfig} */ const config = { @@ -6,16 +13,20 @@ const config = { use: { // Use a single browser since we can't simultaneously test multiple browsers browserName: 'chromium', - baseURL: process.env.TEST_URL ?? 'http://localhost:2368' - } + baseURL: process.env.TEST_URL ?? 'http://localhost:2368', + // TODO: Where to put this + storageState: 'state.json' + }, + globalSetup: './test/e2e-browser/utils/global-setup' }; if (!process.env.TEST_URL) { config.webServer = { + // TODO: Replace yarn start command: 'yarn start', env: { - // TODO: Use `testing` when starting a server - NODE_ENV: 'development' + NODE_ENV: 'development', + WEBHOOK_SECRET: getWebhookSecret() }, reuseExistingServer: !process.env.CI, url: 'http://localhost:2368' diff --git a/ghost/core/test/e2e-browser/admin.spec.js b/ghost/core/test/e2e-browser/admin.spec.js index f7f83b3e60..4d49756e53 100644 --- a/ghost/core/test/e2e-browser/admin.spec.js +++ b/ghost/core/test/e2e-browser/admin.spec.js @@ -1,12 +1,7 @@ const {expect, test} = require('@playwright/test'); -const {setupGhost, setupStripe, createTier, createOffer} = require('./utils'); +const {setupGhost, createTier, createOffer} = require('./utils'); test.describe('Ghost Admin', () => { - test.beforeEach(async ({page}) => { - // Will do initial setup or sign-in to Ghost - await setupGhost(page); - }); - test('Loads admin', async ({page}) => { const response = await page.goto('/ghost'); expect(response.status()).toEqual(200); @@ -19,16 +14,16 @@ test.describe('Ghost Admin', () => { test('Has a set of posts', async ({page}) => { await page.goto('/ghost'); - await page.locator('[data-test-nav="posts"]').click(); + await page.locator('.gh-nav a[href="#/posts/"]').click(); await page.locator('.gh-post-list-title').first().click(); - await page.getByRole('button', {name: 'sidemenu'}).click(); + await page.locator('.settings-menu-toggle').click(); const now = new Date(); const currentDate = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; await expect(page.getByPlaceholder('YYYY-MM-DD')).toHaveValue(currentDate); }); test('Can create a tier and offer', async ({page}) => { - await setupStripe(page); + await page.goto('/ghost'); const tierName = 'New Test Tier'; await createTier(page, { name: tierName, @@ -41,7 +36,7 @@ test.describe('Ghost Admin', () => { percentOff: 5 }); - await page.locator('[data-test-nav="offers"]').click(); + await page.locator('.gh-nav a[href="#/offers/"]').click(); await page.locator('.gh-offers-list').waitFor({state: 'visible', timeout: 1000}); await expect(page.locator('.gh-offers-list')).toContainText(tierName); await expect(page.locator('.gh-offers-list')).toContainText(offerName); diff --git a/ghost/core/test/e2e-browser/frontend.spec.js b/ghost/core/test/e2e-browser/frontend.spec.js index 4582ef45d5..c2f2c5e0bd 100644 --- a/ghost/core/test/e2e-browser/frontend.spec.js +++ b/ghost/core/test/e2e-browser/frontend.spec.js @@ -1,11 +1,7 @@ const {expect, test} = require('@playwright/test'); -const {setupGhost, setupStripe, createTier, createOffer, completeStripeSubscription} = require('./utils'); +const {setupGhost, deleteAllMembers, createTier, createOffer, completeStripeSubscription} = require('./utils'); test.describe('Ghost Frontend', () => { - test.beforeEach(async ({page}) => { - await setupGhost(page); - }); - test.describe('Basic frontend', () => { test('Loads the homepage', async ({page}) => { const response = await page.goto('/'); @@ -15,20 +11,20 @@ test.describe('Ghost Frontend', () => { test.describe('Portal flows', () => { test('Uses an offer successfully', async ({page}) => { - await setupStripe(page); + await deleteAllMembers(page); + const tierName = 'Portal Tier'; await createTier(page, { - name: 'Portal Tier', + name: tierName, monthlyPrice: 6, yearlyPrice: 60 }); const offerName = await createOffer(page, { name: 'Black Friday Special', - tierName: 'Portal Tier', + tierName: tierName, percentOff: 10 }); - // TODO: Click on the offer, copy the link, goto the link - await page.locator('[data-test-list="offer-name"]').filter({hasText: offerName}).click(); + await page.locator('.gh-offers-list .gh-list-row').filter({hasText: offerName}).click(); const portalUrl = await page.locator('input#url').inputValue(); await page.goto(portalUrl); @@ -39,12 +35,14 @@ test.describe('Ghost Frontend', () => { 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(); + await page.waitForSelector('h1.site-title', {state: 'visible'}); + await page.goto('/ghost'); + await page.locator('.gh-nav a[href="#/members/"]').click(); + + // 1 member, should be Testy, on Portal Tier + // TODO: These fail seemingly without reasons + expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); + expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`); }); }); }); 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 7ed5a5d698..3122d37141 100644 --- a/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js +++ b/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js @@ -41,7 +41,7 @@ const setupGhost = async (page) => { // Fill email + password await page.locator('#identification').fill(ownerUser.email); await page.locator('#password').fill(ownerUser.password); - await page.locator('[data-test-button="sign-in"]').click(); + await page.getByRole('button', {name: 'Sign in'}).click(); // Confirm we have reached Ghost Admin await page.locator('.gh-nav').waitFor(options); } else if (action === actions.setup) { @@ -60,48 +60,49 @@ const setupGhost = async (page) => { } }; -// Only ever setup Stripe once, for performance reasons -let isStripeSetup = false; - /** - * Connect from Stripe using the UI, disconnecting if necessary + * Delete all members, 1 by 1, using the UI * @param {import('@playwright/test').Page} page */ -const setupStripe = async (page) => { - if (isStripeSetup) { - return; - } - +const deleteAllMembers = async (page) => { await page.goto('/ghost'); - await page.locator('[data-test-nav="settings"]').click(); - await page.locator('[data-test-nav="members-membership"]').click(); - if (await page.isVisible('.gh-btn-stripe-status.connected')) { - // Disconnect if already connected - await page.locator('.gh-btn-stripe-status.connected').click(); - await page.locator('.modal-content .gh-btn-stripe-disconnect').first().click(); - // TODO: Use a better selector to achieve this + + await page.locator('a[href="#/members/"]').first().click(); + + const firstMember = page.locator('.gh-list tbody tr').first(); + while (await Promise.race([ + firstMember.waitFor({state: 'visible', timeout: 1000}).then(() => true), + page.locator('.gh-members-empty').waitFor({state: 'visible', timeout: 1000}).then(() => false) + ]).catch(() => false)) { + await firstMember.click(); + await page.locator('.view-actions .dropdown > button').click(); + await page.getByRole('button', {name: 'Delete member'}).click(); await page .locator('.modal-content') - .filter({hasText: 'Are you sure you want to disconnect?'}) + .filter({hasText: 'Delete member'}) .first() - .getByRole('button', {name: 'Disconnect'}) + .getByRole('button', {name: 'Delete member'}) .click(); - } else { - await page.locator('.gh-setting-members-tierscontainer .stripe-connect').click(); - } - await page.locator('input[data-test-checkbox="stripe-connect-test-mode"]').first().check(); - const [stripePage] = await Promise.all([ - page.waitForEvent('popup'), - page.getByRole('link', {name: 'Connect with Stripe'}).click() - ]); - await stripePage.locator('#skip-account-app').click(); - const stripeKey = await stripePage.locator('code').innerText(); - await stripePage.close(); - await page.getByPlaceholder('Paste your secure key here').fill(stripeKey); - await page.getByRole('button', {name: 'Save Stripe settings'}).click(); - await page.locator('[data-test-button="stripe-connect-ok"]').click(); - isStripeSetup = true; + // TODO: This seems to be a bug - "Are you sure you want to leave this page" shows after removing member + if (await Promise.race([ + page.locator('.modal-content').filter({hasText: 'Are you sure you want to leave this page'}).first().waitFor({ + state: 'visible', + timeout: 1000 + }).then(() => true), + page.locator('h2.gh-canvas-title').filter({hasText: 'Members'}).first().waitFor({ + state: 'visible', + timeout: 1000 + }).then(() => false) + ]).catch(() => false)) { + await page + .locator('.modal-content') + .filter({hasText: 'Are you sure you want to leave this page'}) + .first() + .getByRole('button', {name: 'Leave'}) + .click(); + } + } }; /** @@ -113,15 +114,15 @@ const setupStripe = async (page) => { * @param {number} tier.yearlyPrice */ const createTier = async (page, {name, monthlyPrice, yearlyPrice}) => { - await page.locator('[data-test-nav="settings"]').click(); - await page.locator('[data-test-nav="members-membership"]').click(); + await page.locator('.gh-nav a[href="#/settings/"]').click(); + await page.locator('.gh-setting-group').filter({hasText: 'Membership'}).click(); // Expand the premium tier list await page.getByRole('button', {name: 'Expand'}).nth(1).click({ - delay: 10 // Wait 10 milliseconds to ensure tier information appears correctly + delay: 50 // TODO: Figure out how to prevent this from opening with an empty list without using delay }); // Archive if already exists - if (await page.locator('.gh-tier-card-name').getByText(name).isVisible()) { + while (await page.locator('.gh-tier-card').filter({hasText: name}).first().isVisible()) { const tierCard = page.locator('.gh-tier-card').filter({hasText: name}).first(); await tierCard.locator('.gh-tier-card-actions-button').click(); await tierCard.getByRole('button', {name: 'Archive'}).click(); @@ -129,11 +130,26 @@ const createTier = async (page, {name, monthlyPrice, yearlyPrice}) => { } await page.locator('.gh-btn-add-tier').click(); - await page.locator('input[data-test-input="tier-name"]').first().fill(name); - await page.locator('#monthlyPrice').fill(`${monthlyPrice}`); - await page.locator('#yearlyPrice').fill(`${yearlyPrice}`); - await page.locator('[data-test-button="save-tier"]').click(); - await page.waitForSelector('input[data-test-input="tier-name"]', {state: 'detached'}); + const modal = page.locator('.modal-content'); + await modal.locator('input#name').first().fill(name); + await modal.locator('#monthlyPrice').fill(`${monthlyPrice}`); + await modal.locator('#yearlyPrice').fill(`${yearlyPrice}`); + await modal.getByRole('button', {name: 'Add tier'}).click(); + await page.waitForSelector('.modal-content input#name', {state: 'detached'}); + + await page.getByRole('button', {name: 'Customize Portal'}).click(); + const portalSettings = page.locator('.modal-content').filter({hasText: 'Portal settings'}); + if (!await portalSettings.locator('label').filter({hasText: name}).locator('input').first().isChecked()) { + await portalSettings.locator('label').filter({hasText: name}).locator('span').first().click(); + } + if (!await portalSettings.locator('label').filter({hasText: 'Monthly'}).locator('input').first().isChecked()) { + await portalSettings.locator('label').filter({hasText: 'Monthly'}).locator('span').first().click(); + } + if (!await portalSettings.locator('label').filter({hasText: 'Yearly'}).locator('input').first().isChecked()) { + await portalSettings.locator('label').filter({hasText: 'Yearly'}).locator('span').first().click(); + } + await portalSettings.getByRole('button', {name: 'Save and close'}).click(); + await page.waitForSelector('.gh-portal-settings', {state: 'detached'}); }; /** @@ -147,7 +163,7 @@ const createTier = async (page, {name, monthlyPrice, yearlyPrice}) => { */ const createOffer = async (page, {name, tierName, percentOff}) => { await page.goto('/ghost'); - await page.locator('[data-test-nav="offers"]').click(); + await page.locator('.gh-nav a[href="#/offers/"]').click(); // Keep offer names unique & <= 40 characters let offerName = `${name} (${new ObjectID().toHexString().slice(0, 40 - name.length - 3)})`; @@ -156,11 +172,11 @@ const createOffer = async (page, {name, tierName, percentOff}) => { // 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(); + page.locator('.gh-offers-list .gh-list-header').filter({hasText: 'active'}).waitFor({state: 'visible', timeout: 1000}).then(() => true), + page.locator('.gh-offers-list-cta').waitFor({state: 'visible', timeout: 1000}).then(() => false) + ]).catch(() => false)) { + const listItem = page.locator('.gh-offers-list .gh-list-row:not(.header)').first(); + await listItem.locator('a[href^="#/offers/"]').last().click(); await page.getByRole('button', {name: 'Archive offer'}).click(); await page .locator('.modal-content') @@ -170,7 +186,7 @@ const createOffer = async (page, {name, tierName, percentOff}) => { .click(); // TODO: Use a more resilient selector - const statusDropdown = await page.getByRole('button', {name: 'Archived offers arrow-down-small'}); + const statusDropdown = await page.getByRole('button', {name: 'Archived offers'}); await statusDropdown.waitFor({ state: 'visible', timeout: 1000 @@ -180,17 +196,17 @@ const createOffer = async (page, {name, tierName, percentOff}) => { } await page.getByRole('link', {name: 'New offer'}).click(); - await page.locator('[data-test-input="offer-name"]').fill(offerName); + await page.locator('input#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({ + await page.getByRole('button', {name: 'Saved'}).waitFor({ state: 'visible', timeout: 1000 }); - await page.locator('[data-test-nav="offers"]').click(); + await page.locator('.gh-nav a[href="#/offers/"]').click(); return offerName; }; @@ -207,7 +223,7 @@ const completeStripeSubscription = async (page) => { module.exports = { setupGhost, - setupStripe, + deleteAllMembers, createTier, createOffer, completeStripeSubscription diff --git a/ghost/core/test/e2e-browser/utils/global-setup.js b/ghost/core/test/e2e-browser/utils/global-setup.js new file mode 100644 index 0000000000..14f21caae1 --- /dev/null +++ b/ghost/core/test/e2e-browser/utils/global-setup.js @@ -0,0 +1,66 @@ +const config = require('../../../core/shared/config'); +const {promisify} = require('util'); +const {spawn, exec} = require('child_process'); +const {knex} = require('../../../core/server/data/db'); +const {setupGhost} = require('./e2e-browser-utils'); +const {chromium} = require('@playwright/test'); + +const startWebhookServer = () => { + const command = `stripe listen --forward-to ${config.getSiteUrl()}/members/webhooks/stripe/`; + spawn(command.split(' ')[0], command.split(' ').slice(1)); +}; + +const setupStripeKeys = async () => { + const inquirer = require('inquirer'); + const stripeDatabaseKeys = { + publishableKey: 'stripe_connect_publishable_key', + secretKey: 'stripe_connect_secret_key', + testMode: 'stripe_connect_test_mode' + }; + const publishableKey = (await knex('settings').select('value').where('key', stripeDatabaseKeys.publishableKey).first())?.value + ?? (await inquirer.prompt([{ + message: 'Stripe publishable key (starts "pk_test_")', + type: 'password', + name: 'value' + }])).value; + const secretKey = (await knex('settings').select('value').where('key', stripeDatabaseKeys.secretKey).first())?.value + ?? (await inquirer.prompt([{ + message: 'Stripe secret key (starts "sk_test_")', + type: 'password', + name: 'value' + }])).value; + + // TODO: Replace with update or insert + await knex('settings').where('key', stripeDatabaseKeys.publishableKey).update({ + value: publishableKey + }); + await knex('settings').where('key', stripeDatabaseKeys.secretKey).update({ + value: secretKey + }); + await knex('settings').where('key', stripeDatabaseKeys.testMode).update({ + value: `${true}` + }); +}; + +/** + * Setup the environment + */ +const setup = async (playwrightConfig) => { + const usingRemoteServer = process.env.CI && process.env.TEST_URL; + + if (!usingRemoteServer) { + startWebhookServer(); + await setupStripeKeys(); + } + + const {baseURL, storageState} = playwrightConfig.projects[0].use; + const browser = await chromium.launch(); + const page = await browser.newPage({ + baseURL + }); + await setupGhost(page); + await page.context().storageState({path: storageState}); + await browser.close(); +}; + +module.exports = setup;