diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs index b5ce3dcd45c..fb8d152975a 100644 --- a/.lintstagedrc.cjs +++ b/.lintstagedrc.cjs @@ -30,7 +30,7 @@ function buildScopedEslintCommand(workspace, files) { .map(shellQuote) .join(' '); - return `cd ${shellQuote(workspace)} && eslint --cache ${relativeFiles}`; + return `yarn --cwd ${shellQuote(workspace)} eslint --cache ${relativeFiles}`; } function buildRootEslintCommand(files) { diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts index d62b1ad406a..4fe6d8e9725 100644 --- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts +++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts @@ -7,6 +7,8 @@ class SettingsMenu extends BasePage { readonly postUrlInput: Locator; readonly publishDateInput: Locator; readonly publishTimeInput: Locator; + readonly deletePostButton: Locator; + readonly deletePostConfirmButton: Locator; constructor(page: Page) { super(page); @@ -14,6 +16,13 @@ class SettingsMenu extends BasePage { this.postUrlInput = page.getByRole('textbox', {name: 'Post URL'}); this.publishDateInput = page.getByLabel('Date Picker'); this.publishTimeInput = page.getByLabel('Time Picker'); + this.deletePostButton = page.locator('[data-test-button="delete-post"]'); + this.deletePostConfirmButton = page.locator('[data-test-button="delete-post-confirm"]'); + } + + async deletePost(): Promise { + await this.deletePostButton.click(); + await this.deletePostConfirmButton.click(); } } @@ -24,6 +33,8 @@ class PublishFlow extends BasePage { readonly emailRecipientsSetting: Locator; readonly continueButton: Locator; readonly confirmButton: Locator; + readonly closeButton: Locator; + readonly completeBookmark: Locator; constructor(page: Page) { super(page); @@ -34,12 +45,18 @@ class PublishFlow extends BasePage { this.emailRecipientsSetting = page.locator('[data-test-setting="email-recipients"]'); this.continueButton = page.locator('[data-test-modal="publish-flow"] [data-test-button="continue"]'); this.confirmButton = page.locator('[data-test-modal="publish-flow"] [data-test-button="confirm-publish"]'); + this.closeButton = page.locator('[data-test-button="close-publish-flow"]'); + this.completeBookmark = page.locator('[data-test-complete-bookmark]'); } async open(): Promise { await this.publishButton.click(); } + async close(): Promise { + await this.closeButton.click(); + } + async selectPublishType(type: 'publish' | 'publish+send' | 'send'): Promise { await this.publishTypeButton.click(); await this.page.locator(`[data-test-publish-type="${type}"] + label`).click(); @@ -50,6 +67,14 @@ class PublishFlow extends BasePage { await this.confirmButton.click({force: true}); await this.confirmButton.waitFor({state: 'hidden'}); } + + async openPublishedPost(): Promise { + const [frontendPage] = await Promise.all([ + this.page.waitForEvent('popup'), + this.completeBookmark.click() + ]); + return frontendPage; + } } export class PostEditorPage extends AdminPage { @@ -59,6 +84,9 @@ export class PostEditorPage extends AdminPage { readonly previewModal: PostPreviewModal; readonly settingsToggleButton: Locator; readonly publishFlow: PublishFlow; + readonly screenTitle: Locator; + readonly lexicalEditor: Locator; + readonly secondaryEditor: Locator; readonly settingsMenu: SettingsMenu; @@ -72,6 +100,9 @@ export class PostEditorPage extends AdminPage { this.previewModal = new PostPreviewModal(page); this.settingsToggleButton = page.getByTestId('settings-menu-toggle'); this.publishFlow = new PublishFlow(page); + this.screenTitle = page.locator('[data-test-screen-title]'); + this.lexicalEditor = page.locator('[data-kg="editor"]').first(); + this.secondaryEditor = page.locator('[data-secondary-instance="true"]'); this.settingsMenu = new SettingsMenu(page); } @@ -81,6 +112,35 @@ export class PostEditorPage extends AdminPage { await this.titleInput.waitFor({state: 'visible'}); } + async createDraft({title = 'Hello world', body = 'This is my post body.'} = {}): Promise { + const editor = this.page.locator('[data-lexical-editor="true"]').first(); + + await this.titleInput.click(); + await this.titleInput.fill(title); + await editor.waitFor({state: 'visible'}); + await this.page.keyboard.press('Enter'); + + await this.page.waitForFunction(() => { + const element = document.querySelector('[data-lexical-editor="true"]'); + if (!element) { + return false; + } + + const activeElement = document.activeElement; + + return Boolean( + activeElement && + (activeElement === element || element.contains(activeElement)) + ); + }); + + await this.page.keyboard.type(body); + } + + async waitForSaved(): Promise { + await this.postStatus.filter({hasText: /Saved/}).waitFor({timeout: 30000}); + } + get previewModalDesktopFrame(): DesktopPreviewFrame { return this.previewModal.desktopPreview; } diff --git a/e2e/helpers/pages/public/post-page.ts b/e2e/helpers/pages/public/post-page.ts index 50023792364..1003a26020a 100644 --- a/e2e/helpers/pages/public/post-page.ts +++ b/e2e/helpers/pages/public/post-page.ts @@ -5,6 +5,8 @@ import {PublicPage} from './public-page'; export class PostPage extends PublicPage { readonly postTitle: Locator; readonly postContent: Locator; + readonly articleTitle: Locator; + readonly articleBody: Locator; readonly commentsSection: CommentsSection; readonly transistorCard: Locator; readonly transistorIframe: Locator; @@ -14,6 +16,8 @@ export class PostPage extends PublicPage { super(page); this.postTitle = page.locator('article h1').first(); this.postContent = page.locator('article.gh-article'); + this.articleTitle = page.locator('.gh-article-title'); + this.articleBody = page.locator('.gh-content.gh-canvas > p'); this.commentsSection = new CommentsSection(page); this.transistorCard = page.locator('.kg-transistor-card'); this.transistorIframe = page.locator('iframe[data-kg-transistor-embed]'); diff --git a/e2e/tests/admin/posts/lexical-editor.test.ts b/e2e/tests/admin/posts/lexical-editor.test.ts new file mode 100644 index 00000000000..88fa51765ae --- /dev/null +++ b/e2e/tests/admin/posts/lexical-editor.test.ts @@ -0,0 +1,27 @@ +import {PostEditorPage, PostsPage} from '@/admin-pages'; +import {expect, test} from '@/helpers/playwright'; + +test.describe('Ghost Admin - Lexical Editor', () => { + test('renders primary lexical editor', async ({page}) => { + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft({title: 'Lexical editor test', body: 'This is my post body.'}); + + await expect(editor.lexicalEditor).toBeVisible(); + }); + + test('renders secondary hidden lexical editor', async ({page}) => { + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft({title: 'Secondary lexical editor test', body: 'This is my post body.'}); + + await expect(editor.secondaryEditor).toHaveCount(1); + await expect(editor.secondaryEditor).toBeHidden(); + }); +}); diff --git a/e2e/tests/admin/posts/publishing.test.ts b/e2e/tests/admin/posts/publishing.test.ts new file mode 100644 index 00000000000..ed82c843dfd --- /dev/null +++ b/e2e/tests/admin/posts/publishing.test.ts @@ -0,0 +1,122 @@ +import {APIRequestContext} from '@playwright/test'; +import {PostEditorPage, PostsPage} from '@/admin-pages'; +import {PostPage} from '@/helpers/pages'; +import {createMemberFactory, generateSlug} from '@/data-factory'; +import {expect, test} from '@/helpers/playwright'; + +async function getNewsletters(request: APIRequestContext): Promise { + const response = await request.get('/ghost/api/admin/newsletters/?status=active&limit=all'); + const data = await response.json(); + return data.newsletters.map((n: {id: string}) => n.id); +} + +test.describe('Ghost Admin - Publishing', () => { + test.use({mailgunEnabled: true}); + + test('publish only - post is visible on frontend', async ({page}) => { + const postData = {title: 'Publish post only', body: 'This is my post body.'}; + + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft(postData); + + await editor.publishFlow.open(); + await editor.publishFlow.confirm(); + await editor.publishFlow.close(); + + const postPage = new PostPage(page); + await postPage.goto(`/${generateSlug(postData.title)}/`); + await expect(postPage.articleTitle).toHaveText(postData.title); + await expect(postPage.articleBody).toHaveText(postData.body); + }); + + test('publish and email - post is visible on frontend', async ({page}) => { + const postData = {title: 'Publish and email post', body: 'This is my post body.'}; + + const memberFactory = createMemberFactory(page.request); + const newsletters = await getNewsletters(page.request); + await memberFactory.create({ + email: 'publish-email-test@example.com', + name: 'Publishing member', + newsletters + }); + + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft(postData); + + await editor.publishFlow.open(); + await editor.publishFlow.selectPublishType('publish+send'); + await editor.publishFlow.confirm(); + + const postPage = new PostPage(page); + await postPage.goto(`/${generateSlug(postData.title)}/`); + await expect(postPage.articleTitle).toHaveText(postData.title); + await expect(postPage.articleBody).toHaveText(postData.body); + }); + + test('email only - post is not visible on frontend', async ({page}) => { + const postData = {title: 'Email only post', body: 'This is my post body.'}; + + const memberFactory = createMemberFactory(page.request); + const newsletters = await getNewsletters(page.request); + await memberFactory.create({ + email: 'email-only-test@example.com', + name: 'Publishing member', + newsletters + }); + + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft(postData); + + await editor.publishFlow.open(); + await editor.publishFlow.selectPublishType('send'); + await editor.publishFlow.confirm(); + + const slug = generateSlug(postData.title); + const response = await page.goto(`/${slug}/`); + expect(response?.status()).toBe(404); + }); +}); + +test.describe('Ghost Admin - Deleting Posts', () => { + test('delete a saved post - redirects to posts list', async ({page}) => { + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.titleInput.fill('Delete a post test'); + await editor.titleInput.press('Enter'); + await expect(editor.postStatus).toContainText('Draft - Saved'); + + await editor.settingsToggleButton.click(); + await editor.settingsMenu.deletePost(); + + await expect(editor.screenTitle).toContainText('Posts'); + }); + + test('delete a post with unsaved changes - redirects to posts list', async ({page}) => { + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft({title: 'Delete a post test', body: 'This is the content'}); + + await editor.settingsToggleButton.click(); + await editor.settingsMenu.deletePost(); + + await expect(editor.screenTitle).toContainText('Posts'); + }); +}); diff --git a/ghost/core/test/e2e-browser/admin/publishing.spec.js b/ghost/core/test/e2e-browser/admin/publishing.spec.js index 3d55fe141c3..b498ef96405 100644 --- a/ghost/core/test/e2e-browser/admin/publishing.spec.js +++ b/ghost/core/test/e2e-browser/admin/publishing.spec.js @@ -201,54 +201,7 @@ const openPublishedPostBookmark = async (page) => { }; test.describe('Publishing', () => { - test.describe('Publish post', () => { - // Post should be available on web and sent as a newsletter - test('Publish and Email', async ({sharedPage}) => { - const postData = { - title: 'Publish and email post', - body: 'This is my post body.' - }; - - // Create a member to send and email to - await createMember(sharedPage, {email: 'test+recipient1@example.com', name: 'Publishing member'}); - - await sharedPage.goto('/ghost'); - await createPostDraft(sharedPage, postData); - await publishPost(sharedPage, {type: 'publish+send'}); - await checkPostPublished(sharedPage, postData); - }); - - // Post should only be available on web - test('Publish only', async ({sharedPage}) => { - const postData = { - title: 'Publish post only', - body: 'This is my post body.' - }; - - await sharedPage.goto('/ghost'); - await createPostDraft(sharedPage, postData); - await publishPost(sharedPage); - await closePublishFlow(sharedPage); - - await checkPostStatus(sharedPage, 'Published'); - await checkPostPublished(sharedPage, postData); - }); - - // Post should be available on web and sent as a newsletter - test('Email only', async ({sharedPage}) => { - const postData = { - title: 'Email only post', - body: 'This is my post body.' - }; - - await createMember(sharedPage, {email: 'test+recipient2@example.com', name: 'Publishing member'}); - - await sharedPage.goto('/ghost'); - await createPostDraft(sharedPage, postData); - await publishPost(sharedPage, {type: 'send'}); - await checkPostNotPublished(sharedPage, postData); - }); - }); + // Publish post tests moved to e2e/tests/admin/posts/publishing.test.ts test.describe('Publish page', () => { // A page can be published and become visible on web @@ -295,28 +248,7 @@ test.describe('Publishing', () => { }); }); - test.describe('Lexical Rendering', () => { - test.describe.configure({retries: 1}); - - test('Renders Lexical editor', async ({sharedPage: adminPage}) => { - await adminPage.goto('/ghost'); - - await createPostDraft(adminPage, {title: 'Lexical editor test', body: 'This is my post body.'}); - - // Check if the lexical editor is present - expect(await adminPage.locator('[data-kg="editor"]').first()).toBeVisible(); - }); - - test('Renders secondary hidden lexical editor', async ({sharedPage: adminPage}) => { - await adminPage.goto('/ghost'); - await createPostDraft(adminPage, {title: 'Secondary lexical editor test', body: 'This is my post body.'}); - const secondaryLexicalEditor = adminPage.locator('[data-secondary-instance="true"]'); - // Check if the secondary lexical editor exists - await expect(secondaryLexicalEditor).toHaveCount(1); - // Check if it is hidden - await expect(secondaryLexicalEditor).toBeHidden(); - }); - }); + // Lexical rendering tests moved to e2e/tests/admin/posts/lexical-editor.test.ts test.describe('Update post', () => { test.describe.configure({retries: 1}); @@ -680,38 +612,4 @@ test.describe('Updating post access', () => { }); }); -test.describe('Deleting a post', () => { - test('Delete a saved post', async ({page}) => { - await page.goto('/ghost'); - - await createPostDraft(page, {title: 'Delete a post test', body: 'This is the content'}); - - await expect(page.locator('[data-test-editor-post-status]')).toContainText('Draft - Saved'); - - await openPostSettingsMenu(page); - - await page.locator('[data-test-button="delete-post"]').click(); - - await page.locator('[data-test-button="delete-post-confirm"]').click(); - - await expect( - page.locator('[data-test-screen-title]') - ).toContainText('Posts'); - }); - - test('Delete a post with unsaved changes', async ({page}) => { - await page.goto('/ghost'); - - await createPostDraft(page, {title: 'Delete a post test', body: 'This is the content'}); - - await openPostSettingsMenu(page); - - await page.locator('[data-test-button="delete-post"]').click(); - - await page.locator('[data-test-button="delete-post-confirm"]').click(); - - await expect( - page.locator('[data-test-screen-title]') - ).toContainText('Posts'); - }); -}); +// Delete post tests moved to e2e/tests/admin/posts/publishing.test.ts