0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Revert "Enable parallel running of browser tests" (#18594)

Reverts TryGhost/Ghost#18544
This commit is contained in:
Sam Lord 2023-10-12 15:55:39 +01:00 committed by GitHub
parent 661acdee95
commit 0e47ae03e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 363 additions and 401 deletions

View file

@ -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();
}
};

View file

@ -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",

View file

@ -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;

View file

@ -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}) => {

View file

@ -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

View file

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

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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);

View file

@ -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', () => {

View file

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

View file

@ -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', () => {

View file

@ -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}]
});

View file

@ -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', () => {

View file

@ -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

View file

@ -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,

View file

@ -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', () => {

View file

@ -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();
}

View file

@ -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,

View file

@ -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;

View file

@ -0,0 +1,8 @@
/**
* Teardown the environment
*/
const teardown = async () => {
// @NOTE: local environment should probably drop the db state here
};
module.exports = teardown;

View file

@ -1,4 +1,5 @@
module.exports = {
...require('./e2e-browser-utils'),
...require('./helpers')
...require('./helpers'),
globalSetup: require('./global-setup')
};