diff --git a/ghost/core/core/cli/record-test.js b/ghost/core/core/cli/record-test.js new file mode 100644 index 0000000000..17d6fc8598 --- /dev/null +++ b/ghost/core/core/cli/record-test.js @@ -0,0 +1,30 @@ +const {chromium} = require('@playwright/test'); +const Command = require('./command'); +const playwrightConfig = require('../../playwright.config'); +const {globalSetup} = require('../../test/e2e-browser/utils'); + +module.exports = class RecordTest extends Command { + setup() { + this.help('Use PlayWright to record a browser-based test'); + } + + permittedEnvironments() { + return ['testing-browser']; + } + + async handle() { + await globalSetup({ + projects: [playwrightConfig] + }); + + const browser = await chromium.launch({headless: false}); + + const context = await browser.newContext(playwrightConfig.use); + + // Pause the page, and start recording manually. + const page = await context.newPage(); + await page.goto('/ghost'); + + await page.pause(); + } +}; diff --git a/ghost/core/package.json b/ghost/core/package.json index 0e41c06b73..66b33ad37b 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -33,10 +33,12 @@ "test:integration": "yarn test:base './test/integration' --timeout=10000", "test:e2e": "yarn test:base ./test/e2e-* --timeout=15000", "test:regression": "yarn test:base './test/regression' --timeout=60000", - "test:browser": "NODE_ENV=testing-browser playwright test", + "test:browser": "yarn test:browser:admin && yarn test:browser:portal", "test:browser:admin": "NODE_ENV=testing-browser playwright test test/e2e-browser --project=admin", "test:browser:portal": "NODE_ENV=testing-browser playwright test test/e2e-browser --project=portal", + "test:browser:single": "NODE_ENV=testing-browser playwright test", "test:browser:setup": "npx playwright install", + "test:browser:record": "NODE_ENV=testing-browser yarn start record-test", "test:ci:e2e": "c8 -c ./.c8rc.e2e.json -o coverage-e2e yarn test:e2e -b --retries=2 --reporter=./test/utils/mocha-retry-reporter.js", "test:ci:regression": "yarn test:regression -b --retries=2 --reporter=./test/utils/mocha-retry-reporter.js", "test:ci:integration": "c8 -c ./.c8rc.e2e.json -o coverage-integration --lines 57 --functions 47 --branches 77 --statements 57 yarn test:integration -b --retries=2 --reporter=./test/utils/mocha-retry-reporter.js", diff --git a/ghost/core/playwright.config.js b/ghost/core/playwright.config.js index 63314f384e..bac5a1d041 100644 --- a/ghost/core/playwright.config.js +++ b/ghost/core/playwright.config.js @@ -5,15 +5,15 @@ const config = { expect: { timeout: 10000 }, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? '100%' : (process.env.PLAYWRIGHT_SLOWMO ? 1 : undefined), + workers: 1, reporter: [['list', {printSteps: true}]], use: { // Use a single browser since we can't simultaneously test multiple browsers browserName: 'chromium', headless: !process.env.PLAYWRIGHT_DEBUG, - // Port doesn't matter, overriden by baseURL fixture for each worker - baseURL: 'http://127.0.0.1:2368' + baseURL: process.env.TEST_URL ?? 'http://127.0.0.1:2369', + // TODO: Where to put this + storageState: 'playwright-state.json' }, // separated tests to projects for better logging to console // portal tests are much more stable when running in the separate DB from admin tests @@ -24,10 +24,11 @@ const config = { }, { name: 'portal', - testDir: 'test/e2e-browser/portal', - fullyParallel: true + testDir: 'test/e2e-browser/portal' } - ] + ], + globalSetup: './test/e2e-browser/utils/global-setup', + globalTeardown: './test/e2e-browser/utils/global-teardown' }; module.exports = config; diff --git a/ghost/core/test/e2e-browser/admin/announcement-bar-settings.spec.js b/ghost/core/test/e2e-browser/admin/announcement-bar-settings.spec.js index 7a8e448656..1300e0736d 100644 --- a/ghost/core/test/e2e-browser/admin/announcement-bar-settings.spec.js +++ b/ghost/core/test/e2e-browser/admin/announcement-bar-settings.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); test.describe('Announcement Bar Settings', () => { test('Bar hidden by default', async ({page}) => { diff --git a/ghost/core/test/e2e-browser/admin/members.spec.js b/ghost/core/test/e2e-browser/admin/members.spec.js index 47c37f4ede..6bfce6cbfd 100644 --- a/ghost/core/test/e2e-browser/admin/members.spec.js +++ b/ghost/core/test/e2e-browser/admin/members.spec.js @@ -1,11 +1,10 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {createMember, deleteAllMembers} = require('../utils/e2e-browser-utils'); const fs = require('fs'); test.describe('Admin', () => { test.describe('Members', () => { - test.describe.configure({retries: 1, mode: 'serial'}); + test.describe.configure({retries: 1}); test('A member can be created', async ({page}) => { await page.goto('/ghost'); await page.locator('.gh-nav a[href="#/members/"]').click(); @@ -280,7 +279,7 @@ test.describe('Admin', () => { }); test('A member can be granted a comp in admin', async ({page}) => { - await page.goto('/ghost'); + page.goto('/ghost'); await deleteAllMembers(page); // create a new member with a comped plan diff --git a/ghost/core/test/e2e-browser/admin/membership-settings.spec.js b/ghost/core/test/e2e-browser/admin/membership-settings.spec.js index b234d41df3..65a4bf5902 100644 --- a/ghost/core/test/e2e-browser/admin/membership-settings.spec.js +++ b/ghost/core/test/e2e-browser/admin/membership-settings.spec.js @@ -1,6 +1,5 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); -const {disconnectStripe, setupStripe, generateStripeIntegrationToken, getStripeAccountId} = require('../utils'); +const {expect, test} = require('@playwright/test'); +const {disconnectStripe, setupStripe, generateStripeIntegrationToken} = require('../utils'); test.describe('Membership Settings', () => { test.describe('Portal settings', () => { @@ -19,8 +18,7 @@ test.describe('Membership Settings', () => { await expect(modal.locator('label').filter({hasText: 'Free'}).first()).toBeVisible(); // Reconnect Stripe for other tests - const stripeAccountId = await getStripeAccountId(); - const stripeToken = await generateStripeIntegrationToken(stripeAccountId); + const stripeToken = await generateStripeIntegrationToken(); await page.goto('/ghost'); await setupStripe(page, stripeToken); }); diff --git a/ghost/core/test/e2e-browser/admin/portal-settings.spec.js b/ghost/core/test/e2e-browser/admin/portal-settings.spec.js index b83c32c267..4ccca63f68 100644 --- a/ghost/core/test/e2e-browser/admin/portal-settings.spec.js +++ b/ghost/core/test/e2e-browser/admin/portal-settings.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); test.describe('Portal Settings', () => { test.describe('Links', () => { diff --git a/ghost/core/test/e2e-browser/admin/posts.spec.js b/ghost/core/test/e2e-browser/admin/posts.spec.js index 20dd40dce5..0a92d8cbb3 100644 --- a/ghost/core/test/e2e-browser/admin/posts.spec.js +++ b/ghost/core/test/e2e-browser/admin/posts.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); test.describe('Admin', () => { test.describe('Posts', () => { diff --git a/ghost/core/test/e2e-browser/admin/private-site.spec.js b/ghost/core/test/e2e-browser/admin/private-site.spec.js index ffc9a9f856..8c96bfbc2b 100644 --- a/ghost/core/test/e2e-browser/admin/private-site.spec.js +++ b/ghost/core/test/e2e-browser/admin/private-site.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {test, expect} = require('@playwright/test'); test.describe('Site Settings', () => { test.describe('Privacy setting', () => { diff --git a/ghost/core/test/e2e-browser/admin/publishing.spec.js b/ghost/core/test/e2e-browser/admin/publishing.spec.js index d1845e6890..1baff0dad1 100644 --- a/ghost/core/test/e2e-browser/admin/publishing.spec.js +++ b/ghost/core/test/e2e-browser/admin/publishing.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {DateTime} = require('luxon'); const {slugify} = require('@tryghost/string'); const {createTier, createMember, createPostDraft, impersonateMember} = require('../utils'); @@ -192,7 +191,7 @@ test.describe('Publishing', () => { }; // Create a member to send and email to - await createMember(page, {email: 'test+recipient1@example.com', name: 'Publishing member'}); + await createMember(page, {email: 'example@example.com', name: 'Publishing member'}); await page.goto('/ghost'); await createPostDraft(page, postData); @@ -221,13 +220,12 @@ test.describe('Publishing', () => { // Post should be available on web and sent as a newsletter test('Email only', async ({page}) => { + // Note: this currently depends on 'Publish and Email' to create a member! const postData = { title: 'Email only post', body: 'This is my post body.' }; - await createMember(page, {email: 'test+recipient2@example.com', name: 'Publishing member'}); - await page.goto('/ghost'); await createPostDraft(page, postData); await publishPost(page, {type: 'send'}); @@ -329,14 +327,13 @@ test.describe('Publishing', () => { test.describe('Schedule post', () => { // Post should be published to web and sent as a newsletter at the scheduled time test('Publish and Email', async ({page}) => { + // Note: this currently depends on the first 'Publish and Email' to create a member! const postData = { // This title should be unique title: 'Scheduled post publish+email test', body: 'This is my scheduled post body.' }; - await createMember(page, {email: 'test+recipient3@example.com', name: 'Publishing member'}); - await page.goto('/ghost'); await createPostDraft(page, postData); @@ -398,8 +395,6 @@ test.describe('Publishing', () => { body: 'This is my scheduled post body.' }; - await createMember(page, {email: 'test+recipient4@example.com', name: 'Publishing member'}); - await page.goto('/ghost'); await createPostDraft(page, postData); diff --git a/ghost/core/test/e2e-browser/admin/setup.spec.js b/ghost/core/test/e2e-browser/admin/setup.spec.js index f6785eab05..b4117b7215 100644 --- a/ghost/core/test/e2e-browser/admin/setup.spec.js +++ b/ghost/core/test/e2e-browser/admin/setup.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); test.describe('Admin', () => { test.describe('Setup', () => { diff --git a/ghost/core/test/e2e-browser/admin/site-settings.spec.js b/ghost/core/test/e2e-browser/admin/site-settings.spec.js index 3991759bdd..9d0a1c07e6 100644 --- a/ghost/core/test/e2e-browser/admin/site-settings.spec.js +++ b/ghost/core/test/e2e-browser/admin/site-settings.spec.js @@ -1,6 +1,5 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); -const {createPostDraft, createTier, disconnectStripe, generateStripeIntegrationToken, setupStripe, getStripeAccountId} = require('../utils'); +const {expect, test} = require('@playwright/test'); +const {createPostDraft, createTier, disconnectStripe, generateStripeIntegrationToken, setupStripe} = require('../utils'); const changeSubscriptionAccess = async (page, access) => { await page.locator('[data-test-nav="settings"]').click(); @@ -126,8 +125,7 @@ test.describe('Site Settings', () => { await changeSubscriptionAccess(page, 'all'); await page.goto('/ghost'); - const stripeAccountId = await getStripeAccountId(); - const stripeToken = await generateStripeIntegrationToken(stripeAccountId); + const stripeToken = await generateStripeIntegrationToken(); await setupStripe(page, stripeToken); }); }); diff --git a/ghost/core/test/e2e-browser/admin/tiers.spec.js b/ghost/core/test/e2e-browser/admin/tiers.spec.js index be53617792..af7f2e2835 100644 --- a/ghost/core/test/e2e-browser/admin/tiers.spec.js +++ b/ghost/core/test/e2e-browser/admin/tiers.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {createTier, createOffer, getUniqueName, getSlug, goToMembershipPage, openTierModal} = require('../utils'); test.describe('Admin', () => { diff --git a/ghost/core/test/e2e-browser/fixtures/ghost-test.js b/ghost/core/test/e2e-browser/fixtures/ghost-test.js deleted file mode 100644 index ad1b4b1e8d..0000000000 --- a/ghost/core/test/e2e-browser/fixtures/ghost-test.js +++ /dev/null @@ -1,141 +0,0 @@ -// express-test.js -const base = require('@playwright/test'); -const {promisify} = require('util'); -const {spawn, exec} = require('child_process'); -const {setupGhost, setupMailgun, enableLabs, setupStripe, getStripeAccountId, generateStripeIntegrationToken} = require('../utils/e2e-browser-utils'); -const {allowStripe, mockMail} = require('../../utils/e2e-framework-mock-manager'); -const MailgunClient = require('@tryghost/mailgun-client'); -const sinon = require('sinon'); -const ObjectID = require('bson-objectid').default; -const Stripe = require('stripe').Stripe; -const configUtils = require('../../utils/configUtils'); - -const startWebhookServer = (port) => { - const command = `stripe listen --forward-connect-to http://127.0.0.1:${port}/members/webhooks/stripe/`; - const webhookServer = spawn(command.split(' ')[0], command.split(' ').slice(1)); - - // Adding event listeners here seems to prevent heisenbug where webhooks aren't received - webhookServer.stdout.on('data', () => {}); - webhookServer.stderr.on('data', () => {}); - - return webhookServer; -}; - -const getWebhookSecret = async () => { - const command = `stripe listen --print-secret ${process.env.CI ? `--api-key ${process.env.STRIPE_SECRET_KEY}` : ''}`.trim(); - const webhookSecret = (await promisify(exec)(command)).stdout; - return webhookSecret.toString().trim(); -}; - -// Global promises for webhook secret / Stripe integration token -const webhookSecretPromise = getWebhookSecret(); - -module.exports = base.test.extend({ - baseURL: async ({port, baseURL}, use) => { - // Replace the port in baseURL with the one we got from the port fixture - const url = new URL(baseURL); - url.port = port.toString(); - await use(url.toString()); - }, - - storageState: async ({ghost}, use) => { - await use(ghost.state); - }, - - // eslint-disable-next-line no-empty-pattern - port: [async ({}, use, workerInfo) => { - await use(2369 + workerInfo.parallelIndex); - }, {scope: 'worker'}], - - ghost: [async ({browser, port}, use, workerInfo) => { - // Do not initialise database before this block - const currentDate = new Date(); - const formattedDate = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}-${String(currentDate.getHours()).padStart(2, '0')}-${String(currentDate.getMinutes()).padStart(2, '0')}-${String(currentDate.getSeconds()).padStart(2, '0')}`; - process.env.database__connection__filename = `/tmp/ghost-playwright.${workerInfo.workerIndex}.${formattedDate}.db`; - configUtils.set('database:connection:filename', process.env.database__connection__filename); - configUtils.set('server:port', port); - configUtils.set('url', `http://127.0.0.1:${port}`); - - const stripeAccountId = await getStripeAccountId(); - const stripeIntegrationToken = await generateStripeIntegrationToken(stripeAccountId); - - const WebhookManager = require('../../../../stripe/lib/WebhookManager'); - const originalParseWebhook = WebhookManager.prototype.parseWebhook; - const sandbox = sinon.createSandbox(); - sandbox.stub(WebhookManager.prototype, 'parseWebhook').callsFake(function (body, signature) { - const parsedBody = JSON.parse(body); - if (!('account' in parsedBody)) { - throw new Error('Webhook without account'); - } else if (parsedBody.account !== stripeAccountId) { - throw new Error('Webhook for wrong account'); - } else { - return originalParseWebhook.call(this, body, signature); - } - }); - - const StripeAPI = require('../../../../stripe/lib/StripeAPI'); - const originalStripeConfigure = StripeAPI.prototype.configure; - sandbox.stub(StripeAPI.prototype, 'configure').callsFake(function (stripeConfig) { - originalStripeConfigure.call(this, stripeConfig); - if (stripeConfig) { - this._stripe = new Stripe(stripeConfig.secretKey, { - apiVersion: '2020-08-27', - stripeAccount: stripeAccountId - }); - } - }); - - const stripeServer = startWebhookServer(port); - - process.env.WEBHOOK_SECRET = await webhookSecretPromise; - - sandbox.stub(MailgunClient.prototype, 'getInstance').returns({ - // @ts-ignore - messages: { - create: async function () { - return { - id: `mailgun-mock-id-${ObjectID().toHexString()}` - }; - } - } - }); - - mockMail(); - - const {startGhost} = require('../../utils/e2e-framework'); - const server = await startGhost({ - frontend: true, - server: true, - backend: true - }); - - // StartGhost automatically disables network, so we need to re-enable it for Stripe - allowStripe(); - - const page = await browser.newPage({ - baseURL: `http://127.0.0.1:${port}/`, - storageState: undefined - }); - - await setupGhost(page); - await setupStripe(page, stripeIntegrationToken); - await setupMailgun(page); - await enableLabs(page); - const state = await page.context().storageState(); - - await page.close(); - - // Use the server in the tests. - try { - await use({ - server, - state - }); - } finally { - const {stopGhost} = require('../../utils/e2e-utils'); - await stopGhost(); - stripeServer.kill(); - sandbox.restore(); - } - }, {scope: 'worker', auto: true}] -}); diff --git a/ghost/core/test/e2e-browser/portal/donations.spec.js b/ghost/core/test/e2e-browser/portal/donations.spec.js index 4acda414b5..b5c5a2f4da 100644 --- a/ghost/core/test/e2e-browser/portal/donations.spec.js +++ b/ghost/core/test/e2e-browser/portal/donations.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {createMember, impersonateMember, completeStripeSubscription} = require('../utils'); test.describe('Portal', () => { diff --git a/ghost/core/test/e2e-browser/portal/member-actions.spec.js b/ghost/core/test/e2e-browser/portal/member-actions.spec.js index 93b03b2c6f..b7d8b27f9c 100644 --- a/ghost/core/test/e2e-browser/portal/member-actions.spec.js +++ b/ghost/core/test/e2e-browser/portal/member-actions.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {createMember, impersonateMember} = require('../utils'); /** @@ -23,9 +22,7 @@ const addNewsletter = async (page) => { test.describe('Portal', () => { test.describe('Member actions', () => { - // Use serial mode as the order of tests matters, we create newsletters during the tests - // TODO: Use a `before` block to create all the requisite newsletters before the tests run - test.describe.configure({retries: 1, mode: 'serial'}); + test.describe.configure({retries: 1}); test('can log out', async ({page}) => { // create a new free member diff --git a/ghost/core/test/e2e-browser/portal/offers.spec.js b/ghost/core/test/e2e-browser/portal/offers.spec.js index eaf87dbdaa..4a1e09f441 100644 --- a/ghost/core/test/e2e-browser/portal/offers.spec.js +++ b/ghost/core/test/e2e-browser/portal/offers.spec.js @@ -1,16 +1,15 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {deleteAllMembers, createTier, createOffer, completeStripeSubscription} = require('../utils'); test.describe('Portal', () => { test.describe('Offers', () => { test('Creates and uses a free-trial Offer', async ({page}) => { // reset members by deleting all existing - await page.goto('/ghost'); + page.goto('/ghost'); await deleteAllMembers(page); // add a new tier for offers - const tierName = 'Trial Tier'; + const tierName = 'Portal Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -44,7 +43,7 @@ test.describe('Portal', () => { // fill member details and click start trial await portalFrame.locator('[data-test-input="input-name"]').fill('Testy McTesterson'); - await portalFrame.locator('[data-test-input="input-email"]').fill('testy+trial@example.com'); + await portalFrame.locator('[data-test-input="input-email"]').fill('testy@example.com'); await portalFrame.getByRole('button', {name: 'Start 14-day free trial'}).click(); // handle newsletter selection page if it opens and click continue @@ -67,7 +66,7 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy+trial@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); // Ensure the offer redemption count was bumped @@ -78,11 +77,11 @@ test.describe('Portal', () => { test('Creates and uses a one-time discount Offer', async ({page}) => { // reset members by deleting all existing - await page.goto('/ghost'); + page.goto('/ghost'); await deleteAllMembers(page); // add new tier - const tierName = 'One-off Tier'; + const tierName = 'Portal Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -115,7 +114,7 @@ test.describe('Portal', () => { // fill member details and continue await portalFrame.locator('#input-name').fill('Testy McTesterson'); - await portalFrame.locator('#input-email').fill('testy+oneoff@example.com'); + await portalFrame.locator('#input-email').fill('testy@example.com'); await portalFrame.getByRole('button', {name: 'Continue'}).click(); // check if newsletter selection screen is shown and continue @@ -137,17 +136,17 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy+oneoff@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); }); test('Creates and uses a multiple-months discount Offer', async ({page}) => { // reset members by deleting all existing - await page.goto('/ghost'); + page.goto('/ghost'); await deleteAllMembers(page); // add new tier - const tierName = 'Multiple-month Tier'; + const tierName = 'Portal Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -184,7 +183,7 @@ test.describe('Portal', () => { // fill member details and continue await portalFrame.locator('#input-name').fill('Testy McTesterson'); - await portalFrame.locator('#input-email').fill('testy+multi@example.com'); + await portalFrame.locator('#input-email').fill('testy@example.com'); await portalFrame.getByRole('button', {name: 'Continue'}).click(); // check newsletter selection if shown and continue @@ -205,17 +204,17 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy+multi@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); }); test('Creates and uses a forever discount Offer', async ({page}) => { // reset members by deleting all existing - await page.goto('/ghost'); + page.goto('/ghost'); await deleteAllMembers(page); // add tier - const tierName = 'Forever Tier'; + const tierName = 'Portal Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -251,7 +250,7 @@ test.describe('Portal', () => { // fill member details and continue await portalFrame.locator('#input-name').fill('Testy McTesterson'); - await portalFrame.locator('#input-email').fill('testy+forever@example.com'); + await portalFrame.locator('#input-email').fill('testy@example.com'); await portalFrame.getByRole('button', {name: 'Continue'}).click(); // check if newsletter selection page is shown and continue @@ -270,15 +269,15 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy+forever@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); }); test('Archiving an offer', async ({page}) => { - await page.goto('/ghost'); + page.goto('/ghost'); // Create a new tier to attach offer to - const tierName = 'Archive Test Tier'; + const tierName = 'Portal Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, diff --git a/ghost/core/test/e2e-browser/portal/tiers.spec.js b/ghost/core/test/e2e-browser/portal/tiers.spec.js index 5a4f9b4bf9..c005901264 100644 --- a/ghost/core/test/e2e-browser/portal/tiers.spec.js +++ b/ghost/core/test/e2e-browser/portal/tiers.spec.js @@ -1,5 +1,4 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); +const {expect, test} = require('@playwright/test'); const {deleteAllMembers, completeStripeSubscription} = require('../utils'); test.describe('Portal', () => { diff --git a/ghost/core/test/e2e-browser/portal/upgrade.spec.js b/ghost/core/test/e2e-browser/portal/upgrade.spec.js index a2cdb12ad2..ff42bd30f8 100644 --- a/ghost/core/test/e2e-browser/portal/upgrade.spec.js +++ b/ghost/core/test/e2e-browser/portal/upgrade.spec.js @@ -1,164 +1,152 @@ -const {expect} = require('@playwright/test'); -const test = require('../fixtures/ghost-test'); -const {completeStripeSubscription, createMember, createTier, impersonateMember} = require('../utils'); - -const tierName = 'Upgrade Tests'; +const {expect, test} = require('@playwright/test'); +const {completeStripeSubscription, createMember, impersonateMember} = require('../utils'); test.describe('Portal', () => { - test.describe('Upgrades', () => { - // Tier created in first test used in subsequent tests - test.describe.configure({mode: 'serial'}); + test.describe('Upgrade: Comped Member', () => { + test('allows comped member to upgrade to paid tier', async ({page}) => { + const tierName = 'The Local Test'; - test.describe('Upgrade: Comped Member', () => { - test('allows comped member to upgrade to paid tier', async ({page}) => { - // create a new member - await page.goto('/ghost'); - await createTier(page, { - name: tierName, - monthlyPrice: 5, - yearlyPrice: 50 - }); - await createMember(page, { - name: 'Testy McTest', - email: 'testy+upgradecompedportal@example.com', - note: 'Testy McTest is a test member' - }); - - //get the url of the current member on admin - const memberUrl = page.url(); - - // Give member comped subscription - await page.locator('[data-test-button="add-complimentary"]').click(); - await page.locator('[data-test-button="save-comp-tier"]').first().click({ - delay: 500 - }); - - await page.waitForLoadState('networkidle'); - await impersonateMember(page); - - const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); - const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); - - // open portal, go to plans and click continue to select the first plan(yearly) - await portalTriggerButton.click(); - await portalFrame.getByRole('button', {name: 'Change'}).click(); - - // select the tier for checkout (yearly) - await choseTierByName(portalFrame, tierName); - - // complete stripe checkout - await completeStripeSubscription(page); - - // open portal and check that member has been upgraded to paid tier - await portalTriggerButton.click(); - await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); - await expect(portalFrame.getByRole('heading', {name: 'Billing info'})).toBeVisible(); - await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); - - // check that member has been upgraded in admin and a tier exists for them - await page.goto(memberUrl); - await expect(page.locator('[data-test-tier]').first()).toBeVisible(); + // create a new member + await page.goto('/ghost'); + await createMember(page, { + name: 'Testy McTest', + email: 'testy+upgradecompedportal@example.com', + note: 'Testy McTest is a test member' }); + + //get the url of the current member on admin + const memberUrl = page.url(); + + // Give member comped subscription + await page.locator('[data-test-button="add-complimentary"]').click(); + await page.locator('[data-test-button="save-comp-tier"]').first().click({ + delay: 500 + }); + + await page.waitForLoadState('networkidle'); + await impersonateMember(page); + + const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); + const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); + //await page.pause(); + + // open portal, go to plans and click continue to select the first plan(yearly) + await portalTriggerButton.click(); + await portalFrame.getByRole('button', {name: 'Change'}).click(); + + // select the tier for checkout (yearly) + await choseTierByName(portalFrame, tierName); + + // complete stripe checkout + await completeStripeSubscription(page); + + // open portal and check that member has been upgraded to paid tier + await portalTriggerButton.click(); + await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); + await expect(portalFrame.getByRole('heading', {name: 'Billing info'})).toBeVisible(); + await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); + + // check that member has been upgraded in admin and a tier exists for them + await page.goto(memberUrl); + await expect(page.locator('[data-test-tier]').first()).toBeVisible(); + }); + }); + + test.describe('Upgrade: Single Tier', () => { + let memberUrl; + const tierName = 'The Local Test'; + const member = { + name: 'Testy McTest', + email: 'testy+upgradeportal@example.com', + note: 'Testy McTest is a test member' + }; + + test('allows free member upgrade to paid tier', async ({page}) => { + await page.goto('/ghost'); + + // create a new free member + await page.goto('/ghost'); + await createMember(page, member); + + //store the url of the member detail page + memberUrl = page.url(); + + // impersonate the member on frontend + await impersonateMember(page); + + const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); + const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); + + // open portal, go to plans and click continue to select the first plan(yearly) + await portalTriggerButton.click(); + // verify the member we created is logged in + await expect(portalFrame.getByText('testy+upgradeportal@example.com')).toBeVisible(); + // view plans button only shows for free member + await portalFrame.getByRole('button', {name: 'View plans'}).click(); + + // select the tier for checkout (yearly) + await choseTierByName(portalFrame, tierName); + + // complete stripe checkout + await completeStripeSubscription(page); + + // open portal and check that member has been upgraded to paid tier + await portalTriggerButton.click(); + // verify member's tier, price and card details + await expect(portalFrame.getByText(tierName)).toBeVisible(); + await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); + await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); + + // verify member's tier on member detail page in admin + await page.goto(memberUrl); + const tierCard = await page.locator('[data-test-tier]').first(); + const tierText = await tierCard.locator('[data-test-text="tier-name"]'); + await expect(tierCard).toBeVisible(); + await expect(tierText, 'Where is tier text').toHaveText(new RegExp(tierName)); }); - test.describe('Upgrade: Single Tier', () => { - // Because memberUrl is set during first test, we need to run these tests in series - test.describe.configure({mode: 'serial'}); + test('allows member to switch plans', async ({page}) => { + // go to member detail page in admin + await page.goto(memberUrl); - let memberUrl; - const member = { - name: 'Testy McTest', - email: 'testy+upgradeportal@example.com', - note: 'Testy McTest is a test member' - }; + // impersonate the member on frontend + await impersonateMember(page); - test('allows free member upgrade to paid tier', async ({page}) => { - await page.goto('/ghost'); + const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); + const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); - // create a new free member - await page.goto('/ghost'); - await createMember(page, member); + // open portal + await portalTriggerButton.click(); - //store the url of the member detail page - memberUrl = page.url(); + // test member can switch to monthly plan from yearly + await portalFrame.locator('[data-test-button="change-plan"]').click(); - // impersonate the member on frontend - await impersonateMember(page); + await portalFrame.locator('[data-test-button="switch-monthly"]').click(); - const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); - const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); + // select the monthly plan + await choseTierByName(portalFrame, tierName); - // open portal, go to plans and click continue to select the first plan(yearly) - await portalTriggerButton.click(); - // verify the member we created is logged in - await expect(portalFrame.getByText('testy+upgradeportal@example.com')).toBeVisible(); - // view plans button only shows for free member - await portalFrame.getByRole('button', {name: 'View plans'}).click(); + // confirm the switch + await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); + // verify member has switched to monthly plan + await expect(portalFrame.getByText(tierName)).toBeVisible(); + await expect(portalFrame.getByText('$5.00/month')).toBeVisible(); - // select the tier for checkout (yearly) - await choseTierByName(portalFrame, tierName); - - // complete stripe checkout - await completeStripeSubscription(page); - - // open portal and check that member has been upgraded to paid tier - await portalTriggerButton.click(); - // verify member's tier, price and card details - await expect(portalFrame.getByText(tierName)).toBeVisible(); - await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); - await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); - - // verify member's tier on member detail page in admin - await page.goto(memberUrl); - const tierCard = await page.locator('[data-test-tier]').first(); - const tierText = await tierCard.locator('[data-test-text="tier-name"]'); - await expect(tierCard).toBeVisible(); - await expect(tierText, 'Where is tier text').toHaveText(new RegExp(tierName)); - }); - - test('allows member to switch plans', async ({page}) => { - // go to member detail page in admin - await page.goto(memberUrl); - - // impersonate the member on frontend - await impersonateMember(page); - - const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); - const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); - - // open portal - await portalTriggerButton.click(); - - // test member can switch to monthly plan from yearly - await portalFrame.locator('[data-test-button="change-plan"]').click(); - - await portalFrame.locator('[data-test-button="switch-monthly"]').click(); - - // select the monthly plan - await choseTierByName(portalFrame, tierName); - - // confirm the switch - await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); - // verify member has switched to monthly plan - await expect(portalFrame.getByText(tierName)).toBeVisible(); - await expect(portalFrame.getByText('$5.00/month')).toBeVisible(); - - // test member can switch back to yearly - await portalFrame.locator('[data-test-button="change-plan"]').click(); - await portalFrame.locator('[data-test-button="switch-yearly"]').click(); - // select the monthly plan - await choseTierByName(portalFrame, tierName); - // confirm the switch - await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); - // verify member has switched to yearly plan, timeout added to allow for delays - await expect(portalFrame.getByText(tierName)).toBeVisible(); - await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); - }); + // test member can switch back to yearly + await portalFrame.locator('[data-test-button="change-plan"]').click(); + await portalFrame.locator('[data-test-button="switch-yearly"]').click(); + // select the monthly plan + await choseTierByName(portalFrame, tierName); + // confirm the switch + await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); + // verify member has switched to yearly plan, timeout added to allow for delays + await expect(portalFrame.getByText(tierName)).toBeVisible(); + await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); }); }); }); -async function choseTierByName(portalFrame, tier) { - const portalTierCard = await portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tier}).first(); +async function choseTierByName(portalFrame, tierName) { + const portalTierCard = await portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tierName}).first(); await portalTierCard.locator('[data-test-button="select-tier"]').click(); } 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 74f1957a04..02a1e6af1b 100644 --- a/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js +++ b/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js @@ -1,7 +1,8 @@ const DataGenerator = require('../../utils/fixtures/data-generator'); const {expect, test} = require('@playwright/test'); const ObjectID = require('bson-objectid').default; -const Stripe = require('stripe').Stripe; +const {promisify} = require('util'); +const {exec} = require('child_process'); /** * Tier @@ -213,7 +214,7 @@ const impersonateMember = async (page) => { */ const createTier = async (page, {name, monthlyPrice, yearlyPrice, trialDays}, enableInPortal = true) => { await test.step('Create a tier', async () => { - // Navigate to the member settings + // Navigate to the member settings await page.locator('[data-test-nav="settings"]').click(); // Tiers request can take time, so waiting until there is no connections before interacting with them @@ -468,48 +469,29 @@ const openTierModal = async (page, {slug}) => { }); }; -const getStripeAccountId = async () => { - if (!('STRIPE_PUBLISHABLE_KEY' in process.env) || !('STRIPE_SECRET_KEY' in process.env)) { - throw new Error('Missing STRIPE_PUBLISHABLE_KEY or STRIPE_SECRET_KEY environment variables'); - } +const generateStripeIntegrationToken = async () => { + const inquirer = require('inquirer'); + const {knex} = require('../../../core/server/data/db'); - const parallelIndex = process.env.TEST_PARALLEL_INDEX; - let accountId; - const accountEmail = `test${parallelIndex}@example.com`; + const stripeDatabaseKeys = { + publishableKey: 'stripe_connect_publishable_key', + secretKey: 'stripe_connect_secret_key', + liveMode: 'stripe_connect_livemode' + }; + const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? (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 = process.env.STRIPE_SECRET_KEY ?? (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; - const secretKey = process.env.STRIPE_SECRET_KEY; - const stripe = new Stripe(secretKey, { - apiVersion: '2020-08-27' - }); - const accounts = await stripe.accounts.list(); - if (accounts.data.length > 0) { - const account = accounts.data.find(acc => acc.email === accountEmail); - if (account) { - await stripe.accounts.del(account.id); - } - } - if (!accountId) { - const account = await stripe.accounts.create({ - type: 'standard', - email: accountEmail, - business_type: 'company', - company: { - name: `Test Company ${parallelIndex}` - } - }); - accountId = account.id; - } - - return accountId; -}; - -const generateStripeIntegrationToken = async (accountId) => { - if (!('STRIPE_PUBLISHABLE_KEY' in process.env) || !('STRIPE_SECRET_KEY' in process.env)) { - throw new Error('Missing STRIPE_PUBLISHABLE_KEY or STRIPE_SECRET_KEY environment variables'); - } - - const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY; - const secretKey = process.env.STRIPE_SECRET_KEY; + const accountId = process.env.STRIPE_ACCOUNT_ID ?? JSON.parse((await promisify(exec)('stripe get account')).stdout).id; return Buffer.from(JSON.stringify({ a: secretKey, @@ -524,7 +506,6 @@ module.exports = { setupStripe, disconnectStripe, enableLabs, - getStripeAccountId, generateStripeIntegrationToken, setupMailgun, deleteAllMembers, 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..0cff970fc3 --- /dev/null +++ b/ghost/core/test/e2e-browser/utils/global-setup.js @@ -0,0 +1,114 @@ +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, setupStripe, setupMailgun, enableLabs} = require('./e2e-browser-utils'); +const {chromium} = require('@playwright/test'); +const {startGhost} = require('../../utils/e2e-framework'); +const {stopGhost} = require('../../utils/e2e-utils'); +const MailgunClient = require('@tryghost/mailgun-client'); +const sinon = require('sinon'); +const ObjectID = require('bson-objectid').default; +const {allowStripe} = require('../../utils/e2e-framework-mock-manager'); + +const startWebhookServer = () => { + const command = `stripe listen --forward-to ${config.getSiteUrl()}members/webhooks/stripe/ ${process.env.CI ? `--api-key ${process.env.STRIPE_SECRET_KEY}` : ''}`.trim(); + spawn(command.split(' ')[0], command.split(' ').slice(1)); +}; + +const getWebhookSecret = async () => { + const command = `stripe listen --print-secret ${process.env.CI ? `--api-key ${process.env.STRIPE_SECRET_KEY}` : ''}`.trim(); + const webhookSecret = (await promisify(exec)(command)).stdout; + return webhookSecret.toString().trim(); +}; + +const generateStripeIntegrationToken = async () => { + const inquirer = require('inquirer'); + const stripeDatabaseKeys = { + publishableKey: 'stripe_connect_publishable_key', + secretKey: 'stripe_connect_secret_key', + liveMode: 'stripe_connect_livemode' + }; + const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? (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 = process.env.STRIPE_SECRET_KEY ?? (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; + + const accountId = process.env.STRIPE_ACCOUNT_ID ?? JSON.parse((await promisify(exec)('stripe get account')).stdout).id; + + return Buffer.from(JSON.stringify({ + a: secretKey, + p: publishableKey, + l: false, + i: accountId + })).toString('base64'); +}; + +const stubMailgun = () => { + // We need to stub the Mailgun client before starting Ghost + sinon.stub(MailgunClient.prototype, 'getInstance').returns({ + // @ts-ignore + messages: { + create: async function () { + return { + id: `mailgun-mock-id-${ObjectID().toHexString()}` + }; + } + } + }); +}; + +/** + * Setup the environment + */ +const setup = async (playwrightConfig) => { + const usingRemoteServer = process.env.CI && process.env.TEST_URL; + + let stripeConnectIntegrationToken; + if (!usingRemoteServer) { + startWebhookServer(); + stripeConnectIntegrationToken = await generateStripeIntegrationToken(); + + process.env.WEBHOOK_SECRET = await getWebhookSecret(); + + // Stub out NodeMailer + stubMailgun(); + + await startGhost({ + frontend: true, + server: true, + backend: true + }); + + // StartGhost automatically disables network, so we need to re-enable it for Stripe + allowStripe(); + } + + const {baseURL, storageState} = playwrightConfig.projects[0].use; + const browser = await chromium.launch(); + const page = await browser.newPage({ + baseURL + }); + await setupGhost(page); + if (!usingRemoteServer) { + await setupStripe(page, stripeConnectIntegrationToken); + await setupMailgun(page); + } + await enableLabs(page); + await page.context().storageState({path: storageState}); + await browser.close(); + + if (!usingRemoteServer) { + await stopGhost(); + } +}; + +module.exports = setup; diff --git a/ghost/core/test/e2e-browser/utils/global-teardown.js b/ghost/core/test/e2e-browser/utils/global-teardown.js new file mode 100644 index 0000000000..e5998d610b --- /dev/null +++ b/ghost/core/test/e2e-browser/utils/global-teardown.js @@ -0,0 +1,8 @@ +/** + * Teardown the environment + */ +const teardown = async () => { + // @NOTE: local environment should probably drop the db state here +}; + +module.exports = teardown; diff --git a/ghost/core/test/e2e-browser/utils/index.js b/ghost/core/test/e2e-browser/utils/index.js index 937f9f6e53..31574a8b31 100644 --- a/ghost/core/test/e2e-browser/utils/index.js +++ b/ghost/core/test/e2e-browser/utils/index.js @@ -1,4 +1,5 @@ module.exports = { ...require('./e2e-browser-utils'), - ...require('./helpers') + ...require('./helpers'), + globalSetup: require('./global-setup') };