From 8fadace4c43d5c8c4e2c33c90d263a75b8e05e10 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 30 Sep 2026 13:22:18 +0200 Subject: [PATCH 01/18] Changed email analytics result merging to linear time (#30667) closes https://linear.app/ghost/issue/BER-3932/make-email-analytics-event-result-merging-linear-time Merging each event's result rebuilt the accumulated email and member ID lists from scratch, so ingestion slowed quadratically as a batch grew towards the 5000-member mid-fetch aggregation threshold. Persistent membership sets now let merge() check only the incoming IDs while keeping the same lists in the same first-seen order. The service's running result now tracks event counts only. It exists because each mid-fetch aggregation resets the processing result, and the job still needs totals for the whole run. It used to copy every page's new IDs as well, which meant diffing against all IDs seen so far on each page and holding them until the job finished, even though only the counts are ever reported. --- .../email-analytics-service.ts | 8 +-- .../event-processing-result.ts | 29 ++++++--- .../email-analytics-service.test.ts | 54 ++++++++++++++-- .../event-processing-result.test.ts | 64 +++++++++++++++++++ 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/ghost/core/core/server/services/email-analytics/email-analytics-service.ts b/ghost/core/core/server/services/email-analytics/email-analytics-service.ts index e4eb42703ce..1e3a5c0e851 100644 --- a/ghost/core/core/server/services/email-analytics/email-analytics-service.ts +++ b/ghost/core/core/server/services/email-analytics/email-analytics-service.ts @@ -518,15 +518,12 @@ export class EmailAnalyticsService { unhandled: processingResult.unhandled, unprocessable: processingResult.unprocessable, }; - const beforeEmailIds = new Set(processingResult.emailIds); - const beforeMemberIds = new Set(processingResult.memberIds); - await eventProcessor.processBatch(events, processingResult, fetchData); processingTimeMs += Date.now() - processingStart; eventCount += events.length; // Calculate delta (only new counts from this batch) and accumulate for final reporting - const batchDelta = new EventProcessingResult({ + cumulativeResult.merge({ storedDelivered: processingResult.storedDelivered - beforeCounts.storedDelivered, storedOpened: processingResult.storedOpened - beforeCounts.storedOpened, storedPermanentFailed: @@ -539,10 +536,7 @@ export class EmailAnalyticsService { complained: processingResult.complained - beforeCounts.complained, unhandled: processingResult.unhandled - beforeCounts.unhandled, unprocessable: processingResult.unprocessable - beforeCounts.unprocessable, - emailIds: processingResult.emailIds.filter((id) => !beforeEmailIds.has(id)), - memberIds: processingResult.memberIds.filter((id) => !beforeMemberIds.has(id)), }); - cumulativeResult.merge(batchDelta); // Offer the event processor a chance to aggregate mid-fetch. try { diff --git a/ghost/core/core/server/services/email-analytics/event-processing-result.ts b/ghost/core/core/server/services/email-analytics/event-processing-result.ts index 92fdffd25a5..070167cc7f0 100644 --- a/ghost/core/core/server/services/email-analytics/event-processing-result.ts +++ b/ghost/core/core/server/services/email-analytics/event-processing-result.ts @@ -1,4 +1,4 @@ -type EventProcessingResultInput = Partial>; +type EventProcessingResultInput = Partial>; export class EventProcessingResult { // counts @@ -19,10 +19,14 @@ export class EventProcessingResult { // processing failures are counted separately in addition to event type counts processingFailures: number = 0; - // ids seen whilst processing ready for passing to stats aggregator + // ids seen whilst processing ready for passing to stats aggregator. + // Only write these through merge() and reset() so they stay in step with the sets below. emailIds: string[] = []; memberIds: string[] = []; + #emailIdSet = new Set(); + #memberIdSet = new Set(); + constructor(result: EventProcessingResultInput = {}) { this.merge(result); } @@ -42,6 +46,8 @@ export class EventProcessingResult { this.processingFailures = 0; this.emailIds = []; this.memberIds = []; + this.#emailIdSet.clear(); + this.#memberIdSet.clear(); } merge(other: EventProcessingResultInput = {}): void { @@ -60,12 +66,17 @@ export class EventProcessingResult { this.processingFailures += other.processingFailures || 0; - // TODO: come up with a cleaner way to merge these without churning through Array and Set - this.emailIds = Array.from(new Set([...this.emailIds, ...(other.emailIds || [])])).filter( - Boolean, - ); - this.memberIds = Array.from(new Set([...this.memberIds, ...(other.memberIds || [])])).filter( - Boolean, - ); + EventProcessingResult.#collect(this.#emailIdSet, this.emailIds, other.emailIds); + EventProcessingResult.#collect(this.#memberIdSet, this.memberIds, other.memberIds); + } + + // Only visit incoming IDs; rebuilding the accumulated arrays per event is quadratic. + static #collect(seen: Set, ordered: string[], ids?: readonly string[] | null): void { + for (const id of ids ?? []) { + if (id && !seen.has(id)) { + seen.add(id); + ordered.push(id); + } + } } } diff --git a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts index 7ffa9997cab..ffe0b2f8a4b 100644 --- a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts +++ b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts @@ -868,18 +868,62 @@ describe('EmailAnalyticsService', function () { assert.equal(result.storedPermanentFailed, 2); }); - it('preserves new email and member IDs in the cumulative result', async function () { + it('retains event counts without retaining processed IDs', async function () { const eventProcessor = createStubEventProcessor(); eventProcessor.processBatch.callsFake(async (_events, result) => { - result.emailIds.push('email-id'); - result.memberIds.push('member-id'); + result.merge({ opened: 1, emailIds: ['email-id'], memberIds: ['member-id'] }); }); const service = createServiceWithEventProcessor(eventProcessor); const result = await service.fetchLatestOpenedEvents(); - assert.deepEqual(result.result.emailIds, ['email-id']); - assert.deepEqual(result.result.memberIds, ['member-id']); + assert.equal(result.result.opened, 1); + assert.deepEqual(result.result.emailIds, []); + assert.deepEqual(result.result.memberIds, []); + }); + + it('accumulates event counts across intermediate resets', async function () { + const eventProcessor = createStubEventProcessor(); + const batches = [ + { opened: 1, emailIds: ['email-1'], memberIds: ['member-1', 'member-2'] }, + { opened: 2, emailIds: ['email-1', 'email-2'], memberIds: ['member-2', 'member-3'] }, + { opened: 3, emailIds: ['email-2'], memberIds: ['member-3'] }, + ]; + eventProcessor.processBatch.callsFake(async (_events, result) => { + result.merge(batches.shift()); + }); + // A processor resets the shared result after an intermediate aggregation, + // so count deltas start from both zero and non-zero values. + let intermediateAggregations = 0; + eventProcessor.aggregate.callsFake(async ({ processingResult, isFinal }) => { + if (!isFinal) { + intermediateAggregations += 1; + if (intermediateAggregations === 2) { + processingResult.reset(); + } + } + return { emailAggregationTimeMs: 0, memberAggregationTimeMs: 0 }; + }); + const service = createService({ + queries: { + getLastEventTimestamp: sinon.stub().resolves(), + setJobTimestamp: sinon.stub().resolves(), + setJobStatus: sinon.stub().resolves(), + }, + fetchEvents: async ({ batchHandler }: { batchHandler: BatchHandler }) => { + for (let i = 0; i < 3; i++) { + await batchHandler([{ type: 'delivered', timestamp: new Date(i + 1) }]); + } + }, + createEventProcessor: () => eventProcessor, + }); + + const result = await service.fetchLatestOpenedEvents(); + + assert.equal(result.eventCount, 3); + assert.equal(result.result.opened, 6); + assert.deepEqual(result.result.emailIds, []); + assert.deepEqual(result.result.memberIds, []); }); it('rejects when fetching events fails', async function () { diff --git a/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts b/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts index 2e36b4b10c1..ff5c717f5f1 100644 --- a/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts +++ b/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts @@ -69,9 +69,73 @@ describe('EventProcessingResult', function () { result.reset(); assert.deepEqual(result, new EventProcessingResult()); + assert.deepEqual(result.emailIds, []); + assert.deepEqual(result.memberIds, []); }); describe('merge()', function () { + it('ignores empty IDs and preserves first-seen order across repeated merges', function () { + const result = new EventProcessingResult({ + emailIds: ['', 'email-2', 'email-2', 'email-1'], + memberIds: ['member-2', '', 'member-1', 'member-2'], + }); + + for (let i = 0; i < 3; i++) { + result.merge({ + opened: 1, + emailIds: ['email-1', '', 'email-3', 'email-2'], + memberIds: ['', 'member-3', 'member-1', 'member-2'], + }); + } + + assert.equal(result.opened, 3); + assert.deepEqual(result.emailIds, ['email-2', 'email-1', 'email-3']); + assert.deepEqual(result.memberIds, ['member-2', 'member-1', 'member-3']); + }); + + it('can collect the same IDs again after reset', function () { + const result = new EventProcessingResult({ + opened: 2, + emailIds: ['email-1', 'email-2'], + memberIds: ['member-1', 'member-2'], + }); + + result.reset(); + result.merge({ + opened: 1, + emailIds: ['email-2', 'email-2', 'email-1'], + memberIds: ['member-2', 'member-2', 'member-1'], + }); + + assert.equal(result.opened, 1); + assert.deepEqual(result.emailIds, ['email-2', 'email-1']); + assert.deepEqual(result.memberIds, ['member-2', 'member-1']); + }); + + it('keeps merged results independent when the source resets and is reused', function () { + const page = new EventProcessingResult({ + opened: 2, + emailIds: ['email-1'], + memberIds: ['member-1', 'member-2'], + }); + const total = new EventProcessingResult(page); + + page.reset(); + page.merge({ + opened: 2, + emailIds: ['email-1', 'email-2'], + memberIds: ['member-2', 'member-3'], + }); + total.merge(page); + page.reset(); + + assert.equal(total.opened, 4); + assert.deepEqual(total.emailIds, ['email-1', 'email-2']); + assert.deepEqual(total.memberIds, ['member-1', 'member-2', 'member-3']); + assert.deepEqual(page.emailIds, []); + assert.deepEqual(page.memberIds, []); + }); + it('adds counts and merges id arrays', function () { const result = new EventProcessingResult({ delivered: 1, From 6df192ea49632d5d90485631b15b81f25cd6ad35 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Wed, 30 Sep 2026 15:21:32 +0200 Subject: [PATCH 02/18] Improved React editor publish flow (#31123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the React editor’s publish flow to match the preview controls and make recipient selection, scheduling, and final confirmation easier to use. Addresses all 11 items in [PLA-461](https://linear.app/ghost/issue/PLA-461/publish-flow-refinements), each in its own commit. - Aligns fullscreen actions, updates spacing and typography, and removes animation when switching between Preview and Publish while retaining editor entry transitions. Closing Preview dismisses the publish flow too and returns to the editor; switching back with Publish preserves its choices. - Adds animated, indented options; reuses the searchable member label picker without editing controls, grouped into active tiers, archived tiers, and labels; places compact scheduling inputs in a bottom-aligned second column so the timing radios stay in place. - Keeps empty and single-row token fields at the standard input height with equal padding, and restores the label-picker chevron in publish recipients and member details. - Places the green publish action on the right with white text, alongside an outlined Back button. - Applies the same content width, typography, spacing, Close positioning, and action alignment to the unschedule and unpublish modals. - Restores Ember’s completion navigation: pages return to the page list, scheduled posts and posts without email return to the post list, and immediate posts with email open post analytics. Includes previously emailed posts and email-only sends, and hands navigation to Ember when it owns the destination list. - Matches completion-screen headings, spacing, card typography, and actions to the other publish steps. - Addresses review findings around keyboard access, heading line height, mobile overflow, transition state reset, and recipient row height while newsletter counts refresh. Validation: 158 focused browser tests passed across publish, preview, editor header, scheduled dates, member labels, and bulk actions; 271 focused unit tests and Admin typecheck passed. Repository-wide formatting, lint, dependency boundaries, package checks, and documentation checks passed. Full `pnpm check` failed in untouched test files: 30 Ghost Core test timeouts, three Admin test/hook timeouts, and one Admin fixture suite unable to resolve the Koenig build output (`koenig-lexical/dist/koenig-lexical.js`). Visually checked desktop and 390px mobile layouts; the mobile dialog has no horizontal overflow. The latest layout follow-ups passed all 56 publish-flow browser tests, including regressions for timing radio movement, recipient section height during newsletter count loading, and single half-turn chevrons alongside legacy Admin CSS. The Preview dismissal follow-up passed all 87 editor-header and preview browser tests, including Close, Escape, the preview shortcut, focus restoration, and reopening. The unschedule/unpublish styling passed all 12 update-flow browser tests and was visually checked at desktop and 390px mobile widths. The completion-navigation follow-up passed all 92 editor-header and preview browser tests plus Admin typecheck, with regression coverage for posts, pages, schedules, email-only sends, and previously emailed posts. The recipient-grouping follow-up passed 68 publish/member-label browser tests and Admin typecheck, with the dropdown visually checked locally. The no-flash follow-up passed 107 publish-flow/editor-header browser tests and Admin typecheck. Completion CI follow-ups passed 51 editor-header and 9 newsletter-retry acceptance tests, Admin typecheck, and E2E typecheck. All 12 journeys across the three affected E2E files passed against the local test backend across the main run and a focused rerun; one unrelated settings-sidebar timeout passed on rerun. A subsequent CI scheduling failure exposed a race with the destination Ember success modal; the test now explicitly waits for and closes that modal before reopening the post. The focused scheduling journey and E2E workspace lint/type checks passed after this correction. These journeys cover scheduling and unscheduling through the returned Ember list, publish-only navigation, newsletter delivery and analytics navigation, access rules, and post settings. Component and acceptance tests use the fake Admin API; E2E tests use isolated backend fixtures. The token-field and chevron follow-up passed all 284 Shade tests, Shade lint/build, 65 publish/member-label browser tests, and Admin typecheck. Visually verified standard/empty/selected fields at 32px in Storybook, plus the chevron on publish and member forms. A subsequent local Admin asset-copy step hit an ActivityPub dist symlink collision after compilation; the clean Admin build passed in CI on commit `9ec27614c8`. - [x] Read and followed the Contributor Guide - [x] Explained the change - [x] Added automated regression coverage --- apps/admin/src/editor/README.md | 7 + apps/admin/src/editor/date-time-picker.tsx | 10 +- .../src/editor/editor-header-actions.tsx | 45 ++- .../editor/editor-header.acceptance.test.tsx | 212 +++++++++--- ...ditor-newsletter-retry.acceptance.test.tsx | 22 +- apps/admin/src/editor/fullscreen-dialog.tsx | 10 +- .../src/editor/preview/post-preview-modal.tsx | 5 +- apps/admin/src/editor/publish/README.md | 2 +- .../publish/components/complete-step.tsx | 54 ++-- .../complete-with-email-error-step.tsx | 13 +- .../publish/components/confirm-step.tsx | 33 +- .../components/email-recipients-options.tsx | 7 +- .../publish/components/options-step.tsx | 8 +- .../publish/components/post-bookmark.tsx | 8 +- .../publish/components/publish-at-options.tsx | 7 +- .../publish/components/publish-setting.tsx | 16 +- .../publish/components/recipient-select.tsx | 37 ++- .../src/editor/publish/publish-flow-modal.tsx | 154 +++++---- .../publish/publish-flow.component.test.tsx | 305 +++++++++++++++++- .../src/editor/publish/update-flow-modal.tsx | 160 ++++----- .../src/editor/publish/use-publish-flow.ts | 22 +- apps/admin/src/members/api.ts | 1 + .../members/label-picker/can-create-label.ts | 2 +- .../src/members/label-picker/edit-row.tsx | 2 +- .../src/members/label-picker/label-picker.tsx | 229 ++++++++----- .../src/components/ui/token-field.stories.tsx | 32 ++ apps/shade/src/components/ui/token-field.ts | 2 +- apps/shade/src/docs/recipes-guide.mdx | 4 +- .../admin/posts/editor-access-react.test.ts | 2 +- .../admin/posts/editor-settings-react.test.ts | 5 +- .../admin/posts/publishing-react.test.ts | 16 +- 31 files changed, 1051 insertions(+), 381 deletions(-) diff --git a/apps/admin/src/editor/README.md b/apps/admin/src/editor/README.md index 35e44f8109c..df1e9f48908 100644 --- a/apps/admin/src/editor/README.md +++ b/apps/admin/src/editor/README.md @@ -49,6 +49,13 @@ members" while the email is on its way and "Published and sent to N members" once the flow's email confirmation finds it submitted; an email-only send reads "Sent to N members" throughout. +After successful completion, the editor follows the publish flow's celebration +handoff to the destination screen. Pages return to `/pages`; scheduled posts +and posts without email return to `/posts`. Immediately published posts with +email, including email-only sends and posts that were emailed previously, open +`/posts/analytics/:id`. Failed saves and failed sends keep the flow open so the +writer can retry. + ## Adding a settings section 1. Add the section's id to `SETTINGS_SECTION_ORDER` in `settings/sections.ts`, diff --git a/apps/admin/src/editor/date-time-picker.tsx b/apps/admin/src/editor/date-time-picker.tsx index 60c7b122693..9d1c6f4edee 100644 --- a/apps/admin/src/editor/date-time-picker.tsx +++ b/apps/admin/src/editor/date-time-picker.tsx @@ -26,6 +26,7 @@ export interface DateTimePickerProps { /** Calendar days after this instant's site-timezone day are not selectable. */ maxDate?: string | null; disabled?: boolean; + className?: string; dateLabel: string; timeLabel: string; dateTestId: string; @@ -47,6 +48,7 @@ export function DateTimePicker({ minDate, maxDate, disabled = false, + className, dateLabel, timeLabel, dateTestId, @@ -108,7 +110,13 @@ export function DateTimePicker({ const describedByProps = describedBy ? { 'aria-describedby': describedBy } : {}; return ( - + diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index 640649fcbc1..1dcfb2bbb67 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -1,4 +1,6 @@ import { useCallback, useState } from 'react'; +import { useEmberOwnedRouteMatcher } from '@/routes'; +import { useNavigate } from '@tryghost/admin-x-framework'; import { Button } from '@tryghost/shade/components'; import { useShade } from '@tryghost/shade/app'; import { PageHeader } from '@tryghost/shade/patterns'; @@ -88,7 +90,12 @@ export function EditorHeaderActions({ const isDraft = post.status === 'draft'; usePreviewShortcut( - useCallback(() => setPreviewOpen((open) => !open), []), + useCallback(() => { + setPreviewOpen(!previewOpen); + if (previewOpen) { + onOpenFlow('none'); + } + }, [onOpenFlow, previewOpen]), isDraft && persistedId !== null, ); @@ -199,6 +206,8 @@ function PublishActions({ onOpenFlow, onPreview, }: PublishActionsProps) { + const navigate = useNavigate(); + const isEmberOwned = useEmberOwnedRouteMatcher(); const { isAdmin7 } = useShade(); const inputs = usePublishInputs(); const limits = usePublishLimits(); @@ -210,6 +219,7 @@ function PublishActions({ }); // A refetch of any input must not unmount an open flow, so readiness latches once. const [everReady, setEverReady] = useState(false); + const [openedFromPreview, setOpenedFromPreview] = useState(false); if (inputs.isReady && !everReady) { setEverReady(true); @@ -226,10 +236,26 @@ function PublishActions({ onOpenFlow('none'); void session.dispatchPublish({ kind: 'revert' }); }, [onOpenFlow, session]); - const closeFlow = useCallback(() => onOpenFlow('none'), [onOpenFlow]); - const openPublishFlow = useCallback(() => onOpenFlow('publish'), [onOpenFlow]); + const closeFlow = useCallback(() => { + setOpenedFromPreview(false); + onOpenFlow('none'); + }, [onOpenFlow]); + const openPublishFlow = useCallback(() => { + setOpenedFromPreview(false); + onOpenFlow('publish'); + }, [onOpenFlow]); const { onOpenChange: setPreviewOpen } = preview; + const changePreviewOpen = useCallback( + (open: boolean) => { + setPreviewOpen(open); + if (!open) { + closeFlow(); + } + }, + [closeFlow, setPreviewOpen], + ); const publishFromPreview = useCallback(() => { + setOpenedFromPreview(true); setPreviewOpen(false); onOpenFlow('publish'); }, [onOpenFlow, setPreviewOpen]); @@ -275,7 +301,9 @@ function PublishActions({ @@ -306,10 +334,12 @@ function PublishActions({ {openFlow === 'publish' && everReady ? ( { + const destination = + post.displayName === 'page' + ? '/pages' + : !isScheduled && (hasEmail || post.email || post.emailOnly) + ? `/posts/analytics/${postId}` + : '/posts'; + navigate(destination, { crossApp: isEmberOwned(destination) }); + }} onPreview={onPreview} onRevertToDraft={revertToDraft} /> diff --git a/apps/admin/src/editor/editor-header.acceptance.test.tsx b/apps/admin/src/editor/editor-header.acceptance.test.tsx index a2c7ebc863c..20663adb889 100644 --- a/apps/admin/src/editor/editor-header.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-header.acceptance.test.tsx @@ -6,10 +6,14 @@ import { publishTypeError } from '@tryghost/test-data/selectors/editor'; import { browseResponse, configResponse, + currentRoute, currentUserResponse, fakeAdminEndpoint, + fakeAdminStats, fakeNewsletters, + fakePages, fakePosts, + fakePostsListScreen, fakeSnippets, fakeTiers, newsletter, @@ -29,7 +33,7 @@ import { publishScreen } from '@/editor/publish/publish.screen'; const POST_ID = 'abc123'; const POST_UUID = 'post-uuid'; -const FLAG_ON = { labs: { editorReact: true } }; +const FLAG_ON = { labs: { editorReact: true, postsListReact: true } }; const LOADED_AT = '2026-01-01T00:00:00.000Z'; const SITE_URL = 'http://test.com'; @@ -77,6 +81,12 @@ function failureBody(status: number) { function publishChrome({ newsletters = 0 } = {}) { fakeSnippets([]); fakePosts([]); + fakePages([]); + fakePostsListScreen(); + // Successful sends leave the editor for the post's analytics screen. + fakeAdminStats.postReferrers(POST_ID, []); + fakeAdminStats.postGrowth(POST_ID); + fakeAdminStats.mrr(); fakeTiers([]); fakeNewsletters( Array.from({ length: newsletters }, () => @@ -117,7 +127,11 @@ function isEmailConfirmationRead(url: string): boolean { */ function fakeSavablePost( overrides: Partial = {}, - { failWith = 0, holdFirstSave }: { failWith?: number; holdFirstSave?: Promise } = {}, + { + failWith = 0, + holdFirstSave, + resource = 'posts', + }: { failWith?: number; holdFirstSave?: Promise; resource?: 'posts' | 'pages' } = {}, ) { let current = post({ id: POST_ID, @@ -139,17 +153,17 @@ function fakeSavablePost( slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], })); - fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), ({ url }) => { + fakeAdminEndpoint('GET', new RegExp(`^/${resource}/${POST_ID}/\\?`), ({ url }) => { // The send has gone out by the time the flow's email confirmation reads the post. if (current.email?.status === 'pending' && isEmailConfirmationRead(url)) { current.email = { ...current.email, status: 'submitted', error: null }; } - return { posts: [current] }; + return { [resource]: [current] }; }); const saveApi = fakeAdminEndpoint( 'PUT', - new RegExp(`^/posts/${POST_ID}/\\?`), + new RegExp(`^/${resource}/${POST_ID}/\\?`), async ({ body, url }) => { saves += 1; @@ -161,7 +175,7 @@ function fakeSavablePost( return failureBody(failWith); } - const submitted = (body as { posts: Partial[] }).posts[0]; + const submitted = (body as Record[]>)[resource][0]; current = { ...current, ...submitted, updated_at: `2026-01-01T00:00:0${saves}.000Z` }; // Core creates a send's email pending and hands it over in the background. @@ -169,7 +183,7 @@ function fakeSavablePost( current.email = { id: 'email-1', status: 'pending', email_count: 20, opened_count: 0 }; } - return { posts: [current] }; + return { [resource]: [current] }; }, { status: failWith || 200 }, ); @@ -227,6 +241,7 @@ afterEach(() => { localStorage.removeItem('ghost-last-published-post'); localStorage.removeItem('ghost-last-scheduled-post'); delete window.EmberBridge; + delete document.body.dataset.externalNavigate; }); /** @@ -235,7 +250,7 @@ afterEach(() => { * make. */ describe('Editor header actions', () => { - it('publishes a draft and keeps the editor open on the new status', async () => { + it('returns to the post list after publishing without email', async () => { publishChrome(); const saveApi = fakeSavablePost(); await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); @@ -243,18 +258,35 @@ describe('Editor header actions', () => { await expect.element(editorScreen.publishButton()).toBeEnabled(); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(submittedPost(saveApi)).toMatchObject({ id: POST_ID, status: 'published' }); // The header never PUTs on its own: the publish is the only write. expect(saveApi.requests).toHaveLength(1); - await expect.element(editorScreen.root()).toBeVisible(); - await expect.element(editorScreen.status()).toHaveTextContent('Published'); - // The header's controls follow the acknowledged status through the session view. - await expect.element(editorScreen.updateButton()).toBeVisible(); - await expect(editorScreen.previewButton()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); }); + it.each(['post', 'page'] as const)( + 'hands completed %s publishing to Ember when it owns the destination list', + async (postType) => { + publishChrome(); + const resource = postType === 'page' ? 'pages' : 'posts'; + fakeSavablePost({}, { resource }); + await renderAdminApp(`/editor/${postType}/${POST_ID}`, { + labs: { editorReact: true, postsListReact: false }, + }); + + await publishThroughFlow(); + + await expect + .poll((): unknown => JSON.parse(document.body.dataset.externalNavigate ?? 'null')) + .toMatchObject({ route: `/${resource}`, isExternal: true }); + // The harness records the handoff; the pending flow stays until Ember navigates. + await expect.element(publishScreen.confirmButton()).toBeDisabled(); + await expect(publishScreen.complete()).toHaveCount(0); + }, + ); + it('sends the newsletter the publish flow selected', async () => { publishChrome({ newsletters: 1 }); const saveApi = fakeSavablePost(); @@ -263,13 +295,13 @@ describe('Editor header actions', () => { await expect.element(editorScreen.publishButton()).toBeEnabled(); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(submittedPost(saveApi)).toMatchObject({ status: 'published' }); expect(saveApi.lastRequest?.url).toContain('newsletter=weekly'); expect(saveApi.lastRequest?.url).toContain('email_segment=all'); }); - it('follows an emailed publish to sent once the flow confirms the send', async () => { + it('opens post analytics once the flow confirms the email send', async () => { publishChrome({ newsletters: 1 }); const saveApi = fakeSavablePost(); await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); @@ -282,12 +314,9 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(saveApi.lastRequest?.url).toContain('newsletter=weekly'); - // The editor's own reads saw the email pending; only the confirmation found it sent. - await expect - .element(editorScreen.status()) - .toHaveTextContent('Published and sent to 20 members'); + await expect(editorScreen.root()).toHaveCount(0); }); it('schedules a draft for the time the flow chose', async () => { @@ -301,12 +330,74 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); const submitted = submittedPost(saveApi); expect(submitted).toMatchObject({ status: 'scheduled' }); expect(Date.parse(String(submitted.published_at))).toBeGreaterThan(Date.now()); }); + it('returns scheduled emails to the post list instead of analytics', async () => { + publishChrome({ newsletters: 1 }); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + + await editorScreen.publishButton().click(); + await publishScreen.setting('publish-at').click(); + await page.getByLabelText('Schedule for later').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(currentRoute).toBe('/posts'); + await expect(editorScreen.root()).toHaveCount(0); + }); + + it.each([false, true])('returns pages to the page list (scheduled: %s)', async (scheduled) => { + publishChrome(); + fakeSavablePost({}, { resource: 'pages' }); + await renderAdminApp(`/editor/page/${POST_ID}`, FLAG_ON); + + await editorScreen.publishButton().click(); + if (scheduled) { + await publishScreen.setting('publish-at').click(); + await page.getByLabelText('Schedule for later').click(); + } + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(currentRoute).toBe('/pages'); + await expect(editorScreen.root()).toHaveCount(0); + }); + + it('opens analytics after an email-only send', async () => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + + await editorScreen.publishButton().click(); + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Email only').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); + expect(submittedPost(saveApi)).toMatchObject({ status: 'published', email_only: true }); + await expect(editorScreen.root()).toHaveCount(0); + }); + + it('opens analytics when republishing a previously emailed post', async () => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost({ + email: { id: 'email-1', status: 'submitted', email_count: 20, opened_count: 0 }, + }); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + + await publishThroughFlow(); + + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); + expect(saveApi.lastRequest?.url).not.toContain('newsletter='); + await expect(editorScreen.root()).toHaveCount(0); + }); + it('saves unsaved work on a published post through Update', async () => { publishChrome(); const saveApi = fakeSavablePost({ @@ -595,6 +686,36 @@ describe('Editor header actions', () => { await expect(previewScreen.modal()).toHaveCount(0); }); + it('animates opening from the editor but switches fullscreen surfaces without animation', async () => { + publishChrome(); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await editorScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); + expect(getComputedStyle(previewScreen.modal().element()).animationName).not.toBe('none'); + await previewScreen.publishButton().click(); + await expect(previewScreen.modal()).toHaveCount(0); + await expect.element(publishScreen.options()).toBeVisible(); + expect(getComputedStyle(publishScreen.root().element()).animationName).toBe('none'); + + await publishScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); + expect(getComputedStyle(previewScreen.modal().element()).animationName).toBe('none'); + await previewScreen.publishButton().click(); + await expect(previewScreen.modal()).toHaveCount(0); + expect(getComputedStyle(publishScreen.root().element()).animationName).toBe('none'); + + await publishScreen.closeButton().click(); + await editorScreen.publishButton().click(); + await expect.element(publishScreen.options()).toBeVisible(); + expect(getComputedStyle(publishScreen.root().element()).animationName).not.toBe('none'); + await publishScreen.closeButton().click(); + await editorScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); + expect(getComputedStyle(previewScreen.modal().element()).animationName).not.toBe('none'); + }); + it('keeps the publish flow and its choices while previewing from inside it', async () => { publishChrome({ newsletters: 1 }); const saveApi = fakeSavablePost(); @@ -614,7 +735,7 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(submittedPost(saveApi)).toMatchObject({ status: 'published' }); expect(saveApi.lastRequest?.url).not.toContain('newsletter='); }); @@ -653,7 +774,7 @@ describe('Editor header actions', () => { await publishScreen.confirmButton().click(); // A remounted flow would default back to publishing and emailing. - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(saveApi.lastRequest?.url).not.toContain('newsletter='); }); @@ -702,21 +823,32 @@ describe('Editor header actions', () => { await expect(editorScreen.publishInputsError()).toHaveCount(0); }); - it('returns to the publish flow when the preview it opened is closed', async () => { - publishChrome(); - fakeSavablePost(); - await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + it.each(['Close', 'Escape', 'preview shortcut'])( + 'returns to the editor when a preview opened from Publish is dismissed with %s', + async (closeWith) => { + publishChrome(); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); - await editorScreen.publishButton().click(); - await publishScreen.previewButton().click(); - await expect.element(previewScreen.modal()).toBeVisible(); + await editorScreen.publishButton().click(); + await publishScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); - await previewScreen.closeButton().click(); + if (closeWith === 'Close') { + await previewScreen.closeButton().click(); + } else { + await userEvent.keyboard(closeWith === 'Escape' ? '{Escape}' : '{Meta>}p{/Meta}'); + } - await expect(previewScreen.modal()).toHaveCount(0); - await expect.element(publishScreen.options()).toBeVisible(); - await expect.element(publishScreen.previewButton()).toHaveFocus(); - }); + await expect(previewScreen.modal()).toHaveCount(0); + await expect(publishScreen.root()).toHaveCount(0); + await expect.element(editorScreen.publishButton()).toHaveFocus(); + + await editorScreen.publishButton().click(); + await expect.element(publishScreen.options()).toBeVisible(); + expect(getComputedStyle(publishScreen.root().element()).animationName).not.toBe('none'); + }, + ); it('publishes from a preview opened by the header Preview button', async () => { publishChrome(); @@ -777,7 +909,7 @@ describe('Editor header actions', () => { await typeIntoBody(' and more'); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(saveApi.requests).toHaveLength(2); expect(submittedPost(saveApi, 0)).toMatchObject({ status: 'draft' }); expect(saveApi.requests[0].url).toContain('save_revision=true'); @@ -827,7 +959,7 @@ describe('Editor header actions', () => { await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(submittedPost(restoredApi)).toMatchObject({ id: POST_ID, status: 'published' }); expect(restoredApi.requests).toHaveLength(1); }); @@ -1010,7 +1142,7 @@ describe('Editor header actions', () => { await expect.element(editorScreen.publishButton()).toBeEnabled(); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(saveApi.lastRequest?.url).toContain('newsletter=weekly'); // The count only covers the period the limit measures. expect(emailsApi.lastRequest?.url).toContain('filter=created_at%3A%3E%3D%27'); @@ -1051,7 +1183,7 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(saveApi.lastRequest?.url).not.toContain('newsletter='); }); diff --git a/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx b/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx index 087f092e3d7..474838ac56c 100644 --- a/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx @@ -3,8 +3,10 @@ import { buildLexicalParagraph } from '@tryghost/test-data'; import { browseResponse, + currentRoute, currentUserResponse, fakeAdminEndpoint, + fakeAdminStats, fakeNewsletters, fakePosts, fakeSnippets, @@ -34,6 +36,10 @@ function publishChrome() { fakeSnippets([]); fakePosts([]); fakeNewsletters(weeklyNewsletters()); + // Successful retries leave the editor for the post's analytics screen. + fakeAdminStats.postReferrers(POST_ID, []); + fakeAdminStats.postGrowth(POST_ID); + fakeAdminStats.mrr(); // The site-wide member total the publish machine reads; the boot entry counts a different shape. fakeAdminEndpoint('GET', /^\/members\/\?.*order=id/, { members: [], @@ -122,14 +128,10 @@ describe('Editor newsletter retry', () => { await expect.element(publishScreen.emailError()).toHaveTextContent(error || 'Unknown error'); await publishScreen.retryEmailButton().click(); - await expect - .element(publishScreen.complete()) - .toHaveTextContent('Your post has been published.'); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(retryApi.requests).toHaveLength(1); - await expect - .element(editorScreen.status()) - .toHaveTextContent('Published and sent to 20 members'); - await expect(editorScreen.viewNewsletterDetails()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); + await expect(publishScreen.root()).toHaveCount(0); }, ); @@ -150,10 +152,10 @@ describe('Editor newsletter retry', () => { await expect.element(publishScreen.emailError()).toHaveTextContent(error || 'Unknown error'); await publishScreen.retryEmailButton().click(); - await expect.element(publishScreen.complete()).toHaveTextContent('Your email has been sent.'); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(retryApi.requests).toHaveLength(1); - await expect.element(editorScreen.status()).toHaveTextContent('Sent to 20 members'); - await expect(editorScreen.retryNewsletter()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); + await expect(publishScreen.root()).toHaveCount(0); }, ); diff --git a/apps/admin/src/editor/fullscreen-dialog.tsx b/apps/admin/src/editor/fullscreen-dialog.tsx index 0bc0fd50116..d14409d761f 100644 --- a/apps/admin/src/editor/fullscreen-dialog.tsx +++ b/apps/admin/src/editor/fullscreen-dialog.tsx @@ -23,6 +23,8 @@ export interface FullscreenDialogProps extends Omit modal?: boolean; title: ReactNode; layout?: keyof typeof LAYOUTS; + /** Skip transitions when moving between fullscreen editor surfaces. */ + animate?: boolean; /** Controls rendered in the header row beside the title. */ headerActions?: ReactNode; /** View controls centered between the title and actions on wide screens. */ @@ -39,6 +41,7 @@ export function FullscreenDialog({ modal = true, title, layout = 'plain', + animate = true, headerActions, headerControls, className, @@ -76,7 +79,12 @@ export function FullscreenDialog({ return ( event.preventDefault()} diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx index dccabc5c3b8..ddfa367bb7f 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.tsx @@ -66,6 +66,7 @@ interface SegmentOption { export interface PostPreviewModalProps { subjectEditor?: EmailSubjectEditor; open: boolean; + animate?: boolean; postId: string; /** The post's public preview URL (`/p/:uuid/`), empty until the post has a uuid. */ previewUrl: string; @@ -85,6 +86,7 @@ export interface PostPreviewModalProps { export function PostPreviewModal({ subjectEditor, open, + animate = true, postId, previewUrl, isPost = true, @@ -314,6 +316,7 @@ export function PostPreviewModal({ return ( {onPublish ? ( - ) : null} diff --git a/apps/admin/src/editor/publish/README.md b/apps/admin/src/editor/publish/README.md index 127fa518f66..4e2a12a69f8 100644 --- a/apps/admin/src/editor/publish/README.md +++ b/apps/admin/src/editor/publish/README.md @@ -186,7 +186,7 @@ Confirming runs `onBeforePublish` (the editor's pre-save cleanup), dispatches th | `failed` (`validation`) | The validation message, in place | | `dropped`/`superseded` | The post is no longer publishable from here | -No completion closes the modal or navigates. Reaching the complete step writes the celebration handoff (`ghost-last-published-post` or `ghost-last-scheduled-post`), and calls `onCompleted` so the caller can navigate; where the user lands is the caller's decision, not this component's. +No completion closes the modal or navigates. Successful completion writes the celebration handoff (`ghost-last-published-post` or `ghost-last-scheduled-post`), and calls `onCompleted` so the caller can navigate; where the user lands is the caller's decision, not this component's. The editor sets `showCompletion={false}` to keep the current step pending until navigation unmounts it, avoiding a flash of the fallback completion screen while the destination loads. Other callers show the completion screen by default. ## Email confirmation diff --git a/apps/admin/src/editor/publish/components/complete-step.tsx b/apps/admin/src/editor/publish/components/complete-step.tsx index 3be423317b2..d29b20486af 100644 --- a/apps/admin/src/editor/publish/components/complete-step.tsx +++ b/apps/admin/src/editor/publish/components/complete-step.tsx @@ -1,6 +1,6 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; -import { formatNumber } from '@tryghost/shade/utils'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber, LucideIcon } from '@tryghost/shade/utils'; import { getRecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; @@ -45,17 +45,20 @@ function RevertToDraft({ onRevertToDraft }: { onRevertToDraft?: () => void }) { } return ( - - Need to make a change?{' '} - - + + Need to make a change? + + + + ); } @@ -90,17 +93,17 @@ export function CompleteStep({ {note} ) : null} - + {captured.isScheduled ? ( <> - All set! Your{' '} + All set! Your{' '} {emailOnly ? 'email' : post.displayName} will be {deliveryVerb}{' '} {formatScheduledCompletion(publishedAt, timezone)}. ) : ( <> {emailOnly && unconfirmed ? null : ( - Boom. It’s out there. + Boom. It’s out there. )} {emailOnly ? ( unconfirmed ? ( @@ -121,9 +124,9 @@ export function CompleteStep({ {emailOnly ? ( - + {unconfirmed ? null : ( - + Your post {captured.isScheduled ? 'will be' : 'was'} sent to{' '} {recipientsConfirmLabel({ @@ -143,16 +146,19 @@ export function CompleteStep({ {captured.isScheduled ? : null} ) : ( - + {captured.isScheduled ? ( ) : ( - - - Back to dashboard - - + + + )} )} diff --git a/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx b/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx index bf3a8419265..d244e47d216 100644 --- a/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx +++ b/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx @@ -1,5 +1,5 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; import { isPartialEmailFailure } from '@/editor/publish/email-confirmation'; import { publishEmailErrorStep, @@ -32,14 +32,14 @@ export function CompleteWithEmailErrorStep({ return ( - - Uh-oh.{' '} + + Uh-oh.{' '} {willOnlyEmail ? 'Your post has been created but the email failed to send.' : `Your ${post.displayName} has been published but the email failed to send.`} - + {emailErrorMessage} {mailgunConfigured ? null : ( <> @@ -56,8 +56,9 @@ export function CompleteWithEmailErrorStep({ ) : null} -
+
+
); } diff --git a/apps/admin/src/editor/publish/components/confirm-step.tsx b/apps/admin/src/editor/publish/components/confirm-step.tsx index 991023706ec..631dc162d29 100644 --- a/apps/admin/src/editor/publish/components/confirm-step.tsx +++ b/apps/admin/src/editor/publish/components/confirm-step.tsx @@ -1,5 +1,6 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; import { getRecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; @@ -76,15 +77,19 @@ export function ConfirmStep({ return ( - + Ready, set, publish. - + Share it with the world. - + {state.isScheduled ? ( <> On {formatSiteDateTime(state.scheduledAt, timezone)} your @@ -123,25 +128,27 @@ export function ConfirmStep({ ) : null} - + - + ); } diff --git a/apps/admin/src/editor/publish/components/email-recipients-options.tsx b/apps/admin/src/editor/publish/components/email-recipients-options.tsx index ceccb175e62..417de9f7c73 100644 --- a/apps/admin/src/editor/publish/components/email-recipients-options.tsx +++ b/apps/admin/src/editor/publish/components/email-recipients-options.tsx @@ -120,7 +120,11 @@ export function EmailRecipientsOptions({ tiers.length > 1 ? [...tiers] .sort((a, b) => Number(b.active) - Number(a.active)) - .map((tier) => ({ segment: `tier:${tier.slug}`, name: tier.name })) + .map((tier) => ({ + segment: `tier:${tier.slug}`, + name: tier.name, + group: tier.active ? 'Active tiers' : 'Archived tiers', + })) : []; return [ @@ -128,6 +132,7 @@ export function EmailRecipientsOptions({ ...labels.map((label) => ({ segment: `label:${label.slug}`, name: label.name, + group: 'Labels', })), ]; }, [labelsData, labelsError, labelsSettled, tiersData, tiersError, tiersSettled]); diff --git a/apps/admin/src/editor/publish/components/options-step.tsx b/apps/admin/src/editor/publish/components/options-step.tsx index f0bdc028b4f..d3fa1c35b83 100644 --- a/apps/admin/src/editor/publish/components/options-step.tsx +++ b/apps/admin/src/editor/publish/components/options-step.tsx @@ -105,10 +105,14 @@ export function OptionsStep({ return ( - + Ready, set, publish. - + Share it with the world. diff --git a/apps/admin/src/editor/publish/components/post-bookmark.tsx b/apps/admin/src/editor/publish/components/post-bookmark.tsx index 6a462020bc4..71cfe5d1e6c 100644 --- a/apps/admin/src/editor/publish/components/post-bookmark.tsx +++ b/apps/admin/src/editor/publish/components/post-bookmark.tsx @@ -23,17 +23,17 @@ export function PostBookmark({ post, siteTitle }: PostBookmarkProps) { style={{ backgroundImage: `url(${post.featureImage})` }} /> ) : null} - - + + {post.title} {post.excerpt ? ( - + {post.excerpt} ) : null} {siteTitle ? ( - + {siteTitle} ) : null} diff --git a/apps/admin/src/editor/publish/components/publish-at-options.tsx b/apps/admin/src/editor/publish/components/publish-at-options.tsx index 83c1e0be354..d2cf4163614 100644 --- a/apps/admin/src/editor/publish/components/publish-at-options.tsx +++ b/apps/admin/src/editor/publish/components/publish-at-options.tsx @@ -1,6 +1,6 @@ import { useId } from 'react'; import { Label, RadioGroup, RadioGroupItem } from '@tryghost/shade/components'; -import { Inline, Stack } from '@tryghost/shade/primitives'; +import { Grid, Inline } from '@tryghost/shade/primitives'; import { publishScheduleDate, publishScheduleTime } from '@tryghost/test-data/selectors/editor'; import { DateTimePicker } from '@/editor/date-time-picker'; import type { PublishOptionsState } from '@/editor/publish/publish-options'; @@ -21,7 +21,7 @@ export function PublishAtOptions({ const id = useId(); return ( - + onToggleScheduled(value === 'schedule')} @@ -38,6 +38,7 @@ export function PublishAtOptions({ {state.isScheduled ? ( ) : null} - +
); } diff --git a/apps/admin/src/editor/publish/components/publish-setting.tsx b/apps/admin/src/editor/publish/components/publish-setting.tsx index 9d221a9adcc..2bd17b4db47 100644 --- a/apps/admin/src/editor/publish/components/publish-setting.tsx +++ b/apps/admin/src/editor/publish/components/publish-setting.tsx @@ -1,4 +1,4 @@ -import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; import { LucideIcon, cn } from '@tryghost/shade/utils'; import type { ReactNode } from 'react'; @@ -37,7 +37,7 @@ export function PublishSetting({ - {open && children ?
{children}
: null} + {open && children ? ( + + {children} + + ) : null} {footer} ); diff --git a/apps/admin/src/editor/publish/components/recipient-select.tsx b/apps/admin/src/editor/publish/components/recipient-select.tsx index 9ee11f471dc..e4586388653 100644 --- a/apps/admin/src/editor/publish/components/recipient-select.tsx +++ b/apps/admin/src/editor/publish/components/recipient-select.tsx @@ -10,6 +10,7 @@ import { formatNumber } from '@tryghost/shade/utils'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { useId, useState } from 'react'; +import { LabelPicker } from '@/members/api'; import { publishRecipientFree, publishRecipientPaid, @@ -21,6 +22,7 @@ export interface SegmentOption { /** The NQL segment, e.g. `tier:gold` or `label:vip`. */ segment: string; name: string; + group: string; } export interface RecipientSelectProps { @@ -40,7 +42,7 @@ function SegmentCount({ filter }: { filter: string | null }) { } return ( - + ({formatNumber(count)}) ); @@ -164,16 +166,29 @@ export function RecipientSelect({ Selection - {segmentOptions.map((option) => ( - - toggleSegment(option.segment)} - /> - - - ))} + ({ + id: option.segment, + slug: option.segment, + name: option.name, + group: option.group, + }))} + optionSource={{ + options: segmentOptions.map((option) => ({ + value: option.segment, + label: option.name, + })), + isInitialLoad: false, + isSearching: false, + isLoadingMore: false, + hasMore: false, + loadMore: () => {}, + shouldClientFilter: true, + }} + placeholder="Search labels and tiers..." + selectedSlugs={segments.specific} + onToggle={toggleSegment} + /> ) : null} diff --git a/apps/admin/src/editor/publish/publish-flow-modal.tsx b/apps/admin/src/editor/publish/publish-flow-modal.tsx index 771e3091f5d..d45fef8ec11 100644 --- a/apps/admin/src/editor/publish/publish-flow-modal.tsx +++ b/apps/admin/src/editor/publish/publish-flow-modal.tsx @@ -1,5 +1,6 @@ import { Button } from '@tryghost/shade/components'; -import { Inline, Stack } from '@tryghost/shade/primitives'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { PageHeader } from '@tryghost/shade/patterns'; import { formatNumber } from '@tryghost/shade/utils'; import { useState } from 'react'; import { @@ -22,6 +23,9 @@ import type { PublishLimitPorts, PublishSiteInput, PublishUserInput } from './pu export interface PublishFlowModalProps { post: PublishFlowPost; + animate?: boolean; + /** Disable when onCompleted navigates, keeping the pending step visible until it unmounts. */ + showCompletion?: boolean; site: PublishSiteInput; user: PublishUserInput; limits?: PublishLimitPorts; @@ -49,6 +53,8 @@ export function PublishFlowModal({ post, ...props }: PublishFlowModalProps) { /** A post change is a new journey; no gate, failure, or completion state carries across it. */ function KeyedPublishFlowModal({ post, + animate = true, + showCompletion, site, user, limits, @@ -101,10 +107,12 @@ function KeyedPublishFlowModal({ return ( !open && close()} > - - - {step === 'complete' ? null : ( - <> - - {flow.emailErrorMessage || !onPreview ? null : ( - - )} - - )} + {flow.emailErrorMessage || !onPreview ? null : ( + + )} + + )} + - {step === 'email-error' && flow.emailErrorMessage ? ( - void flow.retryEmail()} - /> - ) : step === 'complete' ? ( - - ) : step === 'confirm' ? ( - void flow.confirmPublish()} - /> - ) : ( - - )} - + + {step === 'email-error' && flow.emailErrorMessage ? ( + void flow.retryEmail()} + /> + ) : step === 'complete' ? ( + + ) : step === 'confirm' ? ( + void flow.confirmPublish()} + /> + ) : ( + + )} + + ); } diff --git a/apps/admin/src/editor/publish/publish-flow.component.test.tsx b/apps/admin/src/editor/publish/publish-flow.component.test.tsx index 5b78421e285..b2657513076 100644 --- a/apps/admin/src/editor/publish/publish-flow.component.test.tsx +++ b/apps/admin/src/editor/publish/publish-flow.component.test.tsx @@ -1,9 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; import { render } from 'vitest-browser-react'; import { InAppProviders, fakeAdminEndpoint, fakeLabels, fakeTiers } from '@test-utils/acceptance'; -import { publishRecipientFree } from '@tryghost/test-data/selectors/editor'; +import { + publishRecipientFree, + publishRecipientSegments, + publishSettingEmailRecipients, +} from '@tryghost/test-data/selectors/editor'; import { PublishFlowModal } from '@/editor/publish/publish-flow-modal'; import { UpdateFlowModal } from '@/editor/publish/update-flow-modal'; @@ -186,6 +190,23 @@ describe('Publish flow', () => { }); }); + it('keeps confirmation pending during navigation without showing completion', async () => { + const { onCompleted } = await renderPublishFlow({ showCompletion: false }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(() => onCompleted.mock.calls.length).toBe(1); + // The caller has started navigation, but the destination may still be loading. + await expect.element(publishScreen.confirm()).toBeVisible(); + await expect.element(publishScreen.confirmButton()).toBeDisabled(); + await expect(publishScreen.complete()).toHaveCount(0); + expect(JSON.parse(localStorage.getItem('ghost-last-published-post') ?? 'null')).toEqual({ + id: POST_ID, + type: 'post', + }); + }); + it('holds the confirm button through the email poll so the publish cannot be dispatched twice', async () => { const email = { status: 'pending' }; fakeEmailPolling(email); @@ -323,6 +344,50 @@ describe('Publish flow', () => { expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); }); + it('rotates disclosure chevrons only 180 degrees alongside legacy Admin CSS', async () => { + const legacyStyle = document.createElement('style'); + legacyStyle.textContent = '.rotate-180 { transform: rotate(180deg); }'; + document.head.appendChild(legacyStyle); + onTestFinished(() => legacyStyle.remove()); + await renderPublishFlow(); + + const trigger = publishScreen.setting('publish-type'); + const chevron = () => trigger.element().querySelector(':scope > svg')!; + expect(getComputedStyle(chevron()).rotate).toBe('none'); + + await trigger.click(); + await expect.poll(() => getComputedStyle(chevron()).rotate).toBe('180deg'); + expect(getComputedStyle(chevron()).transform).toBe('none'); + + await trigger.click(); + await expect.poll(() => getComputedStyle(chevron()).rotate).toBe('none'); + expect(getComputedStyle(chevron()).transform).toBe('none'); + }); + + it('keeps timing radios in place when scheduling fields appear and disappear', async () => { + await renderPublishFlow(); + await publishScreen.setting('publish-at').click(); + + const now = page.getByRole('radio', { name: 'Set it live now' }); + const schedule = page.getByRole('radio', { name: 'Schedule for later' }); + await expect.element(now).toBeVisible(); + + const radioSpacing = () => { + const first = now.element().getBoundingClientRect(); + const second = schedule.element().getBoundingClientRect(); + return { x: second.x - first.x, y: second.y - first.y }; + }; + const before = radioSpacing(); + + await schedule.click(); + await expect.element(publishScreen.scheduleDate()).toBeVisible(); + expect(radioSpacing()).toEqual(before); + + await now.click(); + await expect.element(publishScreen.scheduleDate()).not.toBeInTheDocument(); + expect(radioSpacing()).toEqual(before); + }); + it('gives each publish-at radio its own id, reached from exactly one label', async () => { await renderPublishFlow(); @@ -408,6 +473,63 @@ describe('Publish flow', () => { await expect.element(publishScreen.options()).toBeInTheDocument(); }); + it('keeps recipient section height stable while a new newsletter count loads', async () => { + let finishCounts = () => {}; + const countsPending = new Promise((resolve) => { + finishCounts = resolve; + }); + fakeAdminEndpoint('GET', /^\/members\/\?.*filter=/, async ({ url }) => { + const isMonthly = new URL(url).searchParams.get('filter')?.includes('monthly'); + if (isMonthly) { + await countsPending; + } + return { + members: [], + meta: { + pagination: { + page: 1, + limit: 1, + pages: 1, + total: isMonthly ? 5 : 20, + next: null, + prev: null, + }, + }, + }; + }); + await renderPublishFlow({ + site: { + ...SITE, + newsletters: [ + ...SITE.newsletters, + { + slug: 'monthly', + name: 'Monthly', + status: 'active', + visibility: 'members', + sortOrder: 1, + }, + ], + }, + }); + await publishScreen.setting('email-recipients').click(); + await expect.element(publishScreen.recipientFree()).toHaveAccessibleName('Free (20)'); + const section = page.getByTestId(publishSettingEmailRecipients); + const before = section.element().getBoundingClientRect().height; + + try { + await page.getByRole('combobox', { name: 'Newsletter' }).click(); + await page.getByRole('option', { name: 'Monthly', exact: true }).click(); + await expect.element(publishScreen.recipientFree()).toHaveAccessibleName('Free'); + expect(section.element().getBoundingClientRect().height).toBe(before); + } finally { + finishCounts(); + } + + await expect.element(publishScreen.recipientFree()).toHaveAccessibleName('Free (5)'); + expect(section.element().getBoundingClientRect().height).toBe(before); + }); + it('loads every page before exposing tier and label recipients', async () => { const pagination = (pageNumber: number) => ({ page: pageNumber, @@ -448,14 +570,165 @@ describe('Publish flow', () => { await expect.poll(() => labelsApi.requests.length).toBe(2); await expect.element(page.getByLabelText('Specific people')).toBeInTheDocument(); await page.getByLabelText('Specific people').click(); - await expect.element(page.getByLabelText('First tier')).toBeInTheDocument(); - await expect.element(page.getByLabelText('Last tier')).toBeInTheDocument(); - await expect.element(page.getByLabelText('First label')).toBeInTheDocument(); - await expect.element(page.getByLabelText('Last label')).toBeInTheDocument(); + await page.getByPlaceholder('Search labels and tiers...').click(); + await expect.element(page.getByRole('option', { name: 'First tier' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'Last tier' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'First label' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'Last label' })).toBeInTheDocument(); expect(new URL(tiersApi.requests[1].url).searchParams.get('page')).toBe('2'); expect(new URL(labelsApi.requests[1].url).searchParams.get('page')).toBe('2'); }); + it('groups specific recipients into active tiers, archived tiers, and labels', async () => { + fakeAdminEndpoint('GET', /^\/tiers\/\?/, { + tiers: [ + { slug: 'legacy', name: 'Legacy tier', active: false }, + { slug: 'supporter', name: 'Supporter', active: true }, + ], + }); + fakeAdminEndpoint('GET', /^\/labels\/\?/, { + labels: [{ slug: 'vip', name: 'VIP' }], + }); + const { dispatch } = await renderPublishFlow({ + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + await page.getByLabelText('Specific people').click(); + const search = page.getByRole('combobox'); + await search.click(); + + await expect + .element( + page + .getByRole('group', { name: 'Active tiers' }) + .getByRole('option', { name: 'Supporter' }), + ) + .toBeVisible(); + await expect + .element( + page + .getByRole('group', { name: 'Archived tiers' }) + .getByRole('option', { name: 'Legacy tier' }), + ) + .toBeVisible(); + await expect + .element(page.getByRole('group', { name: 'Labels' }).getByRole('option', { name: 'VIP' })) + .toBeVisible(); + + await search.fill('Legacy'); + await expect(page.getByRole('group', { name: 'Active tiers' })).toHaveCount(0); + await expect(page.getByRole('group', { name: 'Labels' })).toHaveCount(0); + await userEvent.keyboard('{Enter}'); + await expect.element(page.getByRole('button', { name: 'Remove Legacy tier' })).toBeVisible(); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.element(publishScreen.complete()).toBeVisible(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'tier:legacy' }, + }); + }); + + it('searches existing recipient labels without offering label management', async () => { + fakeAdminEndpoint('GET', /^\/labels\/\?/, { + labels: [ + { slug: 'vip', name: 'VIP' }, + { slug: 'staff', name: 'Staff' }, + ], + }); + const { dispatch } = await renderPublishFlow({ + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + await page.getByLabelText('Specific people').click(); + const search = page.getByRole('combobox'); + await search.fill('VIP'); + await expect.element(page.getByRole('option', { name: 'VIP' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Staff' })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Edit label/ })).toHaveCount(0); + await page.getByRole('option', { name: 'VIP' }).click(); + await search.fill('New label'); + await expect.element(page.getByText('No labels found')).toBeVisible(); + await expect(page.getByRole('option', { name: /Create/ })).toHaveCount(0); + + // Turning the segment audience off and back on retains the selected label. + await page.getByLabelText('Specific people').click(); + await expect(page.getByRole('combobox')).toHaveCount(0); + await page.getByLabelText('Specific people').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'label:vip' }, + }); + }); + + it('selects and removes specific recipients with the keyboard', async () => { + fakeAdminEndpoint('GET', /^\/labels\/\?/, { + labels: [ + { slug: 'vip', name: 'VIP' }, + { slug: 'staff', name: 'Staff' }, + ], + }); + const { dispatch } = await renderPublishFlow({ + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + await page.getByLabelText('Specific people').click(); + const picker = page.getByTestId(publishRecipientSegments); + const search = page.getByRole('combobox'); + await search.click(); + await expect.element(page.getByRole('option', { name: 'VIP' })).toBeVisible(); + await userEvent.keyboard('{ArrowDown}{Enter}'); + await expect.element(picker.getByRole('button', { name: 'Remove Staff' })).toBeVisible(); + await userEvent.keyboard('{Enter}'); + await expect(picker.getByRole('button', { name: 'Remove Staff' })).toHaveCount(0); + await userEvent.keyboard('{ArrowUp}{Enter}'); + await expect.element(picker.getByRole('button', { name: 'Remove VIP' })).toBeVisible(); + await userEvent.keyboard('{Backspace}'); + await expect(picker.getByRole('button', { name: 'Remove VIP' })).toHaveCount(0); + await userEvent.keyboard('{ArrowDown}{Enter}'); + await expect.element(picker.getByRole('button', { name: 'Remove Staff' })).toBeVisible(); + const activeId = search.element().getAttribute('aria-activedescendant'); + expect(activeId).toBeTruthy(); + expect(document.getElementById(activeId ?? '')).toHaveTextContent('Staff'); + await userEvent.keyboard('{Escape}'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect.element(search).toHaveAttribute('aria-expanded', 'false'); + await expect.element(publishScreen.options()).toBeVisible(); + await userEvent.keyboard('{ArrowDown}'); + await expect.element(page.getByRole('listbox')).toBeVisible(); + await expect.element(search).toHaveAttribute('aria-expanded', 'true'); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'label:staff' }, + }); + }); + it('gates the flow behind the TK reminder', async () => { await renderPublishFlow({ tkCount: 2 }); @@ -774,6 +1047,24 @@ describe('Publish flow', () => { expect(retryApi.requests).toHaveLength(1); }); + it('keeps the email retry pending during navigation without showing completion', async () => { + fakeEmailPolling({ status: 'failed', error: 'Sending failed' }, { status: 'submitted' }); + const retryApi = fakeAdminEndpoint('PUT', `/emails/${EMAIL_ID}/retry/`, { emails: [] }); + const { onCompleted } = await renderPublishFlow({ showCompletion: false }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.emailError()).toHaveTextContent('Sending failed'); + await publishScreen.retryEmailButton().click(); + + await expect.poll(() => onCompleted.mock.calls.length).toBe(1); + await expect.element(publishScreen.emailError()).toBeVisible(); + await expect.element(publishScreen.retryEmailButton()).toBeDisabled(); + await expect(publishScreen.complete()).toHaveCount(0); + expect(retryApi.requests).toHaveLength(1); + }); + it('keeps an expired session inside the flow instead of leaving the page', async () => { const { pathname } = window.location; // The recipient count and the email retry are the flow's own requests, diff --git a/apps/admin/src/editor/publish/update-flow-modal.tsx b/apps/admin/src/editor/publish/update-flow-modal.tsx index 47584ae94c4..c1ff6cb39b0 100644 --- a/apps/admin/src/editor/publish/update-flow-modal.tsx +++ b/apps/admin/src/editor/publish/update-flow-modal.tsx @@ -1,6 +1,7 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; import { formatNumber } from '@tryghost/shade/utils'; +import { PageHeader } from '@tryghost/shade/patterns'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; @@ -170,86 +171,99 @@ function KeyedUpdateFlowModal({ open onOpenChange={(open) => !open && close()} > - - - {isSent ? null : ( - - )} + + + + + {isSent ? null : ( + + )} + - - This {post.displayName} {isSent ? 'was' : 'has been'}{' '} - - {post.status} - {isSent ? ' by email' : ''} - - - - - Your {post.displayName} {isScheduled ? 'will be' : 'was'}{' '} - {hasBeenEmailed || willEmail ? ( - <> - {emailOnly ? 'sent to' : 'published and sent to'}{' '} - - {isScheduled - ? pluralSubscribers(count) - : pluralSubscribers(post.email?.email_count ?? null)} - + + + This {post.displayName} {isSent ? 'was' : 'has been'}{' '} + + {post.status} + {isSent ? ' by email' : ''} + + + + + Your {post.displayName} {isScheduled ? 'will be' : 'was'}{' '} + {hasBeenEmailed || willEmail ? ( + <> + {emailOnly ? 'sent to' : 'published and sent to'}{' '} + + {isScheduled + ? pluralSubscribers(count) + : pluralSubscribers(post.email?.email_count ?? null)} + + {showNewsletterName && post.newsletterName ? ( + <> + {' '} + of {post.newsletterName} + + ) : null} + + ) : ( + 'published on your site' + )} + {publishedAt ? <> on {formatSiteDateTime(publishedAt, timezone)}. : '.'} + + + {isScheduled && post.email ? ( + + This post was previously emailed to{' '} + {pluralSubscribers(post.email.email_count ?? null)} {showNewsletterName && post.newsletterName ? ( <> {' '} of {post.newsletterName} ) : null} - - ) : ( - 'published on your site' - )} - {publishedAt ? <> on {formatSiteDateTime(publishedAt, timezone)}. : '.'} - - - {isScheduled && post.email ? ( - - This post was previously emailed to{' '} - {pluralSubscribers(post.email.email_count ?? null)} - {showNewsletterName && post.newsletterName ? ( - <> - {' '} - of {post.newsletterName} - - ) : null} - {post.emailCreatedAt ? ( - <> on {formatSiteDateTime(post.emailCreatedAt, timezone)}. - ) : ( - '.' - )} - - ) : null} - - {failure ? ( - - {failure.message} - - ) : null} - - {isScheduled || !emailOnly ? ( -
- -
- ) : null} -
+ {post.emailCreatedAt ? ( + <> on {formatSiteDateTime(post.emailCreatedAt, timezone)}. + ) : ( + '.' + )} +
+ ) : null} + + {failure ? ( + + {failure.message} + + ) : null} + + {isScheduled || !emailOnly ? ( + + + + ) : null} +
+ ); } diff --git a/apps/admin/src/editor/publish/use-publish-flow.ts b/apps/admin/src/editor/publish/use-publish-flow.ts index a36c636a0a2..1b67c6cd2db 100644 --- a/apps/admin/src/editor/publish/use-publish-flow.ts +++ b/apps/admin/src/editor/publish/use-publish-flow.ts @@ -41,6 +41,7 @@ export interface PublishFlowOptions { /** The machine's clock, injected for tests. */ now?: () => Date; dispatch: PublishDispatcher; + showCompletion?: boolean; onBeforePublish?: () => Promise; onCompleted?: (info: { postId: string; isScheduled: boolean; hasEmail: boolean }) => void; } @@ -106,6 +107,7 @@ export function usePublishFlow({ limits, now, dispatch, + showCompletion = true, onBeforePublish, onCompleted, }: PublishFlowOptions): PublishFlow { @@ -357,18 +359,20 @@ export function usePublishFlow({ return; } completedRef.current = true; - setEmailErrorMessage(null); - setConfirmStatus('success'); - setStep('complete'); - // The server stamps the publish time; this is the closest the client has. - setCompletedAt(new Date().toISOString()); + if (showCompletion) { + setEmailErrorMessage(null); + setConfirmStatus('success'); + setStep('complete'); + // The server stamps the publish time; this is the closest the client has. + setCompletedAt(new Date().toISOString()); + } try { writePublishCelebration({ postId: post.id, displayName: post.displayName, isScheduled }); } finally { onCompleted?.({ postId: post.id, isScheduled, hasEmail }); } }, - [onCompleted, post.displayName, post.id], + [onCompleted, post.displayName, post.id, showCompletion], ); const applyEmailOutcome = useCallback( @@ -536,7 +540,9 @@ export function usePublishFlow({ if (outcome.kind !== 'submitted') { setEmailNote(EMAIL_UNCONFIRMED); } - setRetryStatus('success'); + if (showCompletion) { + setRetryStatus('success'); + } complete(false, outcome.kind !== 'not-needed'); } catch (error) { if (activeRef.current) { @@ -545,7 +551,7 @@ export function usePublishFlow({ setRetryStatus('failure'); } } - }, [complete, confirmation, post.id, refreshPostReads]); + }, [complete, confirmation, post.id, refreshPostReads, showCompletion]); return { ...optionActions, diff --git a/apps/admin/src/members/api.ts b/apps/admin/src/members/api.ts index b70003bb1e8..6ccfe1fd695 100644 --- a/apps/admin/src/members/api.ts +++ b/apps/admin/src/members/api.ts @@ -14,3 +14,4 @@ export { normalizeSharedViewName, parseAllSharedViewsJSON, } from './shared-views'; +export { LabelPicker } from './label-picker'; diff --git a/apps/admin/src/members/label-picker/can-create-label.ts b/apps/admin/src/members/label-picker/can-create-label.ts index ee7a41792f8..be79a8946e8 100644 --- a/apps/admin/src/members/label-picker/can-create-label.ts +++ b/apps/admin/src/members/label-picker/can-create-label.ts @@ -1,6 +1,6 @@ import { type Label } from '@tryghost/admin-x-framework/api/labels'; -export function canCreateLabel(labels: Label[], query: string): boolean { +export function canCreateLabel(labels: Pick[], query: string): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) { return false; diff --git a/apps/admin/src/members/label-picker/edit-row.tsx b/apps/admin/src/members/label-picker/edit-row.tsx index b9548c829d5..de29e522f23 100644 --- a/apps/admin/src/members/label-picker/edit-row.tsx +++ b/apps/admin/src/members/label-picker/edit-row.tsx @@ -6,7 +6,7 @@ import { type Label } from '@tryghost/admin-x-framework/api/labels'; import { getErrorMessage } from '@tryghost/admin-x-framework/errors'; interface EditRowProps { - label: Label; + label: Pick; onSave: (id: string, name: string) => Promise; onCancel: () => void; onDelete: (id: string) => Promise; diff --git a/apps/admin/src/members/label-picker/label-picker.tsx b/apps/admin/src/members/label-picker/label-picker.tsx index d4ad731f656..38c9c49b4de 100644 --- a/apps/admin/src/members/label-picker/label-picker.tsx +++ b/apps/admin/src/members/label-picker/label-picker.tsx @@ -6,8 +6,10 @@ import { Command, CommandEmpty, CommandGroup, + CommandInput, CommandItem, CommandList, + CommandSeparator, } from '@tryghost/shade/components'; import { useShade } from '@tryghost/shade/app'; import { EditRow } from './edit-row'; @@ -25,11 +27,16 @@ const DROPDOWN_CHROME = 6; // Kept clear of the viewport edge so the list never sits flush against it. const GUTTER = 16; +type PickerLabel = Pick & { + /** Optional heading for related options; omitted for a flat label list. */ + group?: string; +}; + export interface LabelPickerProps { - labels: Label[]; + labels: PickerLabel[]; optionSource: ComboboxOptionSource; selectedSlugs: string[]; - resolvedSelectedLabels?: Label[]; + resolvedSelectedLabels?: PickerLabel[]; onToggle: (slug: string) => void; // Creation onCreate?: (name: string) => Promise
+ ) : ( Date: Wed, 30 Sep 2026 10:13:48 -0400 Subject: [PATCH 05/18] Added error reporting for embed previews that fail to load (#31134) no ref Embed previews render in a separate-origin renderer, and when it fails to answer the editor only showed a placeholder. Nothing was reported, so there was no way to tell a blocked frame from a wrong document or a version mismatch. The card now passes each failure to Koenig's `onError`, which the post editors already send to Sentry. `onError` was typed on the composer context but never provided, so it is now passed through. Card render crashes caught by Koenig's error boundary reach the host's handler as a result. --- .changeset/brave-embeds-report.md | 5 ++++ .../src/components/KoenigComposer.tsx | 3 ++- .../src/components/ui/cards/EmbedCard.tsx | 24 ++++++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/brave-embeds-report.md diff --git a/.changeset/brave-embeds-report.md b/.changeset/brave-embeds-report.md new file mode 100644 index 00000000000..4b47be0fca9 --- /dev/null +++ b/.changeset/brave-embeds-report.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Reported embed renderer load failures to the editor's `onError` handler diff --git a/koenig/koenig-lexical/src/components/KoenigComposer.tsx b/koenig/koenig-lexical/src/components/KoenigComposer.tsx index 1174d63d15b..1439f168631 100644 --- a/koenig/koenig-lexical/src/components/KoenigComposer.tsx +++ b/koenig/koenig-lexical/src/components/KoenigComposer.tsx @@ -118,7 +118,8 @@ const KoenigComposer = ({ multiplayerDocId, multiplayerUsername, createWebsocketProvider, - onWordCountChangeRef + onWordCountChangeRef, + onError }}> diff --git a/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx b/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx index 5c6fdaee22b..027e5793041 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx @@ -1,4 +1,5 @@ import '@tryghost/kg-simplemde/dist/simplemde.min.css'; +import KoenigComposerContext from '../../../context/KoenigComposerContext'; import PropTypes from 'prop-types'; import React from 'react'; import {CardCaptionEditor} from '../CardCaptionEditor'; @@ -156,10 +157,19 @@ function EmbedIframe({dataTestId, html}) { function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { const iframeRef = React.useRef(null); const [unavailable, setUnavailable] = React.useState(!rendererUrl); + const {onError} = React.useContext(KoenigComposerContext); + const loadedRef = React.useRef(false); + + // reports to the host, so a preview that fails to load can be debugged + const fail = (reason: string) => { + setUnavailable(true); + onError?.(new Error(`Embed renderer unavailable: ${reason}`)); + }; // a layout effect listens before the iframe can run, so a cached renderer's ready message isn't missed React.useLayoutEffect(() => { if (!rendererUrl) { + fail('invalid url'); return; } @@ -175,7 +185,9 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { if (event.data?.type === EMBED_READY_MESSAGE && !rendered) { if (event.data.version !== EMBED_RENDERER_VERSION) { - setUnavailable(true); + // the effect isn't cleaned up on failure, so stop the timeout reporting again + window.clearTimeout(timeout); + fail(`renderer is v${event.data.version}, editor is v${EMBED_RENDERER_VERSION}`); return; } @@ -198,7 +210,8 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { // the renderer can be blocked, offline or misconfigured const timeout = window.setTimeout(() => { if (!rendered) { - setUnavailable(true); + // the frame loads for error pages too, so loaded means the wrong document came back + fail(`timed out, frame ${loadedRef.current ? 'loaded' : 'never loaded'}`); } }, EMBED_RENDERER_TIMEOUT); @@ -206,6 +219,8 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { window.removeEventListener('message', handleMessage); window.clearTimeout(timeout); }; + // `fail` is recreated every render and mustn't restart the handshake + // eslint-disable-next-line react-hooks/exhaustive-deps }, [html, rendererUrl]); if (unavailable) { @@ -221,7 +236,10 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { sandbox={EMBED_RENDERER_PERMISSIONS} src={rendererUrl} tabIndex={-1} - title="embed-card-iframe"> + title="embed-card-iframe" + onLoad={() => { + loadedRef.current = true; + }}> ); } From c519f9a4e485e78c937bd4c9467f6e1d4328ea39 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 30 Sep 2026 15:16:13 +0100 Subject: [PATCH 06/18] Fixed admin acceptance specs and UI races exposed by slow requests (#31110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref The admin acceptance suite flaked on CI in [this run](https://github.com/TryGhost/Ghost/actions/runs/36593525443/job/109493906527) on TryGhost/Ghost#31093: `Posts list rows › marks a featured post` failed with a 418 for `POST /stats/posts-member-counts/`, even though that endpoint was faked for the test. The request had been issued just before the test ended, but the harness only learned of it once the service worker relayed it. By then the drain had finished and the fakes had been reset. Slowing the MSW service-worker relay (a random 0–150ms delay before it hands each request to the page) turned that one flake into about 80 failures across the suite. It also exposed specs that only passed because the in-browser fake API answers almost instantly. Working through those showed three kinds of problem: the hole in the harness behind the CI flake, specs that assumed an ordering the app doesn't promise, and two real UI races. --- .../src/editor/preview/send-test-email.tsx | 4 +- .../members-filtering.acceptance.test.tsx | 2 + .../post-analytics.acceptance.test.tsx | 21 ++-- .../hooks/use-check-theme-limit-error.tsx | 5 +- .../membership/analytics.acceptance.test.tsx | 21 ++-- .../custom-fields.acceptance.test.tsx | 11 +- apps/admin/src/settings/site/change-theme.tsx | 33 ++--- apps/admin/test-utils/acceptance/setup.ts | 11 +- .../test-utils/acceptance/worker.test.ts | 38 ++++++ apps/admin/test-utils/acceptance/worker.ts | 113 ++++++++++++++---- 10 files changed, 182 insertions(+), 77 deletions(-) diff --git a/apps/admin/src/editor/preview/send-test-email.tsx b/apps/admin/src/editor/preview/send-test-email.tsx index a7b9c3b631d..d085c9c5ea0 100644 --- a/apps/admin/src/editor/preview/send-test-email.tsx +++ b/apps/admin/src/editor/preview/send-test-email.tsx @@ -54,6 +54,8 @@ export function SendTestEmail({ const mailgunIsConfigured = Boolean(configData?.config.mailgunIsConfigured) || Boolean(mailgunApiKey && mailgunDomain && mailgunBaseUrl); + // Until both have loaded, an unconfigured Mailgun can't be told from one not read yet. + const mailgunStatusKnown = configData !== undefined && settingsData !== undefined; const send = async () => { const recipient = address.trim(); @@ -114,7 +116,7 @@ export function SendTestEmail({

You’ll receive this as a {audienceLabel}.

- diff --git a/apps/admin/src/members/members-filtering.acceptance.test.tsx b/apps/admin/src/members/members-filtering.acceptance.test.tsx index b20194b4b5f..8c7f9ba2e66 100644 --- a/apps/admin/src/members/members-filtering.acceptance.test.tsx +++ b/apps/admin/src/members/members-filtering.acceptance.test.tsx @@ -152,6 +152,8 @@ describe('Members list', () => { await renderAdminApp('/members'); await expect(membersScreen.memberRows()).toHaveCount(2); + // The list doesn't wait for the definitions lookup, so let it land before counting. + await expect.poll(() => definitionsApi.requests.length).toBeGreaterThan(0); const requestsBeforeFilter = definitionsApi.requests.length; await membersScreen.addFilter('Name', 'Alice'); diff --git a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx index 965869b048b..4241377227f 100644 --- a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx +++ b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx @@ -15,6 +15,7 @@ import { renderAdminApp, settingsResponse, webAnalyticsBootOverrides, + type TinybirdPipeCapture, } from '@test-utils/acceptance'; import { membersScreen } from '@/members/members.screen'; import { postsListScreen } from '@/posts/list/posts-list.screen'; @@ -825,24 +826,18 @@ describe('Post analytics web', () => { await renderAdminApp(`/posts/analytics/${POST_ID}/web`, { boot: webAnalyticsBootOverrides() }); await expect.element(postAnalyticsScreen.locationRow('US')).toHaveTextContent('United States'); - const initialKpiRequestCount = kpisApi.requests.length; - const initialLocationsRequestCount = topLocationsApi.requests.length; - const initialSourcesRequestCount = topSourcesApi.requests.length; await postAnalyticsScreen.locationRow('US').click(); await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}/web?location=US`); await expect.element(postAnalyticsScreen.filterContainer()).toHaveTextContent('Location'); - await expect.poll(() => kpisApi.requests.length).toBeGreaterThan(initialKpiRequestCount); - await expect - .poll(() => topLocationsApi.requests.length) - .toBeGreaterThan(initialLocationsRequestCount); - await expect - .poll(() => topSourcesApi.requests.length) - .toBeGreaterThan(initialSourcesRequestCount); - expect(kpisApi.lastRequest?.params.get('location')).toBe('US'); - expect(topLocationsApi.lastRequest?.params.get('location')).toBe('US'); - expect(topSourcesApi.lastRequest?.params.get('location')).toBe('US'); + // The applied filter looks up its location options on the same pipe, deliberately + // without the location it filters by, so the filtered request need not come last. + const sentLocation = (pipeApi: TinybirdPipeCapture) => + pipeApi.requests.some(({ params }) => params.get('location') === 'US'); + await expect.poll(() => sentLocation(kpisApi)).toBe(true); + await expect.poll(() => sentLocation(topLocationsApi)).toBe(true); + await expect.poll(() => sentLocation(topSourcesApi)).toBe(true); }); }); diff --git a/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx index 45d14467f76..7dcfd96d17f 100644 --- a/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx +++ b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx @@ -2,6 +2,9 @@ import { HostLimitError } from '@tryghost/admin-x-framework/errors'; import { useCallback } from 'react'; import { useHostLimits, useLimiter } from '@tryghost/admin-x-framework/hooks'; +/** Theme choice is limited by an allowlist alone, so the staff lists never hold the check up. */ +const THEME_LIMITS = ['customThemes'] as const; + interface UseCheckThemeLimitErrorReturn { checkThemeLimitError: (themeName?: string) => Promise; isThemeLimited: boolean; @@ -11,7 +14,7 @@ interface UseCheckThemeLimitErrorReturn { } export const useCheckThemeLimitError = (): UseCheckThemeLimitErrorReturn => { - const limiter = useLimiter(); + const limiter = useLimiter({ limits: THEME_LIMITS }); const allowedThemesList = useHostLimits()?.customThemes?.allowlist; // Single theme: always error diff --git a/apps/admin/src/settings/membership/analytics.acceptance.test.tsx b/apps/admin/src/settings/membership/analytics.acceptance.test.tsx index b4c82bfb4f4..3f24032f6d4 100644 --- a/apps/admin/src/settings/membership/analytics.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/analytics.acceptance.test.tsx @@ -8,6 +8,7 @@ import { renderAdminApp, settingsResponse, } from '@test-utils/acceptance'; +import { deferred } from '@/utils/deferred'; import { settingsScreen } from '@/settings/settings.screen'; type AnalyticsSetting = { key: string; value: boolean; is_read_only?: boolean }; @@ -172,14 +173,11 @@ describe('Analytics settings', () => { it('disables the post analytics export while downloading from the expected endpoint', async () => { fakeSettingsScreens(); - let finishExport!: () => void; + const pendingExport = deferred(); const exportApi = fakeAdminEndpoint( 'GET', '/posts/export/?limit=1000', - () => - new Promise((resolve) => { - finishExport = () => resolve({}); - }), + () => pendingExport.promise, ); await renderAdminApp('/settings'); @@ -188,11 +186,14 @@ describe('Analytics settings', () => { const button = migrationTools.getByTestId('post-analytics-export-button'); await button.click(); - expect(exportApi.requests).toHaveLength(1); - await expect.element(button).toBeDisabled(); - await expect.element(button).toHaveTextContent('Loading...'); - - finishExport(); + try { + await expect.poll(() => exportApi.requests.length).toBe(1); + await expect.element(button).toBeDisabled(); + await expect.element(button).toHaveTextContent('Loading...'); + } finally { + // Settle the held export even when an assertion fails. + pendingExport.resolve({}); + } await expect.element(button).toBeEnabled(); expect(exportApi.lastRequest?.url).toContain('/posts/export/?limit=1000'); diff --git a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx index a60121bcdcb..3131b9d39e1 100644 --- a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx @@ -35,6 +35,11 @@ const archivedField: MemberCustomField = { const flagOn = { labs: { membersCustomFields: true } }; +// A drag that travels to the drop point, as a hand does. dnd-kit works out the row +// under the pointer as it moves, so a drag that jumps straight there in one move can +// be released before the list has registered where it is. +const DRAG = { steps: 10 }; + type CustomField = typeof companyField; function fakeCustomFields(fields: CustomField[] = [companyField]) { @@ -513,7 +518,7 @@ describe('Custom fields', () => { // lifted item by a flat 25px and it never reaches the next row. const handle = settingsScreen.customFields().getByLabelText('Reorder Nickname'); await expect.element(handle).toBeVisible(); - await userEvent.dragAndDrop(handle, rows.first()); + await userEvent.dragAndDrop(handle, rows.first(), DRAG); // The whole list goes up, in the order the drag left it, keys only — order is a // property of the list, so a field never carries a rank. Nickname was dropped on @@ -561,6 +566,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Nickname'), rows.first(), + DRAG, ); await expect.element(rows.first()).toHaveTextContent('Nickname'); @@ -609,6 +615,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Nickname'), rows.first(), + DRAG, ); // The server's own words reach the publisher, not a generic failure: they name @@ -638,6 +645,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Shirt size'), rows.first(), + DRAG, ); // The archived field is named even though it was never on screen: an order states @@ -670,6 +678,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Field 2'), rows.first(), + DRAG, ); // The two fields the publisher cannot see are still named, and still last. diff --git a/apps/admin/src/settings/site/change-theme.tsx b/apps/admin/src/settings/site/change-theme.tsx index 5182b09d7c2..921b0e4351f 100644 --- a/apps/admin/src/settings/site/change-theme.tsx +++ b/apps/admin/src/settings/site/change-theme.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import TopLevelGroup from '@/settings/components/top-level-group'; import { Button, @@ -19,8 +19,6 @@ import { useUpgradeRoute } from '@/settings/hooks/use-upgrade-route'; import { withErrorBoundary } from '@/settings/components/with-error-boundary'; const ChangeTheme: React.FC<{ keywords: string[] }> = ({ keywords }) => { - const [themeLimitError, setThemeLimitError] = useState(null); - const [isCheckingLimit, setIsCheckingLimit] = useState(false); const { checkThemeLimitError } = useCheckThemeLimitError(); const { route, updateRoute } = useSettingsNavigation(); const upgradeRoute = useUpgradeRoute(); @@ -28,31 +26,18 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({ keywords }) => { const { data: themesData } = useBrowseThemes(); const activeTheme = themesData?.themes.find((theme: Theme) => theme.active); - useEffect(() => { - const checkIfThemeChangeAllowed = async () => { - setIsCheckingLimit(true); - const error = await checkThemeLimitError(); - setThemeLimitError(error); - setIsCheckingLimit(false); - }; + const openPreviewModal = async () => { + const limitError = await checkThemeLimitError(); - void checkIfThemeChangeAllowed(); - }, [checkThemeLimitError]); - - const openPreviewModal = () => { - // Wait for limit check if still in progress - if (isCheckingLimit) { - return; - } - - if (themeLimitError) { + if (limitError) { showLimit({ - prompt: themeLimitError, + prompt: limitError, onOk: () => updateRoute({ route: upgradeRoute, isExternal: true }), }); - } else { - updateRoute('design/change-theme'); + return; } + + updateRoute('design/change-theme'); }; const openThemeEditor = async () => { @@ -129,7 +114,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({ keywords }) => { size="sm" type="button" variant="ghost" - onClick={openPreviewModal} + onClick={() => void openPreviewModal()} > Change theme diff --git a/apps/admin/test-utils/acceptance/setup.ts b/apps/admin/test-utils/acceptance/setup.ts index 912c90194ba..24ce59391d7 100644 --- a/apps/admin/test-utils/acceptance/setup.ts +++ b/apps/admin/test-utils/acceptance/setup.ts @@ -3,10 +3,19 @@ import { cleanup } from 'vitest-browser-react'; import './matchers'; import { defaultBootResolver, defaultBootRoutes } from './boot'; -import { resetFakeApi, settleRequests, startFakeApi, verifyNoUnhandledRequests } from './worker'; +import { + resetFakeApi, + settleRequests, + startFakeApi, + trackIssuedRequests, + verifyNoUnhandledRequests, +} from './worker'; import { resetDeclaredResources } from './resources'; import { guardFrameNavigations, resetFakeFrameOrigins } from './frames'; +// At import, before the spec module (and the app) loads. +trackIssuedRequests(); + beforeAll(async () => { // Playwright waits for an element to stop moving before it acts on it, so every // click that opens an animated surface pays that animation — Shade's toaster-in diff --git a/apps/admin/test-utils/acceptance/worker.test.ts b/apps/admin/test-utils/acceptance/worker.test.ts index 2f0a6804d07..f7311bce184 100644 --- a/apps/admin/test-utils/acceptance/worker.test.ts +++ b/apps/admin/test-utils/acceptance/worker.test.ts @@ -19,6 +19,8 @@ function deferredResponse() { return { promise, resolve, reject }; } +const xhrMethods = Object.getOwnPropertyDescriptors(XMLHttpRequest.prototype); + describe('fake API request teardown', () => { let settleRequests: typeof import('./worker').settleRequests; let fetchMock: ReturnType>; @@ -28,12 +30,20 @@ describe('fake API request teardown', () => { vi.useFakeTimers(); fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); + // An XHR is held the same way: sent, but never delivered until the test + // fires its loadend. + XMLHttpRequest.prototype.send = () => {}; const worker = await import('./worker'); settleRequests = worker.settleRequests; + worker.trackIssuedRequests(); await worker.startFakeApi({ resolver: () => undefined, routes: [] }); }); afterEach(() => { + Object.defineProperties(XMLHttpRequest.prototype, { + open: xhrMethods.open, + send: xhrMethods.send, + }); vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -74,6 +84,18 @@ describe('fake API request teardown', () => { await draining; }); + it('removes a fetch that throws before returning from the ledger', async () => { + const error = new TypeError('Failed to construct Request'); + fetchMock.mockImplementation(() => { + throw error; + }); + + expect(() => window.fetch('/ghost/api/admin/stats/posts-member-counts/')).toThrow(error); + const draining = settleRequests(); + await vi.advanceTimersByTimeAsync(60); + await draining; + }); + it('reports the method and path of a fetch still waiting for MSW', async () => { const pending = deferredResponse(); fetchMock.mockReturnValue(pending.promise); @@ -104,4 +126,20 @@ describe('fake API request teardown', () => { pending.resolve(new Response('image')); await request; }); + + it('waits for an XHR upload that has not reached the service worker', async () => { + const upload = new XMLHttpRequest(); + upload.open('POST', '/ghost/api/admin/images/upload/'); + upload.send(); + const settled = vi.fn(); + const draining = settleRequests().then(settled); + + await vi.advanceTimersByTimeAsync(100); + expect(settled).not.toHaveBeenCalled(); + + upload.dispatchEvent(new Event('loadend')); + await vi.advanceTimersByTimeAsync(60); + await draining; + expect(settled).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/admin/test-utils/acceptance/worker.ts b/apps/admin/test-utils/acceptance/worker.ts index 3b7ce089ff6..3e2a079b23c 100644 --- a/apps/admin/test-utils/acceptance/worker.ts +++ b/apps/admin/test-utils/acceptance/worker.ts @@ -113,27 +113,93 @@ export function verifyNoUnhandledRequests(): void { } } -// In-flight ledger for requests the worker owns; drained in afterEach so -// stragglers can't cross test boundaries. Symbols track fetch calls before -// the service worker delivers request:start; MSW request IDs also cover XHR. -const inFlightRequests = new Map(); - -function trackFetchRequests(): void { - const fetch = window.fetch.bind(window); - window.fetch = async (input, init) => { - const url = new URL(input instanceof Request ? input.url : input, window.location.href).href; - if (!isTrackedUrl(url)) { - return fetch(input, init); - } +// In-flight ledger (key → "METHOD path") for requests the worker owns; +// drained in afterEach so stragglers can't cross test boundaries. MSW only +// hears of a request once the service worker has relayed it, so the page also +// records each request from the moment it issues one (trackIssuedRequests) — +// otherwise a request still inside the service worker slips past the drain +// and lands in the next test. +const inFlightRequests = new Map(); + +function describeRequest(method: string, url: string): string { + const path = ADMIN_API_PATTERN.test(url) ? toAdminApiPath(url) : url; + return `${method.toUpperCase()} ${path}`; +} + +let issuedRequestCount = 0; + +/** Records a request the page issues, until the returned callback settles it. */ +function recordIssuedRequest(method: string, url: string): () => void { + if (!isTrackedUrl(url)) { + return () => {}; + } + issuedRequestCount += 1; + const key = `issued:${issuedRequestCount}`; + inFlightRequests.set(key, describeRequest(method, url)); + return () => { + inFlightRequests.delete(key); + }; +} + +function absoluteUrl(url: string | URL): string | undefined { + try { + return new URL(url, document.baseURI).href; + } catch { + return undefined; + } +} + +let issuedRequestsTracked = false; - const id = Symbol(); +/** + * Wraps fetch and XHR (the framework's upload path, for progress) so the + * ledger sees each request from the call. Install before any spec module + * loads, so nothing holds the unwrapped fetch. + */ +export function trackIssuedRequests(): void { + if (issuedRequestsTracked) { + return; + } + issuedRequestsTracked = true; + + const originalFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : absoluteUrl(input); const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); - const path = ADMIN_API_PATTERN.test(url) ? toAdminApiPath(url) : url; - inFlightRequests.set(id, `${method.toUpperCase()} ${path}`); + const settle = url ? recordIssuedRequest(method, url) : () => {}; try { - return await fetch(input, init); - } finally { - inFlightRequests.delete(id); + return originalFetch(input, init).finally(settle); + } catch (error) { + settle(); + throw error; + } + }; + + // Unbound on purpose: each is re-applied to the request instance it wraps. + // eslint-disable-next-line @typescript-eslint/unbound-method + const { open, send } = XMLHttpRequest.prototype; + const opened = new WeakMap(); + XMLHttpRequest.prototype.open = function ( + this: XMLHttpRequest, + method: string, + url: string | URL, + ...rest: unknown[] + ) { + opened.set(this, { method, url: absoluteUrl(url) }); + Reflect.apply(open, this, [method, url, ...rest]); + }; + XMLHttpRequest.prototype.send = function ( + this: XMLHttpRequest, + body?: Document | XMLHttpRequestBodyInit | null, + ) { + const request = opened.get(this); + const settle = request?.url ? recordIssuedRequest(request.method, request.url) : () => {}; + this.addEventListener('loadend', settle, { once: true }); + try { + send.call(this, body); + } catch (error) { + settle(); + throw error; } }; } @@ -141,8 +207,7 @@ function trackFetchRequests(): void { function trackInFlightRequests(worker: SetupWorker): void { worker.events.on('request:start', ({ request, requestId }) => { if (isTrackedUrl(request.url)) { - const path = ADMIN_API_PATTERN.test(request.url) ? toAdminApiPath(request.url) : request.url; - inFlightRequests.set(requestId, `${request.method} ${path}`); + inFlightRequests.set(requestId, describeRequest(request.method, request.url)); } }); // msw 2.x emits "request:end" for completed requests but only @@ -198,7 +263,8 @@ export async function settleRequests({ throw new Error( [ `Request(s) still in flight ${timeoutMs}ms after the test finished:`, - ...[...inFlightRequests.values()].map((description) => ` - ${description}`), + // The page and MSW can both hold the same request. + ...[...new Set(inFlightRequests.values())].map((description) => ` - ${description}`), ].join('\n'), ); } @@ -519,11 +585,6 @@ export async function startFakeApi({ } } - // request:start arrives asynchronously through the service worker. Track - // fetch synchronously too, or teardown can see an empty ledger and remove - // a declared fake while its request is still travelling to MSW. - trackFetchRequests(); - return worker; } From 7020afae558c07f8e5be44b0a38ab68cf3422f63 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Sep 2026 09:17:33 -0500 Subject: [PATCH 07/18] Always rendered server-sent automation description (#30863) closes https://linear.app/ghost/issue/NY-1621 This change should have no impact on functionality. Now that the server always sends an automation description, let's remove the fallback. In addition to automated tests, I also manually verified that this works as before. --- apps/admin-x-framework/src/api/automations.ts | 2 +- .../test/unit/api/automations.test.ts | 1 + apps/admin/src/automations/automations.test.tsx | 2 ++ .../components/automations-list.test.tsx | 7 +++---- .../automations/components/automations-list.tsx | 15 +-------------- apps/admin/src/automations/editor.test.tsx | 1 + 6 files changed, 9 insertions(+), 19 deletions(-) diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 7c455bf2020..7e1b4a37422 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -8,7 +8,7 @@ export const MAX_AUTOMATION_ACTIONS = 20; export type Automation = { id: string; name: string; - description?: string; + description: string; /** @deprecated `slug` will be removed in the future. */ slug?: null | string; status: AutomationStatus; diff --git a/apps/admin-x-framework/test/unit/api/automations.test.ts b/apps/admin-x-framework/test/unit/api/automations.test.ts index ea031e5068e..1af5df5c914 100644 --- a/apps/admin-x-framework/test/unit/api/automations.test.ts +++ b/apps/admin-x-framework/test/unit/api/automations.test.ts @@ -23,6 +23,7 @@ const baseDetail = ( id: 'a1', slug: 'welcome', name: 'Welcome', + description: 'Welcome new members.', status: 'active', created_at: '2026-05-05T00:00:00.000Z', updated_at: '2026-05-05T00:00:00.000Z', diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index 740c9ce7ae2..3d1cd6e2730 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -70,6 +70,7 @@ const automations = [ { id: 'automation-id-1', name: 'Free member welcome flow', + description: 'Greet new free members.', slug: 'member-welcome-email-free', status: 'active' as const, stats: { @@ -81,6 +82,7 @@ const automations = [ { id: 'automation-id-2', name: 'Paid member welcome flow', + description: 'Welcome new paid members.', slug: 'member-welcome-email-paid', status: 'inactive' as const, stats: { diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index 085d20e32d2..18e93d476af 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -20,6 +20,7 @@ const automations = [ { id: 'automation-id-2', name: 'Paid member welcome flow', + description: 'Welcome new paid members after they start their subscription.', slug: 'member-welcome-email-paid', status: 'inactive' as const, stats: { @@ -31,6 +32,7 @@ const automations = [ { id: 'automation-id-3', name: 'Another automation', + description: '', status: 'inactive' as const, stats: { last_run_created_at: null, @@ -68,15 +70,12 @@ describe('AutomationsList', () => { vi.useRealTimers(); }); - it('renders fetched automations with API and fallback descriptions and status labels', () => { + it('renders server descriptions and status labels', () => { renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(3); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); expect(screen.getByText('Greet new free members.')).toBeInTheDocument(); - expect( - screen.queryByText('Welcome new free members after they sign up.'), - ).not.toBeInTheDocument(); expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument(); expect( screen.getByText('Welcome new paid members after they start their subscription.'), diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 4eaeefe1364..5d2d365a6b0 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -13,19 +13,6 @@ import { } from '@tryghost/shade/components'; import { cn, formatNumber, formatTimestamp } from '@tryghost/shade/utils'; -const AUTOMATION_DESCRIPTIONS: Record = { - 'member-welcome-email-free': 'Welcome new free members after they sign up.', - 'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.', -}; - -const getAutomationDescription = (automation: AutomationBrowseItem): string | undefined => { - if (automation.description !== undefined) { - return automation.description; - } - - return automation.slug ? AUTOMATION_DESCRIPTIONS[automation.slug] : undefined; -}; - // Widths are scoped to `lg` because below that the stats lay out on the row's // grid rather than in table cells, where a fixed width would fight the columns. const AUTOMATION_STAT_COLUMNS = [ @@ -145,7 +132,7 @@ const AutomationsList: React.FC = ({ )} {automations.map((automation) => { - const description = getAutomationDescription(automation); + const description = automation.description.trim(); const lastEntry = automation.stats?.last_run_created_at; const totalEntries = automation.stats?.total_run_count ?? 0; const inProgressEntries = automation.stats?.in_progress_run_count ?? 0; diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 310e7d0fec8..0d7c10eceb0 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -376,6 +376,7 @@ const automationDetail: AutomationDetail = { id: 'automation-id-1', slug: 'member-welcome-email-free', name: 'Free member welcome flow', + description: 'Welcome new free members.', status: 'active', created_at: '2026-05-05T00:00:00.000Z', updated_at: '2026-05-05T00:00:00.000Z', From 8ff6447a517949ea33e7facfa243633952185eaa Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 30 Sep 2026 07:27:28 -0700 Subject: [PATCH 08/18] Fixed Mailgun error logging in automation polling (#31059) refs https://linear.app/ghost/issue/NY-1626/ ## Problem MailgunClient rejects failed sends with `{ error, messageData }`. Automation polling logs that wrapper as `err`, but Ghost's logger expects the error itself. The resulting `automations.poll.step_execution_failed` event ends in `undefined` and loses the original message and stack, making failed sends difficult to diagnose. ## Solution Unwrap a nested Error in the step failure handler before logging it. Preserve the original error object, event name, and step ID without adding the email payload to the log. Co-authored-by: Evan Hahn --- .../core/server/services/automations/poll.ts | 3 +- .../core/server/services/lib/mailgun-error.ts | 9 ++++ .../server/services/automations/poll.test.ts | 52 +++++++++++++++++++ .../server/services/lib/mailgun-error.test.ts | 36 +++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 ghost/core/core/server/services/lib/mailgun-error.ts create mode 100644 ghost/core/test/unit/server/services/lib/mailgun-error.test.ts diff --git a/ghost/core/core/server/services/automations/poll.ts b/ghost/core/core/server/services/automations/poll.ts index 2d6c587169d..6d7e3e6d7f0 100644 --- a/ghost/core/core/server/services/automations/poll.ts +++ b/ghost/core/core/server/services/automations/poll.ts @@ -1,5 +1,6 @@ import type { AutomationStepToRun, AutomationsRepository } from './automations-repository'; import { getMailgunMessageId } from '../lib/mailgun-message-id'; +import { getMailgunError } from '../lib/mailgun-error'; import logging from '@tryghost/logging'; import errors from '@tryghost/errors'; import { @@ -104,7 +105,7 @@ const handleStepExecutionFailure = async ({ }>): Promise => { logging.error( { - err, + err: getMailgunError(err), system: { event: 'automations.poll.step_execution_failed', step_id: step.id, diff --git a/ghost/core/core/server/services/lib/mailgun-error.ts b/ghost/core/core/server/services/lib/mailgun-error.ts new file mode 100644 index 00000000000..567d727358d --- /dev/null +++ b/ghost/core/core/server/services/lib/mailgun-error.ts @@ -0,0 +1,9 @@ +/** + * Mailgun clients reject with `{ error, messageData }`. + * + * If passed such an object, we return `error`. Otherwise, we return the input value. + */ +export const getMailgunError = (err: unknown): unknown => + typeof err === 'object' && err !== null && 'error' in err && err.error instanceof Error + ? err.error + : err; diff --git a/ghost/core/test/unit/server/services/automations/poll.test.ts b/ghost/core/test/unit/server/services/automations/poll.test.ts index 3159d35db01..ee02c306991 100644 --- a/ghost/core/test/unit/server/services/automations/poll.test.ts +++ b/ghost/core/test/unit/server/services/automations/poll.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import sinon from 'sinon'; +import logging from '@tryghost/logging'; import { poll } from '../../../../../core/server/services/automations/poll'; import type { AutomationStepToRun } from '../../../../../core/server/services/automations/automations-repository'; @@ -646,6 +647,57 @@ describe('automations poll', function () { sinon.assert.calledOnceWithExactly(options.enqueueAnotherPollAt, retryAt); }); + it('logs native send errors unchanged', async function () { + const step = buildEmailStep(); + const error = new Error('Mailgun request timed out'); + const logError = sinon.stub(logging, 'error'); + automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); + memberWelcomeEmailService.api.sendAutomationEmail.rejects(error); + + await poll(options); + + sinon.assert.calledOnceWithExactly( + logError, + { + err: sinon.match.same(error), + system: { + event: 'automations.poll.step_execution_failed', + step_id: step.id, + }, + }, + `[AUTOMATIONS] Failed to execute automation step ${step.id}`, + ); + sinon.assert.calledOnce(automationsApi.retryStep); + sinon.assert.notCalled(automationsApi.finishStepAndEnqueueNext); + }); + + it('logs the original Mailgun error without the email payload', async function () { + const step = buildEmailStep(); + const error = new Error('Mailgun request timed out'); + const logError = sinon.stub(logging, 'error'); + automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); + memberWelcomeEmailService.api.sendAutomationEmail.rejects({ + error, + messageData: { html: 'Private email content' }, + }); + + await poll(options); + + sinon.assert.calledOnceWithExactly( + logError, + { + err: sinon.match.same(error), + system: { + event: 'automations.poll.step_execution_failed', + step_id: step.id, + }, + }, + `[AUTOMATIONS] Failed to execute automation step ${step.id}`, + ); + sinon.assert.calledOnce(automationsApi.retryStep); + sinon.assert.notCalled(automationsApi.finishStepAndEnqueueNext); + }); + it('permanently fails email send failures at the attempt limit', async function () { const step = buildEmailStep({ step_attempts: 10 }); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); diff --git a/ghost/core/test/unit/server/services/lib/mailgun-error.test.ts b/ghost/core/test/unit/server/services/lib/mailgun-error.test.ts new file mode 100644 index 00000000000..e250873c71b --- /dev/null +++ b/ghost/core/test/unit/server/services/lib/mailgun-error.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; + +import { getMailgunError } from '../../../../../core/server/services/lib/mailgun-error'; + +describe('getMailgunError', function () { + it('returns the original error from a Mailgun rejection', function () { + const error = new Error('Mailgun request timed out'); + const rejection = { error, messageData: { to: 'member@example.com' } }; + + assert.equal(getMailgunError(rejection), error); + }); + + it('returns native errors unchanged', function () { + const error = new Error('Request failed'); + + assert.equal(getMailgunError(error), error); + }); + + it('returns objects without an Error-valued error property unchanged', function () { + for (const value of [ + {}, + { messageData: {} }, + { error: 'Request failed' }, + { error: null }, + { error: { message: 'Request failed' } }, + ]) { + assert.equal(getMailgunError(value), value); + } + }); + + it('returns non-object values unchanged', function () { + for (const value of [null, undefined, 'Request failed', 42, true, Symbol('error'), () => {}]) { + assert.equal(getMailgunError(value), value); + } + }); +}); From 372d160f1279495f854c2f40118e577ce8b81355 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Sep 2026 09:35:44 -0500 Subject: [PATCH 09/18] =?UTF-8?q?=F0=9F=8E=A8=20Increased=20max=20automati?= =?UTF-8?q?on=20actions=20from=2020=20to=2050=20(#31099)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes [NY-1642](https://linear.app/ghost/issue/NY-1642) A simple increase to the limit. --- apps/admin-x-framework/src/api/automations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 7e1b4a37422..54c6b188a36 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -3,7 +3,7 @@ import { Meta, createMutation, createQuery, createQueryWithId } from '../utils/a import type { ReadonlyDeep } from 'type-fest'; export type AutomationStatus = 'active' | 'inactive'; -export const MAX_AUTOMATION_ACTIONS = 20; +export const MAX_AUTOMATION_ACTIONS = 50; export type Automation = { id: string; From ab265eb6aef5bb406834855c391a6339f601584d Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Wed, 30 Sep 2026 16:54:16 +0200 Subject: [PATCH 10/18] Added the post analytics export button back to the self-serve export UI (#30856) ref https://linear.app/ghost/issue/GVA-1001 When the selfServeArchives flag is on, the export tab only showed the "Export data" button, so the quick post analytics CSV download was only reachable by opening the export dialog and downloading a full zip. Post analytics serve a bit of a different purpose, with publishers often exporting these on a regular basis (compared to exporting for the purpose of a backup or change of hosting provider). The button is shared between the flagged and unflagged layouts so the two can't drift apart while the flag is still in place. --- ...migration-tools-export.acceptance.test.tsx | 33 +++++++++---- .../migration-tools-export.tsx | 47 ++++++++++--------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx b/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx index 69e85e53939..f367ddd6ef7 100644 --- a/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx +++ b/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx @@ -47,9 +47,22 @@ describe('Migration tools export', () => { const section = await openExportTab(); await expect.element(section.getByRole('button', { name: 'Content & settings' })).toBeVisible(); await expect.element(section.getByRole('button', { name: 'Post analytics' })).toBeVisible(); - await expect - .element(section.getByRole('button', { name: 'Export data' })) - .not.toBeInTheDocument(); + await expect.element(section.getByRole('button', { name: 'All data' })).not.toBeInTheDocument(); + }); + + it('keeps the post analytics export button alongside the export dialog', async () => { + fakeSettingsScreens(); + const csv = new TextEncoder().encode('id,title\n').buffer; + const postsExport = fakeAdminEndpoint('GET', /^\/posts\/export\//, csv, { + contentType: 'text/csv', + }); + await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); + + const section = await openExportTab(); + await expect.element(section.getByRole('button', { name: 'All data' })).toBeVisible(); + await section.getByRole('button', { name: 'Post analytics' }).click(); + + await expect.poll(() => postsExport.lastRequest?.url).toContain('/posts/export/?limit=1000'); }); it('offers the sync export dialog without media and downloads the zip', async () => { @@ -58,7 +71,7 @@ describe('Migration tools export', () => { await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await expect @@ -82,7 +95,7 @@ describe('Migration tools export', () => { await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('checkbox', { name: 'Members' }).click(); @@ -105,7 +118,7 @@ describe('Migration tools export', () => { await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: 'Export', exact: true }).click(); @@ -124,7 +137,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await expect @@ -168,7 +181,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: 'Export', exact: true }).click(); @@ -190,7 +203,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByText('Post analytics', { exact: true }).click(); @@ -220,7 +233,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: 'Export', exact: true }).click(); diff --git a/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx index a96cae5f58c..81188b03519 100644 --- a/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx @@ -34,6 +34,29 @@ const MigrationToolsExport: React.FC = () => { } }; + const postAnalyticsButton = ( + + ); + if (hasSelfServeArchives) { return ( <> @@ -46,8 +69,9 @@ const MigrationToolsExport: React.FC = () => { onClick={() => setExportAllOpen(true)} > - Export data + All data + {postAnalyticsButton} @@ -65,26 +89,7 @@ const MigrationToolsExport: React.FC = () => { Content & settings - + {postAnalyticsButton} ); }; From 7fa872cc197d77fa9e3747d13d43f91417f79db1 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 30 Sep 2026 17:06:31 +0200 Subject: [PATCH 11/18] Fixed a refetch letting the React editor overwrite another writer's changes (#31094) no ref A background read of the post could hand the React editor another writer's version token without their content. This tab's next save then passed Core's collision check and silently overwrote their changes. --- apps/admin/src/editor/README.md | 4 + .../editor/editor-refetch.acceptance.test.tsx | 207 ++++++++++++++++++ .../editor/editor-save.acceptance.test.tsx | 63 ++++-- apps/admin/src/editor/session/README.md | 35 ++- .../editor-session.record-sync.test.ts | 157 ++++++++++++- .../editor-session.settings-fields.test.ts | 126 +++++------ .../src/editor/session/editor-session.ts | 16 +- .../session/editor-session.view.test.ts | 5 +- .../editor/session/publish-time-save.test.ts | 25 ++- .../session/use-editor-session.test.tsx | 93 +++++++- .../src/editor/session/use-editor-session.ts | 10 +- apps/admin/test-utils/acceptance/README.md | 4 +- apps/admin/test-utils/acceptance/editor.ts | 36 +++ .../test-utils/acceptance/framework-props.ts | 4 +- apps/admin/test-utils/acceptance/index.ts | 1 + .../acceptance/render-admin-app.tsx | 9 +- apps/admin/test-utils/acceptance/worker.ts | 10 +- 17 files changed, 685 insertions(+), 120 deletions(-) create mode 100644 apps/admin/src/editor/editor-refetch.acceptance.test.tsx diff --git a/apps/admin/src/editor/README.md b/apps/admin/src/editor/README.md index df1e9f48908..bb476f84927 100644 --- a/apps/admin/src/editor/README.md +++ b/apps/admin/src/editor/README.md @@ -96,6 +96,10 @@ specific to the editor lives in `apps/admin/test-utils/acceptance/editor.ts`: server that rewrites what it was sent. - `submittedPost(capture, index)` reads the fields of a captured save, the most recent one by default. +- `editorReadLanded(queryClient, version)` resolves once a read of `version` at + its `updated_at` is in the query cache and the editor has handled it, which is + the only sign of a read the editor refuses. `renderAdminApp` resolves with the + `queryClient`. - `fakeUnsplashPhotos()` serves one photo in the shape the picker lays out and inserts, and `UNSPLASH_PICKED` is the rendition the picker asks for. diff --git a/apps/admin/src/editor/editor-refetch.acceptance.test.tsx b/apps/admin/src/editor/editor-refetch.acceptance.test.tsx new file mode 100644 index 00000000000..34aab9ec778 --- /dev/null +++ b/apps/admin/src/editor/editor-refetch.acceptance.test.tsx @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + editorReadLanded, + fakeAdminEndpoint, + fakeEditorChrome, + post, + renderAdminApp, + submittedPost, + unsavedChangesGuarded, + withoutAutosave, + type Post, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; +import { deferred } from '@/utils/deferred'; + +const POST_ID = 'abc123'; +const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); +const LOADED_AT = '2026-01-01T00:00:00.000Z'; +const MY_SAVE_AT = '2026-01-01T00:00:01.000Z'; +const THEIR_SAVE_AT = '2026-01-01T00:00:02.000Z'; +const READ_ROUTE = new RegExp(`^/posts/${POST_ID}/\\?`); + +const UPDATE_COLLISION = { + errors: [ + { + code: 'UPDATE_COLLISION', + type: 'UpdateCollisionError', + message: 'Saving failed! Someone else is editing this post.', + }, + ], +}; + +/** + * A post two writers share: reads serve the stored copy and a save on a stale token is refused. + * Core is laxer: it refuses one only when a posts-row column or its tags, authors or tiers change. + */ +function fakeSharedPost(overrides: Partial = {}) { + fakeEditorChrome(); + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); + + let stored = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + published_at: null, + tags: [], + ...overrides, + }); + const store = (changes: Partial) => { + stored = { + ...stored, + ...changes, + updated_at: new Date(Date.parse(stored.updated_at) + 1000).toISOString(), + }; + }; + + let readsHeld = Promise.resolve(); + const readApi = fakeAdminEndpoint('GET', READ_ROUTE, async () => { + await readsHeld; + return { posts: [stored] }; + }); + const saveApi = fakeAdminEndpoint('PUT', READ_ROUTE, ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + if (submitted.updated_at !== stored.updated_at) { + return Response.json(UPDATE_COLLISION, { status: 409 }); + } + store(submitted); + return { posts: [stored] }; + }); + + return { + readApi, + saveApi, + stored: () => stored, + /** Another writer's save, stored under a token this tab has never been sent. */ + theySave: (changes: Partial) => store(changes), + /** Holds every read until the returned release is called. */ + holdReads: () => { + const gate = deferred(); + readsHeld = gate.promise; + return () => gate.resolve(); + }, + }; +} + +type SharedPost = ReturnType; + +async function appendToBody(text: string) { + const body = editorScreen.body(); + await expect.element(body).toBeVisible(); + await body.fill(`${body.element().textContent ?? ''}${text}`); +} + +/** This tab's title save lands; the other writer's save lands before its refetch is read. */ +async function saveThenTheySave(shared: SharedPost, save: () => Promise) { + await expect.element(editorScreen.titleInput()).toHaveValue('Hello from React'); + const releaseReads = shared.holdReads(); + + await editorScreen.titleInput().fill('My title'); + await save(); + await expect.poll(() => shared.saveApi.requests.length).toBe(1); + await expect.poll(() => shared.readApi.requests.length).toBe(2); + + shared.theySave({ + title: 'Their title', + lexical: buildLexicalParagraph('Their words'), + }); + return releaseReads; +} + +const saveShortcut = () => userEvent.keyboard('{Meta>}s{/Meta}'); + +/** + * A background read can bring back another writer's version; the editor keeps its + * own, so its next save collides with theirs instead of overwriting it. + */ +describe('Post editor refetch', () => { + it('saves a draft against its own version after a refetch brings another writer’s', async () => { + const shared = fakeSharedPost(); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const releaseReads = await saveThenTheySave(shared, saveShortcut); + releaseReads(); + await editorReadLanded(queryClient, shared.stored()); + + await appendToBody(' and mine'); + await saveShortcut(); + + await expect.poll(() => shared.saveApi.requests.length).toBe(2); + expect(submittedPost(shared.saveApi)).toMatchObject({ updated_at: MY_SAVE_AT }); + await expect + .element(editorScreen.conflictBanner()) + .toHaveTextContent('Someone else is editing this post'); + await expect.element(editorScreen.titleInput()).toHaveValue('My title'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and mine'); + expect(shared.stored()).toMatchObject({ title: 'Their title', updated_at: THEIR_SAVE_AT }); + }); + + it('keeps a title staged on a published post through the refetch, and the Update collides', async () => { + const shared = fakeSharedPost({ + status: 'published', + published_at: '2025-12-01T00:00:00.000Z', + }); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const releaseReads = await saveThenTheySave(shared, () => editorScreen.updateButton().click()); + await editorScreen.titleInput().fill('My staged title'); + releaseReads(); + await editorReadLanded(queryClient, shared.stored()); + + await editorScreen.updateButton().click(); + + await expect.poll(() => shared.saveApi.requests.length).toBe(2); + expect(submittedPost(shared.saveApi)).toMatchObject({ + title: 'My staged title', + updated_at: MY_SAVE_AT, + }); + await expect.element(editorScreen.conflictBanner()).toBeVisible(); + await expect.element(editorScreen.titleInput()).toHaveValue('My staged title'); + expect(shared.stored()).toMatchObject({ title: 'Their title', updated_at: THEIR_SAVE_AT }); + }); + + it('leaves a clean writer clean and unsaved when a refetch brings another writer’s version', async () => { + const shared = fakeSharedPost(); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const releaseReads = await saveThenTheySave(shared, saveShortcut); + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + releaseReads(); + await editorReadLanded(queryClient, shared.stored()); + + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + expect(unsavedChangesGuarded()).toBe(false); + await expect(editorScreen.conflictBanner()).toHaveCount(0); + await expect.element(editorScreen.titleInput()).toHaveValue('My title'); + expect(shared.saveApi.requests).toHaveLength(1); + }); + + it('saves again without a conflict once the refetch of its own save has landed', async () => { + const shared = fakeSharedPost(); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await expect.element(editorScreen.titleInput()).toHaveValue('Hello from React'); + + await editorScreen.titleInput().fill('My title'); + await saveShortcut(); + await expect.poll(() => shared.saveApi.requests.length).toBe(1); + await editorReadLanded(queryClient, shared.stored()); + + await appendToBody(' and more'); + await saveShortcut(); + + await expect.poll(() => shared.saveApi.requests.length).toBe(2); + expect(submittedPost(shared.saveApi)).toMatchObject({ updated_at: MY_SAVE_AT }); + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + await expect(editorScreen.conflictBanner()).toHaveCount(0); + expect(shared.stored()).toMatchObject({ title: 'My title' }); + expect(shared.stored().lexical).toContain('Hello from React and more'); + }); +}); diff --git a/apps/admin/src/editor/editor-save.acceptance.test.tsx b/apps/admin/src/editor/editor-save.acceptance.test.tsx index 114c62cfa57..fa1119b7e9c 100644 --- a/apps/admin/src/editor/editor-save.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-save.acceptance.test.tsx @@ -5,6 +5,7 @@ import { buildLexicalParagraph } from '@tryghost/test-data'; import { currentRoute, currentUserResponse, + editorReadLanded, fakeAdminEndpoint, fakeEditorChrome, fakeEditorPost, @@ -328,32 +329,62 @@ describe('Post editor saving', () => { await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); }); - it('reports a status the post reached elsewhere once a save refetches it', async () => { + it('saves its own version when a refetch finds the post published elsewhere, and collides', async () => { const saveApi = fakeSavablePost(); - await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); // A later handler for the same route wins: from here the read answers // with the post as someone else has just published it. - fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { - posts: [ - post({ - id: POST_ID, - title: 'Hello from React', - slug: 'hello-from-react', - status: 'published', - lexical: buildLexicalParagraph('Hello from React'), - updated_at: '2026-01-01T00:01:00.000Z', - published_at: '2026-01-01T00:01:00.000Z', - tags: [], - }), - ], + const publishedElsewhere = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'published', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: '2026-01-01T00:01:00.000Z', + published_at: '2026-01-01T00:01:00.000Z', + tags: [], }); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { posts: [publishedElsewhere] }); await appendToBody(' and more'); await expect.poll(() => saveApi.requests.length).toBe(1); + await editorReadLanded(queryClient, publishedElsewhere); - await expect.element(editorScreen.status()).toHaveTextContent('Published'); + // The server holds their version now, so a save on any other token collides. + const collidingSaveApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + ({ body }) => + (body as { posts: Array<{ updated_at?: string }> }).posts[0].updated_at === + publishedElsewhere.updated_at + ? { posts: [publishedElsewhere] } + : Response.json( + { + errors: [ + { + code: 'UPDATE_COLLISION', + type: 'UpdateCollisionError', + message: 'Saving failed! Someone else is editing this post.', + }, + ], + }, + { status: 409 }, + ), + ); + await appendToBody(' again'); + + await expect.poll(() => collidingSaveApi.requests.length).toBe(1); + expect(submittedPost(collidingSaveApi)).toMatchObject({ + status: 'draft', + updated_at: '2026-01-01T00:00:01.000Z', + }); + await expect + .element(editorScreen.conflictBanner()) + .toHaveTextContent('Someone else is editing this post'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and more again'); }); it('keeps the editor and what was typed when the read after a save fails with a 500', async () => { diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md index 1e9d71c3469..1740cd6e12c 100644 --- a/apps/admin/src/editor/session/README.md +++ b/apps/admin/src/editor/session/README.md @@ -37,8 +37,9 @@ A blank title is held in the projection as the default title while the input stays empty, so a post persisted under that title does not read as permanently diverged from what the writer sees. The title input never shows it; substituting it on the way to the server is the save engine's own duty. The persisted -identity — the id and the collision token — is replaced from every -acknowledgement, so the next request carries the token the server just issued. +identity — the id and the collision token — is moved by every acknowledgement +and every reload and never by a read, so the next request carries the token of +the version the session's content was built on. The snapshot the engine reads is that projection reduced to what the queue reasons about: the id with its collision token, both `null` until the create is @@ -146,6 +147,26 @@ the request submitted and the full record the server acknowledged, so the tracker's three-way rebase has a stable base for every field the request carried. +A read is adopted only at the collision token the session holds. Core leaves the +token alone unless a column of the posts row changes, so such a read can still +carry another writer's tags, authors or tiers, or fields Core stores beside the +post: the meta and social fields and the feature image's alt text and caption. +The session takes the read as its saved copy, and the settings rules below +decide which settings fields the document adopts from it. A read at any other +token is another writer's version, or the session's own save read before its +acknowledgement landed; its content is not in the document, so the session keeps +its token and its saved copy, marks nothing dirty and starts no save. The next +save carries the token the writer's content was built on, and the server refuses +it with a collision instead of letting it overwrite the newer version; reloading +the document is the way onto that version. A refused read is offered again +whenever the engine moves on, so a read of a save that was in flight is adopted +once that save's acknowledgement has landed and the session holds its token. + +Opening the post again before the read that follows its last save has landed +starts from the cached copy that predates that save. That read then carries a +newer token than the session opened with and is refused, so the next save +collides with the writer's own earlier save and shows the conflict banner. + A save writes a title and slug the writer never typed — the request's own default title, the slug derived from the title — and the server may normalize both again. The live document adopts each, before the acknowledgement is @@ -162,12 +183,12 @@ slug behavior. Settings fields follow the same rule. A field a section does not own is carried in the projection but never sent. Successful saves and reverted edits release -ownership, so a later refetch can adopt someone else's change and an unrelated -save cannot overwrite it. An outstanding edit keeps the writer's value through a -refetch or a rejected save, and an acknowledgement adopts the server's +ownership, so a later read or acknowledgement adopts the server's value and an +unrelated save cannot overwrite it. An outstanding edit keeps the writer's value +through a refetch or a rejected save, and an acknowledgement adopts the server's normalized value only where the writer has not edited past the submitted value. -Undoing a field while its save is in flight also stays staged, even if that -save's refetch arrives before its acknowledgement: the next save persists the +Undoing a field while its save is in flight also stays staged, even if a read +at the held token carries another value meanwhile: the next save persists the undo. Ownership is decided by which fields the writer moved and when, never by comparing the live document against a pre-save snapshot, so adopting one refetch inside a save window does not stop a later one from being adopted too, and diff --git a/apps/admin/src/editor/session/editor-session.record-sync.test.ts b/apps/admin/src/editor/session/editor-session.record-sync.test.ts index 4f45c08e90c..ee930a3b502 100644 --- a/apps/admin/src/editor/session/editor-session.record-sync.test.ts +++ b/apps/admin/src/editor/session/editor-session.record-sync.test.ts @@ -7,6 +7,9 @@ import { sessionHarness, updateCollision, } from '@/editor/session/__test-utils__/session-harness'; +import type { EditorRecord } from './projection'; + +const THEIR_SAVE_AT = '2026-01-02T00:00:00.000Z'; describe('createEditorSession', () => { it('adopts a refetched record without re-baselining', () => { @@ -15,7 +18,7 @@ describe('createEditorSession', () => { session.setBaseline(record().lexical); session.patchLexical(edited); - const accepted = session.recordRefetched(record({ updated_at: '2026-01-02T00:00:00.000Z' })); + const accepted = session.recordRefetched(record()); expect(accepted).toBe(true); expect(session.getSaveSnapshot().isDirty).toBe(true); @@ -25,7 +28,7 @@ describe('createEditorSession', () => { const { session } = sessionHarness({ record: record({ title: 'Hello' }) }); session.setBaseline(record().lexical); - session.recordRefetched(record({ title: 'Hello ', updated_at: '2026-01-02T00:00:00.000Z' })); + session.recordRefetched(record({ title: 'Hello ' })); expect(session.getSaveSnapshot()).toMatchObject({ title: 'Hello', @@ -34,6 +37,122 @@ describe('createEditorSession', () => { }); }); + describe('a refetch of a version this session did not load', () => { + const theirs = () => + record({ + title: 'Their title', + lexical: buildLexicalParagraph('Their words'), + custom_excerpt: 'Their summary', + status: 'published', + published_at: THEIR_SAVE_AT, + updated_at: THEIR_SAVE_AT, + }); + + it('keeps the loaded token and saved copy under unsaved work, so the next save collides', async () => { + const { session, state } = sessionHarness( + { record: record(), baseline: record().lexical }, + { failUpdateWith: updateCollision() }, + ); + session.patchTitle('My title'); + session.patchLexical(body('My words')); + + expect(session.recordRefetched(theirs())).toBe(false); + expect(session.getSaveSnapshot()).toMatchObject({ + updatedAt: LOADED_AT, + status: 'draft', + title: 'My title', + isDirty: true, + }); + expect(session.getFields().custom_excerpt).toBeNull(); + + await session.dispatchExplicit(); + + expect(state.updates[0].payload).toMatchObject({ + updated_at: LOADED_AT, + title: 'My title', + status: 'draft', + }); + expect(session.getState().kind).toBe('conflict'); + expect(session.getFields().title).toBe('My title'); + expect(session.getLiveLexical()).toBe(JSON.stringify(body('My words'))); + }); + + it('leaves a clean session clean and unsaved', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + const before = session.getView(); + + expect(session.recordRefetched(theirs())).toBe(false); + await Promise.resolve(); + + expect(session.getView()).toBe(before); + expect(session.getSaveSnapshot()).toMatchObject({ + updatedAt: LOADED_AT, + status: 'draft', + isDirty: false, + }); + expect(session.getView().pendingSave).toBeNull(); + expect(state.updates).toHaveLength(0); + expect(await session.leaveRequested()).toBe('proceed'); + }); + + it('sends the loaded token when the first edit after it saves', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + + session.recordRefetched(theirs()); + session.patchLexical(body('My words')); + await session.dispatchExplicit(); + + expect(state.updates[0].payload.updated_at).toBe(LOADED_AT); + }); + + it('sends the token of its own last save rather than the newer one', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + session.patchLexical(body('Saved words')); + await session.dispatchExplicit(); + const ownSave = state.acknowledged.updated_at; + + expect(session.recordRefetched(theirs())).toBe(false); + session.patchLexical(body('More words')); + await session.dispatchExplicit(); + + expect(state.updates[1].payload.updated_at).toBe(ownSave); + }); + + it('keeps the saved copy it loaded', () => { + const { session } = sessionHarness({ record: record(), baseline: record().lexical }); + session.patchTitle('My title'); + + session.recordRefetched(theirs()); + session.patchTitle('Hello'); + + expect(session.isDirty()).toBe(false); + }); + + it('recognizes a read of its own save once that save has landed', async () => { + let ownSave: EditorRecord | undefined; + let adoptedInFlight: boolean | undefined; + const built = sessionHarness( + { record: record(), baseline: record().lexical }, + { + acknowledge: (acknowledged) => { + ownSave = acknowledged; + // The read reached the server after the write, and this tab before its answer. + adoptedInFlight = built.session.recordRefetched(acknowledged); + return acknowledged; + }, + }, + ); + + built.session.patchLexical(body('Saved words')); + await built.session.dispatchExplicit(); + + expect(adoptedInFlight).toBe(false); + expect(built.session.getSaveSnapshot().updatedAt).toBe(ownSave?.updated_at); + expect(built.session.recordRefetched(ownSave!)).toBe(true); + expect(built.session.isDirty()).toBe(false); + }); + }); + it.each([ ['has no collision token', null], ['has a malformed collision token', 'not-a-date'], @@ -54,13 +173,36 @@ describe('createEditorSession', () => { }); }); + it('ignores a refetched record while it holds no collision token', () => { + const { session } = sessionHarness({ record: record({ updated_at: null }) }); + + const accepted = session.recordRefetched(record({ status: 'published' })); + + expect(accepted).toBe(false); + expect(session.getSaveSnapshot()).toMatchObject({ status: 'draft', updatedAt: '' }); + }); + + it('adopts a read of the held instant written another way, keeping the token as held', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + + const accepted = session.recordRefetched( + record({ status: 'published', updated_at: '2026-01-01T00:00:00Z' }), + ); + session.patchLexical(body('Edited')); + await session.dispatchExplicit(); + + expect(accepted).toBe(true); + expect(state.updates[0].payload).toMatchObject({ + status: 'published', + updated_at: LOADED_AT, + }); + }); + it('ignores a refetch that lands after the session was disposed', () => { const { session } = sessionHarness({ record: record() }); session.dispose(); - const accepted = session.recordRefetched( - record({ status: 'published', updated_at: '2026-01-02T00:00:00.000Z' }), - ); + const accepted = session.recordRefetched(record({ status: 'published' })); expect(accepted).toBe(false); expect(session.getSaveSnapshot()).toMatchObject({ @@ -210,7 +352,7 @@ describe('createEditorSession', () => { expect(session.getLiveLexical()).toBe(JSON.stringify(body('Mine'))); }); - it('accepts a reload at a token a refetch learned after the rejected save', async () => { + it('accepts a reload at the token of a refetch it refused after the rejected save', async () => { const { session } = sessionHarness({ record: record() }, { failUpdateWith: updateCollision() }); const newer = record({ title: 'Their title', @@ -220,7 +362,8 @@ describe('createEditorSession', () => { session.patchLexical(body('Mine')); await session.dispatchExplicit(); - expect(session.recordRefetched(newer)).toBe(true); + expect(session.recordRefetched(newer)).toBe(false); + expect(session.getState().kind).toBe('conflict'); expect(session.recordReloaded(newer)).toBe(true); expect(session.getState().kind).toBe('idle'); diff --git a/apps/admin/src/editor/session/editor-session.settings-fields.test.ts b/apps/admin/src/editor/session/editor-session.settings-fields.test.ts index 45163ca61fc..2c2111e00c2 100644 --- a/apps/admin/src/editor/session/editor-session.settings-fields.test.ts +++ b/apps/admin/src/editor/session/editor-session.settings-fields.test.ts @@ -3,6 +3,7 @@ import { capturedPorts, dispatchedIntents } from '@/editor/session/__test-utils_ import { body, record, + serializedFields, sessionHarness, updateCollision, } from '@/editor/session/__test-utils__/session-harness'; @@ -68,18 +69,14 @@ describe('createEditorSession', () => { expect(session.isDirty()).toBe(false); }); - it('adopts remote settings after the local edit was saved without resending it', async () => { + it('adopts the server’s settings after the local edit was saved without resending it', async () => { const { session, state } = sessionHarness({ record: record({ featured: false }) }); session.patchFields({ featured: true }); await session.dispatchExplicit(); expect(session.isDirty()).toBe(false); - state.acknowledged = { - ...state.acknowledged, - featured: false, - updated_at: '2026-01-01T00:00:01.500Z', - }; + state.acknowledged = { ...state.acknowledged, featured: false }; session.recordRefetched(state.acknowledged); expect(session.getFields().featured).toBe(false); @@ -96,7 +93,7 @@ describe('createEditorSession', () => { session.patchFields({ featured: true }); session.patchFields({ featured: false }); - session.recordRefetched(record({ featured: true, updated_at: '2026-01-02T00:00:00.000Z' })); + session.recordRefetched(record({ featured: true })); expect(session.getFields().featured).toBe(true); expect(session.isDirty()).toBe(false); @@ -107,10 +104,7 @@ describe('createEditorSession', () => { session.patchExcerpt('Temporary'); session.patchExcerpt('Original'); - state.acknowledged = record({ - custom_excerpt: 'Remote', - updated_at: '2026-01-01T00:00:00.500Z', - }); + state.acknowledged = record({ custom_excerpt: 'Remote' }); session.recordRefetched(state.acknowledged); expect(session.getFields().custom_excerpt).toBe('Remote'); @@ -131,12 +125,7 @@ describe('createEditorSession', () => { { record: record({ tags: [{ id: 'tag1', name: 'News' }] }) }, { duringSave: () => { - built.session.recordRefetched( - record({ - tags: [{ id: 'tag3', name: 'Notice' }], - updated_at: '2026-01-01T00:00:01.000Z', - }), - ); + built.session.recordRefetched(record({ tags: [{ id: 'tag3', name: 'Notice' }] })); }, }, ); @@ -168,29 +157,28 @@ describe('createEditorSession', () => { session.patchFields({ authors: [{ id: 'author-2' }] }); session.patchFields({ authors: [{ id: 'author-1' }] }); - session.recordRefetched( - record({ authors: [{ id: 'author-3' }], updated_at: '2026-01-02T00:00:00.000Z' }), - ); + session.recordRefetched(record({ authors: [{ id: 'author-3' }] })); expect(session.getFields().authors).toEqual([{ id: 'author-3' }]); expect(session.isDirty()).toBe(false); }); it('keeps an undo made during a save even when its refetch arrives before the acknowledgement', async () => { + const ownSave = record({ featured: true, updated_at: '2026-01-01T00:00:01.000Z' }); const built = sessionHarness( { record: record({ featured: false }) }, { duringSave: () => { built.session.patchFields({ featured: false }); - built.session.recordRefetched( - record({ featured: true, updated_at: '2026-01-01T00:00:01.000Z' }), - ); + built.session.recordRefetched(ownSave); }, }, ); built.session.patchFields({ featured: true }); await built.session.dispatchExplicit(); + // The screen offers the read again once the save has landed. + expect(built.session.recordRefetched(ownSave)).toBe(true); expect(built.session.getFields().featured).toBe(false); expect(built.session.isDirty()).toBe(true); @@ -212,17 +200,36 @@ describe('createEditorSession', () => { }; }; + it('keeps an undo made during a save from another writer’s value in a read at the held version', async () => { + const built = sessionHarness( + { record: record({ meta_title: 'Loaded' }) }, + { + applied: serializedFields, + duringSave: once(() => { + built.session.patchFields({ meta_title: 'Loaded' }); + // Core stores the meta fields beside the post, so their edit leaves the token. + built.session.recordRefetched(record({ meta_title: 'Theirs' })); + }), + }, + ); + + built.session.patchFields({ meta_title: 'Mine' }); + await built.session.dispatchExplicit(); + + expect(built.session.getFields().meta_title).toBe('Loaded'); + expect(built.session.isDirty()).toBe(true); + + await built.session.dispatchExplicit(); + expect(built.state.updates[1].payload).toMatchObject({ meta_title: 'Loaded' }); + }); + it('adopts the last of two refetches that arrive inside one save', async () => { const built = sessionHarness( { record: record({ visibility: 'public' }) }, { duringSave: once(() => { - built.session.recordRefetched( - record({ visibility: 'members', updated_at: '2026-01-01T00:00:00.500Z' }), - ); - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.700Z' }), - ); + built.session.recordRefetched(record({ visibility: 'members' })); + built.session.recordRefetched(record({ visibility: 'paid' })); }), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'paid' }), }, @@ -258,12 +265,8 @@ describe('createEditorSession', () => { { record: record({ [field]: [] }) }, { duringSave: once(() => { - built.session.recordRefetched( - record({ [field]: first, updated_at: '2026-01-01T00:00:00.500Z' }), - ); - built.session.recordRefetched( - record({ [field]: second, updated_at: '2026-01-01T00:00:00.700Z' }), - ); + built.session.recordRefetched(record({ [field]: first })); + built.session.recordRefetched(record({ [field]: second })); }), acknowledge: (acknowledged) => ({ ...acknowledged, [field]: second }), }, @@ -286,11 +289,7 @@ describe('createEditorSession', () => { const built = sessionHarness( { record: record({ visibility: 'public' }) }, { - duringSave: once(() => - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.500Z' }), - ), - ), + duringSave: once(() => built.session.recordRefetched(record({ visibility: 'paid' }))), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'members' }), }, ); @@ -307,9 +306,7 @@ describe('createEditorSession', () => { { record: record({ visibility: 'public' }) }, { duringSave: once(() => { - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ visibility: 'paid' })); built.session.patchFields({ visibility: 'members' }); }), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'paid' }), @@ -348,9 +345,7 @@ describe('createEditorSession', () => { { duringSave: once(() => { built.session.patchFields({ [field]: edited }); - built.session.recordRefetched( - record({ [field]: edited, updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ [field]: edited })); }), acknowledge: (acknowledged, count) => ({ ...acknowledged, @@ -400,9 +395,7 @@ describe('createEditorSession', () => { { duringSave: once(() => { built.session.patchFields({ visibility: 'public' }); - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ visibility: 'paid' })); }), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'paid' }), }, @@ -427,12 +420,7 @@ describe('createEditorSession', () => { { duringSave: once(() => { built.session.patchFields({ tags: loaded.map((tag) => ({ ...tag })) }); - built.session.recordRefetched( - record({ - tags: [{ id: 'tag-2', name: 'Tech' }], - updated_at: '2026-01-01T00:00:00.500Z', - }), - ); + built.session.recordRefetched(record({ tags: [{ id: 'tag-2', name: 'Tech' }] })); }), acknowledge: (acknowledged) => ({ ...acknowledged, @@ -610,12 +598,24 @@ describe('createEditorSession', () => { record: record({ visibility: 'public', featured: false }), }); + const accepted = session.recordRefetched(record({ visibility: 'paid', featured: true })); + + expect(accepted).toBe(true); + expect(session.getFields()).toMatchObject({ visibility: 'paid', featured: true }); + expect(session.isDirty()).toBe(false); + }); + + it('adopts no settings from a refetch of a version it did not load', () => { + const { session } = sessionHarness({ + record: record({ visibility: 'public', featured: false }), + }); + const accepted = session.recordRefetched( record({ visibility: 'paid', featured: true, updated_at: '2026-01-02T00:00:00.000Z' }), ); - expect(accepted).toBe(true); - expect(session.getFields()).toMatchObject({ visibility: 'paid', featured: true }); + expect(accepted).toBe(false); + expect(session.getFields()).toMatchObject({ visibility: 'public', featured: false }); expect(session.isDirty()).toBe(false); }); @@ -625,9 +625,7 @@ describe('createEditorSession', () => { }); session.patchFields({ featured: true }); - session.recordRefetched( - record({ visibility: 'paid', featured: false, updated_at: '2026-01-02T00:00:00.000Z' }), - ); + session.recordRefetched(record({ visibility: 'paid', featured: false })); expect(session.getFields()).toMatchObject({ visibility: 'paid', featured: true }); expect(session.isDirty()).toBe(true); @@ -637,9 +635,7 @@ describe('createEditorSession', () => { const { session } = sessionHarness({ record: record({ custom_excerpt: 'Opened with' }) }); session.patchExcerpt('typing…'); - session.recordRefetched( - record({ custom_excerpt: 'From elsewhere', updated_at: '2026-01-02T00:00:00.000Z' }), - ); + session.recordRefetched(record({ custom_excerpt: 'From the server' })); expect(session.getFields().custom_excerpt).toBe('typing…'); }); @@ -730,9 +726,7 @@ describe('createEditorSession', () => { duringSave: once(() => { built.session.patchFields({ authors: [AUTHORS[0]] }); // The server has not seen the removal yet, so its copy still has both. - built.session.recordRefetched( - record({ authors: AUTHORS, updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ authors: AUTHORS })); }), acknowledge: (acknowledged) => ({ ...acknowledged, authors: AUTHORS }), }, diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts index 594b28657ee..a24bc60e99e 100644 --- a/apps/admin/src/editor/session/editor-session.ts +++ b/apps/admin/src/editor/session/editor-session.ts @@ -264,6 +264,15 @@ export function isOlderToken(candidate: string, held: string | null): boolean { return !Number.isNaN(candidateTime) && !Number.isNaN(heldTime) && candidateTime < heldTime; } +/** Whether a record's collision token names the version already held. */ +function isHeldToken(candidate: string | null | undefined, held: string | null): boolean { + return ( + isCollisionToken(candidate) && + isCollisionToken(held) && + Date.parse(candidate) === Date.parse(held) + ); +} + /** * Composes the change tracker, slug machine and save engine into one editing * session: one per opened post, never shared between two new posts. @@ -928,12 +937,12 @@ export function createEditorSession({ getLiveLexical: () => live.lexical, recordRefetched: (next) => { - const updatedAt = next.updated_at ?? ''; + // Another version's content is not in this document: holding its token + // would let the next save overwrite it instead of colliding. if ( disposed || identity.id !== next.id || - !isCollisionToken(updatedAt) || - isOlderToken(updatedAt, identity.updatedAt) + !isHeldToken(next.updated_at, identity.updatedAt) ) { return false; } @@ -942,7 +951,6 @@ export function createEditorSession({ const projection = projectionOf(next); tracker.setSaved(next.id, projection); adoptSettings(projection, (key) => adoptable.has(key)); - identity = { id: next.id, updatedAt }; status = next.status ?? status; publishedAt = next.published_at ?? null; releaseSavedPublishTime(); diff --git a/apps/admin/src/editor/session/editor-session.view.test.ts b/apps/admin/src/editor/session/editor-session.view.test.ts index e4e10b59e17..c58f8cc3261 100644 --- a/apps/admin/src/editor/session/editor-session.view.test.ts +++ b/apps/admin/src/editor/session/editor-session.view.test.ts @@ -76,9 +76,8 @@ describe('createEditorSession', () => { session.recordRefetched( record({ status: 'published', - custom_excerpt: 'Another editor’s summary', + custom_excerpt: 'The server’s summary', published_at: '2025-12-01T10:00:00.000Z', - updated_at: '2026-01-01T00:00:01.000Z', }), ), ).toBe(true); @@ -88,7 +87,7 @@ describe('createEditorSession', () => { isDirty: true, settings: { meta_title: 'My unsaved search title', - custom_excerpt: 'Another editor’s summary', + custom_excerpt: 'The server’s summary', }, publishTime: { status: 'published', publishedAt: '2025-12-01T10:00:00.000Z' }, }); diff --git a/apps/admin/src/editor/session/publish-time-save.test.ts b/apps/admin/src/editor/session/publish-time-save.test.ts index c3fbba720c2..f6f89945a15 100644 --- a/apps/admin/src/editor/session/publish-time-save.test.ts +++ b/apps/admin/src/editor/session/publish-time-save.test.ts @@ -375,7 +375,7 @@ describe('staging the publish time', () => { slug: 'post', status: 'published', lexical: null, - updated_at: '2026-01-01T00:00:05.000Z', + updated_at: LOADED_AT, published_at: PAST, tags: [], }); @@ -396,11 +396,32 @@ describe('staging the publish time', () => { slug: 'post', status: 'published', lexical: null, - updated_at: '2026-01-01T00:00:05.000Z', + updated_at: LOADED_AT, published_at: OLDER, tags: [], }); expect(session.isDirty()).toBe(false); }); + + it('keeps a staged time through a refetch of a version it did not load', () => { + const { session } = publishTimeSession('published', PAST); + + session.editPublishedAt(OLDER); + session.recordRefetched({ + id: 'post-id', + uuid: 'post-uuid', + url: 'https://example.com/post/', + title: 'Post', + slug: 'post', + status: 'published', + lexical: null, + updated_at: '2026-01-01T00:00:05.000Z', + published_at: OLDER, + tags: [], + }); + + expect(session.getPublishedAt()).toBe(OLDER); + expect(session.isDirty()).toBe(true); + }); }); diff --git a/apps/admin/src/editor/session/use-editor-session.test.tsx b/apps/admin/src/editor/session/use-editor-session.test.tsx index 562375382f0..628b8330328 100644 --- a/apps/admin/src/editor/session/use-editor-session.test.tsx +++ b/apps/admin/src/editor/session/use-editor-session.test.tsx @@ -5,18 +5,48 @@ import type { ReactNode } from 'react'; import { dispatchedIntents } from './__test-utils__/save-engine-spy'; import { record } from './__test-utils__/session-harness'; import { reportLeaveConfirmation, reportSaveFailure } from '@/editor/report-error'; +import type { SaveCompletion } from '@/editor/engine/save-engine'; +import { deferred } from '@/utils/deferred'; import type { EditorRecord } from './projection'; import { useEditorSession } from './use-editor-session'; type SaveEngineModule = typeof import('@/editor/engine/save-engine'); +type EditorSessionModule = typeof import('./editor-session'); vi.mock('@/editor/engine/save-engine', async (importOriginal) => { const spy = await import('@/editor/session/__test-utils__/save-engine-spy'); return spy.spiedSaveEngine(await importOriginal()); }); +// The screen's read of the post and the update the session sends, driven by each spec. +const postApi = vi.hoisted(() => ({ + read: undefined as { posts: EditorRecord[] } | undefined, + edit: vi.fn(), +})); + +// Every read the screen offers the session, in order. +const offeredReads = vi.hoisted((): EditorRecord[] => []); + +vi.mock('./editor-session', async (importOriginal) => { + const actual = await importOriginal(); + const createEditorSession: EditorSessionModule['createEditorSession'] = (options) => { + const session = actual.createEditorSession(options); + return { + ...session, + recordRefetched: (read) => { + offeredReads.push(read); + return session.recordRefetched(read); + }, + }; + }; + return { ...actual, createEditorSession }; +}); + beforeEach(() => { dispatchedIntents.length = 0; + postApi.read = undefined; + postApi.edit.mockReset(); + offeredReads.length = 0; }); vi.mock('@tryghost/admin-x-framework', () => ({ @@ -47,8 +77,8 @@ vi.mock('@tryghost/admin-x-framework/api/slugs', () => ({ vi.mock('@tryghost/admin-x-framework/api/posts', () => ({ useAddPost: () => ({ mutateAsync: vi.fn() }), - useEditPost: () => ({ mutateAsync: vi.fn() }), - useEditorPost: () => ({ data: undefined }), + useEditPost: () => ({ mutateAsync: postApi.edit }), + useEditorPost: () => ({ data: postApi.read }), postsDataType: 'PostsResponseType', })); @@ -205,3 +235,62 @@ describe('useEditorSession reporting', () => { expect(postType).toBe('post'); }); }); + +describe('useEditorSession refetched record', () => { + it('offers an accepted read only once, however the engine moves on', async () => { + const loaded = record(); + postApi.read = { posts: [loaded] }; + postApi.edit.mockResolvedValue({ + posts: [record({ title: 'A new title', updated_at: '2026-01-01T00:00:01.000Z' })], + }); + const { result } = setup(loaded); + expect(offeredReads).toEqual([loaded]); + + act(() => result.current.bind.onTitleChange('A new title')); + await act(() => result.current.saveExplicit()); + + expect(postApi.edit).toHaveBeenCalledTimes(1); + expect(offeredReads).toEqual([loaded]); + }); + + it('keeps describing its own version when a read brings another writer’s', () => { + const { result, rerender } = setup(); + const loaded = result.current.loadedRecord; + + postApi.read = { + posts: [record({ title: 'Their title', updated_at: '2026-01-02T00:00:00.000Z' })], + }; + rerender(); + + expect(result.current.loadedRecord).toBe(loaded); + expect(result.current.isDirty()).toBe(false); + }); + + it('describes a read of its own save once that save has landed', async () => { + const answer = deferred<{ posts: EditorRecord[] }>(); + postApi.edit.mockReturnValueOnce(answer.promise); + const { result, rerender } = setup(); + const loaded = result.current.loadedRecord; + + act(() => result.current.bind.onTitleChange('A new title')); + let saving!: Promise; + act(() => { + saving = result.current.saveExplicit(); + }); + await waitFor(() => expect(postApi.edit).toHaveBeenCalledTimes(1)); + + // The read reached the server after the write, and this tab before its answer. + const ownSave = record({ title: 'A new title', updated_at: '2026-01-01T00:00:01.000Z' }); + postApi.read = { posts: [ownSave] }; + rerender(); + expect(result.current.loadedRecord).toBe(loaded); + + await act(async () => { + answer.resolve({ posts: [ownSave] }); + await saving; + }); + + expect(result.current.loadedRecord).toBe(ownSave); + expect(result.current.isDirty()).toBe(false); + }); +}); diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts index 9dcf5768806..40c498ce64f 100644 --- a/apps/admin/src/editor/session/use-editor-session.ts +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -336,16 +336,18 @@ export function useEditorSession({ }); const saved = postType === 'page' ? pageQuery.data?.pages[0] : postQuery.data?.posts[0]; + // Only the version the session holds may replace what the screen describes. A + // refused read is offered again as the engine moves on: a landing save may claim it. + const acceptedRead = useRef(undefined); useEffect(() => { - if (!saved) { + if (!saved || saved === acceptedRead.current) { return; } - // The screen's query and a reload both answer with the post; only a valid, - // non-older collision token may replace what the screen describes. if (session.recordRefetched(saved)) { + acceptedRead.current = saved; setLoadedRecord(saved); } - }, [saved, session]); + }, [saved, session, state]); // Its own request: a query refetch would land before the session could refuse the copy. const reload = useCallback(async (): Promise => { diff --git a/apps/admin/test-utils/acceptance/README.md b/apps/admin/test-utils/acceptance/README.md index 5bbe7e71efd..737b9062c3b 100644 --- a/apps/admin/test-utils/acceptance/README.md +++ b/apps/admin/test-utils/acceptance/README.md @@ -30,7 +30,7 @@ await expect.poll(() => document.documentElement.classList.contains("dark")).toB **Unsaved-changes guards.** Typing into a dirty screen arms its navigation guard through a chain of passive effects, so a spec that navigates straight after a `fill` races the guard and an unguarded navigation just goes through. Wait for the guard itself, never for a frame count: `await expect.poll(unsavedChangesGuarded).toBe(true)` before the click or `window.history.back()`. -**One render per test.** Each `renderAdminApp` gets a fresh QueryClient and the fake API resets between tests — there is no reload. State that would be persisted on a real server (user preferences, settings) is _represented_ by boot overrides; a journey that genuinely needs persistence across reloads belongs in `e2e/`. +**One render per test.** Each `renderAdminApp` gets a fresh QueryClient, which it resolves with for a wait nothing on screen can show, and the fake API resets between tests — there is no reload. State that would be persisted on a real server (user preferences, settings) is _represented_ by boot overrides; a journey that genuinely needs persistence across reloads belongs in `e2e/`. **Host page.** `renderAdminApp` mounts into a stand-in of the production host page (the `react-admin` body class + `#root` from index.html), so the shell's viewport-bounded grid applies and scroll-driven behaviors — virtualized lists, infinite paging — work like production. @@ -77,7 +77,7 @@ For a **browse endpoint** (`GET //`), add a resource fake in `resource - `{kind: "passthrough"}` — serves exactly the declared entities, never interprets the query. Right for NQL-filtered lists; per-request responses are declared with a function of the parsed query. - `{kind: "declared-query", covers, select}` — implements _trivial declared_ behaviors only (a field match, page/limit slicing); any filter component outside `covers` 418s instead of silently serving the full world. -For a **one-off endpoint** (stats subpaths, settings chrome, a mutation the spec asserts on), use `fakeAdminEndpoint(method, apiPath, response)` — it enters the route listing, returns a capture, and `response` may be a function of the captured request (`({body}) => body` is an honest echo). +For a **one-off endpoint** (stats subpaths, settings chrome, a mutation the spec asserts on), use `fakeAdminEndpoint(method, apiPath, response)` — it enters the route listing, returns a capture, and `response` may be a function of the captured request (`({body}) => body` is an honest echo). A `Response`, given or returned by the function, is served as it is, so a status can depend on the request. ## Faking Tinybird (web analytics) diff --git a/apps/admin/test-utils/acceptance/editor.ts b/apps/admin/test-utils/acceptance/editor.ts index c9069ed53ad..d875bc06192 100644 --- a/apps/admin/test-utils/acceptance/editor.ts +++ b/apps/admin/test-utils/acceptance/editor.ts @@ -1,3 +1,5 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; import { buildLexicalParagraph, post, settingsResponse, type Post } from '@tryghost/test-data'; import type { RenderAdminAppOptions } from './render-admin-app'; import { fakeNewsletters, fakePosts, fakeSnippets } from './resources'; @@ -45,6 +47,40 @@ export function submittedPost(capture: EndpointCapture, index = -1): Record, +): Promise { + const cache = queryClient.getQueryCache(); + const landed = () => + cache.findAll({ queryKey: [postsDataType] }).some((query) => { + const data = query.state.data as { posts?: Array> } | undefined; + return ( + String(query.queryKey[1]).includes(`/posts/${version.id}/`) && + data?.posts?.[0]?.updated_at === version.updated_at + ); + }); + + return new Promise((resolve) => { + // The cache hears of a read before its observers, which it tells on its next timer turn. + const handled = () => setTimeout(() => setTimeout(resolve)); + if (landed()) { + handled(); + return; + } + const unsubscribe = cache.subscribe(() => { + if (landed()) { + unsubscribe(); + handled(); + } + }); + }); +} + const UNSPLASH_REGULAR = 'https://images.unsplash.com/photo-1?ixid=1&w=1080'; // The picker asks Unsplash for a wider rendition of the image it inserts. export const UNSPLASH_PICKED = 'https://images.unsplash.com/photo-1?ixid=1&w=2000'; diff --git a/apps/admin/test-utils/acceptance/framework-props.ts b/apps/admin/test-utils/acceptance/framework-props.ts index 179d1844212..a214dbba51c 100644 --- a/apps/admin/test-utils/acceptance/framework-props.ts +++ b/apps/admin/test-utils/acceptance/framework-props.ts @@ -9,7 +9,7 @@ import { defaultUnsplashConfig, type TopLevelFrameworkProps } from '@tryghost/ad */ export function createFrameworkProps( overrides: Partial = {}, -): TopLevelFrameworkProps { +): TopLevelFrameworkProps & { queryClient: QueryClient } { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -32,7 +32,7 @@ export function createFrameworkProps( onUpdate: () => {}, onInvalidate: () => {}, onDelete: () => {}, - queryClient, ...overrides, + queryClient: overrides.queryClient ?? queryClient, }; } diff --git a/apps/admin/test-utils/acceptance/index.ts b/apps/admin/test-utils/acceptance/index.ts index cb798d17ab5..27cb898f7b2 100644 --- a/apps/admin/test-utils/acceptance/index.ts +++ b/apps/admin/test-utils/acceptance/index.ts @@ -3,6 +3,7 @@ export { authToken, fakeSetupStatus, plainText, signedOut } from './auth'; export { fakeAnalyticsOverview } from './analytics'; export { UNSPLASH_PICKED, + editorReadLanded, fakeEditorChrome, fakeEditorPost, fakeUnsplashPhotos, diff --git a/apps/admin/test-utils/acceptance/render-admin-app.tsx b/apps/admin/test-utils/acceptance/render-admin-app.tsx index acb5b29fc38..9d8253c2fbd 100644 --- a/apps/admin/test-utils/acceptance/render-admin-app.tsx +++ b/apps/admin/test-utils/acceptance/render-admin-app.tsx @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query'; import { render } from 'vitest-browser-react'; import '@/index.css'; @@ -42,12 +43,13 @@ export function currentRoute(): string { * Boots the real admin app (the same provider stack as src/main.tsx) at the * given hash route, e.g. "/tags" or "/members?filter=label:VIP". Cross-app * (Ember-owned) navigations are recorded on - * `document.body.dataset.externalNavigate` instead of navigating. + * `document.body.dataset.externalNavigate` instead of navigating. Resolves + * with the render and the `queryClient` it booted. */ export async function renderAdminApp( route: string = '/', { labs, boot, autosaveDebounceMs }: RenderAdminAppOptions = {}, -): Promise>> { +): Promise> & { queryClient: QueryClient }> { let overrides: BootOverrides = labs ? composeLabsBootOverrides(labs, boot) : { ...boot }; if (autosaveDebounceMs !== undefined) { overrides = composeConfigBootOverrides( @@ -89,5 +91,6 @@ export async function renderAdminApp( }, }); - return await render(, { container: rootElement }); + const rendered = await render(, { container: rootElement }); + return Object.assign(rendered, { queryClient: framework.queryClient }); } diff --git a/apps/admin/test-utils/acceptance/worker.ts b/apps/admin/test-utils/acceptance/worker.ts index 3e2a079b23c..6927765f6fc 100644 --- a/apps/admin/test-utils/acceptance/worker.ts +++ b/apps/admin/test-utils/acceptance/worker.ts @@ -443,8 +443,9 @@ export type FakeAdminEndpointResponse = * Fake one admin API endpoint that has no resource fake. `apiPath` is * relative to /ghost/api/admin (string = exact including the query, RegExp = * test). `response` may be a synchronous or async function of the captured request — - * `({body}) => body` echoes. Returns a capture of every matched request. - * Prefer `defineResource` for browse endpoints. + * `({body}) => body` echoes. A `Response`, given or returned, is served as it is, so a + * function can answer with a status that depends on the request. Returns a capture of + * every matched request. Prefer `defineResource` for browse endpoints. */ export function fakeAdminEndpoint( method: string, @@ -471,6 +472,11 @@ export function fakeAdminEndpoint( const responseBody: unknown = typeof response === 'function' ? await response(captured) : response; + if (responseBody instanceof Response) { + // A body can be read only once, so every request is served its own copy. + return responseBody.clone(); + } + if (responseBody instanceof ArrayBuffer) { return new HttpResponse(responseBody, { status, From cd95247c58cc14a58adc02f911f90880b68a612f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Sep 2026 10:08:33 -0500 Subject: [PATCH 12/18] Run automations from trigger data, not hard-coded slug (#30835) closes https://linear.app/ghost/issue/NY-1618 *I recommend reviewing with whitespace changes disabled.* What ---- This change should have no user impact. Before this change, automations were run off their `slug`. Now, they're run off their `trigger_tier_scope`. (This will get more complicated when we add new trigger types, but this is good enough for now and much more easily extensible.) Why --- In the near future, publishers will be able to create their own automations. We don't want to rely on hard-coded slugs for this. Test plan --------- In addition to manual tests, I also manually ensured automations still ran (e.g., received an email) after this change. --- .../server/services/automations/README.md | 2 +- .../automations/automations-repository.ts | 3 +- .../database-automations-repository.ts | 138 ++++++++++------ .../core/server/services/automations/poll.ts | 47 ++++-- .../database-automations-repository.test.ts | 7 +- .../database-automations-repository.test.ts | 147 ++++++++++++++++-- .../server/services/automations/poll.test.ts | 22 ++- ghost/core/test/utils/automations-fixtures.ts | 2 + 8 files changed, 281 insertions(+), 87 deletions(-) diff --git a/ghost/core/core/server/services/automations/README.md b/ghost/core/core/server/services/automations/README.md index 83a82de202a..917cd9c70de 100644 --- a/ghost/core/core/server/services/automations/README.md +++ b/ghost/core/core/server/services/automations/README.md @@ -6,7 +6,7 @@ Automations run a series of actions for a member after signup. Each automation i The member repository calls `trigger()` after signup. -The database repository creates run(s) and queues their first step. Ghost's boot process starts the service, which checks for ready steps. It checks again when a new run starts or a later step becomes ready. An in-memory timer handles checks while Ghost is running; the scheduler can wake Ghost after a restart. +The database repository creates run(s) and queues their first step(s). Ghost's boot process starts the service, which checks for ready steps. It checks again when a new run starts or a later step becomes ready. An in-memory timer handles checks while Ghost is running; the scheduler can wake Ghost after a restart. Each check locks ready steps, then runs up to 100 at once. A `wait` action advances when its time arrives. A `send_email` action checks the member's current status and email preference before sending. Disabled automations and ineligible members stop. diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts index 15df4c0fd1a..08d61946e2c 100644 --- a/ghost/core/core/server/services/automations/automations-repository.ts +++ b/ghost/core/core/server/services/automations/automations-repository.ts @@ -116,8 +116,7 @@ type AutomationStepBase = { locked_by: string; automation_run_id: string; automation_id: string; - // NOTE: This property will be removed once we support additional automation triggers. - automation_slug: null | string; + automation_trigger_tier_scope: null | AutomationTriggerTierScope; automation_status: 'inactive' | 'active'; member_id: string | null; member_email: string; diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index 0abcc6374ba..7f5dcfb9f2b 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -47,6 +47,10 @@ const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [ trigger_tier_scope: 'all_paid', }, ] as const; +const TRIGGER_TIER_SCOPE_BY_MEMBER_STATUS = { + free: 'free', + paid: 'all_paid', +} as const satisfies Record<'free' | 'paid', AutomationTriggerTierScope>; const messages = { invalidAutomationActionRevision: @@ -111,6 +115,24 @@ type EdgeRow = { target_action_id: string; }; +type RunToInsert = { + id: string; + created_at: DatabaseDate; + updated_at: DatabaseDate; + automation_id: string; + member_id: string; + member_email: string; +}; + +type StepToInsert = { + id: string; + created_at: DatabaseDate; + updated_at: DatabaseDate; + automation_run_id: string; + automation_action_revision_id: string; + ready_at: DatabaseDate; +}; + type NextActionRevisionRow = { automation_id: string; action_id: string; @@ -124,7 +146,7 @@ type StepToRunRow = { locked_by: string; automation_run_id: string; automation_id: string; - automation_slug: null | string; + automation_trigger_tier_scope: null | AutomationTriggerTierScope; automation_status: 'inactive' | 'active'; member_id: string | null; member_email: string; @@ -623,67 +645,88 @@ async function trigger( await lockMemberForTriggering(trx, memberId); - const firstAction = await findFirstActionRevision(trx, memberStatus); - if (!firstAction) { - return; - } + const firstActions = await findFirstActionRevisions(trx, memberStatus); - const automationId = firstAction.automation_id; + const runsToInsert: RunToInsert[] = []; + const stepsToInsert: StepToInsert[] = []; - if (await hasMemberAlreadyEnteredAutomation(trx, automationId, memberId)) { - logging.info( - `Skipping automation ${automationId} for member ${memberId} because they have already run it`, - ); - return; - } + await Promise.all( + firstActions.map(async (firstAction) => { + const automationId = firstAction.automation_id; - const now = new Date(); - const nowString = toDatabaseDate(now); + if (await hasMemberAlreadyEnteredAutomation(trx, automationId, memberId)) { + logging.info( + `Skipping automation ${automationId} for member ${memberId} because they have already run it`, + ); + return; + } - const readyAt = getReadyAtForAction(firstAction, now, fakeWaitHoursMultiplier); + const now = new Date(); + const nowString = toDatabaseDate(now); - const run = { - id: ObjectId().toHexString(), - created_at: nowString, - updated_at: nowString, - automation_id: automationId, - member_id: memberId, - member_email: memberEmail, - }; + const readyAt = getReadyAtForAction(firstAction, now, fakeWaitHoursMultiplier); - await trx('automation_runs').insert(run); - await insertRunStep(trx, { - automationRunId: run.id, - automationActionRevisionId: firstAction.automation_action_revision_id, - now, - readyAt, - }); + const run = { + id: ObjectId().toHexString(), + created_at: nowString, + updated_at: nowString, + automation_id: automationId, + member_id: memberId, + member_email: memberEmail, + }; + runsToInsert.push(run); + + const step = createRunStep({ + automationRunId: run.id, + automationActionRevisionId: firstAction.automation_action_revision_id, + now, + readyAt, + }); + stepsToInsert.push(step); + }), + ); + + if (runsToInsert.length > 0) { + await Promise.all([ + trx('automation_runs').insert(runsToInsert), + trx('automation_run_steps').insert(stepsToInsert), + ]); + } } async function insertRunStep( trx: Knex.Transaction, - { - automationRunId, - automationActionRevisionId, - now, - readyAt, - }: ReadonlyDeep<{ + options: ReadonlyDeep<{ automationRunId: string; automationActionRevisionId: string; now: Date; readyAt: Date; }>, ): Promise { + await trx('automation_run_steps').insert(createRunStep(options)); +} + +function createRunStep({ + automationRunId, + automationActionRevisionId, + now, + readyAt, +}: ReadonlyDeep<{ + automationRunId: string; + automationActionRevisionId: string; + now: Date; + readyAt: Date; +}>): StepToInsert { const nowString = toDatabaseDate(now); - await trx('automation_run_steps').insert({ + return { id: ObjectId().toHexString(), created_at: nowString, updated_at: nowString, automation_run_id: automationRunId, automation_action_revision_id: automationActionRevisionId, ready_at: toDatabaseDate(readyAt), - }); + }; } async function fetchAndLockSteps( @@ -752,7 +795,7 @@ async function fetchAndLockSteps( 'step.locked_by as locked_by', 'step.automation_run_id as automation_run_id', 'run.automation_id as automation_id', - 'automation.slug as automation_slug', + 'automation.trigger_tier_scope as automation_trigger_tier_scope', 'automation.status as automation_status', 'run.member_id as member_id', 'run.member_email as member_email', @@ -807,7 +850,7 @@ function buildStepToRun(row: ReadonlyDeep): AutomationStepToRun { locked_by: row.locked_by, automation_run_id: row.automation_run_id, automation_id: row.automation_id, - automation_slug: row.automation_slug, + automation_trigger_tier_scope: row.automation_trigger_tier_scope, automation_status: row.automation_status, member_id: row.member_id, member_email: row.member_email, @@ -837,13 +880,11 @@ function buildStepToRun(row: ReadonlyDeep): AutomationStepToRun { } } -async function findFirstActionRevision( +async function findFirstActionRevisions( trx: Knex.Transaction, memberStatus: 'free' | 'paid', -): Promise { - const automationSlug: NonNullable = MEMBER_WELCOME_EMAIL_SLUGS[memberStatus]; - - const row = await trx('automations as automation') +): Promise { + return await trx('automations as automation') .select( 'automation.id as automation_id', 'actions.id as action_id', @@ -853,7 +894,7 @@ async function findFirstActionRevision( ) .innerJoin('automation_actions as actions', 'actions.automation_id', 'automation.id') .innerJoin('automation_action_revisions as revisions', 'revisions.action_id', 'actions.id') - .where('automation.slug', automationSlug) + .where('automation.trigger_tier_scope', TRIGGER_TIER_SCOPE_BY_MEMBER_STATUS[memberStatus]) .where('automation.status', 'active') .whereNull('actions.deleted_at') .whereNotExists( @@ -873,10 +914,7 @@ async function findFirstActionRevision( .max('created_at') .where('action_id', trx.ref('actions.id')), ) - .orderBy(['actions.created_at', 'actions.id']) - .first(); - - return row ?? null; + .orderBy(['actions.created_at', 'actions.id']); } async function finishStepAndEnqueueNext( diff --git a/ghost/core/core/server/services/automations/poll.ts b/ghost/core/core/server/services/automations/poll.ts index 6d7e3e6d7f0..081d7817212 100644 --- a/ghost/core/core/server/services/automations/poll.ts +++ b/ghost/core/core/server/services/automations/poll.ts @@ -1,12 +1,13 @@ -import type { AutomationStepToRun, AutomationsRepository } from './automations-repository'; +import type { + AutomationStepToRun, + AutomationTriggerTierScope, + AutomationsRepository, +} from './automations-repository'; import { getMailgunMessageId } from '../lib/mailgun-message-id'; import { getMailgunError } from '../lib/mailgun-error'; import logging from '@tryghost/logging'; import errors from '@tryghost/errors'; -import { - MEMBER_WELCOME_EMAIL_ELIGIBLE_STATUSES, - MEMBER_WELCOME_EMAIL_SLUGS, -} from '../member-welcome-emails/constants'; +import { MEMBER_WELCOME_EMAIL_ELIGIBLE_STATUSES } from '../member-welcome-emails/constants'; import { MAX_ATTEMPTS, MAX_STEPS_PER_BATCH, RETRY_DELAY_MS } from './constants'; // @ts-expect-error Models currently lack type definitions. import { Member } from '../../models'; @@ -60,12 +61,11 @@ type PollOptions = { memberWelcomeEmailService: MemberWelcomeEmailService; }; -const slugToMemberStatus = new Map( - Object.entries(MEMBER_WELCOME_EMAIL_SLUGS).map(([status, slug]) => [ - slug as string, - status as 'free' | 'paid', - ]), -); +// TODO(NY-1643) Add support for "selected_paid" trigger tier scope. +const MEMBER_STATUS_BY_TRIGGER_TIER_SCOPE = new Map([ + ['free', 'free'], + ['all_paid', 'paid'], +]); const hasUpdatesAndAnnouncementsEnabled = (member: MemberModel): boolean => { const preference = member.get('enable_updates_and_announcements'); @@ -151,19 +151,32 @@ const processStep = async ({ } // NOTE: This will change once we support additional automation triggers. - const memberStatus = step.automation_slug - ? slugToMemberStatus.get(step.automation_slug) - : undefined; + const triggerTierScope = step.automation_trigger_tier_scope; + if (!triggerTierScope) { + logging.error( + { + system: { + event: 'automations.poll.missing_trigger_tier_scope', + step_id: step.id, + }, + }, + `[AUTOMATIONS] Trigger tier scope is missing for step ${step.id}. Currently, this is unexpected`, + ); + await automationsApi.markStepTerminal(step, 'failed'); + return null; + } + + const memberStatus = MEMBER_STATUS_BY_TRIGGER_TIER_SCOPE.get(triggerTierScope); if (!memberStatus) { logging.error( { system: { - event: 'automations.poll.unknown_slug', - slug: step.automation_slug, + event: 'automations.poll.unsupported_trigger_tier_scope', + trigger_tier_scope: triggerTierScope, step_id: step.id, }, }, - `[AUTOMATIONS] Unknown automation slug: ${step.automation_slug}`, + `[AUTOMATIONS] Unsupported trigger tier scope for step ${step.id}: ${triggerTierScope}`, ); await automationsApi.markStepTerminal(step, 'failed'); return null; diff --git a/ghost/core/test/integration/services/automations/database-automations-repository.test.ts b/ghost/core/test/integration/services/automations/database-automations-repository.test.ts index 447aac56db0..ed3cbdb1b05 100644 --- a/ghost/core/test/integration/services/automations/database-automations-repository.test.ts +++ b/ghost/core/test/integration/services/automations/database-automations-repository.test.ts @@ -22,6 +22,7 @@ describe('database automations repository', function () { slug: MEMBER_WELCOME_EMAIL_SLUGS.free, name: 'Free welcome automation', status: 'active', + trigger_tier_scope: 'free', created_at: now, updated_at: now, }); @@ -69,7 +70,7 @@ describe('database automations repository', function () { const bothMemberLocksRequested = Promise.withResolvers(); const timeout = setTimeout( () => bothMemberLocksRequested.reject(new Error('Triggers did not reach member lock')), - 5000, + 10_000, ); let memberLockCount = 0; const onKnexQuery = (query: { sql: string }) => { @@ -106,8 +107,8 @@ describe('database automations repository', function () { automation_id: automationId, member_id: memberId, }); - const steps = await knex('automation_run_steps').where('automation_run_id', runs[0].id); assert.equal(runs.length, 1); + const steps = await knex('automation_run_steps').where('automation_run_id', runs[0].id); assert.equal(steps.length, 1); }); @@ -127,7 +128,7 @@ describe('database automations repository', function () { const bothRunLookupsFinished = Promise.withResolvers(); const timeout = setTimeout( () => bothRunLookupsFinished.reject(new Error('Triggers did not reach run lookup')), - 5000, + 10_000, ); let runLookupCount = 0; const onKnexQueryResponse = (_response: unknown, query: { sql: string }) => { diff --git a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts index bef87f2b2fb..a25de92d3a0 100644 --- a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts @@ -12,6 +12,7 @@ import type { AutomationAction, AutomationsRepository, AutomationStepToRun, + AutomationTriggerTierScope, } from '../../../../../core/server/services/automations/automations-repository'; import { fromDatabaseDate, toDatabaseDate } from '../../../../../core/server/lib/db-types/date'; @@ -225,6 +226,7 @@ const createDatabase = async (): Promise => { slug: 'member-welcome-email-free', name: 'Free member welcome flow', description: 'Welcome new free members after they sign up.', + trigger_tier_scope: 'free', status: 'active', }, { @@ -234,6 +236,7 @@ const createDatabase = async (): Promise => { slug: 'member-welcome-email-paid', name: 'Paid member welcome flow', description: 'Welcome new paid members after they start their subscription.', + trigger_tier_scope: 'all_paid', status: 'active', }, ]); @@ -432,12 +435,11 @@ describe('automations repository', function () { let knex: Knex; let repo: AutomationsRepository; - const getRunByMemberEmail = async (email: string): Promise => + const getRunsByMemberEmail = async (email: string): Promise => await knex('automation_runs') .select('automation_runs.*', 'automations.slug as automation_slug') .innerJoin('automations', 'automations.id', 'automation_runs.automation_id') - .where('automation_runs.member_email', email) - .first(); + .where('automation_runs.member_email', email); const getStepByRunId = async (runId: string) => await knex('automation_run_steps') @@ -533,6 +535,44 @@ describe('automations repository', function () { return result; }; + const insertAutomation = async ({ + slug, + triggerTierScope, + }: { + slug: string; + triggerTierScope: null | AutomationTriggerTierScope; + }) => { + const automationId = ObjectId().toHexString(); + const actionId = ObjectId().toHexString(); + const now = toDatabaseDate(new Date()); + + await knex('automations').insert({ + id: automationId, + created_at: now, + updated_at: now, + slug, + name: slug, + description: slug, + status: 'active', + trigger_tier_scope: triggerTierScope, + }); + await knex('automation_actions').insert({ + id: actionId, + created_at: now, + updated_at: now, + automation_id: automationId, + type: 'wait', + }); + await knex('automation_action_revisions').insert({ + id: ObjectId().toHexString(), + created_at: now, + action_id: actionId, + wait_hours: 24, + }); + + return automationId; + }; + const insertRun = async (automationId: string, createdAt = new Date()) => { const run = { id: ObjectId().toHexString(), @@ -1241,7 +1281,9 @@ describe('automations repository', function () { memberStatus: 'free', }); - const run = await getRunByMemberEmail('free@example.com'); + const runs = await getRunsByMemberEmail('free@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); assert.equal(run.member_email, 'free@example.com'); assert.equal(run.member_id, 'member_123'); @@ -1264,6 +1306,25 @@ describe('automations repository', function () { assert.equal(step.locked_at, null); }); + it('can trigger multiple automations for a free signup', async function () { + await insertAutomation({ + slug: 'member-welcome-email-free-second', + triggerTierScope: 'free', + }); + + await repo.trigger({ + memberEmail: 'free-multiple@example.com', + memberId: 'member_123', + memberStatus: 'free', + }); + + const runs = await getRunsByMemberEmail('free-multiple@example.com'); + assert.deepEqual(runs.map((run) => run.automation_slug).sort(), [ + 'member-welcome-email-free', + 'member-welcome-email-free-second', + ]); + }); + it('uses the fake wait hours multiplier for triggered wait actions when configured', async function () { repo = createDatabaseAutomationsRepository({ knex, @@ -1278,7 +1339,9 @@ describe('automations repository', function () { }); const afterTrigger = Date.now(); - const run = await getRunByMemberEmail('fake-wait@example.com'); + const runs = await getRunsByMemberEmail('fake-wait@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); const step = await getStepByRunId(run.id); @@ -1295,7 +1358,9 @@ describe('automations repository', function () { memberStatus: 'paid', }); - const run = await getRunByMemberEmail('paid@example.com'); + const runs = await getRunsByMemberEmail('paid@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); assert.equal(run.automation_slug, 'member-welcome-email-paid'); @@ -1305,6 +1370,25 @@ describe('automations repository', function () { assert.equal(step.action_type, 'wait'); }); + it('can trigger multiple automations for a paid signup', async function () { + await insertAutomation({ + slug: 'member-welcome-email-paid-second', + triggerTierScope: 'all_paid', + }); + + await repo.trigger({ + memberEmail: 'paid-multiple@example.com', + memberId: 'member_123', + memberStatus: 'paid', + }); + + const runs = await getRunsByMemberEmail('paid-multiple@example.com'); + assert.deepEqual(runs.map((run) => run.automation_slug).sort(), [ + 'member-welcome-email-paid', + 'member-welcome-email-paid-second', + ]); + }); + it('inserts the first non-deleted step', async function () { const automation = await getAutomationBySlug('member-welcome-email-free'); await repo.edit(automation.id, { @@ -1346,7 +1430,9 @@ describe('automations repository', function () { memberStatus: 'free', }); - const run = await getRunByMemberEmail('free@example.com'); + const runs = await getRunsByMemberEmail('free@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); const step = await getStepByRunId(run.id); @@ -1367,7 +1453,7 @@ describe('automations repository', function () { memberStatus: 'free', }); - assert.equal(await getRunByMemberEmail('inactive-free@example.com'), undefined); + assert.deepEqual(await getRunsByMemberEmail('inactive-free@example.com'), []); assert.equal(await getRunCountByAutomationId(freeAutomation.id), 0); }); @@ -1385,10 +1471,51 @@ describe('automations repository', function () { memberStatus: 'free', }); - assert.equal(await getRunByMemberEmail('free-no-actions@example.com'), undefined); + assert.deepEqual(await getRunsByMemberEmail('free-no-actions@example.com'), []); assert.equal(await getRunCountByAutomationId(freeAutomation.id), 0); }); + // TODO(NY-1643) This test will change when we add support for this trigger tier scope. + it('does not trigger an automation for the "selected_paid" trigger tier scope', async function () { + const automationId = await insertAutomation({ + slug: 'member-welcome-email-selected-paid', + triggerTierScope: 'selected_paid', + }); + + await repo.trigger({ + memberEmail: 'selected-paid-free@example.com', + memberId: 'member_123', + memberStatus: 'free', + }); + await repo.trigger({ + memberEmail: 'selected-paid-paid@example.com', + memberId: 'member_123', + memberStatus: 'paid', + }); + + assert.equal(await getRunCountByAutomationId(automationId), 0); + }); + + it('does not trigger an automation when there is no trigger tier scope', async function () { + const automationId = await insertAutomation({ + slug: 'member-welcome-email-without-scope', + triggerTierScope: null, + }); + + await repo.trigger({ + memberEmail: 'no-scope-free@example.com', + memberId: 'member_123', + memberStatus: 'free', + }); + await repo.trigger({ + memberEmail: 'no-scope-paid@example.com', + memberId: 'member_123', + memberStatus: 'paid', + }); + + assert.equal(await getRunCountByAutomationId(automationId), 0); + }); + it('does not enter the same automation twice for a member', async function () { const options = { memberEmail: 'free@example.com', @@ -1796,7 +1923,7 @@ describe('automations repository', function () { locked_by: assertSingleBatchLock(steps), automation_run_id: run.id, automation_id: automation.id, - automation_slug: automation.slug, + automation_trigger_tier_scope: 'free', automation_status: 'active', member_id: run.member_id, member_email: run.member_email, diff --git a/ghost/core/test/unit/server/services/automations/poll.test.ts b/ghost/core/test/unit/server/services/automations/poll.test.ts index ee02c306991..c92f8298abb 100644 --- a/ghost/core/test/unit/server/services/automations/poll.test.ts +++ b/ghost/core/test/unit/server/services/automations/poll.test.ts @@ -4,7 +4,6 @@ import logging from '@tryghost/logging'; import { poll } from '../../../../../core/server/services/automations/poll'; import type { AutomationStepToRun } from '../../../../../core/server/services/automations/automations-repository'; -import { MEMBER_WELCOME_EMAIL_SLUGS } from '../../../../../core/server/services/member-welcome-emails/constants'; // @ts-expect-error Models currently lack type definitions. import { Member } from '../../../../../core/server/models'; @@ -94,7 +93,7 @@ function buildStep(attrs: Partial = {}): StepBase { locked_by: 'lock-id', automation_run_id: 'run-id', automation_id: 'automation-id', - automation_slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + automation_trigger_tier_scope: 'free', automation_status: 'active', member_id: 'member-id', member_email: 'member@example.com', @@ -274,7 +273,7 @@ describe('automations poll', function () { ); }); - it('bails if the member status changed', async function () { + it('bails if the member status changed from free to paid', async function () { const step = buildEmailStep(); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); Member.findOne.resolves(buildMember({ status: 'paid' })); @@ -289,6 +288,21 @@ describe('automations poll', function () { ); }); + it('bails if the member status changed paid to free', async function () { + const step = buildEmailStep({ automation_trigger_tier_scope: 'all_paid' }); + automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); + Member.findOne.resolves(buildMember({ status: 'free' })); + + await poll(options); + + sinon.assert.notCalled(memberWelcomeEmailService.api.sendAutomationEmail); + sinon.assert.calledOnceWithExactly( + automationsApi.markStepTerminal, + step, + 'member changed status', + ); + }); + it('ends the automation run if the member unsubscribed from updates & announcements', async function () { const step = buildEmailStep(); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); @@ -351,7 +365,7 @@ describe('automations poll', function () { it('gift members run through paid automations', async function () { const step = buildEmailStep({ - automation_slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, + automation_trigger_tier_scope: 'all_paid', }); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); Member.findOne.resolves(buildMember({ status: 'gift' })); diff --git a/ghost/core/test/utils/automations-fixtures.ts b/ghost/core/test/utils/automations-fixtures.ts index 63dfea85058..49aa5263678 100644 --- a/ghost/core/test/utils/automations-fixtures.ts +++ b/ghost/core/test/utils/automations-fixtures.ts @@ -56,6 +56,7 @@ export async function setupAutomationsFixture(): Promise { created_at: timestamp(0), updated_at: timestamp(0), slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + trigger_tier_scope: 'free', name: 'Free member welcome flow', description: 'Welcome new free members after they sign up.', status: 'active', @@ -65,6 +66,7 @@ export async function setupAutomationsFixture(): Promise { created_at: timestamp(1), updated_at: timestamp(1), slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, + trigger_tier_scope: 'all_paid', name: 'Paid member welcome flow', description: 'Welcome new paid members after they start their subscription.', status: 'active', From a6db7210b5b2323cd150e9ce2056ae6021d5c629 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 30 Sep 2026 16:15:48 +0100 Subject: [PATCH 13/18] =?UTF-8?q?=F0=9F=8E=A8=20Improved=20display=20of=20?= =?UTF-8?q?YouTube=20video=20images=20in=20emails=20(#31093)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Embed cards kept the oembed provider's `thumbnail_url`, so newsletter video previews hotlinked third-party images and broke whenever the provider stopped serving them. Imported posts already had their embed thumbnails self-hosted, but newly inserted embeds did not. Also switched YouTube thumbnail fetching to their larger, higher-quality images without borders. - The oEmbed endpoint now stores the thumbnail via image storage and returns the stored URL as `thumbnail_url`, keeping the provider's URL in a new `thumbnail_url_original` property - YouTube's `hqdefault.jpg` letterboxes 16:9 videos with black borders, so the 1280x720 `maxresdefault.jpg` is stored instead and the thumbnail dimensions are updated to keep email previews at the right aspect ratio. Videos without a max resolution thumbnail (YouTube 404s for older low-res uploads and some Shorts) fall back to the provider's thumbnail - If the image can't be stored, the provider's URL is kept so the card still has a thumbnail - Mentions are skipped because they aren't stored in content and can be triggered by third parties sending webmentions - The embed node now URL-transforms `metadata.thumbnail_url`, so self-hosted thumbnails (relative or absolute site URLs) are stored with the `__GHOST_URL__` placeholder. Otherwise the relative URLs returned by local storage would reach emails unresolved --- .changeset/thick-jobs-fetch.md | 5 + .../utils/serializers/output/oembed.js | 3 + .../server/services/oembed/oembed-service.js | 96 ++++++++++ ghost/core/test/e2e-api/admin/oembed.test.js | 15 ++ .../services/oembed/oembed-service.test.js | 174 ++++++++++++++++++ .../src/nodes/embed/EmbedNode.ts | 4 +- .../kg-default-nodes/test/nodes/embed.test.ts | 3 +- 7 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 .changeset/thick-jobs-fetch.md diff --git a/.changeset/thick-jobs-fetch.md b/.changeset/thick-jobs-fetch.md new file mode 100644 index 00000000000..13f5ef25b64 --- /dev/null +++ b/.changeset/thick-jobs-fetch.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-default-nodes": patch +--- + +Added URL transforms for embed card thumbnails so self-hosted thumbnails are stored with the site URL placeholder diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js index 89d3696843b..35079523c24 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js @@ -4,6 +4,9 @@ const mappers = require('./mappers'); module.exports = { all(data, apiConfig, frame) { debug('all'); + if (data?.thumbnail_url) { + data.thumbnail_url = mappers.oembed(data.thumbnail_url); + } if (data?.metadata?.thumbnail) { data.metadata.thumbnail = mappers.oembed(data.metadata.thumbnail); } diff --git a/ghost/core/core/server/services/oembed/oembed-service.js b/ghost/core/core/server/services/oembed/oembed-service.js index 1678fda31b8..704efb06d7d 100644 --- a/ghost/core/core/server/services/oembed/oembed-service.js +++ b/ghost/core/core/server/services/oembed/oembed-service.js @@ -84,6 +84,31 @@ const shouldRasterize = (buffer, ext) => { return looksLikeSvg(buffer, SVG_SNIFF_BYTES); }; +const YOUTUBE_MAXRES_THUMBNAIL_WIDTH = 1280; +const YOUTUBE_MAXRES_THUMBNAIL_HEIGHT = 720; + +/** + * YouTube's oEmbed thumbnail is a letterboxed 4:3 `hqdefault.jpg`. Videos with + * an HD upload also have a 1280x720 `maxresdefault.jpg` without the borders. + * + * @param {string} thumbnailUrl + * @returns {string|undefined} + */ +const getYouTubeMaxResThumbnailUrl = (thumbnailUrl) => { + if (!URL.canParse(thumbnailUrl)) { + return; + } + + const url = new URL(thumbnailUrl); + const isYouTubeImage = url.hostname === 'ytimg.com' || url.hostname.endsWith('.ytimg.com'); + if (!isYouTubeImage || !url.pathname.endsWith('/hqdefault.jpg')) { + return; + } + + url.pathname = url.pathname.replace(/hqdefault\.jpg$/, 'maxresdefault.jpg'); + return url.href; +}; + /** * @param {string} url * @returns {{url: string, provider: boolean}} @@ -128,6 +153,13 @@ const findUrlWithProvider = (url) => { * @typedef {import('got').GotRequestFunction} IExternalRequest */ +/** + * @typedef {object} EmbedThumbnail + * @prop {string} url + * @prop {number|string|null} [width] + * @prop {number|string|null} [height] + */ + /** * @typedef {object} ICustomProvider * @prop {(url: URL) => Promise} canSupportRequest @@ -271,6 +303,39 @@ class OEmbedService { return this.imageStore.saveRaw(imageBuffer, targetPath); } + /** + * Stores a provider's thumbnail, preferring YouTube's un-letterboxed max + * resolution image. Falls back to the provider's thumbnail if the image + * can't be stored. + * + * @param {EmbedThumbnail} thumbnail + * @returns {Promise} + */ + async storeThumbnail(thumbnail) { + const maxResUrl = getYouTubeMaxResThumbnailUrl(thumbnail.url); + if (maxResUrl) { + try { + return { + url: await this.processImageFromUrl(maxResUrl, 'thumbnail'), + width: YOUTUBE_MAXRES_THUMBNAIL_WIDTH, + height: YOUTUBE_MAXRES_THUMBNAIL_HEIGHT, + }; + } catch { + // YouTube 404s for videos without a max resolution thumbnail + } + } + + try { + return { + ...thumbnail, + url: await this.processImageFromUrl(thumbnail.url, 'thumbnail'), + }; + } catch (err) { + logging.error(err); + return thumbnail; + } + } + /** * Fetch bookmark enrichment from an allowlisted oEmbed provider without * exposing provider-supplied HTML. @@ -754,6 +819,37 @@ class OEmbedService { * @returns {Promise} */ async fetchOembedDataFromUrl(url, type, options = {}) { + const data = await this.#fetchOembedDataFromUrl(url, type, options); + + // Mentions aren't stored in content and can be triggered by third + // parties sending webmentions, so their images are never downloaded + if (type === 'mention' || !data?.thumbnail_url) { + return data; + } + + const thumbnail = await this.storeThumbnail({ + url: data.thumbnail_url, + width: data.thumbnail_width, + height: data.thumbnail_height, + }); + + return { + ...data, + thumbnail_url: thumbnail.url, + thumbnail_width: thumbnail.width, + thumbnail_height: thumbnail.height, + thumbnail_url_original: data.thumbnail_url, + }; + } + + /** + * @param {string} url + * @param {string} type + * @param {Object} options + * + * @returns {Promise} + */ + async #fetchOembedDataFromUrl(url, type, options) { const { shouldRethrowFetchError, ...fetchOptions } = options; try { diff --git a/ghost/core/test/e2e-api/admin/oembed.test.js b/ghost/core/test/e2e-api/admin/oembed.test.js index 154313cfdc8..b30c7e8b019 100644 --- a/ghost/core/test/e2e-api/admin/oembed.test.js +++ b/ghost/core/test/e2e-api/admin/oembed.test.js @@ -70,6 +70,21 @@ describe('Oembed API', function () { assert.equal(requestMock.isDone(), true); assertExists(res.body.html); + assert.equal( + res.body.thumbnail_url, + `${urlUtils.urlFor('home', true)}content/images/thumbnail/image-01.png`, + ); + assert.equal(res.body.thumbnail_width, 1280); + assert.equal(res.body.thumbnail_height, 720); + assert.equal( + res.body.thumbnail_url_original, + 'https://i.ytimg.com/vi/E5yFcdPAGv0/hqdefault.jpg', + ); + sinon.assert.calledOnceWithExactly( + processImageFromUrlStub, + 'https://i.ytimg.com/vi/E5yFcdPAGv0/maxresdefault.jpg', + 'thumbnail', + ); }); it('does not use http preferentially to https', async function () { diff --git a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js index 986ae4b31cf..a44be9145b4 100644 --- a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js +++ b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const http = require('http'); const nock = require('nock'); const got = require('got').default; +const logging = require('@tryghost/logging'); const sinon = require('sinon'); const sharp = require('sharp'); const zlib = require('zlib'); @@ -618,6 +619,179 @@ describe('oembed-service', function () { await oembedService.fetchOembedDataFromUrl('https://youtube.com/live/1234?param=existing'); }); + describe('embed thumbnails', function () { + const thumbnailUrl = 'https://i.vimeocdn.com/video/123-d_640x360'; + + const mockVimeoOembed = () => { + nock('https://vimeo.com').get('/api/oembed.json').query(true).reply(200, { + type: 'video', + version: '1.0', + html: '', + width: 640, + height: 360, + thumbnail_url: thumbnailUrl, + thumbnail_width: 640, + thumbnail_height: 360, + }); + }; + + afterEach(function () { + sinon.restore(); + }); + + it('stores the thumbnail and returns the provider URL alongside', async function () { + const processImageFromUrlStub = sinon + .stub(oembedService, 'processImageFromUrl') + .resolves('/content/images/thumbnail/vimeo.jpg'); + mockVimeoOembed(); + + const response = await oembedService.fetchOembedDataFromUrl('https://vimeo.com/123'); + + assert.equal(response.thumbnail_url, '/content/images/thumbnail/vimeo.jpg'); + assert.equal(response.thumbnail_url_original, thumbnailUrl); + assert.equal(response.thumbnail_width, 640); + assert.equal(response.thumbnail_height, 360); + sinon.assert.calledOnceWithExactly(processImageFromUrlStub, thumbnailUrl, 'thumbnail'); + }); + + it('keeps the provider thumbnail when it cannot be stored', async function () { + sinon.stub(oembedService, 'processImageFromUrl').rejects(new Error('fetch failed')); + const loggingStub = sinon.stub(logging, 'error'); + mockVimeoOembed(); + + const response = await oembedService.fetchOembedDataFromUrl('https://vimeo.com/123'); + + assert.equal(response.thumbnail_url, thumbnailUrl); + assert.equal(response.thumbnail_url_original, thumbnailUrl); + assert.equal(response.thumbnail_width, 640); + assert.equal(response.thumbnail_height, 360); + sinon.assert.calledOnce(loggingStub); + }); + + describe('YouTube', function () { + const hqThumbnailUrl = 'https://i.ytimg.com/vi/1234/hqdefault.jpg'; + let service; + let saveRaw; + let jpegBytes; + + beforeAll(async function () { + jpegBytes = await sharp({ + create: { width: 1, height: 1, channels: 3, background: 'red' }, + }) + .jpeg() + .toBuffer(); + }); + + beforeEach(function () { + saveRaw = sinon.stub().callsFake(async (buffer, targetPath) => { + return `/content/images/${targetPath}`; + }); + service = new OembedService({ + config: ghostConfig, + externalRequest: requestExternal, + imageStore: { getSanitizedFileName: (name) => name, saveRaw }, + }); + + nock('https://www.youtube.com').get('/oembed').query(true).reply(200, { + type: 'video', + version: '1.0', + html: '', + width: 200, + height: 113, + thumbnail_url: hqThumbnailUrl, + thumbnail_width: 480, + thumbnail_height: 360, + }); + }); + + it('stores the un-letterboxed max resolution thumbnail', async function () { + nock('https://i.ytimg.com').get('/vi/1234/maxresdefault.jpg').reply(200, jpegBytes); + + const response = await service.fetchOembedDataFromUrl( + 'https://www.youtube.com/watch?v=1234', + ); + + assert.match( + response.thumbnail_url, + /^\/content\/images\/thumbnail\/maxresdefault-.+\.jpg$/, + ); + assert.equal(response.thumbnail_width, 1280); + assert.equal(response.thumbnail_height, 720); + assert.equal(response.thumbnail_url_original, hqThumbnailUrl); + sinon.assert.calledOnce(saveRaw); + }); + + it('falls back to the provider thumbnail when there is no max resolution thumbnail', async function () { + const loggingStub = sinon.stub(logging, 'error'); + nock('https://i.ytimg.com') + .get('/vi/1234/maxresdefault.jpg') + .reply(404, jpegBytes) + .get('/vi/1234/hqdefault.jpg') + .reply(200, jpegBytes); + + const response = await service.fetchOembedDataFromUrl( + 'https://www.youtube.com/watch?v=1234', + ); + + assert.match(response.thumbnail_url, /^\/content\/images\/thumbnail\/hqdefault-.+\.jpg$/); + assert.equal(response.thumbnail_width, 480); + assert.equal(response.thumbnail_height, 360); + assert.equal(response.thumbnail_url_original, hqThumbnailUrl); + sinon.assert.calledOnce(saveRaw); + sinon.assert.notCalled(loggingStub); + }); + }); + + it('leaves embeds without a thumbnail unchanged', async function () { + const processImageFromUrlStub = sinon.stub(oembedService, 'processImageFromUrl'); + + nock('https://www.youtube.com').get('/oembed').query(true).reply(200, { + type: 'video', + version: '1.0', + html: '', + width: 200, + height: 113, + }); + + const response = await oembedService.fetchOembedDataFromUrl( + 'https://www.youtube.com/watch?v=1234', + ); + + assert.equal('thumbnail_url' in response, false); + assert.equal('thumbnail_url_original' in response, false); + sinon.assert.notCalled(processImageFromUrlStub); + }); + + it('does not store thumbnails for mentions', async function () { + const service = new OembedService({ + config: { + get() { + return true; + }, + }, + externalRequest: requestExternal, + }); + service.registerProvider({ + async canSupportRequest() { + return true; + }, + async getOEmbedData() { + return { type: 'video', version: '1.0', thumbnail_url: thumbnailUrl }; + }, + }); + const processImageFromUrlStub = sinon.stub(service, 'processImageFromUrl'); + + const response = await service.fetchOembedDataFromUrl( + 'https://www.example.com/video', + 'mention', + ); + + assert.equal(response.thumbnail_url, thumbnailUrl); + assert.equal('thumbnail_url_original' in response, false); + sinon.assert.notCalled(processImageFromUrlStub); + }); + }); + it('keeps unknown provider fallback by default when the page fetch fails', async function () { const fetchError = new Error('Service Unavailable'); fetchError.response = { diff --git a/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts b/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts index 49ee924eb6c..84a4e57ccb3 100644 --- a/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts +++ b/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts @@ -9,7 +9,9 @@ const embedProperties = { metadata: { get default() { return {} as Record; - } + }, + urlType: 'url', + urlPath: 'metadata.thumbnail_url' }, caption: {default: '', wordCount: true} } satisfies DecoratorNodePropertyMap; diff --git a/koenig/kg-default-nodes/test/nodes/embed.test.ts b/koenig/kg-default-nodes/test/nodes/embed.test.ts index 4cfd6c1628f..ad5d3e9d20f 100644 --- a/koenig/kg-default-nodes/test/nodes/embed.test.ts +++ b/koenig/kg-default-nodes/test/nodes/embed.test.ts @@ -141,7 +141,8 @@ describe('EmbedNode', function () { describe('urlTransformMap', function () { it('contains the expected URL mapping', editorTest(function () { expect(EmbedNode.urlTransformMap).toEqual({ - url: 'url' + url: 'url', + 'metadata.thumbnail_url': 'url' }); })); }); From 033c0ac0ac630d4440051e69bfb65c05f6a89b51 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 30 Sep 2026 11:21:45 -0400 Subject: [PATCH 14/18] Added user status to the SSO adapter repository (#31135) SSO adapters currently receive only the owner's ID and email, so an owner-mapping adapter cannot distinguish an active seeded owner from Ghost's inactive first-run placeholder. Expose the existing user status through both SSO repository lookups and an optional field on the base interface. Existing production adapters keep their current behavior. This supports Ghost-Moya's preview-only IAP adapter: authorized reviewers share the active preview owner, without changing its email or reseeding the database. Empty previews do not activate that adapter and retain owner setup and password login. Validation: - SSO base package tests and type checks passed. - Ghost session-from-token unit test passed. - Database-backed SSO API tests passed for verified login, inactive-owner rejection/setup availability, and SSO-disabled behavior. - Changed-file lint, format, dependency-boundary, Markdown, and secret checks passed; Ghost TypeScript build passed. - `pnpm check` passed formatting/lint but failed on unrelated gift-preview image timeouts and Koenig browser tests requiring an unavailable Firefox binary. Deployment: merge before enabling seeded preview SSO in Moya, then deploy a Ghost PR image containing this commit or a descendant. No migration or database rewrite is needed. A patch changeset covers the SSO base package. --- .changeset/preview-sso-user-status.md | 5 +++ .../server/services/auth/session/index.js | 6 ++- ghost/core/test/e2e-api/admin/sso.test.js | 45 +++++++++++++++++-- packages/adapters/sso-base/README.md | 3 ++ packages/adapters/sso-base/src/base.ts | 1 + 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 .changeset/preview-sso-user-status.md diff --git a/.changeset/preview-sso-user-status.md b/.changeset/preview-sso-user-status.md new file mode 100644 index 00000000000..38aa37eca04 --- /dev/null +++ b/.changeset/preview-sso-user-status.md @@ -0,0 +1,5 @@ +--- +"@tryghost/adapter-base-sso": patch +--- + +Exposed optional user status so SSO adapters can reject inactive owners before first-run setup. diff --git a/ghost/core/core/server/services/auth/session/index.js b/ghost/core/core/server/services/auth/session/index.js index a77c44ccc16..b6d5ccf27a9 100644 --- a/ghost/core/core/server/services/auth/session/index.js +++ b/ghost/core/core/server/services/auth/session/index.js @@ -88,11 +88,13 @@ module.exports.createSessionFromToken = () => { ssoAdapter.setUserRepository({ async getByEmail(email) { const user = await models.User.findOne({ email }); - return user ? { id: user.id, email: user.get('email') } : null; + return user ? { id: user.id, email: user.get('email'), status: user.get('status') } : null; }, async getOwner() { const owner = await models.User.findOne({ role: 'Owner', status: 'all' }); - return owner ? { id: owner.id, email: owner.get('email') } : null; + return owner + ? { id: owner.id, email: owner.get('email'), status: owner.get('status') } + : null; }, }); diff --git a/ghost/core/test/e2e-api/admin/sso.test.js b/ghost/core/test/e2e-api/admin/sso.test.js index 2535b462b8f..5219ed8b6d6 100644 --- a/ghost/core/test/e2e-api/admin/sso.test.js +++ b/ghost/core/test/e2e-api/admin/sso.test.js @@ -7,11 +7,16 @@ const { const { restore } = require('../../utils/e2e-framework-mock-manager'); const { stringMatching } = matchers; const sinon = require('sinon'); +const assert = require('node:assert/strict'); +const models = require('../../../core/server/models'); const adapterManager = require('../../../core/server/services/adapter-manager').default; const { SSOBase } = require('@tryghost/adapter-base-sso'); describe('SSO API', function () { let agent; + let observedOwner; + let ownerId; + let enabled = true; beforeAll(async function () { // Mock SSO adapter that always returns the owner. The stub stays registered @@ -23,9 +28,7 @@ describe('SSO API', function () { // below) would hit an unmigrated database. class MockSSOAdapter extends SSOBase { async getRequestCredentials() { - return { - id: 'mock-credentials', - }; + return enabled ? { id: 'mock-credentials' } : null; } async getIdentityFromCredentials() { @@ -35,7 +38,8 @@ describe('SSO API', function () { } async getUserForIdentity() { - return this.getOwnerUser(); + observedOwner = await this.getOwnerUser(); + return observedOwner?.status === 'active' ? observedOwner : null; } } @@ -77,6 +81,39 @@ describe('SSO API', function () { // Verify we can access authenticated endpoints after SSO login await agent.get('api/admin/users/me').expectStatus(200); + assert.equal(observedOwner.status, 'active'); + }); + }); + describe('owner status and first-run setup', function () { + beforeEach(async function () { + const owner = await models.User.findOne({ role: 'Owner', status: 'all' }); + ownerId = owner.id; + agent.resetAuthentication(); + enabled = true; + }); + + afterEach(async function () { + enabled = true; + await models.User.edit({ status: 'active' }, { id: ownerId, context: { internal: true } }); + agent.resetAuthentication(); + }); + + it('does not grant a session to an inactive first-run owner', async function () { + const owner = await models.User.findOne({ role: 'Owner', status: 'all' }); + await models.User.edit({ status: 'inactive' }, { id: owner.id, context: { internal: true } }); + const response = await agent.post('/').expectEmptyBody(); + assert.equal(response.headers['set-cookie'], undefined); + assert.equal(observedOwner.status, 'inactive'); + const setup = await agent.get('api/admin/authentication/setup').expectStatus(200); + assert.equal(setup.body.setup[0].status, false); + await agent.get('api/admin/users/me').expectStatus(403); + }); + + it('preserves normal authentication when a preview does not activate SSO', async function () { + enabled = false; + const response = await agent.post('/').expectEmptyBody(); + assert.equal(response.headers['set-cookie'], undefined); + await agent.get('api/admin/users/me').expectStatus(403); }); }); }); diff --git a/packages/adapters/sso-base/README.md b/packages/adapters/sso-base/README.md index 2ca44fb5a8a..6e9fe6357a4 100644 --- a/packages/adapters/sso-base/README.md +++ b/packages/adapters/sso-base/README.md @@ -26,6 +26,9 @@ Extend `SSOBase` and implement every method listed in `requiredFns`: Ghost injects a user repository after constructing the adapter, exposed through the protected `getUserByEmail(email)` and `getOwnerUser()` helpers — use these to resolve Ghost users without reaching into Ghost's model layer directly. +Ghost supplies `id`, `email`, and `status`. Adapters can require +`status === 'active'` before granting a session, including when the owner is still the inactive +placeholder used by first-run setup. Older Ghost versions omit `status`. ```js const {SSOBase} = require('@tryghost/adapter-base-sso'); diff --git a/packages/adapters/sso-base/src/base.ts b/packages/adapters/sso-base/src/base.ts index 888af27aba0..292f3a8a8c4 100644 --- a/packages/adapters/sso-base/src/base.ts +++ b/packages/adapters/sso-base/src/base.ts @@ -4,6 +4,7 @@ import errors from '@tryghost/errors'; export interface User { id: string; email: string; + status?: string; } /** From 3c3c0a3e76d792ea260810ba4585fcaed453eebf Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Sep 2026 10:22:35 -0500 Subject: [PATCH 15/18] Tested running all migrations from scratch (#31138) closes https://linear.app/ghost/issue/NY-1468 This is a test-only change that should help keep migrations and `schema.js` in sync. --- .../migrations/schema-sync.test.ts | 479 +++++++ .../utils/fixtures/schema/v4-fixtures.json | 778 ++++++++++++ ghost/core/test/utils/fixtures/schema/v4.ts | 1121 +++++++++++++++++ 3 files changed, 2378 insertions(+) create mode 100644 ghost/core/test/integration/migrations/schema-sync.test.ts create mode 100644 ghost/core/test/utils/fixtures/schema/v4-fixtures.json create mode 100644 ghost/core/test/utils/fixtures/schema/v4.ts diff --git a/ghost/core/test/integration/migrations/schema-sync.test.ts b/ghost/core/test/integration/migrations/schema-sync.test.ts new file mode 100644 index 00000000000..19536385d00 --- /dev/null +++ b/ghost/core/test/integration/migrations/schema-sync.test.ts @@ -0,0 +1,479 @@ +import ObjectId from 'bson-objectid'; +import type { Knex } from 'knex'; +import KnexMigrator from 'knex-migrator'; +import { groupBy, kebabCase, omitBy, sortBy } from 'lodash'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import semver from 'semver'; +import { knex as dbKnex } from '../../../core/server/data/db'; +import { v4Schema } from '../../utils/fixtures/schema/v4'; +// @ts-expect-error This module lacks type definitions. +import rawSchema from '../../../core/server/data/schema/schema'; +// @ts-expect-error This module lacks type definitions. +import * as commands from '../../../core/server/data/schema/commands'; +import { isInDevelopmentTable } from '../../../core/server/data/schema/in-development'; +// @ts-expect-error This module lacks type definitions. +import * as dbUtils from '../../utils/db-utils'; + +const schema = rawSchema as SchemaTables; + +type SchemaTables = Record>; + +type ColumnSpec = { + type: string; + maxlength?: number; + fieldtype?: string; + nullable?: boolean; + defaultTo?: unknown; + unsigned?: boolean; + primary?: boolean; + unique?: boolean; + index?: boolean; + references?: string; + constraintName?: string; + cascadeDelete?: boolean; + restrictDelete?: boolean; + setNullDelete?: boolean; +}; + +type IndexSpec = string[] | { columns: string[] }; + +type NormalizedColumn = { + type: string; + maxlength?: number; + fieldtype?: string; + nullable: boolean; + defaultTo?: string; + unsigned?: true; + references?: string; + constraintName?: string; + onDelete?: string; +}; + +type NormalizedTable = { + columns: Record; + primaryKey: string[]; + indexes: string[][]; + uniques: string[][]; +}; + +type NormalizedSchema = Record; + +type ColumnRow = { + TABLE_NAME: string; + COLUMN_NAME: string; + DATA_TYPE: string; + COLUMN_TYPE: string; + CHARACTER_MAXIMUM_LENGTH: number | null; + IS_NULLABLE: 'YES' | 'NO'; + COLUMN_DEFAULT: string | null; +}; + +type IndexRow = { + TABLE_NAME: string; + INDEX_NAME: string; + COLUMN_NAME: string; + NON_UNIQUE: number; +}; + +type ForeignKeyRow = { + TABLE_NAME: string; + COLUMN_NAME: string; + CONSTRAINT_NAME: string; + REFERENCED_TABLE_NAME: string; + REFERENCED_COLUMN_NAME: string; + DELETE_RULE: string; +}; + +type FixtureEntry = Record; + +type Fixtures = { + models: { name: string; entries: FixtureEntry[] }[]; + relations: { from: { model: string }; entries: Record }[]; +}; + +type ExecutedMigration = { + name: string; + version: string; + currentVersion: string; +}; + +type FixtureId = { + entry: FixtureEntry; + id: string; +}; + +const MYSQL_TYPES = new Map([ + ['varchar', 'string'], + ['int', 'integer'], + ['bigint', 'bigInteger'], + ['datetime', 'dateTime'], + ['text', 'text'], + ['mediumtext', 'text'], + ['longtext', 'text'], + ['varbinary', 'binary'], + ['blob', 'binary'], +]); + +const MIGRATIONS_PATH = path.join(__dirname, '../../../core/server/data/migrations'); + +// The last v4 version with migrations. Sites must be on v4 before they can +// migrate to v5, so the versioned migrations start from a database at this +// version. +const V4_VERSION = '4.47'; + +// Fixtures are inserted with knex because today's models no longer match the v4 +// tables. Posts and tags are left out as no migration needs them. +const V4_FIXTURE_TABLES: Record = { + Product: 'products', + Newsletter: 'newsletters', + Role: 'roles', + Permission: 'permissions', + User: 'users', + Integration: 'integrations', +}; + +// Tables knex-migrator uses to track its own state; they are not in schema.js +const KNEX_MIGRATOR_TABLES = ['migrations', 'migrations_lock']; + +const knexMigrator = new KnexMigrator({ + knexMigratorFilePath: path.join(__dirname, '../../../'), +}); + +/** + * Reduces schema.js-style table definitions to what can be read back out of + * the database, so that they can be compared with `readSchemaFromDatabase()`. + */ +function normalizeSchema(tables: SchemaTables): NormalizedSchema { + const result: NormalizedSchema = {}; + + for (const [tableName, tableSpec] of Object.entries(tables)) { + const columns: Record = {}; + const indexes: string[][] = []; + const uniques: string[][] = []; + let primaryKey: string[] = []; + + for (const [columnName, value] of Object.entries(tableSpec)) { + if (columnName.startsWith('@@')) { + continue; + } + const spec = value as ColumnSpec; + + const column: NormalizedColumn = { type: spec.type, nullable: spec.nullable === true }; + + if (spec.type === 'string') { + column.maxlength = spec.maxlength ?? 191; + } else if (spec.type === 'binary' && spec.maxlength !== undefined) { + column.maxlength = spec.maxlength; + } + if (spec.type === 'text' && spec.fieldtype) { + column.fieldtype = spec.fieldtype; + } + if (Object.hasOwn(spec, 'defaultTo')) { + column.defaultTo = normalizeDefault(spec.defaultTo); + } + if (spec.unsigned) { + column.unsigned = true; + } + if (spec.references) { + column.references = spec.references; + column.constraintName = spec.constraintName ?? `${tableName}_${columnName}_foreign`; + if (spec.cascadeDelete) { + column.onDelete = 'CASCADE'; + } else if (spec.restrictDelete) { + column.onDelete = 'RESTRICT'; + } else if (spec.setNullDelete) { + column.onDelete = 'SET NULL'; + } else { + column.onDelete = 'NO ACTION'; + } + } + + if (spec.primary) { + primaryKey.push(columnName); + } + if (spec.unique) { + uniques.push([columnName]); + } + if (spec.index) { + indexes.push([columnName]); + } + + columns[columnName] = column; + } + + for (const index of (tableSpec['@@INDEXES@@'] ?? []) as IndexSpec[]) { + indexes.push(Array.isArray(index) ? index : index.columns); + } + for (const unique of (tableSpec['@@UNIQUE_CONSTRAINTS@@'] ?? []) as IndexSpec[]) { + uniques.push(Array.isArray(unique) ? unique : unique.columns); + } + if (tableSpec['@@PRIMARY_KEY@@']) { + primaryKey = tableSpec['@@PRIMARY_KEY@@'] as string[]; + } + + result[tableName] = { + columns, + primaryKey, + indexes: sortIndexes(indexes), + uniques: sortIndexes(uniques), + }; + } + + return result; +} + +function normalizeDefault(value: unknown): string { + switch (value) { + case true: + return '1'; + case false: + return '0'; + default: + return String(value); + } +} + +function sortIndexes(indexes: string[][]): string[][] { + return sortBy(indexes, (columns) => columns.join(',')); +} + +/** + * Reads the tables in the database into the same shape as `normalizeSchema()` + */ +async function readSchemaFromDatabase(knex: Knex): Promise { + const database = (knex.client.config.connection as Knex.ConnectionConfig).database; + + const columnRows: ColumnRow[] = await knex('information_schema.COLUMNS') + .where('TABLE_SCHEMA', database) + .whereNotIn('TABLE_NAME', KNEX_MIGRATOR_TABLES) + .whereIn( + 'TABLE_NAME', + knex('information_schema.TABLES') + .select('TABLE_NAME') + .where({ TABLE_SCHEMA: database, TABLE_TYPE: 'BASE TABLE' }), + ) + .orderBy(['TABLE_NAME', 'ORDINAL_POSITION']); + + const indexRows: IndexRow[] = await knex('information_schema.STATISTICS') + .where('TABLE_SCHEMA', database) + .whereNotIn('TABLE_NAME', KNEX_MIGRATOR_TABLES) + .orderBy(['TABLE_NAME', 'INDEX_NAME', 'SEQ_IN_INDEX']); + + const foreignKeyRows: ForeignKeyRow[] = await knex('information_schema.KEY_COLUMN_USAGE as k') + .join('information_schema.REFERENTIAL_CONSTRAINTS as r', function () { + this.on('k.CONSTRAINT_SCHEMA', 'r.CONSTRAINT_SCHEMA').andOn( + 'k.CONSTRAINT_NAME', + 'r.CONSTRAINT_NAME', + ); + }) + .where('k.TABLE_SCHEMA', database) + .select( + 'k.TABLE_NAME', + 'k.COLUMN_NAME', + 'k.CONSTRAINT_NAME', + 'k.REFERENCED_TABLE_NAME', + 'k.REFERENCED_COLUMN_NAME', + 'r.DELETE_RULE', + ); + + const result: NormalizedSchema = {}; + + for (const row of columnRows) { + result[row.TABLE_NAME] ??= { columns: {}, primaryKey: [], indexes: [], uniques: [] }; + + const type = + row.COLUMN_TYPE === 'tinyint(1)' + ? 'boolean' + : (MYSQL_TYPES.get(row.DATA_TYPE) ?? row.COLUMN_TYPE); + const column: NormalizedColumn = { type, nullable: row.IS_NULLABLE === 'YES' }; + + if (type === 'string' || type === 'binary') { + column.maxlength = Number(row.CHARACTER_MAXIMUM_LENGTH); + } + if (row.DATA_TYPE === 'mediumtext') { + column.fieldtype = 'medium'; + } else if (row.DATA_TYPE === 'longtext') { + column.fieldtype = 'long'; + } + if (row.COLUMN_DEFAULT !== null) { + column.defaultTo = row.COLUMN_DEFAULT; + } + if (row.COLUMN_TYPE.includes('unsigned')) { + column.unsigned = true; + } + + result[row.TABLE_NAME].columns[row.COLUMN_NAME] = column; + } + + for (const row of foreignKeyRows) { + Object.assign(result[row.TABLE_NAME].columns[row.COLUMN_NAME], { + references: `${row.REFERENCED_TABLE_NAME}.${row.REFERENCED_COLUMN_NAME}`, + constraintName: row.CONSTRAINT_NAME, + onDelete: row.DELETE_RULE, + }); + } + + // MySQL implicitly creates an index for a foreign key that no other index + // covers, and names it after the constraint. schema.js doesn't list those. + const foreignKeyNames = new Set(foreignKeyRows.map((row) => row.CONSTRAINT_NAME)); + + const indexesByTable = groupBy(indexRows, 'TABLE_NAME'); + for (const [tableName, rows] of Object.entries(indexesByTable)) { + const table = result[tableName]; + if (!table) { + continue; + } + for (const [indexName, indexColumns] of Object.entries(groupBy(rows, 'INDEX_NAME'))) { + const columns = indexColumns.map((row) => row.COLUMN_NAME); + if (indexName === 'PRIMARY') { + table.primaryKey = columns; + } else if (Number(indexColumns[0].NON_UNIQUE) === 0) { + table.uniques.push(columns); + } else if (!foreignKeyNames.has(indexName)) { + table.indexes.push(columns); + } + } + table.indexes = sortIndexes(table.indexes); + table.uniques = sortIndexes(table.uniques); + } + + return result; +} + +/** + * Builds the database as it was on the last v4 release, which is where the + * versioned migrations start from, and tells knex-migrator that everything up + * to then has run, then applies every later migration. + */ +async function createMigratedDatabase(knex: Knex) { + await knexMigrator.init({ noScripts: true }); + + for (const [tableName, tableSpec] of Object.entries(v4Schema)) { + await commands.createTable(tableName, knex, tableSpec); + } + + await knex.schema.createTable('migrations', function (table) { + table.increments().primary(); + table.string('name'); + table.string('version'); + table.string('currentVersion'); + }); + + const executed: ExecutedMigration[] = []; + for (const name of fs.readdirSync(path.join(MIGRATIONS_PATH, 'init'))) { + executed.push({ name, version: 'init', currentVersion: V4_VERSION }); + } + for (const version of fs.readdirSync(path.join(MIGRATIONS_PATH, 'versions'))) { + if (semver.lte(semver.coerce(version)!, semver.coerce(V4_VERSION)!)) { + for (const name of fs.readdirSync(path.join(MIGRATIONS_PATH, 'versions', version))) { + executed.push({ name, version, currentVersion: V4_VERSION }); + } + } + } + await knex('migrations').insert(executed); + + await insertV4Fixtures(knex); + await knexMigrator.migrate({ force: true }); +} + +async function insertV4Fixtures(knex: Knex) { + const v4Fixtures: Fixtures = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../utils/fixtures/schema/v4-fixtures.json'), 'utf8'), + ); + const ids: Record = {}; + + for (const { name, entries } of v4Fixtures.models) { + const tableName = V4_FIXTURE_TABLES[name]; + if (!tableName) { + continue; + } + + ids[name] = []; + for (const entry of entries) { + const row: Record = { id: new ObjectId().toHexString() }; + for (const [columnName, value] of Object.entries(v4Schema[tableName])) { + const spec = value as ColumnSpec; + if (Object.hasOwn(entry, columnName) && columnName !== 'id') { + row[columnName] = entry[columnName]; + } else if (spec.nullable === false && !Object.hasOwn(spec, 'defaultTo')) { + if (spec.type === 'dateTime') { + row[columnName] = new Date(); + } else if (columnName === 'uuid') { + row[columnName] = crypto.randomUUID(); + } else if (columnName === 'created_by' || columnName === 'updated_by') { + row[columnName] = '1'; + } else if (columnName === 'slug') { + row[columnName] = kebabCase(entry.name as string); + } else if (columnName === 'password') { + row[columnName] = 'not-a-real-password-hash'; + } + } + } + await knex(tableName).insert(row); + ids[name].push({ entry, id: row.id as string }); + } + } + + const findId = (model: string, predicate: (entry: FixtureEntry) => boolean) => + ids[model].find(({ entry }) => predicate(entry))!.id; + + const rolePermissions = v4Fixtures.relations.find((r) => r.from.model === 'Role')!; + const roleEntries = rolePermissions.entries as Record>; + for (const [roleName, objectTypes] of Object.entries(roleEntries)) { + const roleId = findId('Role', (role) => role.name === roleName); + for (const [objectType, actionTypes] of Object.entries(objectTypes)) { + const permissions = ids.Permission.filter( + ({ entry }) => + entry.object_type === objectType && + (actionTypes === 'all' || actionTypes.includes(entry.action_type as string)), + ); + for (const { id: permissionId } of permissions) { + await knex('permissions_roles').insert({ + id: new ObjectId().toHexString(), + role_id: roleId, + permission_id: permissionId, + }); + } + } + } + + await knex('roles_users').insert({ + id: new ObjectId().toHexString(), + role_id: findId('Role', (role) => role.name === 'Owner'), + user_id: ids.User[0].id, + }); +} + +describe('migrations', function () { + beforeAll(async function () { + await dbUtils.teardown(); + await knexMigrator.reset({ force: true }); + }); + + afterAll(async function () { + await knexMigrator.reset({ force: true }); + await knexMigrator.init(); + }, 120_000); + + it('builds the schema in schema.js when every migration runs', async function () { + await createMigratedDatabase(dbKnex); + + const actualSchema = await readSchemaFromDatabase(dbKnex); + const expectedSchema = normalizeSchema( + omitBy(schema, (_tableSpec, tableName) => isInDevelopmentTable(tableName)), + ); + + // Known drift between migrations and schema.js. Sites that were created + // with `knex-migrator init` have what's in schema.js; sites that ran these + // migrations have what's below. + // + // TODO: Fix this drift. See NY-1482 and NY-1483. + expectedSchema.members_subscribe_events.columns.newsletter_id.nullable = false; + expectedSchema.post_revisions.columns.post_status.defaultTo = 'draft'; + + assert.deepEqual(actualSchema, expectedSchema); + }, 600_000); +}); diff --git a/ghost/core/test/utils/fixtures/schema/v4-fixtures.json b/ghost/core/test/utils/fixtures/schema/v4-fixtures.json new file mode 100644 index 00000000000..9a3d492c2ae --- /dev/null +++ b/ghost/core/test/utils/fixtures/schema/v4-fixtures.json @@ -0,0 +1,778 @@ +{ + "models": [ + { + "name": "Product", + "entries": [ + { + "name": "Free", + "slug": "free", + "type": "free", + "active": true, + "visibility": "public" + }, + { + "name": "Default Product", + "slug": "default-product", + "type": "paid", + "active": true, + "visibility": "public" + } + ] + }, + { + "name": "Newsletter", + "entries": [ + { + "name": "Default Newsletter", + "slug": "default-newsletter", + "show_header_name": false + } + ] + }, + { + "name": "Tag", + "entries": [ + { + "name": "News", + "slug": "news", + "description": null, + "parent_id": null, + "meta_title": null, + "meta_description": null + } + ] + }, + { + "name": "Role", + "entries": [ + { + "name": "Administrator", + "description": "Administrators" + }, + { + "name": "Editor", + "description": "Editors" + }, + { + "name": "Author", + "description": "Authors" + }, + { + "name": "Contributor", + "description": "Contributors" + }, + { + "name": "Owner", + "description": "Blog Owner" + }, + { + "name": "Admin Integration", + "description": "External Apps" + }, + { + "name": "DB Backup Integration", + "description": "Internal DB Backup Client" + }, + { + "name": "Scheduler Integration", + "description": "Internal Scheduler Client" + } + ] + }, + { + "name": "Permission", + "entries": [ + { + "name": "Export database", + "action_type": "exportContent", + "object_type": "db" + }, + { + "name": "Import database", + "action_type": "importContent", + "object_type": "db" + }, + { + "name": "Delete all content", + "action_type": "deleteAllContent", + "object_type": "db" + }, + { + "name": "Send mail", + "action_type": "send", + "object_type": "mail" + }, + { + "name": "Browse notifications", + "action_type": "browse", + "object_type": "notification" + }, + { + "name": "Add notifications", + "action_type": "add", + "object_type": "notification" + }, + { + "name": "Delete notifications", + "action_type": "destroy", + "object_type": "notification" + }, + { + "name": "Browse posts", + "action_type": "browse", + "object_type": "post" + }, + { + "name": "Read posts", + "action_type": "read", + "object_type": "post" + }, + { + "name": "Edit posts", + "action_type": "edit", + "object_type": "post" + }, + { + "name": "Add posts", + "action_type": "add", + "object_type": "post" + }, + { + "name": "Delete posts", + "action_type": "destroy", + "object_type": "post" + }, + { + "name": "Browse settings", + "action_type": "browse", + "object_type": "setting" + }, + { + "name": "Read settings", + "action_type": "read", + "object_type": "setting" + }, + { + "name": "Edit settings", + "action_type": "edit", + "object_type": "setting" + }, + { + "name": "Generate slugs", + "action_type": "generate", + "object_type": "slug" + }, + { + "name": "Browse tags", + "action_type": "browse", + "object_type": "tag" + }, + { + "name": "Read tags", + "action_type": "read", + "object_type": "tag" + }, + { + "name": "Edit tags", + "action_type": "edit", + "object_type": "tag" + }, + { + "name": "Add tags", + "action_type": "add", + "object_type": "tag" + }, + { + "name": "Delete tags", + "action_type": "destroy", + "object_type": "tag" + }, + { + "name": "Browse themes", + "action_type": "browse", + "object_type": "theme" + }, + { + "name": "Edit themes", + "action_type": "edit", + "object_type": "theme" + }, + { + "name": "Activate themes", + "action_type": "activate", + "object_type": "theme" + }, + { + "name": "Upload themes", + "action_type": "add", + "object_type": "theme" + }, + { + "name": "Download themes", + "action_type": "read", + "object_type": "theme" + }, + { + "name": "Delete themes", + "action_type": "destroy", + "object_type": "theme" + }, + { + "name": "Browse users", + "action_type": "browse", + "object_type": "user" + }, + { + "name": "Read users", + "action_type": "read", + "object_type": "user" + }, + { + "name": "Edit users", + "action_type": "edit", + "object_type": "user" + }, + { + "name": "Add users", + "action_type": "add", + "object_type": "user" + }, + { + "name": "Delete users", + "action_type": "destroy", + "object_type": "user" + }, + { + "name": "Assign a role", + "action_type": "assign", + "object_type": "role" + }, + { + "name": "Browse roles", + "action_type": "browse", + "object_type": "role" + }, + { + "name": "Browse invites", + "action_type": "browse", + "object_type": "invite" + }, + { + "name": "Read invites", + "action_type": "read", + "object_type": "invite" + }, + { + "name": "Edit invites", + "action_type": "edit", + "object_type": "invite" + }, + { + "name": "Add invites", + "action_type": "add", + "object_type": "invite" + }, + { + "name": "Delete invites", + "action_type": "destroy", + "object_type": "invite" + }, + { + "name": "Download redirects", + "action_type": "download", + "object_type": "redirect" + }, + { + "name": "Upload redirects", + "action_type": "upload", + "object_type": "redirect" + }, + { + "name": "Add webhooks", + "action_type": "add", + "object_type": "webhook" + }, + { + "name": "Edit webhooks", + "action_type": "edit", + "object_type": "webhook" + }, + { + "name": "Delete webhooks", + "action_type": "destroy", + "object_type": "webhook" + }, + { + "name": "Browse integrations", + "action_type": "browse", + "object_type": "integration" + }, + { + "name": "Read integrations", + "action_type": "read", + "object_type": "integration" + }, + { + "name": "Edit integrations", + "action_type": "edit", + "object_type": "integration" + }, + { + "name": "Add integrations", + "action_type": "add", + "object_type": "integration" + }, + { + "name": "Delete integrations", + "action_type": "destroy", + "object_type": "integration" + }, + { + "name": "Browse API keys", + "action_type": "browse", + "object_type": "api_key" + }, + { + "name": "Read API keys", + "action_type": "read", + "object_type": "api_key" + }, + { + "name": "Edit API keys", + "action_type": "edit", + "object_type": "api_key" + }, + { + "name": "Add API keys", + "action_type": "add", + "object_type": "api_key" + }, + { + "name": "Delete API keys", + "action_type": "destroy", + "object_type": "api_key" + }, + { + "name": "Browse Actions", + "action_type": "browse", + "object_type": "action" + }, + { + "name": "Browse Members", + "action_type": "browse", + "object_type": "member" + }, + { + "name": "Read Members", + "action_type": "read", + "object_type": "member" + }, + { + "name": "Edit Members", + "action_type": "edit", + "object_type": "member" + }, + { + "name": "Add Members", + "action_type": "add", + "object_type": "member" + }, + { + "name": "Delete Members", + "action_type": "destroy", + "object_type": "member" + }, + { + "name": "Browse Products", + "action_type": "browse", + "object_type": "product" + }, + { + "name": "Read Products", + "action_type": "read", + "object_type": "product" + }, + { + "name": "Edit Products", + "action_type": "edit", + "object_type": "product" + }, + { + "name": "Add Products", + "action_type": "add", + "object_type": "product" + }, + { + "name": "Delete Products", + "action_type": "destroy", + "object_type": "product" + }, + { + "name": "Publish posts", + "action_type": "publish", + "object_type": "post" + }, + { + "name": "Backup database", + "action_type": "backupContent", + "object_type": "db" + }, + { + "name": "Email preview", + "action_type": "read", + "object_type": "email_preview" + }, + { + "name": "Send test email", + "action_type": "sendTestEmail", + "object_type": "email_preview" + }, + { + "name": "Browse emails", + "action_type": "browse", + "object_type": "email" + }, + { + "name": "Read emails", + "action_type": "read", + "object_type": "email" + }, + { + "name": "Retry emails", + "action_type": "retry", + "object_type": "email" + }, + { + "name": "Browse labels", + "action_type": "browse", + "object_type": "label" + }, + { + "name": "Read labels", + "action_type": "read", + "object_type": "label" + }, + { + "name": "Edit labels", + "action_type": "edit", + "object_type": "label" + }, + { + "name": "Add labels", + "action_type": "add", + "object_type": "label" + }, + { + "name": "Delete labels", + "action_type": "destroy", + "object_type": "label" + }, + { + "name": "Read member signin urls", + "action_type": "read", + "object_type": "member_signin_url" + }, + { + "name": "Read identities", + "action_type": "read", + "object_type": "identity" + }, + { + "name": "Auth Stripe Connect for Members", + "action_type": "auth", + "object_type": "members_stripe_connect" + }, + { + "name": "Browse snippets", + "action_type": "browse", + "object_type": "snippet" + }, + { + "name": "Read snippets", + "action_type": "read", + "object_type": "snippet" + }, + { + "name": "Edit snippets", + "action_type": "edit", + "object_type": "snippet" + }, + { + "name": "Add snippets", + "action_type": "add", + "object_type": "snippet" + }, + { + "name": "Delete snippets", + "action_type": "destroy", + "object_type": "snippet" + }, + { + "name": "Browse offers", + "action_type": "browse", + "object_type": "offer" + }, + { + "name": "Read offers", + "action_type": "read", + "object_type": "offer" + }, + { + "name": "Edit offers", + "action_type": "edit", + "object_type": "offer" + }, + { + "name": "Add offers", + "action_type": "add", + "object_type": "offer" + }, + { + "name": "Reset all passwords", + "action_type": "resetAllPasswords", + "object_type": "authentication" + }, + { + "name": "Browse custom theme settings", + "action_type": "browse", + "object_type": "custom_theme_setting" + }, + { + "name": "Edit custom theme settings", + "action_type": "edit", + "object_type": "custom_theme_setting" + }, + { + "name": "Browse newsletters", + "action_type": "browse", + "object_type": "newsletter" + }, + { + "name": "Read newsletters", + "action_type": "read", + "object_type": "newsletter" + }, + { + "name": "Add newsletters", + "action_type": "add", + "object_type": "newsletter" + }, + { + "name": "Edit newsletters", + "action_type": "edit", + "object_type": "newsletter" + } + ] + }, + { + "name": "User", + "entries": [ + { + "id": 1, + "name": "Ghost", + "email": "ghost@example.com", + "status": "inactive", + "roles": [] + } + ] + }, + { + "name": "Post", + "entries": [ + { + "title": "Coming soon", + "slug": "coming-soon", + "feature_image": "https://static.ghost.org/v4.0.0/images/feature-image.jpg", + "mobiledoc": "{\"version\":\"0.3.1\",\"atoms\":[],\"cards\":[],\"markups\":[[\"a\",[\"href\",\"#/portal/\",\"rel\",\"noopener noreferrer\"]]],\"sections\":[[1,\"p\",[[0,[],0,\"This is a brand new site that's just getting started. Things will be up and running here shortly, but you can \"],[0,[0],1,\"subscribe\"],[0,[],0,\" in the meantime if you'd like to stay up to date and receive emails when new content is published!\"]]]],\"ghostVersion\":\"4.0\"}", + "featured": false, + "type": "post", + "status": "published", + "visibility": "public", + "created_by": "1", + "published_by": "1", + "author_id": "1" + }, + { + "title": "About this site", + "slug": "about", + "mobiledoc": "{\"version\":\"0.3.1\",\"atoms\":[],\"cards\":[[\"hr\",{}]],\"markups\":[[\"a\",[\"href\",\"https://ghost.org\"]]],\"sections\":[[1,\"p\",[[0,[],0,\"This is an independent publication. If you subscribe today, you'll get full access to the website as well as email newsletters about new content when it's available. Your subscription makes this site possible. Thank you!\"]]],[1,\"h3\",[[0,[],0,\"Access all areas\"]]],[1,\"p\",[[0,[],0,\"By signing up, you'll get access to the full archive of everything that's been published before and everything that's still to come. Your very own private library.\"]]],[1,\"h3\",[[0,[],0,\"Fresh content, delivered\"]]],[1,\"p\",[[0,[],0,\"Stay up to date with new content sent straight to your inbox! No more worrying about whether you missed something because of a pesky algorithm or news feed.\"]]],[1,\"h3\",[[0,[],0,\"Meet people like you\"]]],[1,\"p\",[[0,[],0,\"Join a community of other subscribers who share the same interests.\"]]],[10,0],[1,\"h3\",[[0,[],0,\"Start your own thing\"]]],[1,\"p\",[[0,[],0,\"Enjoying the experience? Get started for free and set up your very own subscription business using \"],[0,[0],1,\"Ghost\"],[0,[],0,\", the same platform that powers this website.\"]]]],\"ghostVersion\":\"4.0\"}", + "featured": false, + "type": "page", + "status": "published", + "visibility": "public", + "created_by": "1", + "published_by": "1", + "author_id": "1" + } + ] + }, + { + "name": "Integration", + "entries": [ + { + "slug": "zapier", + "name": "Zapier", + "description": "Built-in Zapier integration", + "type": "builtin", + "api_keys": [{ "type": "admin" }] + }, + { + "slug": "ghost-backup", + "name": "Ghost Backup", + "description": "Internal DB Backup integration", + "type": "internal", + "api_keys": [{ "type": "admin", "role": "DB Backup Integration" }] + }, + { + "slug": "ghost-scheduler", + "name": "Ghost Scheduler", + "description": "Internal Scheduler integration", + "type": "internal", + "api_keys": [{ "type": "admin", "role": "Scheduler Integration" }] + } + ] + } + ], + "relations": [ + { + "from": { + "model": "Role", + "match": "name", + "relation": "permissions" + }, + "to": { + "model": "Permission", + "match": ["object_type", "action_type"] + }, + "entries": { + "Administrator": { + "db": "all", + "mail": "all", + "notification": "all", + "post": "all", + "setting": "all", + "slug": "all", + "tag": "all", + "theme": "all", + "user": "all", + "role": "all", + "invite": "all", + "redirect": "all", + "webhook": "all", + "integration": "all", + "api_key": "all", + "action": "all", + "member": "all", + "product": "all", + "label": "all", + "email_preview": "all", + "email": "all", + "member_signin_url": "read", + "snippet": "all", + "custom_theme_setting": "all", + "offer": "all", + "authentication": "resetAllPasswords", + "members_stripe_connect": "auth", + "newsletter": "all" + }, + "DB Backup Integration": { + "db": "all" + }, + "Scheduler Integration": { + "post": "publish" + }, + "Admin Integration": { + "mail": "all", + "notification": "all", + "post": "all", + "setting": "all", + "slug": "all", + "tag": "all", + "theme": "all", + "user": "all", + "role": "all", + "invite": "all", + "redirect": "all", + "webhook": "all", + "action": "all", + "member": "all", + "label": "all", + "email_preview": "all", + "email": "all", + "snippet": "all", + "product": ["browse", "read", "add", "edit"], + "offer": ["browse", "read", "add", "edit"] + }, + "Editor": { + "notification": "all", + "post": "all", + "setting": ["browse", "read"], + "slug": "all", + "tag": "all", + "user": "all", + "role": "all", + "invite": "all", + "theme": ["browse"], + "email_preview": "all", + "email": "all", + "snippet": "all", + "label": ["browse", "read"], + "product": ["browse", "read"] + }, + "Author": { + "post": ["browse", "read", "add"], + "setting": ["browse", "read"], + "slug": "all", + "tag": ["browse", "read", "add"], + "user": ["browse", "read"], + "role": ["browse"], + "theme": ["browse"], + "email_preview": "read", + "email": "read", + "snippet": ["browse", "read"], + "product": ["browse", "read"] + }, + "Contributor": { + "post": ["browse", "read", "add"], + "setting": ["browse", "read"], + "slug": "all", + "tag": ["browse", "read"], + "user": ["browse", "read"], + "role": ["browse"], + "theme": ["browse"], + "email_preview": "read", + "email": "read", + "snippet": ["browse", "read"] + } + } + }, + { + "from": { + "model": "Post", + "match": "slug", + "relation": "tags" + }, + "to": { + "model": "Tag", + "match": "name" + }, + "entries": { + "coming-soon": ["News"] + } + }, + { + "from": { + "model": "User", + "match": "id", + "relation": "roles" + }, + "to": { + "model": "Role", + "match": "name" + }, + "entries": { + "1": ["Owner"] + } + } + ] +} diff --git a/ghost/core/test/utils/fixtures/schema/v4.ts b/ghost/core/test/utils/fixtures/schema/v4.ts new file mode 100644 index 00000000000..3b5e19ec116 --- /dev/null +++ b/ghost/core/test/utils/fixtures/schema/v4.ts @@ -0,0 +1,1121 @@ +/** + * schema.js as of Ghost v4.48.9, the last v4 release, converted to TypeScript. + * + * Versioned migrations start from a database in this state, so tests use it to + * build a database from scratch by running every migration. + * + * Should probably not be edited. + * + * @see https://github.com/TryGhost/Ghost/blob/v4.48.9/core/server/data/schema/schema.js + */ +export const v4Schema = { + newsletters: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + uuid: { + type: 'string', + maxlength: 36, + nullable: false, + unique: true, + validations: { isUUID: true }, + }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + description: { type: 'string', maxlength: 2000, nullable: true }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + sender_name: { type: 'string', maxlength: 191, nullable: true }, + sender_email: { type: 'string', maxlength: 191, nullable: true }, + sender_reply_to: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'newsletter', + validations: { isIn: [['newsletter', 'support']] }, + }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'active', + validations: { isIn: [['active', 'archived']] }, + }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'members', + }, + subscribe_on_signup: { type: 'bool', nullable: false, defaultTo: true }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + header_image: { type: 'string', maxlength: 2000, nullable: true }, + show_header_icon: { type: 'bool', nullable: false, defaultTo: true }, + show_header_title: { type: 'bool', nullable: false, defaultTo: true }, + title_font_category: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'sans_serif', + validations: { isIn: [['serif', 'sans_serif']] }, + }, + title_alignment: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'center', + validations: { isIn: [['center', 'left']] }, + }, + show_feature_image: { type: 'bool', nullable: false, defaultTo: true }, + body_font_category: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'sans_serif', + validations: { isIn: [['serif', 'sans_serif']] }, + }, + footer_content: { type: 'text', maxlength: 1000000000, nullable: true }, + show_badge: { type: 'bool', nullable: false, defaultTo: true }, + show_header_name: { type: 'bool', nullable: false, defaultTo: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + posts: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + uuid: { type: 'string', maxlength: 36, nullable: false, validations: { isUUID: true } }, + title: { + type: 'string', + maxlength: 2000, + nullable: false, + validations: { isLength: { max: 255 } }, + }, + slug: { type: 'string', maxlength: 191, nullable: false }, + mobiledoc: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + html: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + comment_id: { type: 'string', maxlength: 50, nullable: true }, + plaintext: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + feature_image: { type: 'string', maxlength: 2000, nullable: true }, + featured: { type: 'bool', nullable: false, defaultTo: false }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'post', + validations: { isIn: [['post', 'page']] }, + }, + status: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'draft' }, + // NOTE: unused at the moment and reserved for future features + locale: { type: 'string', maxlength: 6, nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'public', + }, + email_recipient_filter: { + type: 'text', + maxlength: 1000000000, + nullable: false, + defaultTo: 'none', + }, + /** + * @deprecated: single authors was superceded by multiple authors in Ghost 1.22.0 + * If we keep it, then only, because you can easier query post.author_id than posts_authors[*].sort_order. + */ + author_id: { type: 'string', maxlength: 24, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + /** + * @deprecated: https://github.com/TryGhost/Ghost/issues/10286 + * + * This is valid for all x_by fields. + */ + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + published_at: { type: 'dateTime', nullable: true }, + published_by: { type: 'string', maxlength: 24, nullable: true }, + custom_excerpt: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + codeinjection_head: { type: 'text', maxlength: 65535, nullable: true }, + codeinjection_foot: { type: 'text', maxlength: 65535, nullable: true }, + custom_template: { type: 'string', maxlength: 100, nullable: true }, + canonical_url: { type: 'text', maxlength: 2000, nullable: true }, + newsletter_id: { type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id' }, + '@@UNIQUE_CONSTRAINTS@@': [['slug', 'type']], + }, + posts_meta: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'posts.id', + unique: true, + }, + og_image: { type: 'string', maxlength: 2000, nullable: true }, + og_title: { type: 'string', maxlength: 300, nullable: true }, + og_description: { type: 'string', maxlength: 500, nullable: true }, + twitter_image: { type: 'string', maxlength: 2000, nullable: true }, + twitter_title: { type: 'string', maxlength: 300, nullable: true }, + twitter_description: { type: 'string', maxlength: 500, nullable: true }, + meta_title: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + meta_description: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + email_subject: { type: 'string', maxlength: 300, nullable: true }, + frontmatter: { type: 'text', maxlength: 65535, nullable: true }, + feature_image_alt: { + type: 'string', + maxlength: 191, + nullable: true, + validations: { isLength: { max: 125 } }, + }, + feature_image_caption: { type: 'text', maxlength: 65535, nullable: true }, + email_only: { type: 'bool', nullable: false, defaultTo: false }, + }, + users: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + password: { type: 'string', maxlength: 60, nullable: false }, + email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isEmail: true }, + }, + profile_image: { type: 'string', maxlength: 2000, nullable: true }, + cover_image: { type: 'string', maxlength: 2000, nullable: true }, + bio: { + type: 'text', + maxlength: 65535, + nullable: true, + validations: { isLength: { max: 200 } }, + }, + website: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isEmptyOrURL: true }, + }, + location: { + type: 'text', + maxlength: 65535, + nullable: true, + validations: { isLength: { max: 150 } }, + }, + facebook: { type: 'string', maxlength: 2000, nullable: true }, + twitter: { type: 'string', maxlength: 2000, nullable: true }, + accessibility: { type: 'text', maxlength: 65535, nullable: true }, + // TODO: would be good to add validation here to control for all possible status values. + // The ones that come up by reviewing the user model are: + // 'active', 'inactive', 'locked', 'warn-1', 'warn-2', 'warn-3', 'warn-4' + status: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'active' }, + // NOTE: unused at the moment and reserved for future features + locale: { type: 'string', maxlength: 6, nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'public', + validations: { isIn: [['public']] }, + }, + meta_title: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + meta_description: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + tour: { type: 'text', maxlength: 65535, nullable: true }, + last_seen: { type: 'dateTime', nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + oauth: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + provider: { type: 'string', maxlength: 50, nullable: false }, + provider_id: { type: 'string', maxlength: 191, nullable: false }, + access_token: { type: 'text', maxlength: 65535, nullable: true }, + refresh_token: { type: 'text', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + user_id: { type: 'string', maxlength: 24, nullable: false, references: 'users.id' }, + }, + posts_authors: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, references: 'posts.id' }, + author_id: { type: 'string', maxlength: 24, nullable: false, references: 'users.id' }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + roles: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 50, nullable: false, unique: true }, + description: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + roles_users: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + role_id: { type: 'string', maxlength: 24, nullable: false }, + user_id: { type: 'string', maxlength: 24, nullable: false }, + }, + permissions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 50, nullable: false, unique: true }, + object_type: { type: 'string', maxlength: 50, nullable: false }, + action_type: { type: 'string', maxlength: 50, nullable: false }, + object_id: { type: 'string', maxlength: 24, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + permissions_users: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + user_id: { type: 'string', maxlength: 24, nullable: false }, + permission_id: { type: 'string', maxlength: 24, nullable: false }, + }, + permissions_roles: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + role_id: { type: 'string', maxlength: 24, nullable: false }, + permission_id: { type: 'string', maxlength: 24, nullable: false }, + }, + settings: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + group: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'core', + validations: { + isIn: [ + [ + 'amp', + 'core', + 'email', + 'labs', + 'members', + 'portal', + 'private', + 'site', + 'slack', + 'theme', + 'unsplash', + 'views', + ], + ], + }, + }, + key: { type: 'string', maxlength: 50, nullable: false, unique: true }, + // NOTE: as JSON objects are no longer stored in `value` we could potentially reduce the maxlength + value: { type: 'text', maxlength: 65535, nullable: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { + isIn: [['array', 'string', 'number', 'boolean', 'object']], + }, + }, + flags: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + tags: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { + type: 'string', + maxlength: 191, + nullable: false, + validations: { matches: /^([^,]|$)/ }, + }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + description: { + type: 'text', + maxlength: 65535, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + feature_image: { type: 'string', maxlength: 2000, nullable: true }, + parent_id: { type: 'string', nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'public', + validations: { isIn: [['public', 'internal']] }, + }, + og_image: { type: 'string', maxlength: 2000, nullable: true }, + og_title: { type: 'string', maxlength: 300, nullable: true }, + og_description: { type: 'string', maxlength: 500, nullable: true }, + twitter_image: { type: 'string', maxlength: 2000, nullable: true }, + twitter_title: { type: 'string', maxlength: 300, nullable: true }, + twitter_description: { type: 'string', maxlength: 500, nullable: true }, + meta_title: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + meta_description: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + codeinjection_head: { type: 'text', maxlength: 65535, nullable: true }, + codeinjection_foot: { type: 'text', maxlength: 65535, nullable: true }, + canonical_url: { type: 'string', maxlength: 2000, nullable: true }, + accent_color: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + posts_tags: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, references: 'posts.id' }, + tag_id: { type: 'string', maxlength: 24, nullable: false, references: 'tags.id' }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + invites: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + role_id: { type: 'string', maxlength: 24, nullable: false }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'pending', + validations: { isIn: [['pending', 'sent']] }, + }, + token: { type: 'string', maxlength: 191, nullable: false, unique: true }, + email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isEmail: true }, + }, + expires: { type: 'bigInteger', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + brute: { + key: { type: 'string', maxlength: 191, primary: true }, + firstRequest: { type: 'bigInteger' }, + lastRequest: { type: 'bigInteger' }, + lifetime: { type: 'bigInteger' }, + count: { type: 'integer' }, + }, + sessions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + session_id: { type: 'string', maxlength: 32, nullable: false, unique: true }, + user_id: { type: 'string', maxlength: 24, nullable: false }, + session_data: { type: 'string', maxlength: 2000, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + integrations: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'custom', + validations: { isIn: [['internal', 'builtin', 'custom']] }, + }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + icon_image: { type: 'string', maxlength: 2000, nullable: true }, + description: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + webhooks: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + event: { type: 'string', maxlength: 50, nullable: false, validations: { isLowercase: true } }, + target_url: { type: 'string', maxlength: 2000, nullable: false }, + name: { type: 'string', maxlength: 191, nullable: true }, + secret: { type: 'string', maxlength: 191, nullable: true }, + // NOTE: the defaultTo does not make sense to set on DB layer as it leads to unnecessary maintenance every major release + // it might make sense to introduce "isIn" validation checking if it's a valid version e.g: 'v3', 'v4', 'canary' + api_version: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'v2' }, + // NOTE: integration_id column needs "nullable: true" -> "nullable: false" migration (recreate table with nullable: false) + // CASE: Ghost instances initialized pre 4.0 will have this column set to nullable: true in db schema + integration_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'integrations.id', + cascadeDelete: true, + }, + status: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'available' }, + last_triggered_at: { type: 'dateTime', nullable: true }, + last_triggered_status: { type: 'string', maxlength: 50, nullable: true }, + last_triggered_error: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + api_keys: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { isIn: [['content', 'admin']] }, + }, + secret: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isLength: { min: 26, max: 128 } }, + }, + role_id: { type: 'string', maxlength: 24, nullable: true }, + // integration_id is nullable to allow "internal" API keys that don't show in the UI + integration_id: { type: 'string', maxlength: 24, nullable: true }, + user_id: { type: 'string', maxlength: 24, nullable: true }, + last_seen_at: { type: 'dateTime', nullable: true }, + last_seen_version: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + mobiledoc_revisions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, index: true }, + mobiledoc: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + created_at_ts: { type: 'bigInteger', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + members: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + uuid: { + type: 'string', + maxlength: 36, + nullable: true, + unique: true, + validations: { isUUID: true }, + }, + email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isEmail: true }, + }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'free', + validations: { + isIn: [['free', 'paid', 'comped']], + }, + }, + name: { type: 'string', maxlength: 191, nullable: true }, + note: { type: 'string', maxlength: 2000, nullable: true }, + geolocation: { type: 'string', maxlength: 2000, nullable: true }, + subscribed: { type: 'bool', nullable: true, defaultTo: true }, + email_count: { type: 'integer', unsigned: true, nullable: false, defaultTo: 0 }, + email_opened_count: { type: 'integer', unsigned: true, nullable: false, defaultTo: 0 }, + email_open_rate: { type: 'integer', unsigned: true, nullable: true, index: true }, + last_seen_at: { type: 'dateTime', nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + active: { type: 'boolean', nullable: false, defaultTo: true }, + welcome_page_url: { type: 'string', maxlength: 2000, nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'none', + validations: { isIn: [['public', 'none']] }, + }, + monthly_price_id: { type: 'string', maxlength: 24, nullable: true }, + yearly_price_id: { type: 'string', maxlength: 24, nullable: true }, + description: { type: 'string', maxlength: 191, nullable: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'paid', + validations: { isIn: [['paid', 'free']] }, + }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + offers: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + active: { type: 'boolean', nullable: false, defaultTo: true }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + code: { type: 'string', maxlength: 191, nullable: false, unique: true }, + product_id: { type: 'string', maxlength: 24, nullable: false, references: 'products.id' }, + stripe_coupon_id: { type: 'string', maxlength: 255, nullable: true, unique: true }, + interval: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { isIn: [['month', 'year']] }, + }, + currency: { type: 'string', maxlength: 50, nullable: true }, + discount_type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { isIn: [['percent', 'amount']] }, + }, + discount_amount: { type: 'integer', nullable: false }, + duration: { type: 'string', maxlength: 50, nullable: false }, + duration_in_months: { type: 'integer', nullable: true }, + portal_title: { type: 'string', maxlength: 191, nullable: true }, + portal_description: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + benefits: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + products_benefits: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: true, + }, + benefit_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'benefits.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + members_products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + posts_products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'posts.id', + cascadeDelete: true, + }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + members_cancel_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + from_plan: { type: 'string', maxlength: 255, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_payment_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + amount: { type: 'integer', nullable: false }, + currency: { type: 'string', maxLength: 3, nullable: false }, + source: { type: 'string', maxlength: 50, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_login_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_email_change_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + to_email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: false, + validations: { isEmail: true }, + }, + from_email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: false, + validations: { isEmail: true }, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_status_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + from_status: { + type: 'string', + maxlength: 50, + nullable: true, + validations: { + isIn: [['free', 'paid', 'comped']], + }, + }, + to_status: { + type: 'string', + maxlength: 50, + nullable: true, + validations: { + isIn: [['free', 'paid', 'comped']], + }, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_product_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: false, + }, + action: { + type: 'string', + maxlength: 50, + nullable: true, + validations: { + isIn: [['added', 'removed']], + }, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_paid_subscription_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + type: { type: 'string', maxlength: 50, nullable: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + subscription_id: { type: 'string', maxlength: 24, nullable: true }, + from_plan: { type: 'string', maxlength: 255, nullable: true }, + to_plan: { type: 'string', maxlength: 255, nullable: true }, + currency: { type: 'string', maxLength: 3, nullable: false }, + source: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { + isIn: [['stripe']], + }, + }, + mrr_delta: { type: 'integer', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + labels: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + members_labels: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + label_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'labels.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + members_stripe_customers: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + unique: false, + references: 'members.id', + cascadeDelete: true, + }, + customer_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + name: { type: 'string', maxlength: 191, nullable: true }, + email: { type: 'string', maxlength: 191, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + members_stripe_customers_subscriptions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + customer_id: { + type: 'string', + maxlength: 255, + nullable: false, + unique: false, + references: 'members_stripe_customers.customer_id', + cascadeDelete: true, + }, + subscription_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + stripe_price_id: { + type: 'string', + maxlength: 255, + nullable: false, + unique: false, + index: true, + defaultTo: '', + }, + status: { type: 'string', maxlength: 50, nullable: false }, + cancel_at_period_end: { type: 'bool', nullable: false, defaultTo: false }, + cancellation_reason: { type: 'string', maxlength: 500, nullable: true }, + current_period_end: { type: 'dateTime', nullable: false }, + start_date: { type: 'dateTime', nullable: false }, + default_payment_card_last4: { type: 'string', maxlength: 4, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + mrr: { type: 'integer', unsigned: true, nullable: false, defaultTo: 0 }, + offer_id: { + type: 'string', + maxlength: 24, + nullable: true, + unique: false, + references: 'offers.id', + }, + /* Below fields are now redundant as we link prie_id to stripe_prices table */ + plan_id: { type: 'string', maxlength: 255, nullable: false, unique: false }, + plan_nickname: { type: 'string', maxlength: 50, nullable: false }, + plan_interval: { type: 'string', maxlength: 50, nullable: false }, + plan_amount: { type: 'integer', nullable: false }, + plan_currency: { type: 'string', maxLength: 3, nullable: false }, + }, + offer_redemptions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + offer_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'offers.id', + cascadeDelete: true, + }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + subscription_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members_stripe_customers_subscriptions.id', + cascadeDelete: true, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_subscribe_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + unique: false, + references: 'members.id', + cascadeDelete: true, + }, + subscribed: { type: 'bool', nullable: false, defaultTo: true }, + created_at: { type: 'dateTime', nullable: false }, + source: { type: 'string', maxlength: 50, nullable: true }, + newsletter_id: { + type: 'string', + maxlength: 24, + nullable: true, + references: 'newsletters.id', + cascadeDelete: false, + }, + }, + stripe_products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + unique: false, + references: 'products.id', + }, + stripe_product_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + stripe_prices: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + stripe_price_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + stripe_product_id: { + type: 'string', + maxlength: 255, + nullable: false, + unique: false, + references: 'stripe_products.stripe_product_id', + }, + active: { type: 'bool', nullable: false }, + nickname: { type: 'string', maxlength: 50, nullable: true }, + currency: { type: 'string', maxLength: 3, nullable: false }, + amount: { type: 'integer', nullable: false }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'recurring', + validations: { isIn: [['recurring', 'one_time']] }, + }, + interval: { type: 'string', maxlength: 50, nullable: true }, + description: { type: 'string', maxlength: 191, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + actions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + resource_id: { type: 'string', maxlength: 24, nullable: true }, + resource_type: { type: 'string', maxlength: 50, nullable: false }, + actor_id: { type: 'string', maxlength: 24, nullable: false }, + actor_type: { type: 'string', maxlength: 50, nullable: false }, + // @NOTE: The event column contains short buzzwords e.g. subscribed, started, added, deleted, edited etc. + // We already store and require the target resource type. No need to remember e.g. post.edited + event: { type: 'string', maxlength: 50, nullable: false }, + // @NOTE: The context object can be used to store information about an action e.g. diffs, meta + context: { type: 'text', maxlength: 1000000000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + }, + emails: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, index: true, unique: true }, + uuid: { type: 'string', maxlength: 36, nullable: false, validations: { isUUID: true } }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'pending', + validations: { isIn: [['pending', 'submitting', 'submitted', 'failed']] }, + }, + recipient_filter: { + type: 'text', + maxlength: 1000000000, + nullable: false, + defaultTo: 'status:-free', + }, + error: { type: 'string', maxlength: 2000, nullable: true }, + error_data: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + email_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + delivered_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + opened_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + failed_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + subject: { type: 'string', maxlength: 300, nullable: true }, + from: { type: 'string', maxlength: 2000, nullable: true }, + reply_to: { type: 'string', maxlength: 2000, nullable: true }, + html: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + plaintext: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + track_opens: { type: 'bool', nullable: false, defaultTo: false }, + submitted_at: { type: 'dateTime', nullable: false }, + newsletter_id: { type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id' }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + email_batches: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + email_id: { type: 'string', maxlength: 24, nullable: false, references: 'emails.id' }, + provider_id: { type: 'string', maxlength: 255, nullable: true }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'pending', + validations: { isIn: [['pending', 'submitting', 'submitted', 'failed']] }, + }, + member_segment: { type: 'text', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: false }, + }, + email_recipients: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + email_id: { type: 'string', maxlength: 24, nullable: false, references: 'emails.id' }, + member_id: { type: 'string', maxlength: 24, nullable: false, index: true }, + batch_id: { type: 'string', maxlength: 24, nullable: false, references: 'email_batches.id' }, + processed_at: { type: 'dateTime', nullable: true }, + delivered_at: { type: 'dateTime', nullable: true, index: true }, + opened_at: { type: 'dateTime', nullable: true, index: true }, + failed_at: { type: 'dateTime', nullable: true, index: true }, + member_uuid: { type: 'string', maxlength: 36, nullable: false }, + member_email: { type: 'string', maxlength: 191, nullable: false }, + member_name: { type: 'string', maxlength: 191, nullable: true }, + '@@INDEXES@@': [['email_id', 'member_email']], + }, + tokens: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + token: { type: 'string', maxlength: 32, nullable: false, index: true }, + data: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + }, + snippets: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + mobiledoc: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + temp_member_analytic_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + event_name: { type: 'string', maxlength: 50, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + member_id: { type: 'string', maxlength: 24, nullable: false }, + member_status: { type: 'string', maxlength: 50, nullable: false }, + entry_id: { type: 'string', maxlength: 24, nullable: true }, + source_url: { type: 'string', maxlength: 2000, nullable: true }, + metadata: { type: 'string', maxlength: 191, nullable: true }, + }, + custom_theme_settings: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + theme: { type: 'string', maxlength: 191, nullable: false }, + key: { type: 'string', maxlength: 191, nullable: false }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { + isIn: [['select', 'boolean', 'color', 'text', 'image']], + }, + }, + value: { type: 'text', maxlength: 65535, nullable: true }, + }, + members_newsletters: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + newsletter_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'newsletters.id', + cascadeDelete: true, + }, + }, +}; From 6722ca079e8efac54382e9daa69679e0ca30e89b Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Sep 2026 10:31:39 -0500 Subject: [PATCH 16/18] TypeScriptified 52 unit tests (#31139) no ref --- ...ion.test.js => cache-invalidation.test.ts} | 6 ++--- ...assword.test.js => input-password.test.ts} | 7 +++--- ...over-image.test.js => cover-image.test.ts} | 5 ++-- ...d-data.test.js => structured-data.test.ts} | 5 ++-- .../data/{checks.test.js => checks.test.ts} | 5 ++-- .../{markdown.test.js => markdown.test.ts} | 7 +++--- ...pter.test.js => permalink-adapter.test.ts} | 6 ++--- ...{rss-router.test.js => rss-router.test.ts} | 17 +++++++------ .../{config.test.js => config.test.ts} | 7 +++--- ...end-apps.test.js => frontend-apps.test.ts} | 10 +++++--- ...ber-count.test.js => member-count.test.ts} | 8 +++--- ...ry.test.js => redis-store-factory.test.ts} | 5 ++-- ...manager.test.js => import-manager.test.ts} | 11 ++++---- ...ewsletters.test.js => newsletters.test.ts} | 5 ++-- .../data/{posts.test.js => posts.test.ts} | 9 ++++--- .../{products.test.js => products.test.ts} | 5 ++-- .../{integrity.test.js => integrity.test.ts} | 25 +++++++++++-------- ...{file-types.test.js => file-types.test.ts} | 4 +-- .../lib/{lexical.test.js => lexical.test.ts} | 9 ++++--- .../{filter.test.js => filter.test.ts} | 5 ++-- .../{read.test.js => read.test.ts} | 14 ++++++----- .../auth/{setup.test.js => setup.test.ts} | 7 +++--- ... comments-service-emails-renderer.test.ts} | 7 +++--- ...{validation.test.js => validation.test.ts} | 5 ++-- ....test.js => deposit-address-store.test.ts} | 8 +++--- ...ller.test.js => member-controller.test.ts} | 5 ++-- ...ory.test.js => product-repository.test.ts} | 7 +++--- ...ce.test.js => geolocation-service.test.ts} | 9 ++++--- ... request-integrity-token-provider.test.ts} | 7 +++--- .../members/{utils.test.js => utils.test.ts} | 10 +++++--- ...s => in-memory-mention-repository.test.ts} | 2 +- ...t.js => mention-discovery-service.test.ts} | 12 +++++---- .../{mention.test.js => mention.test.ts} | 2 +- ...rvice.test.js => resource-service.test.ts} | 9 ++++--- ...ervice.test.js => routing-service.test.ts} | 6 ++--- .../{milestone.test.js => milestone.test.ts} | 2 +- .../{index.test.js => index.test.ts} | 2 +- ...checker.test.js => unique-checker.test.ts} | 7 +++--- ...er-amount.test.js => offer-amount.test.ts} | 7 +++--- ...e-coupon.test.js => stripe-coupon.test.ts} | 5 ++-- ...-context.test.js => parse-context.test.ts} | 5 ++-- .../{providers.test.js => providers.test.ts} | 13 ++++++---- .../themes/{list.test.js => list.test.ts} | 11 ++++---- ...{middleware.test.js => middleware.test.ts} | 9 ++++--- ...{middleware.test.js => middleware.test.ts} | 7 +++--- ....test.js => legacy-api-path-match.test.ts} | 5 ++-- .../{image.test.js => image.test.ts} | 11 ++++---- .../{brute.test.js => brute.test.ts} | 7 +++--- ...-config.test.js => adapter-config.test.ts} | 7 +++--- .../{helpers.test.js => helpers.test.ts} | 5 ++-- .../{cache.test.js => cache.test.ts} | 5 ++-- ...es.test.js => labs-flag-overrides.test.ts} | 4 +-- 52 files changed, 213 insertions(+), 170 deletions(-) rename ghost/core/test/unit/api/{cache-invalidation.test.js => cache-invalidation.test.ts} (92%) rename ghost/core/test/unit/frontend/apps/private-blogging/{input-password.test.js => input-password.test.ts} (87%) rename ghost/core/test/unit/frontend/meta/{cover-image.test.js => cover-image.test.ts} (94%) rename ghost/core/test/unit/frontend/meta/{structured-data.test.js => structured-data.test.ts} (97%) rename ghost/core/test/unit/frontend/services/data/{checks.test.js => checks.test.ts} (93%) rename ghost/core/test/unit/frontend/services/llms/{markdown.test.js => markdown.test.ts} (98%) rename ghost/core/test/unit/frontend/services/routing/{permalink-adapter.test.js => permalink-adapter.test.ts} (88%) rename ghost/core/test/unit/frontend/services/routing/{rss-router.test.js => rss-router.test.ts} (69%) rename ghost/core/test/unit/frontend/services/theme-engine/{config.test.js => config.test.ts} (84%) rename ghost/core/test/unit/frontend/utils/{frontend-apps.test.js => frontend-apps.test.ts} (83%) rename ghost/core/test/unit/frontend/utils/{member-count.test.js => member-count.test.ts} (82%) rename ghost/core/test/unit/server/adapters/lib/redis/{redis-store-factory.test.js => redis-store-factory.test.ts} (83%) rename ghost/core/test/unit/server/data/importer/{import-manager.test.js => import-manager.test.ts} (73%) rename ghost/core/test/unit/server/data/importer/importers/data/{newsletters.test.js => newsletters.test.ts} (87%) rename ghost/core/test/unit/server/data/importer/importers/data/{posts.test.js => posts.test.ts} (94%) rename ghost/core/test/unit/server/data/importer/importers/data/{products.test.js => products.test.ts} (94%) rename ghost/core/test/unit/server/data/schema/{integrity.test.js => integrity.test.ts} (82%) rename ghost/core/test/unit/server/lib/{file-types.test.js => file-types.test.ts} (97%) rename ghost/core/test/unit/server/lib/{lexical.test.js => lexical.test.ts} (94%) rename ghost/core/test/unit/server/lib/package-json/{filter.test.js => filter.test.ts} (97%) rename ghost/core/test/unit/server/lib/package-json/{read.test.js => read.test.ts} (97%) rename ghost/core/test/unit/server/services/auth/{setup.test.js => setup.test.ts} (90%) rename ghost/core/test/unit/server/services/comments/{comments-service-emails-renderer.test.js => comments-service-emails-renderer.test.ts} (92%) rename ghost/core/test/unit/server/services/custom-redirects/{validation.test.js => validation.test.ts} (93%) rename ghost/core/test/unit/server/services/machine-payments/{deposit-address-store.test.js => deposit-address-store.test.ts} (95%) rename ghost/core/test/unit/server/services/members/members-api/controllers/{member-controller.test.js => member-controller.test.ts} (97%) rename ghost/core/test/unit/server/services/members/members-api/repositories/{product-repository.test.js => product-repository.test.ts} (80%) rename ghost/core/test/unit/server/services/members/members-api/services/{geolocation-service.test.js => geolocation-service.test.ts} (90%) rename ghost/core/test/unit/server/services/members/{request-integrity-token-provider.test.js => request-integrity-token-provider.test.ts} (90%) rename ghost/core/test/unit/server/services/members/{utils.test.js => utils.test.ts} (92%) rename ghost/core/test/unit/server/services/mentions/{in-memory-mention-repository.test.js => in-memory-mention-repository.test.ts} (98%) rename ghost/core/test/unit/server/services/mentions/{mention-discovery-service.test.js => mention-discovery-service.test.ts} (94%) rename ghost/core/test/unit/server/services/mentions/{mention.test.js => mention.test.ts} (99%) rename ghost/core/test/unit/server/services/mentions/{resource-service.test.js => resource-service.test.ts} (91%) rename ghost/core/test/unit/server/services/mentions/{routing-service.test.js => routing-service.test.ts} (98%) rename ghost/core/test/unit/server/services/milestones/{milestone.test.js => milestone.test.ts} (98%) rename ghost/core/test/unit/server/services/newsletters/{index.test.js => index.test.ts} (91%) rename ghost/core/test/unit/server/services/offers/application/{unique-checker.test.js => unique-checker.test.ts} (88%) rename ghost/core/test/unit/server/services/offers/domain/models/{offer-amount.test.js => offer-amount.test.ts} (96%) rename ghost/core/test/unit/server/services/offers/domain/models/{stripe-coupon.test.js => stripe-coupon.test.ts} (96%) rename ghost/core/test/unit/server/services/permissions/{parse-context.test.js => parse-context.test.ts} (94%) rename ghost/core/test/unit/server/services/permissions/{providers.test.js => providers.test.ts} (95%) rename ghost/core/test/unit/server/services/themes/{list.test.js => list.test.ts} (89%) rename ghost/core/test/unit/server/web/admin/{middleware.test.js => middleware.test.ts} (86%) rename ghost/core/test/unit/server/web/api/canary/content/{middleware.test.js => middleware.test.ts} (67%) rename ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/{legacy-api-path-match.test.js => legacy-api-path-match.test.ts} (92%) rename ghost/core/test/unit/server/web/gift-preview/{image.test.js => image.test.ts} (91%) rename ghost/core/test/unit/server/web/shared/middleware/{brute.test.js => brute.test.ts} (91%) rename ghost/core/test/unit/shared/config/{adapter-config.test.js => adapter-config.test.ts} (91%) rename ghost/core/test/unit/shared/config/{helpers.test.js => helpers.test.ts} (95%) rename ghost/core/test/unit/shared/custom-theme-settings-cache/{cache.test.js => cache.test.ts} (94%) rename ghost/core/test/unit/shared/{labs-flag-overrides.test.js => labs-flag-overrides.test.ts} (94%) diff --git a/ghost/core/test/unit/api/cache-invalidation.test.js b/ghost/core/test/unit/api/cache-invalidation.test.ts similarity index 92% rename from ghost/core/test/unit/api/cache-invalidation.test.js rename to ghost/core/test/unit/api/cache-invalidation.test.ts index fdd021db53d..11532b0508d 100644 --- a/ghost/core/test/unit/api/cache-invalidation.test.js +++ b/ghost/core/test/unit/api/cache-invalidation.test.ts @@ -1,7 +1,7 @@ -const assert = require('node:assert/strict'); -const path = require('path'); +import assert from 'node:assert/strict'; +import path from 'path'; -const { globSync } = require('glob'); +import { globSync } from 'glob'; describe('API', function () { describe('Cache Invalidation', function () { diff --git a/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.js b/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.ts similarity index 87% rename from ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.js rename to ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.ts index 360699464c8..f50317288c8 100644 --- a/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.js +++ b/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.ts @@ -1,9 +1,10 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../utils/assertions'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../utils/assertions'; // We use the name input_password to match the helper for consistency: // Stuff we are testing -const input_password = require('../../../../../core/frontend/apps/private-blogging/lib/helpers/input_password'); +// @ts-expect-error This module lacks type definitions. +import input_password from '../../../../../core/frontend/apps/private-blogging/lib/helpers/input_password'; describe('{{input_password}} helper', function () { it('has input_password helper', function () { diff --git a/ghost/core/test/unit/frontend/meta/cover-image.test.js b/ghost/core/test/unit/frontend/meta/cover-image.test.ts similarity index 94% rename from ghost/core/test/unit/frontend/meta/cover-image.test.js rename to ghost/core/test/unit/frontend/meta/cover-image.test.ts index 1ea1bf030f9..329b659f419 100644 --- a/ghost/core/test/unit/frontend/meta/cover-image.test.js +++ b/ghost/core/test/unit/frontend/meta/cover-image.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const getCoverImage = require('../../../../core/frontend/meta/cover-image'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import getCoverImage from '../../../../core/frontend/meta/cover-image'; describe('getCoverImage', function () { it('should return absolute cover image url for home', function () { diff --git a/ghost/core/test/unit/frontend/meta/structured-data.test.js b/ghost/core/test/unit/frontend/meta/structured-data.test.ts similarity index 97% rename from ghost/core/test/unit/frontend/meta/structured-data.test.js rename to ghost/core/test/unit/frontend/meta/structured-data.test.ts index c25af66bcab..fb7a5271666 100644 --- a/ghost/core/test/unit/frontend/meta/structured-data.test.js +++ b/ghost/core/test/unit/frontend/meta/structured-data.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const getStructuredData = require('../../../../core/frontend/meta/structured-data'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import getStructuredData from '../../../../core/frontend/meta/structured-data'; describe('getStructuredData', function () { it('should return structured data from metadata per post', function () { diff --git a/ghost/core/test/unit/frontend/services/data/checks.test.js b/ghost/core/test/unit/frontend/services/data/checks.test.ts similarity index 93% rename from ghost/core/test/unit/frontend/services/data/checks.test.js rename to ghost/core/test/unit/frontend/services/data/checks.test.ts index 88b24d8a809..e32db620928 100644 --- a/ghost/core/test/unit/frontend/services/data/checks.test.js +++ b/ghost/core/test/unit/frontend/services/data/checks.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const { checks } = require('../../../../../core/frontend/services/data'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import { checks } from '../../../../../core/frontend/services/data'; describe('Checks', function () { it('methods', function () { diff --git a/ghost/core/test/unit/frontend/services/llms/markdown.test.js b/ghost/core/test/unit/frontend/services/llms/markdown.test.ts similarity index 98% rename from ghost/core/test/unit/frontend/services/llms/markdown.test.js rename to ghost/core/test/unit/frontend/services/llms/markdown.test.ts index c438c54df89..0eefa9161a7 100644 --- a/ghost/core/test/unit/frontend/services/llms/markdown.test.js +++ b/ghost/core/test/unit/frontend/services/llms/markdown.test.ts @@ -1,5 +1,5 @@ -const assert = require('node:assert/strict'); -const { +import assert from 'node:assert/strict'; +import { collapseWhitespace, formatIsoDate, truncateDescription, @@ -10,7 +10,8 @@ const { markdownFromHtml, renderEntryMarkdown, renderEntryMarkdownBody, -} = require('../../../../../core/frontend/services/llms/markdown'); + // @ts-expect-error This module lacks type definitions. +} from '../../../../../core/frontend/services/llms/markdown'; describe('Unit: frontend/services/llms/markdown', function () { describe('collapseWhitespace', function () { diff --git a/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js b/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.ts similarity index 88% rename from ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js rename to ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.ts index 4cd1ed58319..e3d49eb9b51 100644 --- a/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js +++ b/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.ts @@ -1,7 +1,5 @@ -const assert = require('node:assert/strict'); -const { - toExpressNotation, -} = require('../../../../../core/frontend/services/routing/permalink-adapter'); +import assert from 'node:assert/strict'; +import { toExpressNotation } from '../../../../../core/frontend/services/routing/permalink-adapter'; describe('services/routing/permalink-adapter', function () { describe('toExpressNotation', function () { diff --git a/ghost/core/test/unit/frontend/services/routing/rss-router.test.js b/ghost/core/test/unit/frontend/services/routing/rss-router.test.ts similarity index 69% rename from ghost/core/test/unit/frontend/services/routing/rss-router.test.js rename to ghost/core/test/unit/frontend/services/routing/rss-router.test.ts index 8d1d6c6d4ab..bef0a4f184e 100644 --- a/ghost/core/test/unit/frontend/services/routing/rss-router.test.js +++ b/ghost/core/test/unit/frontend/services/routing/rss-router.test.ts @@ -1,10 +1,13 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../utils/assertions'); -const sinon = require('sinon'); -const configUtils = require('../../../../utils/config-utils'); -const controllers = require('../../../../../core/frontend/services/routing/controllers'); -const RSSRouter = require('../../../../../core/frontend/services/routing/rss-router'); -const urlUtils = require('../../../../../core/shared/url-utils').default; +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../utils/assertions'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../../utils/config-utils'; +// @ts-expect-error This module lacks type definitions. +import controllers from '../../../../../core/frontend/services/routing/controllers'; +// @ts-expect-error This module lacks type definitions. +import RSSRouter from '../../../../../core/frontend/services/routing/rss-router'; +import urlUtils from '../../../../../core/shared/url-utils'; describe('services/routing/RSSRouter', function () { describe('instantiate', function () { diff --git a/ghost/core/test/unit/frontend/services/theme-engine/config.test.js b/ghost/core/test/unit/frontend/services/theme-engine/config.test.ts similarity index 84% rename from ghost/core/test/unit/frontend/services/theme-engine/config.test.js rename to ghost/core/test/unit/frontend/services/theme-engine/config.test.ts index a68f468f104..06d0fa0250e 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/config.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/config.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const themeConfig = require('../../../../../core/frontend/services/theme-engine/config'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import themeConfig from '../../../../../core/frontend/services/theme-engine/config'; describe('Themes', function () { afterEach(function () { diff --git a/ghost/core/test/unit/frontend/utils/frontend-apps.test.js b/ghost/core/test/unit/frontend/utils/frontend-apps.test.ts similarity index 83% rename from ghost/core/test/unit/frontend/utils/frontend-apps.test.js rename to ghost/core/test/unit/frontend/utils/frontend-apps.test.ts index ee3a241a97d..7efe6079d72 100644 --- a/ghost/core/test/unit/frontend/utils/frontend-apps.test.js +++ b/ghost/core/test/unit/frontend/utils/frontend-apps.test.ts @@ -1,9 +1,11 @@ -const assert = require('node:assert/strict'); -const { +import assert from 'node:assert/strict'; +import { getFrontendAppConfig, getDataAttributes, -} = require('../../../../core/frontend/utils/frontend-apps'); -const configUtils = require('../../../utils/config-utils'); + // @ts-expect-error This module lacks type definitions. +} from '../../../../core/frontend/utils/frontend-apps'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../utils/config-utils'; describe('Frontend apps:', function () { describe('getFrontendAppConfig', function () { diff --git a/ghost/core/test/unit/frontend/utils/member-count.test.js b/ghost/core/test/unit/frontend/utils/member-count.test.ts similarity index 82% rename from ghost/core/test/unit/frontend/utils/member-count.test.js rename to ghost/core/test/unit/frontend/utils/member-count.test.ts index 34badcaa2b0..cb7b1be3ce6 100644 --- a/ghost/core/test/unit/frontend/utils/member-count.test.js +++ b/ghost/core/test/unit/frontend/utils/member-count.test.ts @@ -1,8 +1,6 @@ -const assert = require('node:assert/strict'); -const { - memberCountRounding, - getMemberStats, -} = require('../../../../core/frontend/utils/member-count'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import { memberCountRounding, getMemberStats } from '../../../../core/frontend/utils/member-count'; const getMemberStatsMock = [ { diff --git a/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.js b/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.ts similarity index 83% rename from ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.js rename to ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.ts index 918393c8227..77a83a42dcd 100644 --- a/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.js +++ b/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const redisStoreFactory = require('../../../../../../core/server/adapters/lib/redis/redis-store-factory'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import redisStoreFactory from '../../../../../../core/server/adapters/lib/redis/redis-store-factory'; class CacheManagerMock { static create() { diff --git a/ghost/core/test/unit/server/data/importer/import-manager.test.js b/ghost/core/test/unit/server/data/importer/import-manager.test.ts similarity index 73% rename from ghost/core/test/unit/server/data/importer/import-manager.test.js rename to ghost/core/test/unit/server/data/importer/import-manager.test.ts index e74f12d0708..64a65e36f9a 100644 --- a/ghost/core/test/unit/server/data/importer/import-manager.test.js +++ b/ghost/core/test/unit/server/data/importer/import-manager.test.ts @@ -1,8 +1,9 @@ -const assert = require('node:assert/strict'); -const fs = require('fs-extra'); -const path = require('path'); -const { globSync } = require('glob'); -const importManager = require('../../../../../core/server/data/importer/import-manager'); +import assert from 'node:assert/strict'; +import fs from 'fs-extra'; +import path from 'path'; +import { globSync } from 'glob'; +// @ts-expect-error This module lacks type definitions. +import importManager from '../../../../../core/server/data/importer/import-manager'; describe('Import Manager', function () { describe('extractZip', function () { diff --git a/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.js b/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.ts similarity index 87% rename from ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.js rename to ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.ts index 2e30024b65b..be69a168ef2 100644 --- a/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const NewslettersImporter = require('../../../../../../../core/server/data/importer/importers/data/newsletters-importer'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import NewslettersImporter from '../../../../../../../core/server/data/importer/importers/data/newsletters-importer'; const fakeNewsletters = [ { diff --git a/ghost/core/test/unit/server/data/importer/importers/data/posts.test.js b/ghost/core/test/unit/server/data/importer/importers/data/posts.test.ts similarity index 94% rename from ghost/core/test/unit/server/data/importer/importers/data/posts.test.js rename to ghost/core/test/unit/server/data/importer/importers/data/posts.test.ts index 37944166905..5c6fcc3dc55 100644 --- a/ghost/core/test/unit/server/data/importer/importers/data/posts.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/data/posts.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../../../utils/assertions'); -const find = require('lodash/find'); -const PostsImporter = require('../../../../../../../core/server/data/importer/importers/data/posts-importer'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../../../utils/assertions'; +import find from 'lodash/find'; +// @ts-expect-error This module lacks type definitions. +import PostsImporter from '../../../../../../../core/server/data/importer/importers/data/posts-importer'; describe('PostsImporter', function () { describe('#beforeImport', function () { diff --git a/ghost/core/test/unit/server/data/importer/importers/data/products.test.js b/ghost/core/test/unit/server/data/importer/importers/data/products.test.ts similarity index 94% rename from ghost/core/test/unit/server/data/importer/importers/data/products.test.js rename to ghost/core/test/unit/server/data/importer/importers/data/products.test.ts index d6800d60901..14c44867ccb 100644 --- a/ghost/core/test/unit/server/data/importer/importers/data/products.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/data/products.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const ProductsImporter = require('../../../../../../../core/server/data/importer/importers/data/products-importer'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import ProductsImporter from '../../../../../../../core/server/data/importer/importers/data/products-importer'; const fakeProducts = [ { diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.ts similarity index 82% rename from ghost/core/test/unit/server/data/schema/integrity.test.js rename to ghost/core/test/unit/server/data/schema/integrity.test.ts index e094c55aac7..d70efdb1f91 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.ts @@ -1,16 +1,19 @@ -const assert = require('node:assert/strict'); -const _ = require('lodash'); -const crypto = require('crypto'); -const fs = require('fs-extra'); -const path = require('path'); -const { config } = require('../../../../utils/config-utils'); -const schema = require('../../../../../core/server/data/schema/schema'); -const inDevelopment = require('../../../../../core/server/data/schema/in-development'); -const fixtures = require('../../../../../core/server/data/schema/fixtures/fixtures.json'); -const defaultSettings = require('../../../../../core/server/data/schema/default-settings/default-settings.json'); +import assert from 'node:assert/strict'; +import _ from 'lodash'; +import crypto from 'crypto'; +import fs from 'fs-extra'; +import path from 'path'; +// @ts-expect-error This module lacks type definitions. +import { config } from '../../../../utils/config-utils'; +// @ts-expect-error This module lacks type definitions. +import schema from '../../../../../core/server/data/schema/schema'; +import * as inDevelopment from '../../../../../core/server/data/schema/in-development'; +import fixtures from '../../../../../core/server/data/schema/fixtures/fixtures.json'; +import defaultSettings from '../../../../../core/server/data/schema/default-settings/default-settings.json'; // Routes are yaml so we can require the file directly -const routeSettings = require('../../../../../core/server/services/route-settings'); +// @ts-expect-error This module lacks type definitions. +import routeSettings from '../../../../../core/server/services/route-settings'; routeSettings.init(); const { parseRouteSettings, diff --git a/ghost/core/test/unit/server/lib/file-types.test.js b/ghost/core/test/unit/server/lib/file-types.test.ts similarity index 97% rename from ghost/core/test/unit/server/lib/file-types.test.js rename to ghost/core/test/unit/server/lib/file-types.test.ts index 01fc4dfd0aa..7538c7caa01 100644 --- a/ghost/core/test/unit/server/lib/file-types.test.js +++ b/ghost/core/test/unit/server/lib/file-types.test.ts @@ -1,5 +1,5 @@ -const assert = require('node:assert/strict'); -const { getStorageContentType } = require('../../../../core/server/lib/file-types'); +import assert from 'node:assert/strict'; +import { getStorageContentType } from '../../../../core/server/lib/file-types'; describe('file-types', function () { describe('getStorageContentType', function () { diff --git a/ghost/core/test/unit/server/lib/lexical.test.js b/ghost/core/test/unit/server/lib/lexical.test.ts similarity index 94% rename from ghost/core/test/unit/server/lib/lexical.test.js rename to ghost/core/test/unit/server/lib/lexical.test.ts index a22c4dbaff4..b3645d381d1 100644 --- a/ghost/core/test/unit/server/lib/lexical.test.js +++ b/ghost/core/test/unit/server/lib/lexical.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const jsdom = require('jsdom'); -const lexicalLib = require('../../../../core/server/lib/lexical'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import jsdom from 'jsdom'; +// @ts-expect-error This module lacks type definitions. +import lexicalLib from '../../../../core/server/lib/lexical'; describe('lib/lexical', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/lib/package-json/filter.test.js b/ghost/core/test/unit/server/lib/package-json/filter.test.ts similarity index 97% rename from ghost/core/test/unit/server/lib/package-json/filter.test.js rename to ghost/core/test/unit/server/lib/package-json/filter.test.ts index b20fc38e5fb..2f9b3bce5cb 100644 --- a/ghost/core/test/unit/server/lib/package-json/filter.test.js +++ b/ghost/core/test/unit/server/lib/package-json/filter.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const packageJSON = require('../../../../../core/server/lib/package-json'); +// @ts-expect-error This module lacks type definitions. +import packageJSON from '../../../../../core/server/lib/package-json'; describe('package-json filter', function () { // @TODO: introduce some non-theme package examples diff --git a/ghost/core/test/unit/server/lib/package-json/read.test.js b/ghost/core/test/unit/server/lib/package-json/read.test.ts similarity index 97% rename from ghost/core/test/unit/server/lib/package-json/read.test.js rename to ghost/core/test/unit/server/lib/package-json/read.test.ts index de541a326cc..313cf3b71ab 100644 --- a/ghost/core/test/unit/server/lib/package-json/read.test.js +++ b/ghost/core/test/unit/server/lib/package-json/read.test.ts @@ -1,9 +1,11 @@ -const assert = require('node:assert/strict'); - -const tmp = require('tmp'); -const join = require('path').join; -const fs = require('fs-extra'); -const packageJSON = require('../../../../../core/server/lib/package-json'); +import assert from 'node:assert/strict'; + +// @ts-expect-error This module lacks type definitions. +import tmp from 'tmp'; +import { join } from 'path'; +import fs from 'fs-extra'; +// @ts-expect-error This module lacks type definitions. +import packageJSON from '../../../../../core/server/lib/package-json'; describe('package-json read', function () { describe('readPackages', function () { diff --git a/ghost/core/test/unit/server/services/auth/setup.test.js b/ghost/core/test/unit/server/services/auth/setup.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/auth/setup.test.js rename to ghost/core/test/unit/server/services/auth/setup.test.ts index 449a8249680..bf994084af8 100644 --- a/ghost/core/test/unit/server/services/auth/setup.test.js +++ b/ghost/core/test/unit/server/services/auth/setup.test.ts @@ -1,7 +1,6 @@ -const { - doProductAndNewsletter: doTierAndNewsletter, -} = require('../../../../../core/server/services/auth/setup'); -const sinon = require('sinon'); +// @ts-expect-error This module lacks type definitions. +import { doProductAndNewsletter as doTierAndNewsletter } from '../../../../../core/server/services/auth/setup'; +import sinon from 'sinon'; describe('Auth Service: setup', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.js b/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.ts similarity index 92% rename from ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.js rename to ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.ts index 49b75dfd335..5bb56b034a6 100644 --- a/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.js +++ b/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const i18nLib = require('@tryghost/i18n').default; -const CommentsServiceEmailRenderer = require('../../../../../core/server/services/comments/comments-service-email-renderer'); +import assert from 'node:assert/strict'; +import i18nLib from '@tryghost/i18n'; +// @ts-expect-error This module lacks type definitions. +import CommentsServiceEmailRenderer from '../../../../../core/server/services/comments/comments-service-email-renderer'; describe('Comments Service Email Renderer', function () { describe('renderEmail Template with different locales', function () { diff --git a/ghost/core/test/unit/server/services/custom-redirects/validation.test.js b/ghost/core/test/unit/server/services/custom-redirects/validation.test.ts similarity index 93% rename from ghost/core/test/unit/server/services/custom-redirects/validation.test.js rename to ghost/core/test/unit/server/services/custom-redirects/validation.test.ts index a48c2f18008..7e6fdba3c87 100644 --- a/ghost/core/test/unit/server/services/custom-redirects/validation.test.js +++ b/ghost/core/test/unit/server/services/custom-redirects/validation.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const { validate } = require('../../../../../core/server/services/custom-redirects/validation'); +// @ts-expect-error This module lacks type definitions. +import { validate } from '../../../../../core/server/services/custom-redirects/validation'; describe('custom redirects validation', function () { it('passes validation for a valid redirects config', function () { diff --git a/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.js b/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.ts similarity index 95% rename from ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.js rename to ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.ts index 2a07f185f15..6cfec115e18 100644 --- a/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.js +++ b/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.ts @@ -1,8 +1,6 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const { - DepositAddressStore, -} = require('../../../../../core/server/services/machine-payments/stripe/deposit-address-store'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import { DepositAddressStore } from '../../../../../core/server/services/machine-payments/stripe/deposit-address-store'; describe('Unit: server/services/machine-payments/deposit-address-store', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.ts similarity index 97% rename from ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.js rename to ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.ts index 9ee605dcc44..79f7d4d97cc 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.ts @@ -1,5 +1,6 @@ -const sinon = require('sinon'); -const MemberController = require('../../../../../../../core/server/services/members/members-api/controllers/member-controller'); +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import MemberController from '../../../../../../../core/server/services/members/members-api/controllers/member-controller'; describe('MemberController', function () { describe('updateSubscription', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.ts similarity index 80% rename from ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.js rename to ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.ts index bba3a44de40..e4d9fdb1aa6 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const ProductRepository = require('../../../../../../../core/server/services/members/members-api/repositories/product-repository'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import ProductRepository from '../../../../../../../core/server/services/members/members-api/repositories/product-repository'; describe('MemberRepository', function () { describe('getDefaultProduct', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js rename to ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.ts index a68f1d58855..f0324c87f12 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../../../utils/assertions'); -const nock = require('nock'); -const GeolocationService = require('../../../../../../../core/server/services/members/members-api/services/geolocation-service'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../../../utils/assertions'; +import nock from 'nock'; +// @ts-expect-error This module lacks type definitions. +import GeolocationService from '../../../../../../../core/server/services/members/members-api/services/geolocation-service'; const GEOJS_RESPONSE = { longitude: '-2.2417', diff --git a/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.js b/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.js rename to ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.ts index f3ed5baca7b..eb15d265c70 100644 --- a/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.js +++ b/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; -const RequestIntegrityTokenProvider = require('../../../../../core/server/services/members/request-integrity-token-provider'); +// @ts-expect-error This module lacks type definitions. +import RequestIntegrityTokenProvider from '../../../../../core/server/services/members/request-integrity-token-provider'; const tokenProvider = new RequestIntegrityTokenProvider({ themeSecret: 'test', diff --git a/ghost/core/test/unit/server/services/members/utils.test.js b/ghost/core/test/unit/server/services/members/utils.test.ts similarity index 92% rename from ghost/core/test/unit/server/services/members/utils.test.js rename to ghost/core/test/unit/server/services/members/utils.test.ts index 2dbad305190..e02d4e832dc 100644 --- a/ghost/core/test/unit/server/services/members/utils.test.js +++ b/ghost/core/test/unit/server/services/members/utils.test.ts @@ -1,7 +1,9 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const { formattedMemberResponse } = require('../../../../../core/server/services/members/utils'); -const labs = require('../../../../../core/shared/labs'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import { formattedMemberResponse } from '../../../../../core/server/services/members/utils'; +// @ts-expect-error This module lacks type definitions. +import labs from '../../../../../core/shared/labs'; describe('Members Service - utils', function () { describe('formattedMemberResponse', function () { diff --git a/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.js b/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.ts similarity index 98% rename from ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.js rename to ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.ts index 612242d332e..546d326eb0b 100644 --- a/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.js +++ b/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; const ObjectID = require('bson-objectid').default; const InMemoryMentionRepository = require('../../../../../core/server/services/mentions/in-memory-mention-repository'); const Mention = require('../../../../../core/server/services/mentions/mention'); diff --git a/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.js b/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.ts similarity index 94% rename from ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.js rename to ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.ts index 1d6a9477704..5d980f9ce0e 100644 --- a/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.ts @@ -1,12 +1,14 @@ -const sinon = require('sinon'); +import sinon from 'sinon'; -const assert = require('node:assert/strict'); -const nock = require('nock'); +import assert from 'node:assert/strict'; +import nock from 'nock'; // non-standard to use externalRequest here, but this is required for the overrides in the library, which we want to test for security reasons in combination with the package -const externalRequest = require('../../../../../core/server/lib/request-external.js'); +// @ts-expect-error This module lacks type definitions. +import externalRequest from '../../../../../core/server/lib/request-external.js'; -const MentionDiscoveryService = require('../../../../../core/server/services/mentions/mention-discovery-service'); +// @ts-expect-error This module lacks type definitions. +import MentionDiscoveryService from '../../../../../core/server/services/mentions/mention-discovery-service'; describe('MentionDiscoveryService', function () { const service = new MentionDiscoveryService({ externalRequest }); diff --git a/ghost/core/test/unit/server/services/mentions/mention.test.js b/ghost/core/test/unit/server/services/mentions/mention.test.ts similarity index 99% rename from ghost/core/test/unit/server/services/mentions/mention.test.js rename to ghost/core/test/unit/server/services/mentions/mention.test.ts index c8b5b38f968..c1bbf5c737e 100644 --- a/ghost/core/test/unit/server/services/mentions/mention.test.js +++ b/ghost/core/test/unit/server/services/mentions/mention.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; const ObjectID = require('bson-objectid').default; const cheerio = require('cheerio/slim'); const sinon = require('sinon'); diff --git a/ghost/core/test/unit/server/services/mentions/resource-service.test.js b/ghost/core/test/unit/server/services/mentions/resource-service.test.ts similarity index 91% rename from ghost/core/test/unit/server/services/mentions/resource-service.test.js rename to ghost/core/test/unit/server/services/mentions/resource-service.test.ts index 470fe85059f..579da64b3a7 100644 --- a/ghost/core/test/unit/server/services/mentions/resource-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/resource-service.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const ResourceService = require('../../../../../core/server/services/mentions/resource-service'); -const UrlUtils = require('@tryghost/url-utils'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import ResourceService from '../../../../../core/server/services/mentions/resource-service'; +import UrlUtils from '@tryghost/url-utils'; function buildUrlServiceWithStubbedResolve() { const resolveUrl = sinon.stub(); diff --git a/ghost/core/test/unit/server/services/mentions/routing-service.test.js b/ghost/core/test/unit/server/services/mentions/routing-service.test.ts similarity index 98% rename from ghost/core/test/unit/server/services/mentions/routing-service.test.js rename to ghost/core/test/unit/server/services/mentions/routing-service.test.ts index 7fa2338e726..ea4daa92b0c 100644 --- a/ghost/core/test/unit/server/services/mentions/routing-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/routing-service.test.ts @@ -1,6 +1,6 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const nock = require('nock'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import nock from 'nock'; const ObjectID = require('bson-objectid').default; const externalRequest = require('../../../../../core/server/lib/request-external'); diff --git a/ghost/core/test/unit/server/services/milestones/milestone.test.js b/ghost/core/test/unit/server/services/milestones/milestone.test.ts similarity index 98% rename from ghost/core/test/unit/server/services/milestones/milestone.test.js rename to ghost/core/test/unit/server/services/milestones/milestone.test.ts index 76df04472b6..70b9a82e82d 100644 --- a/ghost/core/test/unit/server/services/milestones/milestone.test.js +++ b/ghost/core/test/unit/server/services/milestones/milestone.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; const ObjectID = require('bson-objectid').default; const Milestone = require('../../../../../core/server/services/milestones/milestone'); diff --git a/ghost/core/test/unit/server/services/newsletters/index.test.js b/ghost/core/test/unit/server/services/newsletters/index.test.ts similarity index 91% rename from ghost/core/test/unit/server/services/newsletters/index.test.js rename to ghost/core/test/unit/server/services/newsletters/index.test.ts index 83d1687303e..62673f96b35 100644 --- a/ghost/core/test/unit/server/services/newsletters/index.test.js +++ b/ghost/core/test/unit/server/services/newsletters/index.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; describe('Newsletters Service', function () { let newslettersService; diff --git a/ghost/core/test/unit/server/services/offers/application/unique-checker.test.js b/ghost/core/test/unit/server/services/offers/application/unique-checker.test.ts similarity index 88% rename from ghost/core/test/unit/server/services/offers/application/unique-checker.test.js rename to ghost/core/test/unit/server/services/offers/application/unique-checker.test.ts index b506cc858a6..6313ce0797b 100644 --- a/ghost/core/test/unit/server/services/offers/application/unique-checker.test.js +++ b/ghost/core/test/unit/server/services/offers/application/unique-checker.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const UniqueChecker = require('../../../../../../core/server/services/offers/application/unique-checker'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import UniqueChecker from '../../../../../../core/server/services/offers/application/unique-checker'; describe('UniqueChecker', function () { describe('#isUniqueCode', function () { diff --git a/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.js b/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.ts similarity index 96% rename from ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.js rename to ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.ts index cbca443b6c2..9c379021b9a 100644 --- a/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.js +++ b/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.ts @@ -1,10 +1,11 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const { +import { OfferPercentageAmount, OfferFixedAmount, OfferTrialAmount, -} = require('../../../../../../../core/server/services/offers/domain/models/offer-amount'); + // @ts-expect-error This module lacks type definitions. +} from '../../../../../../../core/server/services/offers/domain/models/offer-amount'; describe('OfferAmount', function () { describe('OfferPercentageAmount', function () { diff --git a/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.js b/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.ts similarity index 96% rename from ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.js rename to ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.ts index a1315585997..d3a7088f18d 100644 --- a/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.js +++ b/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const StripeCoupon = require('../../../../../../../core/server/services/offers/domain/models/stripe-coupon'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import StripeCoupon from '../../../../../../../core/server/services/offers/domain/models/stripe-coupon'; describe('StripeCoupon', function () { describe('StripeCoupon.create factory', function () { diff --git a/ghost/core/test/unit/server/services/permissions/parse-context.test.js b/ghost/core/test/unit/server/services/permissions/parse-context.test.ts similarity index 94% rename from ghost/core/test/unit/server/services/permissions/parse-context.test.js rename to ghost/core/test/unit/server/services/permissions/parse-context.test.ts index 31cb6c0a1d8..77cce35e55c 100644 --- a/ghost/core/test/unit/server/services/permissions/parse-context.test.js +++ b/ghost/core/test/unit/server/services/permissions/parse-context.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const parseContext = require('../../../../../core/server/services/permissions/parse-context'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import parseContext from '../../../../../core/server/services/permissions/parse-context'; describe('Permissions', function () { describe('parseContext', function () { diff --git a/ghost/core/test/unit/server/services/permissions/providers.test.js b/ghost/core/test/unit/server/services/permissions/providers.test.ts similarity index 95% rename from ghost/core/test/unit/server/services/permissions/providers.test.js rename to ghost/core/test/unit/server/services/permissions/providers.test.ts index 4931e7c93ed..a469680453a 100644 --- a/ghost/core/test/unit/server/services/permissions/providers.test.js +++ b/ghost/core/test/unit/server/services/permissions/providers.test.ts @@ -1,8 +1,11 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const testUtils = require('../../../../utils'); -const models = require('../../../../../core/server/models'); -const providers = require('../../../../../core/server/services/permissions/providers'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import testUtils from '../../../../utils'; +// @ts-expect-error This module lacks type definitions. +import models from '../../../../../core/server/models'; +// @ts-expect-error This module lacks type definitions. +import providers from '../../../../../core/server/services/permissions/providers'; describe('Permission Providers', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/themes/list.test.js b/ghost/core/test/unit/server/services/themes/list.test.ts similarity index 89% rename from ghost/core/test/unit/server/services/themes/list.test.js rename to ghost/core/test/unit/server/services/themes/list.test.ts index 768ab789b78..8c87caf639c 100644 --- a/ghost/core/test/unit/server/services/themes/list.test.js +++ b/ghost/core/test/unit/server/services/themes/list.test.ts @@ -1,8 +1,9 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../utils/assertions'); -const sinon = require('sinon'); -const _ = require('lodash'); -const themeList = require('../../../../../core/server/services/themes/list'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../utils/assertions'; +import sinon from 'sinon'; +import _ from 'lodash'; +// @ts-expect-error This module lacks type definitions. +import themeList from '../../../../../core/server/services/themes/list'; describe('Themes', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/web/admin/middleware.test.js b/ghost/core/test/unit/server/web/admin/middleware.test.ts similarity index 86% rename from ghost/core/test/unit/server/web/admin/middleware.test.js rename to ghost/core/test/unit/server/web/admin/middleware.test.ts index b66ef262663..b39a6df875f 100644 --- a/ghost/core/test/unit/server/web/admin/middleware.test.js +++ b/ghost/core/test/unit/server/web/admin/middleware.test.ts @@ -1,9 +1,10 @@ -const assert = require('node:assert/strict'); -const express = require('express'); -const request = require('supertest'); +import assert from 'node:assert/strict'; +import express from 'express'; +import request from 'supertest'; // Thing we are testing -const redirectAdminUrls = require('../../../../../core/server/web/admin/middleware/redirect-admin-urls'); +// @ts-expect-error This module lacks type definitions. +import redirectAdminUrls from '../../../../../core/server/web/admin/middleware/redirect-admin-urls'; describe('Admin App', function () { describe('middleware', function () { diff --git a/ghost/core/test/unit/server/web/api/canary/content/middleware.test.js b/ghost/core/test/unit/server/web/api/canary/content/middleware.test.ts similarity index 67% rename from ghost/core/test/unit/server/web/api/canary/content/middleware.test.js rename to ghost/core/test/unit/server/web/api/canary/content/middleware.test.ts index aec2d0929a9..e6efa651443 100644 --- a/ghost/core/test/unit/server/web/api/canary/content/middleware.test.js +++ b/ghost/core/test/unit/server/web/api/canary/content/middleware.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../../../utils/assertions'); -const middleware = require('../../../../../../../core/server/web/api/endpoints/content/middleware'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../../../utils/assertions'; +// @ts-expect-error This module lacks type definitions. +import middleware from '../../../../../../../core/server/web/api/endpoints/content/middleware'; describe('Content API middleware', function () { it('exports an authenticatePublic middleware', function () { diff --git a/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.js b/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.ts similarity index 92% rename from ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.js rename to ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.ts index 27ae48c35f9..58026e2c5dd 100644 --- a/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const legacyApiPathMatch = require('../../../../../../../core/server/web/api/middleware/api-version-compatibility/legacy-api-path-match'); +// @ts-expect-error This module lacks type definitions. +import legacyApiPathMatch from '../../../../../../../core/server/web/api/middleware/api-version-compatibility/legacy-api-path-match'; describe('Legacy Path Match', function () { it('returns null, admin for all supported permutations', function () { diff --git a/ghost/core/test/unit/server/web/gift-preview/image.test.js b/ghost/core/test/unit/server/web/gift-preview/image.test.ts similarity index 91% rename from ghost/core/test/unit/server/web/gift-preview/image.test.js rename to ghost/core/test/unit/server/web/gift-preview/image.test.ts index e5732f2488e..9fe65952fc7 100644 --- a/ghost/core/test/unit/server/web/gift-preview/image.test.js +++ b/ghost/core/test/unit/server/web/gift-preview/image.test.ts @@ -1,8 +1,9 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const sinon = require('sinon'); -const imageModule = require('../../../../../core/server/web/gift-preview/image'); +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import imageModule from '../../../../../core/server/web/gift-preview/image'; const giftPreviewPath = path.dirname( require.resolve('../../../../../core/server/web/gift-preview/image'), diff --git a/ghost/core/test/unit/server/web/shared/middleware/brute.test.js b/ghost/core/test/unit/server/web/shared/middleware/brute.test.ts similarity index 91% rename from ghost/core/test/unit/server/web/shared/middleware/brute.test.js rename to ghost/core/test/unit/server/web/shared/middleware/brute.test.ts index 9f6af51f27f..c1f58a10481 100644 --- a/ghost/core/test/unit/server/web/shared/middleware/brute.test.js +++ b/ghost/core/test/unit/server/web/shared/middleware/brute.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const brute = require('../../../../../../core/server/web/shared/middleware/brute'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import brute from '../../../../../../core/server/web/shared/middleware/brute'; describe('brute middleware', function () { afterEach(function () { diff --git a/ghost/core/test/unit/shared/config/adapter-config.test.js b/ghost/core/test/unit/shared/config/adapter-config.test.ts similarity index 91% rename from ghost/core/test/unit/shared/config/adapter-config.test.js rename to ghost/core/test/unit/shared/config/adapter-config.test.ts index 6ead3bea16e..2943189f2e7 100644 --- a/ghost/core/test/unit/shared/config/adapter-config.test.js +++ b/ghost/core/test/unit/shared/config/adapter-config.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const path = require('path'); -const configUtils = require('../../../utils/config-utils'); +import assert from 'node:assert/strict'; +import path from 'path'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../utils/config-utils'; /** * This is a somewhat legacy set of tests that check we get the right values for storage diff --git a/ghost/core/test/unit/shared/config/helpers.test.js b/ghost/core/test/unit/shared/config/helpers.test.ts similarity index 95% rename from ghost/core/test/unit/shared/config/helpers.test.js rename to ghost/core/test/unit/shared/config/helpers.test.ts index 5b9571a7469..bf4f3b8ac85 100644 --- a/ghost/core/test/unit/shared/config/helpers.test.js +++ b/ghost/core/test/unit/shared/config/helpers.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const configUtils = require('../../../utils/config-utils'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../utils/config-utils'; describe('vhost utils', function () { beforeEach(function () { diff --git a/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.js b/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.ts similarity index 94% rename from ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.js rename to ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.ts index be45392860d..f276d10694b 100644 --- a/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.js +++ b/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const Cache = require('../../../../core/shared/custom-theme-settings-cache/custom-theme-settings-cache'); +// @ts-expect-error This module lacks type definitions. +import Cache from '../../../../core/shared/custom-theme-settings-cache/custom-theme-settings-cache'; describe('Cache', function () { describe('populate()', function () { diff --git a/ghost/core/test/unit/shared/labs-flag-overrides.test.js b/ghost/core/test/unit/shared/labs-flag-overrides.test.ts similarity index 94% rename from ghost/core/test/unit/shared/labs-flag-overrides.test.js rename to ghost/core/test/unit/shared/labs-flag-overrides.test.ts index f02ac226f9c..889f4b55b95 100644 --- a/ghost/core/test/unit/shared/labs-flag-overrides.test.js +++ b/ghost/core/test/unit/shared/labs-flag-overrides.test.ts @@ -1,6 +1,6 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const flagOverrides = require('../../../core/shared/labs-flag-overrides'); +import * as flagOverrides from '../../../core/shared/labs-flag-overrides'; describe('Labs flag overrides store', function () { afterEach(function () { From a6b13c03354b38e91bbbd3f22d65b4e94e505cc5 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 30 Sep 2026 11:56:10 -0400 Subject: [PATCH 17/18] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20the=20editor=20fre?= =?UTF-8?q?ezing=20when=20adding=20a=20YouTube=20embed=20(#31140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref The embed renderer works out an iframe embed's height from its own width. Where scrollbars take up space, a frame a few pixels too short shows one, which narrows the renderer and shrinks the height, which removes the scrollbar and grows it again. Each change triggered another report, so the renderer never yielded and flooded the editor with resize messages. The editor sizes the frame to fit, so the renderer never needs to scroll. Hiding its overflow keeps the width, and so the height, stable. The message protocol is unchanged, so this ships as the same renderer version. --- .changeset/calm-frames-settle.md | 5 ++++ .../public/embed-renderer/v1.html | 2 ++ .../test/e2e/cards/embed-card.test.ts | 23 ++++++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-frames-settle.md diff --git a/.changeset/calm-frames-settle.md b/.changeset/calm-frames-settle.md new file mode 100644 index 00000000000..2782ba07b75 --- /dev/null +++ b/.changeset/calm-frames-settle.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Fixed the editor freezing on embed previews when the browser shows scrollbars that take up space diff --git a/koenig/koenig-lexical/public/embed-renderer/v1.html b/koenig/koenig-lexical/public/embed-renderer/v1.html index c15c0713da1..250a83ae7c0 100644 --- a/koenig/koenig-lexical/public/embed-renderer/v1.html +++ b/koenig/koenig-lexical/public/embed-renderer/v1.html @@ -26,6 +26,8 @@ body.style.justifyContent = 'center'; // don't stretch embeds to the frame, or ones that clip overflow (eg, TikTok) never grow body.style.alignItems = 'flex-start'; + // the frame is sized to fit, and a scrollbar would change the width the height is worked out from + document.documentElement.style.overflow = 'hidden'; function setSize(element, property, value) { if (element.style[property] !== value) { diff --git a/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts b/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts index 710ee29f5c6..62729dd2000 100644 --- a/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts +++ b/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import {E2E_PORT} from '../../../playwright.config'; import {EMBED_RENDERER_MAX_HEIGHT} from '../../../src/utils/embed-renderer'; import {assertHTML, createSnippet, focusEditor, html, initialize, isMac, pasteText} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; +import {chromium, expect, test} from '@playwright/test'; import {fileURLToPath} from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -154,6 +154,27 @@ test.describe('Embed card', async () => { await expect(iframe).toHaveCSS('height', `${EMBED_RENDERER_MAX_HEIGHT}px`); }); + test('settles on a height when the renderer shows scrollbars that take up space', async function () { + // Playwright hides scrollbars by default, and a scrollbar narrows the + // renderer, changing the height it works out from its width + const browser = await chromium.launch({ignoreDefaultArgs: ['--hide-scrollbars']}); + + try { + const scrollbarPage = await browser.newPage({baseURL: `http://localhost:${E2E_PORT}`}); + await scrollbarPage.route(`${rendererDirectory}**`, route => route.fulfill({path: rendererFile, contentType: 'text/html'})); + + const embedHtml = ''; + await initialize({page: scrollbarPage, uri: `/#/?embedPreviewUrl=${encodeURIComponent(rendererDirectory)}&content=${embedContent(embedHtml)}`}); + + const iframe = scrollbarPage.getByTestId('embed-iframe'); + const {width} = await iframe.boundingBox(); + + await expect(iframe).toHaveCSS('height', `${Math.ceil(width / (200 / 113))}px`); + } finally { + await browser.close(); + } + }); + test('shows a placeholder when the renderer is on the editor origin', async function () { const sameOriginDirectory = `http://localhost:${E2E_PORT}/embed-renderer/`; From b89dfe53eb555d9b6a4d861529a5d4950a96d5ac Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Sep 2026 11:09:13 -0500 Subject: [PATCH 18/18] TypeScriptified private site access code unit tests (#31141) no ref --- ...de.test.js => private-site-access-code.test.ts} | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) rename ghost/core/test/unit/server/services/settings/{private-site-access-code.test.js => private-site-access-code.test.ts} (73%) diff --git a/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js b/ghost/core/test/unit/server/services/settings/private-site-access-code.test.ts similarity index 73% rename from ghost/core/test/unit/server/services/settings/private-site-access-code.test.js rename to ghost/core/test/unit/server/services/settings/private-site-access-code.test.ts index 1766f32c5ce..a86baa25447 100644 --- a/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js +++ b/ghost/core/test/unit/server/services/settings/private-site-access-code.test.ts @@ -1,8 +1,8 @@ -const assert = require('node:assert/strict'); -const { - ACCESS_CODE_WORDS, - generatePrivateSiteAccessCode, -} = require('../../../../../core/server/services/settings/private-site-access-code'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import * as accessCode from '../../../../../core/server/services/settings/private-site-access-code'; + +const { ACCESS_CODE_WORDS, generatePrivateSiteAccessCode } = accessCode; describe('private-site-access-code', function () { it('returns a curated word with a three-digit suffix', function () { @@ -21,11 +21,11 @@ describe('private-site-access-code', function () { it('uses a 48-word lowercase list with no duplicate entries', function () { assert.equal(ACCESS_CODE_WORDS.length, 48); assert.equal(new Set(ACCESS_CODE_WORDS).size, ACCESS_CODE_WORDS.length); - assert.ok(ACCESS_CODE_WORDS.every((word) => /^[a-z]+$/.test(word))); + assert.ok(ACCESS_CODE_WORDS.every((word: string) => /^[a-z]+$/.test(word))); }); it('produces varied codes across consecutive calls', function () { - const codes = new Set(); + const codes = new Set(); for (let i = 0; i < 50; i++) { codes.add(generatePrivateSiteAccessCode()); }