4815aa6e7f
refs: https://github.com/TryGhost/DevOps/issues/78 Re-introduce parallel browser tests These were adding in a previous PR, but the difference between local running tests and using CI introduced failures. Added additional fixes to ensure the Stripe API key is used in the CLI when running in CI.
347 lines
19 KiB
JavaScript
347 lines
19 KiB
JavaScript
const {expect} = require('@playwright/test');
|
||
const test = require('../fixtures/ghost-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('A member can be created', async ({page}) => {
|
||
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"]');
|
||
let name = 'Test Member';
|
||
let email = 'tester@testmember.com';
|
||
let note = 'This is a test member';
|
||
let label = 'Test Label';
|
||
await page.fill('input[name="name"]', name);
|
||
await page.fill('input[name="email"]', email);
|
||
await page.fill('textarea[name="note"]', note);
|
||
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")');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
|
||
// check number of members
|
||
await expect(page.locator('[data-test-list="members-list-item"]')).toHaveCount(1);
|
||
|
||
const member = page.locator('tbody > tr > a > div > div > h3').nth(0);
|
||
await expect(member).toHaveText(name);
|
||
const memberEmail = page.locator('tbody > tr > a > div > div > p').nth(0);
|
||
await expect(memberEmail).toHaveText(email);
|
||
});
|
||
|
||
test('A member cannot be created with invalid email', async ({page}) => {
|
||
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"]');
|
||
let name = 'Test Member';
|
||
let email = 'tester+invalid@testmember.com<6F>';
|
||
let note = 'This is a test member';
|
||
let label = 'Test Label';
|
||
await page.fill('input[name="name"]', name);
|
||
await page.fill('input[name="email"]', email);
|
||
await page.fill('textarea[name="note"]', note);
|
||
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("Retry")');
|
||
|
||
// check we are unable to save member with invalid email
|
||
await expect(page.locator('button span:has-text("Retry")')).toBeVisible();
|
||
await expect(page.locator('text=Invalid Email')).toBeVisible();
|
||
});
|
||
|
||
test('A member can be edited', async ({page}) => {
|
||
await page.goto('/ghost');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
await page.locator('tbody > tr > a').nth(0).click();
|
||
await page.waitForSelector('input[name="name"]');
|
||
let name = 'Test Member Edited';
|
||
let email = 'tester.edited@example.com';
|
||
let note = 'This is an edited test member';
|
||
await page.fill('input[name="name"]', name);
|
||
await page.fill('input[name="email"]', email);
|
||
await page.fill('textarea[name="note"]', note);
|
||
await page.locator('label:has-text("Labels") + div').click();
|
||
await page.keyboard.press('Backspace');
|
||
await page.locator('body').click(); // this is to close the dropdown & lose focus
|
||
await page.locator('input[name="subscribed"] + span').click();
|
||
await page.locator('button span:has-text("Save")').click();
|
||
await page.waitForSelector('button span:has-text("Saved")');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
|
||
// check number of members
|
||
await expect(page.locator('[data-test-list="members-list-item"]')).toHaveCount(1);
|
||
|
||
const member = page.locator('tbody > tr > a > div > div > h3').nth(0);
|
||
await expect(member).toHaveText(name);
|
||
const memberEmail = page.locator('tbody > tr > a > div > div > p').nth(0);
|
||
await expect(memberEmail).toHaveText(email);
|
||
});
|
||
|
||
test('A member can be impersonated', async ({page}) => {
|
||
await page.goto('/ghost');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
await page.locator('tbody > tr > a').nth(0).click();
|
||
await page.waitForSelector('[data-test-button="member-actions"]');
|
||
await page.locator('[data-test-button="member-actions"]').click();
|
||
await page.getByRole('button', {name: 'Impersonate'}).click();
|
||
await page.getByRole('button', {name: 'Copy link'}).click();
|
||
await page.waitForSelector('button span:has-text("Link copied")');
|
||
// get value from input because we don't have access to the clipboard during headless testing
|
||
const elem = await page.$('input[name="member-signin-url"]');
|
||
const link = await elem.inputValue();
|
||
await page.goto(link);
|
||
await page.frameLocator('#ghost-portal-root iframe[title="portal-trigger"]').locator('div').nth(1).click();
|
||
const title = await page.frameLocator('#ghost-portal-root div iframe[title="portal-popup"]').locator('h2').innerText();
|
||
await expect(title).toEqual('Your account'); // this is the title of the popup when member is logged in
|
||
});
|
||
|
||
test('A member can be deleted', async ({page}) => {
|
||
await page.goto('/ghost');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
await page.locator('tbody > tr > a').nth(0).click();
|
||
await page.waitForSelector('[data-test-button="member-actions"]');
|
||
await page.locator('[data-test-button="member-actions"]').click();
|
||
await page.getByRole('button', {name: 'Delete member'}).click();
|
||
await page.locator('button[data-test-button="confirm"] span:has-text("Delete member")').click();
|
||
// should have no members now, so we should see the empty state
|
||
expect(await page.locator('div h4:has-text("Start building your audience")')).not.toBeNull();
|
||
});
|
||
|
||
const membersFixture = [
|
||
{
|
||
name: 'Test Member 1',
|
||
email: 'test@member1.com',
|
||
note: 'This is a test member',
|
||
label: 'old'
|
||
},
|
||
{
|
||
name: 'Test Member 2',
|
||
email: 'test@member2.com',
|
||
note: 'This is a test member',
|
||
label: 'old'
|
||
},
|
||
{
|
||
name: 'Test Member 3',
|
||
email: 'test@member3.com',
|
||
note: 'This is a test member',
|
||
label: 'old'
|
||
},
|
||
{
|
||
name: 'Sashi',
|
||
email: 'test@member4.com',
|
||
note: 'This is a test member',
|
||
label: 'dog'
|
||
},
|
||
{
|
||
name: 'Mia',
|
||
email: 'test@member5.com',
|
||
note: 'This is a test member',
|
||
label: 'dog'
|
||
},
|
||
{
|
||
name: 'Minki',
|
||
email: 'test@member6.com',
|
||
note: 'This is a test member',
|
||
label: 'dog'
|
||
}
|
||
];
|
||
|
||
test('All members can be exported', async ({page}) => {
|
||
// adds 6 members, 3 with the same label
|
||
for (let member of membersFixture) {
|
||
await createMember(page, member);
|
||
}
|
||
await page.goto('/ghost');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
await page.waitForSelector('button[data-test-button="members-actions"]');
|
||
await page.locator('button[data-test-button="members-actions"]').click();
|
||
await page.waitForSelector('button[data-test-button="export-members"]');
|
||
const [download] = await Promise.all([
|
||
page.waitForEvent('download'),
|
||
page.locator('button[data-test-button="export-members"]').click()
|
||
]);
|
||
const filename = await download.suggestedFilename();
|
||
expect(filename).toContain('.csv');
|
||
const csv = await download.path();
|
||
let csvContents = await fs.readFileSync(csv).toString();
|
||
expect(csvContents).toMatch(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/);
|
||
membersFixture.forEach((member) => {
|
||
expect(csvContents).toMatch(member.name);
|
||
expect(csvContents).toMatch(member.email);
|
||
expect(csvContents).toMatch(member.note);
|
||
expect(csvContents).toMatch(member.label);
|
||
});
|
||
// expect(csvContents).toMatch('Test Label'); we deleted the label in a previous test so it's not in this the export
|
||
const countIds = csvContents.match(/[a-z0-9]{24}/gm).length;
|
||
expect(countIds).toEqual(6);
|
||
const countTimestamps = csvContents.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm).length;
|
||
expect(countTimestamps).toEqual(6);
|
||
const countRows = csvContents.match(/(?:"(?:[^"]|"")*"|[^,\n]*)(?:,(?:"(?:[^"]|"")*"|[^,\n]*))*\n/g).length;
|
||
expect(countRows).toEqual(6);
|
||
const csvRegex = /^[^",]+((?<=[,\n])|(?=[,\n]))|[^",]+/gm;
|
||
expect(csvContents).toMatch(csvRegex);
|
||
});
|
||
|
||
test('A filtered list of members can be exported', async ({page}) => {
|
||
await page.goto('/ghost');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
await page.waitForSelector('button[data-test-button="members-actions"]');
|
||
await page.locator('button[data-test-button="members-actions"]').click();
|
||
await page.waitForSelector('div[data-test-button="members-filter-actions"]');
|
||
await page.locator('div[data-test-button="members-filter-actions"]').click();
|
||
await page.locator('select[data-test-select="members-filter"]').click();
|
||
await page.locator('select[data-test-select="members-filter"]').selectOption('label');
|
||
await page.locator('div[data-test-members-filter="0"] > div > div').click();
|
||
await page.locator('span[data-test-label-filter="dog"]').click();
|
||
await page.keyboard.press('Tab');
|
||
await page.locator('button[data-test-button="members-apply-filter"]').click();
|
||
await page.locator('button[data-test-button="members-actions"]').click();
|
||
const exportButton = await page.locator('button[data-test-button="export-members"] > span').innerText();
|
||
expect(exportButton).toEqual('Export selected members (3)');
|
||
await page.waitForSelector('button[data-test-button="export-members"]');
|
||
const [download] = await Promise.all([
|
||
page.waitForEvent('download'),
|
||
page.locator('button[data-test-button="export-members"]').click()
|
||
]);
|
||
const filename = await download.suggestedFilename();
|
||
expect(filename).toContain('.csv');
|
||
const csv = await download.path();
|
||
let csvContents = await fs.readFileSync(csv).toString();
|
||
expect(csvContents).toMatch(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/);
|
||
// filter memberFixtures to only include members with the label 'dog'
|
||
const filteredMembersFixture = membersFixture.filter((member) => {
|
||
return member.label === 'dog';
|
||
});
|
||
filteredMembersFixture.forEach((member) => {
|
||
expect(csvContents).toMatch(member.name);
|
||
expect(csvContents).toMatch(member.email);
|
||
expect(csvContents).toMatch(member.note);
|
||
expect(csvContents).toMatch('dog');
|
||
});
|
||
const countIds = csvContents.match(/[a-z0-9]{24}/gm).length;
|
||
expect(countIds).toEqual(3);
|
||
const countTimestamps = csvContents.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm).length;
|
||
expect(countTimestamps).toEqual(3);
|
||
const countRows = csvContents.match(/(?:"(?:[^"]|"")*"|[^,\n]*)(?:,(?:"(?:[^"]|"")*"|[^,\n]*))*\n/g).length;
|
||
expect(countRows).toEqual(3);
|
||
const csvRegex = /^[^",]+((?<=[,\n])|(?=[,\n]))|[^",]+/gm;
|
||
expect(csvContents).toMatch(csvRegex);
|
||
});
|
||
|
||
// saves time by going directly to the members page with the label filter applied
|
||
let labelFilterUrl;
|
||
|
||
test('A filtered list of members can have a label added to them', async ({page}) => {
|
||
await page.goto('/ghost');
|
||
await page.locator('.gh-nav a[href="#/members/"]').click();
|
||
await page.waitForSelector('button[data-test-button="members-actions"]');
|
||
await page.locator('button[data-test-button="members-actions"]').click();
|
||
await page.waitForSelector('div[data-test-button="members-filter-actions"]');
|
||
await page.locator('div[data-test-button="members-filter-actions"]').click();
|
||
await page.locator('select[data-test-select="members-filter"]').click();
|
||
await page.locator('select[data-test-select="members-filter"]').selectOption('label');
|
||
await page.locator('div[data-test-members-filter="0"] > div > div').click();
|
||
await page.locator('span[data-test-label-filter="old"]').click(); // this label should only be on 3 members
|
||
await page.keyboard.press('Tab');
|
||
await page.locator('button[data-test-button="members-apply-filter"]').click();
|
||
await page.waitForSelector('button[data-test-button="members-actions"]');
|
||
await page.locator('button[data-test-button="members-actions"]').click();
|
||
await page.locator('button[data-test-button="add-label-selected"]').click();
|
||
await page.locator('div[data-test-state="add-label-unconfirmed"] > span > select').selectOption({label: 'Test Label'});
|
||
const members = await page.locator('div[data-test-state="add-label-unconfirmed"] > p > span[data-test-text="member-count"]').innerText();
|
||
expect(members).toEqual('3 members');
|
||
await page.locator('button[data-test-button="confirm"]').click();
|
||
await page.waitForSelector('div[data-test-state="add-complete"]');
|
||
const success = await page.locator('div[data-test-state="add-complete"] > div > p').innerText();
|
||
expect(success).toEqual('Label added to 3 members successfully');
|
||
labelFilterUrl = await page.url();
|
||
});
|
||
|
||
test('A filtered list of members can have a label removed from them', async ({page}) => {
|
||
await page.goto(labelFilterUrl);
|
||
await page.waitForSelector('button[data-test-button="members-actions"]');
|
||
await page.locator('button[data-test-button="members-actions"]').click();
|
||
await page.waitForSelector('button[data-test-button="remove-label-selected"]');
|
||
await page.locator('button[data-test-button="remove-label-selected"]').click();
|
||
await page.locator('div[data-test-state="remove-label-unconfirmed"] > span > select').selectOption({label: 'old'});
|
||
await page.locator('button[data-test-button="confirm"]').click();
|
||
const success = await page.locator('div[data-test-state="remove-complete"] > div > p').innerText();
|
||
expect(success).toEqual('Label removed from 3 members successfully');
|
||
});
|
||
|
||
test('A member can be granted a comp in admin', async ({page}) => {
|
||
await page.goto('/ghost');
|
||
await deleteAllMembers(page);
|
||
|
||
// create a new member with a comped plan
|
||
await createMember(page, {
|
||
name: 'Test Member 1',
|
||
email: 'test@member1.com',
|
||
note: 'This is a test member',
|
||
label: 'Test Label',
|
||
compedPlan: 'The Local Test'
|
||
});
|
||
|
||
// open the impersonate modal
|
||
await page.locator('[data-test-button="member-actions"]').click();
|
||
await page.getByRole('button', {name: 'Impersonate'}).click();
|
||
await page.getByRole('button', {name: 'Copy link'}).click();
|
||
await page.waitForSelector('button span:has-text("Link copied")');
|
||
|
||
// get value from input because we don't have access to the clipboard during headless testing
|
||
const elem = await page.$('input[name="member-signin-url"]');
|
||
const link = await elem.inputValue();
|
||
|
||
// go to the frontend with the impersonate link
|
||
await page.goto(link);
|
||
|
||
// click the paid-only post
|
||
await page.locator('.gh-card-link[href="/sell/"]').click();
|
||
|
||
// check for content CTA and expect it to be zero
|
||
await expect(page.locator('.gh-post-upgrade-cta')).toHaveCount(0);
|
||
});
|
||
|
||
test('An existing member cannot be saved with invalid email address', async ({page}) => {
|
||
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"]');
|
||
let name = 'Test Member';
|
||
let email = 'tester+invalid@example.com';
|
||
let note = 'This is a test member';
|
||
let label = 'Test Label';
|
||
await page.fill('input[name="name"]', name);
|
||
await page.fill('input[name="email"]', email);
|
||
await page.fill('textarea[name="note"]', note);
|
||
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")');
|
||
|
||
// Update email to invalid and try saving
|
||
let updatedEmail = 'tester+invalid@example.com<6F>';
|
||
await page.fill('input[name="email"]', updatedEmail);
|
||
await page.waitForSelector('button span:has-text("Save")');
|
||
await page.locator('button span:has-text("Save")').click();
|
||
await page.waitForSelector('button span:has-text("Retry")');
|
||
|
||
// check we are unable to save member with invalid email
|
||
await expect(page.locator('button span:has-text("Retry")')).toBeVisible();
|
||
await expect(page.locator('text=Invalid Email')).toBeVisible();
|
||
});
|
||
});
|
||
});
|