From f4c223fbfca34abddd3a4440cfddf1dc94a25141 Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Tue, 17 Feb 2026 12:17:22 -0500 Subject: [PATCH 1/3] Fixed display of member welcome email sender in modal (#26420) closes [NY-1041](https://linear.app/ghost/issue/NY-1041/oss-issue-welcome-email-sender-still-appears-as-noreply-in-preview) and https://github.com/TryGhost/Ghost/issues/26381 - In https://github.com/TryGhost/Ghost/pull/26358 we made it so that member welcome emails, when sent, would use sender info and reply_to from the default newsletter - this PR updates the modal to display that information properly as well - Since this info is used in both the button that opens the modal and the modal itself, added a hook to de-duplicate some of the logic for determining exactly what to display - No logic yet for updating sender name, email, and reply to specifically for welcome emails, but this code is written in such a way that once that's supported it can easily take precedence over default newsletter values ### State 1 - defaults The default values for the newsletter (first screenshot) are what welcome emails use.
Default Newsletter Settings Welcome Email Buttons Modal
Screenshot 2026-02-16 at 3 01 30 PM Screenshot 2026-02-16 at 3 02 04 PM Screenshot 2026-02-16 at 3 01 43 PM
### State 2 - custom name and sender email Updated sender name and sender email for the default newsletter. Sender name is updated in the button that opens the modal, and name + sender email are updated in the modal. The test email matches the same values.
Newsletter Settings Welcome Email Buttons
Screenshot 2026-02-16 at 3 02 38 PM Screenshot 2026-02-16 at 3 02 48 PM
Modal Resulting Test Email
Screenshot 2026-02-16 at 3 02 54 PM Screenshot 2026-02-16 at 3 03 08 PM
### State 3 - custom reply to Same as above, but with a custom reply-to address. It appears in the modal and in the actual sent email.
Newsletter Reply-To Setting Modal (w/ Reply-To) Resulting Test Email
Screenshot 2026-02-16 at 3 03 24 PM Screenshot 2026-02-16 at 3 03 33 PM Screenshot 2026-02-16 at 3 03 42 PM
--- .../settings/membership/member-emails.tsx | 8 +- .../member-emails/welcome-email-modal.tsx | 15 +- .../hooks/use-welcome-email-sender-details.ts | 54 ++++++ .../membership/member-welcome-emails.test.ts | 158 ++++++++++++++++++ 4 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx index 9d9a67e42b2..ca1430deb59 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx @@ -8,6 +8,7 @@ import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/ import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; import {useGlobalData} from '../../providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useWelcomeEmailSenderDetails} from '../../../hooks/use-welcome-email-sender-details'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; // Default welcome email content in Lexical JSON format @@ -32,10 +33,9 @@ const EmailPreview: React.FC<{ onToggle }) => { const {settings} = useGlobalData(); - const [accentColor, icon, siteTitle] = getSettingValues(settings, ['accent_color', 'icon', 'title']); + const [accentColor, icon] = getSettingValues(settings, ['accent_color', 'icon']); const color = accentColor || '#F6414E'; - - const senderName = automatedEmail.sender_name || siteTitle || 'Your Site'; + const {resolvedSenderName} = useWelcomeEmailSenderDetails(automatedEmail); return (
}
-
{senderName}
+
{resolvedSenderName}
{automatedEmail.subject}
diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx index 9834b261b34..9b012b48a93 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx @@ -4,6 +4,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import MemberEmailEditor from './member-email-editor'; import {Button, Hint, Modal, TextField} from '@tryghost/admin-x-design-system'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useWelcomeEmailSenderDetails} from '../../../../hooks/use-welcome-email-sender-details'; import TestEmailDropdown from './test-email-dropdown'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; @@ -51,7 +52,8 @@ const WelcomeEmailModal = NiceModal.create(({emailType = const hasEditorBeenFocused = useRef(false); const handleError = useHandleError(); const {settings} = useGlobalData(); - const [siteTitle, defaultEmailAddress] = getSettingValues(settings, ['title', 'default_email_address']); + const [siteTitle] = getSettingValues(settings, ['title']); + const {resolvedSenderName, resolvedSenderEmail, resolvedReplyToEmail, hasDistinctReplyTo} = useWelcomeEmailSenderDetails(automatedEmail); const {formState, saveState, updateForm, setFormState, handleSave, okProps, errors, validate} = useForm({ initialState: { @@ -135,9 +137,6 @@ const WelcomeEmailModal = NiceModal.create(({emailType = } }, [setFormState, updateForm]); - const senderEmail = automatedEmail?.sender_email || defaultEmailAddress; - const replyToEmail = automatedEmail?.sender_reply_to || defaultEmailAddress; - return ( { @@ -177,15 +176,15 @@ const WelcomeEmailModal = NiceModal.create(({emailType =
From:
- {automatedEmail?.sender_name || siteTitle} - {`<${senderEmail}>`} + {resolvedSenderName} + {`<${resolvedSenderEmail}>`}
- {replyToEmail !== senderEmail && ( + {hasDistinctReplyTo && (
Reply-to:
- {replyToEmail} + {resolvedReplyToEmail}
)} diff --git a/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts b/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts new file mode 100644 index 00000000000..96679011410 --- /dev/null +++ b/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts @@ -0,0 +1,54 @@ +import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {renderReplyToEmail, renderSenderEmail} from '../utils/newsletter-emails'; +import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; +import {useGlobalData} from '../components/providers/global-data-provider'; +import {useMemo} from 'react'; +import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; + +type AutomatedEmailSenderFields = Pick | null | undefined; + +const trimValue = (value: string | null | undefined) => value?.trim() || ''; + +export const useWelcomeEmailSenderDetails = (automatedEmail: AutomatedEmailSenderFields) => { + const {settings, config} = useGlobalData(); + const [siteTitle, defaultEmailAddress, supportEmailAddress] = getSettingValues(settings, ['title', 'default_email_address', 'support_email_address']); + const {data: newslettersData} = useBrowseNewsletters({ + searchParams: { + filter: 'status:active', + limit: '1' + } + }); + const defaultNewsletter = newslettersData?.newsletters?.[0]; + + return useMemo(() => { + const automatedSenderName = trimValue(automatedEmail?.sender_name); + const automatedSenderEmail = trimValue(automatedEmail?.sender_email); + const automatedSenderReplyTo = trimValue(automatedEmail?.sender_reply_to); + + const defaultNewsletterSenderName = trimValue(defaultNewsletter?.sender_name); + const defaultNewsletterSenderEmail = defaultNewsletter ? trimValue(renderSenderEmail(defaultNewsletter, config, defaultEmailAddress)) : ''; + const defaultNewsletterReplyTo = defaultNewsletter ? trimValue(renderReplyToEmail(defaultNewsletter, config, supportEmailAddress, defaultEmailAddress)) : ''; + + const resolvedSenderName = automatedSenderName || defaultNewsletterSenderName || trimValue(siteTitle) || 'Your Site'; + const resolvedSenderEmail = automatedSenderEmail || defaultNewsletterSenderEmail || trimValue(defaultEmailAddress) || ''; + const resolvedReplyToEmail = automatedSenderReplyTo || defaultNewsletterReplyTo || ''; + const hasDistinctReplyTo = resolvedReplyToEmail !== '' && resolvedReplyToEmail !== resolvedSenderEmail; + + return { + resolvedSenderName, + resolvedSenderEmail, + resolvedReplyToEmail, + defaultNewsletterSenderName, + hasDistinctReplyTo + }; + }, [ + automatedEmail?.sender_email, + automatedEmail?.sender_name, + automatedEmail?.sender_reply_to, + config, + defaultEmailAddress, + defaultNewsletter, + siteTitle, + supportEmailAddress + ]); +}; diff --git a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts index 9f8c2e016e1..22ded8e1a80 100644 --- a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts @@ -18,6 +18,10 @@ const automatedEmailsFixture = { }] }; +const newslettersRequest = { + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters} +}; + test.describe('Member emails settings', async () => { test.describe('Welcome email modal', async () => { test('Escape key closes test email dropdown without closing modal', async ({page}) => { @@ -33,6 +37,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -82,6 +87,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -121,6 +127,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -178,6 +185,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -227,6 +235,150 @@ test.describe('Member emails settings', async () => { document.querySelector('[data-kg-link-input]')?.remove(); }); }); + + test('uses automated email sender fields when populated, even if newsletter differs', async ({page}) => { + const configResponse = { + config: { + ...responseFixtures.config.config, + labs: { + welcomeEmails: true + } + } + }; + + const populatedAutomatedEmailsFixture = { + automated_emails: [{ + ...automatedEmailsFixture.automated_emails[0], + sender_name: 'Automated Sender', + sender_email: 'automated@example.com', + sender_reply_to: 'reply-automated@example.com' + }] + }; + + const defaultNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_name: 'Newsletter Sender', + sender_email: 'newsletter@example.com', + sender_reply_to: 'support' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: populatedAutomatedEmailsFixture}, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByTestId('free-welcome-email-preview').click(); + + const modal = page.getByTestId('welcome-email-modal'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('Automated Sender'); + await expect(modal).toContainText('automated@example.com'); + await expect(modal).toContainText('reply-automated@example.com'); + await expect(modal).not.toContainText('newsletter@example.com'); + }); + + test('falls back to default newsletter sender values when automated fields are empty', async ({page}) => { + const configResponse = { + config: { + ...responseFixtures.config.config, + labs: { + welcomeEmails: true + } + } + }; + + const emptyAutomatedSenderFixture = { + automated_emails: [{ + ...automatedEmailsFixture.automated_emails[0], + sender_name: ' ', + sender_email: ' ', + sender_reply_to: ' ' + }] + }; + + const defaultNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_name: 'Newsletter Sender', + sender_email: 'newsletter@example.com', + sender_reply_to: 'support' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedSenderFixture}, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByTestId('free-welcome-email-preview').click(); + + const modal = page.getByTestId('welcome-email-modal'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('Newsletter Sender'); + await expect(modal).toContainText('newsletter@example.com'); + await expect(modal).toContainText('support@example.com'); + await expect(modal).not.toContainText('default@example.com'); + }); + + test('preview card uses newsletter sender name when automated sender name is empty', async ({page}) => { + const configResponse = { + config: { + ...responseFixtures.config.config, + labs: { + welcomeEmails: true + } + } + }; + + const emptyAutomatedSenderFixture = { + automated_emails: [{ + ...automatedEmailsFixture.automated_emails[0], + sender_name: ' ' + }] + }; + + const defaultNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_name: 'Newsletter Sender' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedSenderFixture}, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + + const cardSenderName = section.locator('[data-testid="free-welcome-email-preview"] .font-semibold').first(); + await expect(cardSenderName).toHaveText('Newsletter Sender'); + }); }); // NY-842: Tests for editing/viewing welcome emails before activation @@ -249,6 +401,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture} }}); @@ -305,6 +458,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: createdAutomatedEmailResponse} @@ -361,6 +515,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: existingAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: existingAutomatedEmailsFixture} @@ -416,6 +571,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: createdActiveResponse} @@ -475,6 +631,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: inactiveAutomatedEmailsFixture}, editAutomatedEmail: {method: 'PUT', path: '/automated_emails/free-welcome-email-id/', response: updatedActiveResponse} @@ -534,6 +691,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: activeAutomatedEmailsFixture}, editAutomatedEmail: {method: 'PUT', path: '/automated_emails/free-welcome-email-id/', response: updatedInactiveResponse} From 1a1955d5dae452d01389229d9f3a4ac992628db7 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Tue, 17 Feb 2026 19:12:47 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20Portal=20crash=20whe?= =?UTF-8?q?n=20navigating=20via=20hash=20links=20on=20a=20page=20(#26445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/ONC-1484/re-account-page-links-are-broken ## Summary - Portal's `updateStateForPreviewLinks()` (the `hashchange` event handler) called `fetchLinkData()` without arguments, leaving `site` and `member` as `undefined` - This caused a crash when clicking portal hash links (e.g. ``) on an already-loaded page - Loading the full URL directly (e.g. `http://localhost:2368/#/portal/account/profile`) worked fine because the initial `fetchData()` code path passed the arguments correctly - Fixed by passing `this.state.site` and `this.state.member` to `fetchLinkData()` in `updateStateForPreviewLinks()` - Added tests for both logged-in and logged-out hashchange navigation --- apps/portal/package.json | 2 +- apps/portal/src/app.js | 2 +- apps/portal/test/portal-links.test.js | 75 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index 25bd6d06639..93a628ef7ce 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.2", + "version": "2.64.3", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/app.js b/apps/portal/src/app.js index 92afee6399e..80960853f08 100644 --- a/apps/portal/src/app.js +++ b/apps/portal/src/app.js @@ -744,7 +744,7 @@ export default class App extends React.Component { /**Handle state update for preview url and Portal Link changes */ updateStateForPreviewLinks() { const {site: previewSite, ...restPreviewData} = this.fetchPreviewData(); - const {site: linkSite, ...restLinkData} = this.fetchLinkData(); + const {site: linkSite, ...restLinkData} = this.fetchLinkData(this.state.site, this.state.member); const updatedState = { site: { diff --git a/apps/portal/test/portal-links.test.js b/apps/portal/test/portal-links.test.js index 3e72a64cae5..909756f173e 100644 --- a/apps/portal/test/portal-links.test.js +++ b/apps/portal/test/portal-links.test.js @@ -385,4 +385,79 @@ describe('Portal Data links:', () => { }); }); }); + + describe('hashchange account page access', () => { + test.each([ + {path: 'account', expectedText: /your account/i}, + {path: 'account/plans', expectedText: /choose a plan/i}, + {path: 'account/profile', expectedText: /account settings/i} + ])('#/portal/$path opens account page via hashchange when logged in', async ({path, expectedText}) => { + // Start with no hash — simulates an already-loaded page + window.location.hash = ''; + let { + popupFrame, triggerButtonFrame, ...utils + } = await setup({ + site: FixtureSite.singleTier.basic, + member: FixtureMember.free, + showPopup: false + }); + expect(triggerButtonFrame).toBeInTheDocument(); + + // Navigate via hash change (e.g. clicking ) + window.location.hash = `#/portal/${path}`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + + const pageTitle = within(popupFrame.contentDocument).queryByText(expectedText); + expect(pageTitle).toBeInTheDocument(); + }); + + test.each([ + {path: 'account', label: 'account'}, + {path: 'account/plans', label: 'account/plans'}, + {path: 'account/profile', label: 'account/profile'}, + {path: 'account/newsletters', label: 'account/newsletters'} + ])('#/portal/$label redirects to signin via hashchange when not logged in', async ({path}) => { + // Start with no hash — simulates an already-loaded page + window.location.hash = ''; + let { + ghostApi, popupFrame, triggerButtonFrame, ...utils + } = await setup({ + site: FixtureSite.singleTier.basic, + member: null, + showPopup: false + }); + expect(triggerButtonFrame).toBeInTheDocument(); + + // Now navigate via hash change (e.g. clicking ) + window.location.hash = `#/portal/${path}`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + + // Should show signin page instead of account page + const popupIframeDocument = popupFrame.contentDocument; + const signinTitle = within(popupIframeDocument).queryByText(/sign in/i); + expect(signinTitle).toBeInTheDocument(); + + // Fill in email and submit to verify the redirect URL is passed through + const emailInput = within(popupIframeDocument).getByLabelText(/email/i); + const submitButton = within(popupIframeDocument).getByRole('button', {name: 'Continue'}); + fireEvent.change(emailInput, {target: {value: 'test@example.com'}}); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(ghostApi.member.sendMagicLink).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + emailType: 'signin', + redirect: `https://portal.localhost#/portal/${path}/` + }) + ); + }); + }); + }); }); From 6b90356c87fd8a873d28c3df27b4063da53a17f1 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 17 Feb 2026 12:34:31 -0800 Subject: [PATCH 3/3] Added better debug logging to e2e tests when the environment setup fails (#26446) ref https://github.com/TryGhost/Ghost/actions/runs/22108351018/job/63898093273 This is a test only change, and doesn't impact any user-facing Ghost functionality. The E2E test suite sometimes fails in CI at the `docker compose up ...` step during environment setup. Currently it's practically impossible to debug this with the logs that are output. This change doesn't fix the problem, but adds more detailed logs that should hopefully make it easier to debug next time we see the same failure. --- e2e/helpers/environment/docker-compose.ts | 74 +++++++++++++++++++---- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/e2e/helpers/environment/docker-compose.ts b/e2e/helpers/environment/docker-compose.ts index 5c0011e91f2..5df31b69120 100644 --- a/e2e/helpers/environment/docker-compose.ts +++ b/e2e/helpers/environment/docker-compose.ts @@ -28,12 +28,16 @@ export class DockerCompose { } async up(): Promise { + const command = this.composeCommand('up -d'); + try { logging.info('Starting docker compose services...'); - execSync(`docker compose -f ${this.composeFilePath} -p ${this.projectName} up -d`, {stdio: 'inherit'}); + execSync(command, {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10}); logging.info('Docker compose services are up'); } catch (error) { + this.logCommandFailure(command, error); logging.error('Failed to start docker compose services:', error); + this.ps(); this.logs(); throw error; } @@ -43,30 +47,30 @@ export class DockerCompose { // Stop and remove all services for the project including volumes down(): void { + const command = this.composeCommand('down -v'); + try { - execSync( - `docker compose -f ${this.composeFilePath} -p ${this.projectName} down -v`, - {stdio: 'inherit'} - ); + execSync(command, {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10}); } catch (error) { + this.logCommandFailure(command, error); logging.error('Failed to stop docker compose services:', error); throw error; } } execShellInService(service: string, shellCommand: string): string { - const command = `docker compose -f ${this.composeFilePath} -p ${this.projectName} run --rm -T --entrypoint sh ${service} -c "${shellCommand}"`; + const command = this.composeCommand(`run --rm -T --entrypoint sh ${service} -c "${shellCommand}"`); debug('readFileFromService running:', command); - return execSync(command, {encoding: 'utf-8'}).toString(); + return execSync(command, {encoding: 'utf-8'}); } execInService(service: string, command: string[]): string { const cmdArgs = command.map(arg => `"${arg}"`).join(' '); - const cmd = `docker compose -f ${this.composeFilePath} -p ${this.projectName} run --rm -T ${service} ${cmdArgs}`; + const cmd = this.composeCommand(`run --rm -T ${service} ${cmdArgs}`); debug('execInService running:', cmd); - return execSync(cmd, {encoding: 'utf-8'}).toString(); + return execSync(cmd, {encoding: 'utf-8'}); } async getContainerForService(serviceLabel: string): Promise { @@ -154,7 +158,7 @@ export class DockerCompose { logging.error('\n=== Docker compose logs ==='); const logs = execSync( - `docker compose -f ${this.composeFilePath} -p ${this.projectName} logs`, + this.composeCommand('logs'), {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10} // 10MB buffer for logs ); @@ -165,8 +169,56 @@ export class DockerCompose { } } + private ps(): void { + try { + logging.error('\n=== Docker compose ps -a ==='); + + const ps = execSync(this.composeCommand('ps -a'), { + encoding: 'utf-8', + maxBuffer: 1024 * 1024 * 10 + }); + + logging.error(ps); + logging.error('=== End docker compose ps -a ===\n'); + } catch (psError) { + debug('Could not get docker compose ps -a:', psError); + } + } + + private composeCommand(args: string): string { + return `docker compose -f ${this.composeFilePath} -p ${this.projectName} ${args}`; + } + + private logCommandFailure(command: string, error: unknown): void { + if (!(error instanceof Error)) { + return; + } + + const commandError = error as Error & { + stdout?: Buffer | string; + stderr?: Buffer | string; + }; + + const stdout = commandError.stdout?.toString().trim(); + const stderr = commandError.stderr?.toString().trim(); + + logging.error(`Command failed: ${command}`); + + if (stdout) { + logging.error('\n=== docker compose command stdout ==='); + logging.error(stdout); + logging.error('=== End docker compose command stdout ===\n'); + } + + if (stderr) { + logging.error('\n=== docker compose command stderr ==='); + logging.error(stderr); + logging.error('=== End docker compose command stderr ===\n'); + } + } + private async getContainers(): Promise { - const command = `docker compose -f ${this.composeFilePath} -p ${this.projectName} ps -a --format json`; + const command = this.composeCommand('ps -a --format json'); const output = execSync(command, {encoding: 'utf-8'}).trim(); if (!output) {