mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 10:21:36 +03:00
89a3774983
refs https://github.com/TryGhost/Ghost/pull/18422 - now that we're using the externally-loaded editor we need to wait for it to load and be ready before moving focus to it and starting to type
527 lines
22 KiB
JavaScript
527 lines
22 KiB
JavaScript
const DataGenerator = require('../../utils/fixtures/data-generator');
|
|
const {expect, test} = require('@playwright/test');
|
|
const ObjectID = require('bson-objectid').default;
|
|
const {promisify} = require('util');
|
|
const {exec} = require('child_process');
|
|
|
|
/**
|
|
* Tier
|
|
* @typedef {object} Tier
|
|
* @property {string} tier.name
|
|
* @property {number} tier.monthlyPrice
|
|
* @property {number} tier.yearlyPrice
|
|
*/
|
|
|
|
/**
|
|
* Setup Ghost Admin, or login if there's a login prompt
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
const setupGhost = async (page) => {
|
|
await page.goto('/ghost');
|
|
|
|
const actions = {
|
|
signin: Symbol(),
|
|
setup: Symbol(),
|
|
noAction: Symbol()
|
|
};
|
|
/**
|
|
* Using this to fix ESLint
|
|
* @type {object}
|
|
* @field {string} state
|
|
* @field {number} timeout
|
|
*/
|
|
const options = {
|
|
state: 'visible',
|
|
timeout: 10000
|
|
};
|
|
const action = await Promise.race([
|
|
page.locator('.gh-signin').waitFor(options).then(() => actions.signin).catch(() => {}),
|
|
page.locator('.gh-setup').waitFor(options).then(() => actions.setup).catch(() => {}),
|
|
page.locator('.gh-nav').waitFor(options).then(() => actions.noAction).catch(() => {})
|
|
]);
|
|
|
|
// Add owner user data from usual fixture
|
|
const ownerUser = DataGenerator.Content.users.find(user => user.id === '1');
|
|
|
|
if (process.env.CI && process.env.TEST_URL) {
|
|
ownerUser.email = process.env.TEST_OWNER_EMAIL;
|
|
ownerUser.password = process.env.TEST_OWNER_PASSWORD;
|
|
}
|
|
|
|
if (action === actions.signin) {
|
|
// Fill email + password
|
|
await page.locator('#identification').fill(ownerUser.email);
|
|
await page.locator('#password').fill(ownerUser.password);
|
|
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) {
|
|
// Complete setup process
|
|
await page.getByPlaceholder('The Daily Awesome').click();
|
|
await page.getByPlaceholder('The Daily Awesome').fill('The Local Test');
|
|
|
|
// Add owner user data from usual fixture
|
|
await page.getByPlaceholder('Jamie Larson').fill(ownerUser.name);
|
|
await page.getByPlaceholder('jamie@example.com').fill(ownerUser.email);
|
|
await page.getByPlaceholder('At least 10 characters').fill(ownerUser.password);
|
|
|
|
await page.getByPlaceholder('At least 10 characters').press('Enter');
|
|
await page.locator('.gh-done-pink').click();
|
|
await page.locator('.gh-nav').waitFor(options);
|
|
}
|
|
};
|
|
|
|
const disconnectStripe = async (page) => {
|
|
await deleteAllMembers(page);
|
|
await page.locator('.gh-nav a[href="#/settings/"]').click();
|
|
await page.locator('.gh-setting-group').filter({hasText: '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();
|
|
await page
|
|
.locator('.modal-content')
|
|
.filter({hasText: 'Are you sure you want to disconnect?'})
|
|
.first()
|
|
.getByRole('button', {name: 'Disconnect'})
|
|
.click();
|
|
}
|
|
};
|
|
|
|
const setupStripe = async (page, stripConnectIntegrationToken) => {
|
|
await deleteAllMembers(page);
|
|
await page.locator('.gh-nav a[href="#/settings/"]').click();
|
|
await page.locator('.gh-setting-group').filter({hasText: '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();
|
|
await page
|
|
.locator('.modal-content')
|
|
.filter({hasText: 'Are you sure you want to disconnect?'})
|
|
.first()
|
|
.getByRole('button', {name: 'Disconnect'})
|
|
.click();
|
|
} else {
|
|
await page.locator('.gh-setting-members-tierscontainer .stripe-connect').click();
|
|
}
|
|
await page.getByPlaceholder('Paste your secure key here').first().fill(stripConnectIntegrationToken);
|
|
await page.getByRole('button', {name: 'Save Stripe settings'}).click();
|
|
// We need to wait for the saving to succeed
|
|
await expect(page.locator('[data-test-button="stripe-disconnect"]')).toBeVisible();
|
|
await page.locator('[data-test-button="close-stripe-connect"]').click();
|
|
};
|
|
|
|
// Setup Mailgun with fake data, for Ghost Admin to allow bulk sending
|
|
const setupMailgun = async (page) => {
|
|
await page.locator('.gh-nav a[href="#/settings/"]').click();
|
|
await page.locator('.gh-setting-group').filter({hasText: 'Email newsletter'}).click();
|
|
await page.locator('.gh-expandable-block').filter({hasText: 'Mailgun configuration'}).getByRole('button', {name: 'Expand'}).click();
|
|
|
|
await page.locator('[data-test-mailgun-domain-input]').fill('api.testgun.com');
|
|
await page.locator('[data-test-mailgun-api-key-input]').fill('Not an API key');
|
|
await page.locator('[data-test-button="save-members-settings"]').click();
|
|
await page.waitForSelector('[data-test-button="save-members-settings"] [data-test-task-button-state="success"]');
|
|
};
|
|
|
|
/**
|
|
* Enable experimental labs features
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
const enableLabs = async (page) => {
|
|
await page.locator('.gh-nav a[href="#/settings/"]').click();
|
|
await page.locator('.gh-setting-group').filter({hasText: 'Labs'}).click();
|
|
const alphaList = page.locator('.gh-main-section').filter({hasText: 'Alpha Features'});
|
|
await alphaList.locator('label[for="labs-webmentions"]').click();
|
|
await alphaList.locator('label[for="labs-tipsAndDonations"]').click();
|
|
};
|
|
|
|
/**
|
|
* Delete all members, 1 by 1, using the UI
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
const deleteAllMembers = async (page) => {
|
|
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: 'Delete member'})
|
|
.first()
|
|
.getByRole('button', {name: 'Delete member'})
|
|
.click();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Archive all tiers, 1 by 1, using the UI
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
const archiveAllTiers = async (page) => {
|
|
// Navigate to the member settings
|
|
await page.locator('[data-test-nav="settings"]').click();
|
|
await page.locator('[data-test-nav="members-membership"]').click();
|
|
|
|
// Tiers request can take time, so waiting until there is no connections before interacting with them
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Expand the premium tier list
|
|
await page.locator('[data-test-toggle-pub-info]').click();
|
|
|
|
// Archive if already exists
|
|
while (await page.locator('.gh-tier-card').first().isVisible()) {
|
|
const tierCard = page.locator('.gh-tier-card').first();
|
|
await tierCard.locator('.gh-tier-card-actions-button').click();
|
|
await tierCard.getByRole('button', {name: 'Archive'}).click();
|
|
await page.locator('.modal-content').getByRole('button', {name: 'Archive'}).click();
|
|
await page.locator('.modal-content').waitFor({state: 'detached', timeout: 1000});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Allows impersonating a member by copying the impersonate link
|
|
* opens site with member logged in via the link
|
|
* Expects starting at member detail page
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
const impersonateMember = async (page) => {
|
|
// open member impersonation modal and copy link
|
|
await page.locator('[data-test-button="member-actions"]').click();
|
|
await page.locator('[data-test-button="impersonate"]').click();
|
|
await page.locator('[data-test-button="copy-impersonate-link"]').click();
|
|
await page.waitForSelector('[data-test-button="copy-impersonate-link"] span:has-text("Link copied")');
|
|
|
|
// get impersonation link from input and redirect to it
|
|
const link = await page.locator('[data-test-input="member-signin-url"]').inputValue();
|
|
await page.goto(link);
|
|
};
|
|
|
|
/**
|
|
* Connect from Stripe using the UI, disconnecting if necessary
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {object} tier
|
|
* @param {string} tier.name
|
|
* @param {number} tier.monthlyPrice
|
|
* @param {number} tier.yearlyPrice
|
|
* @param {number} [tier.trialDays]
|
|
*/
|
|
const createTier = async (page, {name, monthlyPrice, yearlyPrice, trialDays}, enableInPortal = true) => {
|
|
// Navigate to the member settings
|
|
await page.locator('[data-test-nav="settings"]').click();
|
|
await page.locator('[data-test-nav="members-membership"]').click();
|
|
|
|
// Tiers request can take time, so waiting until there is no connections before interacting with them
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Expand the premium tier list
|
|
await page.locator('[data-test-toggle-pub-info]').click();
|
|
|
|
// Archive if already exists
|
|
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();
|
|
await page.locator('.modal-content').getByRole('button', {name: 'Archive'}).click();
|
|
await page.locator('.modal-content').waitFor({state: 'detached', timeout: 1000});
|
|
}
|
|
if (!await page.locator('.gh-btn-add-tier').isVisible()) {
|
|
await page.locator('[data-test-toggle-pub-info]').click();
|
|
}
|
|
// Add the tier
|
|
await page.locator('.gh-btn-add-tier').click();
|
|
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}`);
|
|
if (trialDays) {
|
|
await modal.locator('[data-test-toggle="free-trial"]').click();
|
|
await modal.locator('#trial').fill(`${trialDays}`);
|
|
}
|
|
await modal.getByRole('button', {name: 'Add tier'}).click();
|
|
await page.waitForSelector('.modal-content input#name', {state: 'detached'});
|
|
|
|
// Close the premium tier list
|
|
await page.locator('[data-test-toggle-pub-info]').click();
|
|
|
|
// Enable the tier in portal
|
|
if (enableInPortal) {
|
|
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'});
|
|
}
|
|
|
|
// Navigate back to the dashboard
|
|
await page.goto('/ghost');
|
|
};
|
|
|
|
/**
|
|
* Create an offer on a tier
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {object} options
|
|
* @param {string} options.name
|
|
* @param {string} options.tierName
|
|
* @param {string} options.offerType
|
|
* @param {string} [options.discountType]
|
|
* @param {number} [options.discountDuration]
|
|
* @param {number} options.amount
|
|
* @returns {Promise<string>} Unique offer name
|
|
*/
|
|
const createOffer = async (page, {name, tierName, offerType, amount, discountType = null, discountDuration = 3}) => {
|
|
await page.goto('/ghost');
|
|
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)})`;
|
|
|
|
// Archive other offers to keep the list tidy
|
|
// We only need 1 offer to be active at a time
|
|
// Either the list of active offers loads, or the CTA when no offers exist
|
|
while (await Promise.race([
|
|
page.locator('.gh-offers-list .gh-list-header').filter({hasText: 'active'}).waitFor({state: 'visible', timeout: 1000}).then(() => true),
|
|
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')
|
|
.filter({hasText: 'Archive offer'})
|
|
.first()
|
|
.getByRole('button', {name: 'Archive'})
|
|
.click();
|
|
|
|
// TODO: Use a more resilient selector
|
|
const statusDropdown = await page.getByRole('button', {name: 'Archived offers'});
|
|
await statusDropdown.waitFor({
|
|
state: 'visible',
|
|
timeout: 1000
|
|
});
|
|
await statusDropdown.click();
|
|
await page.getByRole('option', {name: 'Active offers'}).click();
|
|
}
|
|
|
|
await page.getByRole('link', {name: 'New offer'}).click();
|
|
await page.locator('input#name').fill(offerName);
|
|
|
|
if (offerType === 'freeTrial') {
|
|
await page.getByRole('button', {name: 'Free trial Give free access for a limited time.'}).click();
|
|
await page.locator('input#trial-duration').fill(`${amount}`);
|
|
} else if (offerType === 'discount') {
|
|
await page.locator('input#amount').fill(`${amount}`);
|
|
if (discountType === 'multiple-months') {
|
|
await page.locator('[data-test-select="offer-duration"]').selectOption('repeating');
|
|
await page.locator('input#duration-months').fill(discountDuration.toString());
|
|
}
|
|
|
|
if (discountType === 'forever') {
|
|
await page.locator('[data-test-select="offer-duration"]').selectOption('forever');
|
|
}
|
|
}
|
|
|
|
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.getByRole('button', {name: 'Saved'}).waitFor({
|
|
state: 'visible',
|
|
timeout: 1000
|
|
});
|
|
await page.locator('.gh-nav a[href="#/offers/"]').click();
|
|
|
|
return offerName;
|
|
};
|
|
|
|
const fillInputIfExists = async (page, selector, value) => {
|
|
if (await page.isVisible(selector)) {
|
|
await page.locator(selector).fill(value);
|
|
}
|
|
};
|
|
|
|
const completeStripeSubscription = async (page) => {
|
|
await page.locator('#cardNumber').fill('4242 4242 4242 4242');
|
|
await page.locator('#cardExpiry').fill('04 / 24');
|
|
await page.locator('#cardCvc').fill('424');
|
|
await page.locator('#billingName').fill('Testy McTesterson');
|
|
await page.getByRole('combobox', {name: 'Country or region'}).selectOption('US');
|
|
await page.locator('#billingPostalCode').fill('42424');
|
|
|
|
await fillInputIfExists(page, '#billingAddressLine1', '123 Test St');
|
|
await fillInputIfExists(page, '#billingAddressLine2', 'Apt 1');
|
|
await fillInputIfExists(page, '#billingLocality', 'Testville');
|
|
|
|
// Wait for submit button complete
|
|
await page.waitForSelector('[data-testid="hosted-payment-submit-button"].SubmitButton--complete', {state: 'attached'});
|
|
|
|
await page.getByTestId('hosted-payment-submit-button').click();
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
};
|
|
|
|
/**
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {Object} options
|
|
* @param {String} options.email
|
|
* @param {String} [options.name]
|
|
* @param {String} [options.note]
|
|
* @param {String} [options.label]
|
|
* @param {String} [options.compedPlan]
|
|
*/
|
|
const createMember = async (page, {email, name, note, label = '', compedPlan}) => {
|
|
await page.goto('/ghost');
|
|
await page.locator('.gh-nav a[href="#/members/"]').click();
|
|
await page.waitForSelector('a[href="#/members/new/"] span');
|
|
await page.locator('a[href="#/members/new/"] span:has-text("New member")').click();
|
|
await page.waitForSelector('input[name="name"]');
|
|
|
|
await page.fill('input[name="email"]', email);
|
|
|
|
if (name) {
|
|
await page.fill('input[name="name"]', name);
|
|
}
|
|
|
|
if (note) {
|
|
await page.fill('textarea[name="note"]', note);
|
|
}
|
|
|
|
if (label) {
|
|
await page.locator('label:has-text("Labels") + div').click();
|
|
await page.keyboard.type(label);
|
|
await page.keyboard.press('Tab');
|
|
}
|
|
|
|
await page.locator('button span:has-text("Save")').click();
|
|
await page.waitForSelector('button span:has-text("Saved")');
|
|
|
|
if (compedPlan) {
|
|
await page.locator('[data-test-button="add-complimentary"]').click();
|
|
// TODO: switch [data-test-modal="add-complimentary"] and better plan selector once modal is refactored
|
|
await page.locator('.fullscreen-modal h4').getByText(compedPlan).click();
|
|
await page.locator('[data-test-button="save-comp-tier"]').click();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Start a post draft with a filled in title and body.
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {Object} options
|
|
* @param {String} [options.title]
|
|
* @param {String} [options.body]
|
|
*/
|
|
const createPostDraft = async (page, {title = 'Hello world', body = 'This is my post body.'} = {}) => {
|
|
await page.locator('.gh-nav a[href="#/posts/"]').click();
|
|
|
|
// Create a new post
|
|
await page.locator('[data-test-new-post-button]').click();
|
|
|
|
// Fill in the post title
|
|
await page.locator('[data-test-editor-title-input]').click();
|
|
await page.locator('[data-test-editor-title-input]').fill(title);
|
|
|
|
// wait for editor to be ready
|
|
await expect(page.locator('[data-lexical-editor="true"]')).toBeVisible();
|
|
|
|
// Continue to the body by pressing enter
|
|
await page.keyboard.press('Enter');
|
|
|
|
await page.waitForTimeout(100); // allow new->draft switch to occur fully, without this some initial typing events can be missed
|
|
await page.keyboard.type(body);
|
|
};
|
|
|
|
/**
|
|
* Go to Membership setting page
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
const goToMembershipPage = async (page) => {
|
|
return await test.step('Open Membership settings', async () => {
|
|
await page.goto('/ghost');
|
|
await page.locator('[data-test-nav="settings"]').click();
|
|
await page.locator('[data-test-nav="members-membership"]').click();
|
|
// Tiers request can take time, so waiting until there is no connections before interacting with UI
|
|
await page.waitForLoadState('networkidle');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get tier card from membership page
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {Object} options
|
|
* @param {String} [options.id]
|
|
*/
|
|
const getTierCardById = async (page, {id}) => {
|
|
return await test.step('Expand the premium tier list and find the tier', async () => {
|
|
await page.locator('[data-test-toggle-pub-info]').click();
|
|
await page.waitForSelector(`[data-test-tier-card="${id}"]`);
|
|
|
|
return page.locator(`[data-test-tier-card="${id}"]`);
|
|
});
|
|
};
|
|
|
|
const generateStripeIntegrationToken = async () => {
|
|
const inquirer = require('inquirer');
|
|
const {knex} = require('../../../core/server/data/db');
|
|
|
|
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');
|
|
};
|
|
|
|
module.exports = {
|
|
setupGhost,
|
|
setupStripe,
|
|
disconnectStripe,
|
|
enableLabs,
|
|
generateStripeIntegrationToken,
|
|
setupMailgun,
|
|
deleteAllMembers,
|
|
createTier,
|
|
archiveAllTiers,
|
|
createOffer,
|
|
createMember,
|
|
createPostDraft,
|
|
completeStripeSubscription,
|
|
impersonateMember,
|
|
goToMembershipPage,
|
|
getTierCardById
|
|
};
|