ccbcba0969
refs: https://github.com/TryGhost/DevOps/issues/78 This speeds up the tests by another 30 seconds on my local machine, and hopefully takes some time off in CI too
347 lines
20 KiB
JavaScript
347 lines
20 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 ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.waitForSelector('a[href="#/members/new/"] span');
|
||
await sharedPage.locator('a[href="#/members/new/"] span:has-text("New member")').click();
|
||
await sharedPage.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 sharedPage.fill('input[name="name"]', name);
|
||
await sharedPage.fill('input[name="email"]', email);
|
||
await sharedPage.fill('textarea[name="note"]', note);
|
||
await sharedPage.locator('label:has-text("Labels") + div').click();
|
||
await sharedPage.keyboard.type(label);
|
||
await sharedPage.keyboard.press('Tab');
|
||
await sharedPage.locator('button span:has-text("Save")').click();
|
||
await sharedPage.waitForSelector('button span:has-text("Saved")');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
|
||
// check number of members
|
||
await expect(sharedPage.locator('[data-test-list="members-list-item"]')).toHaveCount(1);
|
||
|
||
const member = sharedPage.locator('tbody > tr > a > div > div > h3').nth(0);
|
||
await expect(member).toHaveText(name);
|
||
const memberEmail = sharedPage.locator('tbody > tr > a > div > div > p').nth(0);
|
||
await expect(memberEmail).toHaveText(email);
|
||
});
|
||
|
||
test('A member cannot be created with invalid email', async ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.waitForSelector('a[href="#/members/new/"] span');
|
||
await sharedPage.locator('a[href="#/members/new/"] span:has-text("New member")').click();
|
||
await sharedPage.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 sharedPage.fill('input[name="name"]', name);
|
||
await sharedPage.fill('input[name="email"]', email);
|
||
await sharedPage.fill('textarea[name="note"]', note);
|
||
await sharedPage.locator('label:has-text("Labels") + div').click();
|
||
await sharedPage.keyboard.type(label);
|
||
await sharedPage.keyboard.press('Tab');
|
||
await sharedPage.locator('button span:has-text("Save")').click();
|
||
await sharedPage.waitForSelector('button span:has-text("Retry")');
|
||
|
||
// check we are unable to save member with invalid email
|
||
await expect(sharedPage.locator('button span:has-text("Retry")')).toBeVisible();
|
||
await expect(sharedPage.locator('text=Invalid Email')).toBeVisible();
|
||
});
|
||
|
||
test('A member can be edited', async ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.locator('tbody > tr > a').nth(0).click();
|
||
await sharedPage.waitForSelector('input[name="name"]');
|
||
let name = 'Test Member Edited';
|
||
let email = 'tester.edited@example.com';
|
||
let note = 'This is an edited test member';
|
||
await sharedPage.fill('input[name="name"]', name);
|
||
await sharedPage.fill('input[name="email"]', email);
|
||
await sharedPage.fill('textarea[name="note"]', note);
|
||
await sharedPage.locator('label:has-text("Labels") + div').click();
|
||
await sharedPage.keyboard.press('Backspace');
|
||
await sharedPage.locator('body').click(); // this is to close the dropdown & lose focus
|
||
await sharedPage.locator('input[name="subscribed"] + span').click();
|
||
await sharedPage.locator('button span:has-text("Save")').click();
|
||
await sharedPage.waitForSelector('button span:has-text("Saved")');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
|
||
// check number of members
|
||
await expect(sharedPage.locator('[data-test-list="members-list-item"]')).toHaveCount(1);
|
||
|
||
const member = sharedPage.locator('tbody > tr > a > div > div > h3').nth(0);
|
||
await expect(member).toHaveText(name);
|
||
const memberEmail = sharedPage.locator('tbody > tr > a > div > div > p').nth(0);
|
||
await expect(memberEmail).toHaveText(email);
|
||
});
|
||
|
||
test('A member can be impersonated', async ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.locator('tbody > tr > a').nth(0).click();
|
||
await sharedPage.waitForSelector('[data-test-button="member-actions"]');
|
||
await sharedPage.locator('[data-test-button="member-actions"]').click();
|
||
await sharedPage.getByRole('button', {name: 'Impersonate'}).click();
|
||
await sharedPage.getByRole('button', {name: 'Copy link'}).click();
|
||
await sharedPage.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 sharedPage.$('input[name="member-signin-url"]');
|
||
const link = await elem.inputValue();
|
||
await sharedPage.goto(link);
|
||
await sharedPage.frameLocator('#ghost-portal-root iframe[title="portal-trigger"]').locator('div').nth(1).click();
|
||
const title = await sharedPage.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 ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.locator('tbody > tr > a').nth(0).click();
|
||
await sharedPage.waitForSelector('[data-test-button="member-actions"]');
|
||
await sharedPage.locator('[data-test-button="member-actions"]').click();
|
||
await sharedPage.getByRole('button', {name: 'Delete member'}).click();
|
||
await sharedPage.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 sharedPage.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 ({sharedPage}) => {
|
||
// adds 6 members, 3 with the same label
|
||
for (let member of membersFixture) {
|
||
await createMember(sharedPage, member);
|
||
}
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.waitForSelector('button[data-test-button="members-actions"]');
|
||
await sharedPage.locator('button[data-test-button="members-actions"]').click();
|
||
await sharedPage.waitForSelector('button[data-test-button="export-members"]');
|
||
const [download] = await Promise.all([
|
||
sharedPage.waitForEvent('download'),
|
||
sharedPage.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 ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.waitForSelector('button[data-test-button="members-actions"]');
|
||
await sharedPage.locator('button[data-test-button="members-actions"]').click();
|
||
await sharedPage.waitForSelector('div[data-test-button="members-filter-actions"]');
|
||
await sharedPage.locator('div[data-test-button="members-filter-actions"]').click();
|
||
await sharedPage.locator('select[data-test-select="members-filter"]').click();
|
||
await sharedPage.locator('select[data-test-select="members-filter"]').selectOption('label');
|
||
await sharedPage.locator('div[data-test-members-filter="0"] > div > div').click();
|
||
await sharedPage.locator('span[data-test-label-filter="dog"]').click();
|
||
await sharedPage.keyboard.press('Tab');
|
||
await sharedPage.locator('button[data-test-button="members-apply-filter"]').click();
|
||
await sharedPage.locator('button[data-test-button="members-actions"]').click();
|
||
const exportButton = await sharedPage.locator('button[data-test-button="export-members"] > span').innerText();
|
||
expect(exportButton).toEqual('Export selected members (3)');
|
||
await sharedPage.waitForSelector('button[data-test-button="export-members"]');
|
||
const [download] = await Promise.all([
|
||
sharedPage.waitForEvent('download'),
|
||
sharedPage.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 ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.waitForSelector('button[data-test-button="members-actions"]');
|
||
await sharedPage.locator('button[data-test-button="members-actions"]').click();
|
||
await sharedPage.waitForSelector('div[data-test-button="members-filter-actions"]');
|
||
await sharedPage.locator('div[data-test-button="members-filter-actions"]').click();
|
||
await sharedPage.locator('select[data-test-select="members-filter"]').click();
|
||
await sharedPage.locator('select[data-test-select="members-filter"]').selectOption('label');
|
||
await sharedPage.locator('div[data-test-members-filter="0"] > div > div').click();
|
||
await sharedPage.locator('span[data-test-label-filter="old"]').click(); // this label should only be on 3 members
|
||
await sharedPage.keyboard.press('Tab');
|
||
await sharedPage.locator('button[data-test-button="members-apply-filter"]').click();
|
||
await sharedPage.waitForSelector('button[data-test-button="members-actions"]');
|
||
await sharedPage.locator('button[data-test-button="members-actions"]').click();
|
||
await sharedPage.locator('button[data-test-button="add-label-selected"]').click();
|
||
await sharedPage.locator('div[data-test-state="add-label-unconfirmed"] > span > select').selectOption({label: 'Test Label'});
|
||
const members = await sharedPage.locator('div[data-test-state="add-label-unconfirmed"] > p > span[data-test-text="member-count"]').innerText();
|
||
expect(members).toEqual('3 members');
|
||
await sharedPage.locator('button[data-test-button="confirm"]').click();
|
||
await sharedPage.waitForSelector('div[data-test-state="add-complete"]');
|
||
const success = await sharedPage.locator('div[data-test-state="add-complete"] > div > p').innerText();
|
||
expect(success).toEqual('Label added to 3 members successfully');
|
||
labelFilterUrl = await sharedPage.url();
|
||
});
|
||
|
||
test('A filtered list of members can have a label removed from them', async ({sharedPage}) => {
|
||
await sharedPage.goto(labelFilterUrl);
|
||
await sharedPage.waitForSelector('button[data-test-button="members-actions"]');
|
||
await sharedPage.locator('button[data-test-button="members-actions"]').click();
|
||
await sharedPage.waitForSelector('button[data-test-button="remove-label-selected"]');
|
||
await sharedPage.locator('button[data-test-button="remove-label-selected"]').click();
|
||
await sharedPage.locator('div[data-test-state="remove-label-unconfirmed"] > span > select').selectOption({label: 'old'});
|
||
await sharedPage.locator('button[data-test-button="confirm"]').click();
|
||
const success = await sharedPage.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 ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await deleteAllMembers(sharedPage);
|
||
|
||
// create a new member with a comped plan
|
||
await createMember(sharedPage, {
|
||
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 sharedPage.locator('[data-test-button="member-actions"]').click();
|
||
await sharedPage.getByRole('button', {name: 'Impersonate'}).click();
|
||
await sharedPage.getByRole('button', {name: 'Copy link'}).click();
|
||
await sharedPage.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 sharedPage.$('input[name="member-signin-url"]');
|
||
const link = await elem.inputValue();
|
||
|
||
// go to the frontend with the impersonate link
|
||
await sharedPage.goto(link);
|
||
|
||
// click the paid-only post
|
||
await sharedPage.locator('.gh-card-link[href="/sell/"]').click();
|
||
|
||
// check for content CTA and expect it to be zero
|
||
await expect(sharedPage.locator('.gh-post-upgrade-cta')).toHaveCount(0);
|
||
});
|
||
|
||
test('An existing member cannot be saved with invalid email address', async ({sharedPage}) => {
|
||
await sharedPage.goto('/ghost');
|
||
await sharedPage.locator('.gh-nav a[href="#/members/"]').click();
|
||
await sharedPage.waitForSelector('a[href="#/members/new/"] span');
|
||
await sharedPage.locator('a[href="#/members/new/"] span:has-text("New member")').click();
|
||
await sharedPage.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 sharedPage.fill('input[name="name"]', name);
|
||
await sharedPage.fill('input[name="email"]', email);
|
||
await sharedPage.fill('textarea[name="note"]', note);
|
||
await sharedPage.locator('label:has-text("Labels") + div').click();
|
||
await sharedPage.keyboard.type(label);
|
||
await sharedPage.keyboard.press('Tab');
|
||
await sharedPage.locator('button span:has-text("Save")').click();
|
||
await sharedPage.waitForSelector('button span:has-text("Saved")');
|
||
|
||
// Update email to invalid and try saving
|
||
let updatedEmail = 'tester+invalid@example.com<6F>';
|
||
await sharedPage.fill('input[name="email"]', updatedEmail);
|
||
await sharedPage.waitForSelector('button span:has-text("Save")');
|
||
await sharedPage.locator('button span:has-text("Save")').click();
|
||
await sharedPage.waitForSelector('button span:has-text("Retry")');
|
||
|
||
// check we are unable to save member with invalid email
|
||
await expect(sharedPage.locator('button span:has-text("Retry")')).toBeVisible();
|
||
await expect(sharedPage.locator('text=Invalid Email')).toBeVisible();
|
||
});
|
||
});
|
||
});
|