diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md new file mode 100644 index 00000000000..a9f1b26a831 --- /dev/null +++ b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md @@ -0,0 +1,5 @@ +## 0.2.1 + +### Patch Changes + +- Fixed the test suite type-check against the typed @tryghost/logging error signature. diff --git a/.changeset/changelogs/@tryghost!kg-converters@1.2.4.md b/.changeset/changelogs/@tryghost!kg-converters@1.2.4.md new file mode 100644 index 00000000000..a87f1933d05 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-converters@1.2.4.md @@ -0,0 +1,5 @@ +## 1.2.4 + +### Patch Changes + +- Fixed the test suite type-check failing under TypeScript 7. diff --git a/.changeset/changelogs/@tryghost!kg-default-cards@10.3.4.md b/.changeset/changelogs/@tryghost!kg-default-cards@10.3.4.md new file mode 100644 index 00000000000..9395107bc9d --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-cards@10.3.4.md @@ -0,0 +1,5 @@ +## 10.3.4 + +### Patch Changes + +- Removed a third-party network request (spacergif.org) from the web-rendered video card's poster image, replacing it with a local transparent placeholder. Email rendering is unaffected. diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.1.5.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.1.5.md new file mode 100644 index 00000000000..3f6e2a2d85c --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-nodes@2.1.5.md @@ -0,0 +1,7 @@ +## 2.1.5 + +### Patch Changes + +- Removed a third-party network request (spacergif.org) from the web-rendered video card's poster image, replacing it with a local transparent placeholder. Email rendering is unaffected. + +- Fixed contrast text colors for light backgrounds diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md new file mode 100644 index 00000000000..c45d98a4d85 --- /dev/null +++ b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md @@ -0,0 +1,5 @@ +## 1.9.1 + +### Patch Changes + +- Fixed contrast text colors for light backgrounds diff --git a/.changeset/ledger.yaml b/.changeset/ledger.yaml index 30ce8e086a7..bf4eb15a2e8 100644 --- a/.changeset/ledger.yaml +++ b/.changeset/ledger.yaml @@ -12,19 +12,40 @@ dir: packages/adapters/scheduling-base intents: - fifty-maps-sing +"@tryghost/adapter-base-scheduling@0.2.1": + dir: packages/adapters/scheduling-base + intents: + - typed-logging-args "@tryghost/adapter-base-sso@0.1.1": dir: packages/adapters/sso-base intents: - major-areas-fail - plain-singers-cheat +"@tryghost/kg-converters@1.2.4": + dir: koenig/kg-converters + intents: + - tidy-types-check +"@tryghost/kg-default-cards@10.3.4": + dir: koenig/kg-default-cards + intents: + - spacy-poodles-hunt "@tryghost/kg-default-nodes@2.1.4": dir: koenig/kg-default-nodes intents: - floppy-bobcats-spend +"@tryghost/kg-default-nodes@2.1.5": + dir: koenig/kg-default-nodes + intents: + - spacy-poodles-hunt + - warm-hotels-make "@tryghost/koenig-lexical@1.9.0": dir: koenig/koenig-lexical intents: - bright-walls-preview +"@tryghost/koenig-lexical@1.9.1": + dir: koenig/koenig-lexical + intents: + - warm-hotels-make ghost-storage-base@3.0.0: dir: packages/adapters/storage-base intents: diff --git a/.changeset/spacy-poodles-hunt.md b/.changeset/spacy-poodles-hunt.md new file mode 100644 index 00000000000..516ab568751 --- /dev/null +++ b/.changeset/spacy-poodles-hunt.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-default-nodes": patch +"@tryghost/kg-default-cards": patch +--- + +Removed a third-party network request (spacergif.org) from the web-rendered video card's poster image, replacing it with a local transparent placeholder. Email rendering is unaffected. diff --git a/.changeset/tidy-types-check.md b/.changeset/tidy-types-check.md new file mode 100644 index 00000000000..e319b5f8836 --- /dev/null +++ b/.changeset/tidy-types-check.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-converters": patch +--- + +Fixed the test suite type-check failing under TypeScript 7. diff --git a/.changeset/typed-logging-args.md b/.changeset/typed-logging-args.md new file mode 100644 index 00000000000..8e3c5ec1423 --- /dev/null +++ b/.changeset/typed-logging-args.md @@ -0,0 +1,5 @@ +--- +"@tryghost/adapter-base-scheduling": patch +--- + +Fixed the test suite type-check against the typed @tryghost/logging error signature. diff --git a/.changeset/warm-hotels-make.md b/.changeset/warm-hotels-make.md new file mode 100644 index 00000000000..3dd25e644c1 --- /dev/null +++ b/.changeset/warm-hotels-make.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-default-nodes": patch +"@tryghost/koenig-lexical": patch +--- + +Fixed contrast text colors for light backgrounds diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c32d7fca9e..f3df26033d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -484,8 +484,7 @@ jobs: # Changing the runner label or the hyperfine version makes new results # incomparable with the existing history - treat both as pinned. job_perf-tests: - runs-on: - labels: ubuntu-latest-4-cores + runs-on: blacksmith-2vcpu-ubuntu-2404 needs: [job_setup] if: (needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_development == 'true') || needs.job_setup.outputs.has_perf_tests_label == 'true' name: Performance tests @@ -533,10 +532,10 @@ jobs: # bump would land in the code series as a regression with no Ghost commit behind it. job_perf-tests-image: name: Performance tests (production image) - runs-on: - labels: ubuntu-latest-4-cores + runs-on: blacksmith-2vcpu-ubuntu-2404 needs: [job_setup, job_docker] - # Registry path only: the core image is pushed to GHCR, never saved as an artifact. + # Registry path only: benchmarks are a canonical-repo series, so this pulls the + # core image from GHCR rather than loading the artifact-path tarball. if: | needs.job_docker.result == 'success' && needs.job_docker.outputs.use-artifact == 'false' && @@ -1401,6 +1400,29 @@ jobs: cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-core-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # Uploaded here, before the full image is even built: on the artifact path + # consumers (Ghost-Moya CD) need the core image — server only, no admin — + # and would otherwise have to fall back to `docker-image-production`. + - name: Save core image as artifact + if: steps.strategy.outputs.use-artifact == 'true' + run: | + IMAGE_TAG=$(echo "${{ steps.meta-core.outputs.tags }}" | head -n1) + echo "Saving image: $IMAGE_TAG" + # Written outside the repo root: a stray tarball there would change the + # `.` context between the core and full builds and bust the deploy-stage + # COPY cache (same reason the admin artifact lands in RUNNER_TEMP below). + docker save "$IMAGE_TAG" | gzip > "${RUNNER_TEMP}/docker-image-core.tar.gz" + ls -lh "${RUNNER_TEMP}/docker-image-core.tar.gz" + + - name: Upload core image artifact + if: steps.strategy.outputs.use-artifact == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: docker-image-core + path: ${{ runner.temp }}/docker-image-core.tar.gz + retention-days: 1 + if-no-files-found: error + # Synchronise with job_build_admin only now, after core is built: the full # image is core + admin. No `needs` edge, so the two jobs run concurrently # and core builds during the wait. diff --git a/adr/0001-aaa-test-structure.md b/adr/0001-aaa-test-structure.md deleted file mode 100644 index 769b6c79cab..00000000000 --- a/adr/0001-aaa-test-structure.md +++ /dev/null @@ -1,40 +0,0 @@ -# Adopt Arrange–Act–Assert (AAA) Pattern for All Tests - -## Status -Proposed - -## Context - -Our tests are currently written in different styles, which makes them harder to read, understand, and maintain. - -To improve **readability** and make it easier to **debug failing tests**, we want to standardize the structure of tests by following the well-known **Arrange–Act–Assert (AAA)** pattern. - -## Decision - -We will adopt the AAA pattern for tests. Every test should follow this structure: - -1. **Arrange**: Set up data, mocks, page state, or environment -2. **Act**: Perform the action being tested -3. **Assert**: Check the expected outcome - -## Guidelines - -- ✅ Multiple actions and assertions are **allowed** as long as they belong to a **single AAA flow** -- 🚫 **Repeating the full AAA structure in a single test is discouraged**, except for performance‑sensitive tests where setup cost is prohibitively high -- ✂️ If a test involves multiple unrelated behaviors, **split it into separate test cases** -- 🧼 Keep tests focused and predictable: one test = one scenario - -## Example - -```ts -test('user can view their post', async ({ page }) => { - // Arrange - const user = await userFactory.create(); - const post = await postFactory.create({ userId: user.id }); - - // Act - await page.goto(`/posts/${post.id}`); - - // Assert - await expect(page.getByText(post.title)).toBeVisible(); -}); diff --git a/adr/0002-page-objects-pattern.md b/adr/0002-page-objects-pattern.md deleted file mode 100644 index dce4506602d..00000000000 --- a/adr/0002-page-objects-pattern.md +++ /dev/null @@ -1,101 +0,0 @@ -# Adopt Page Objects Pattern for E2E Test Organization - -## Status -Proposed - -## Context - -Our Playwright tests currently interact directly with page elements using raw selectors and actions scattered throughout test files. This approach leads to several issues: - -- **Code duplication**: The same selectors and interactions are repeated across multiple tests -- **Maintenance burden**: When UI changes, we need to update selectors in many places -- **Poor readability**: Tests are cluttered with low-level DOM interactions instead of focusing on business logic -- **Fragile tests**: Direct coupling between tests and implementation details makes tests brittle - -To improve **maintainability**, **readability**, and **test stability**, we want to adopt the Page Objects pattern to encapsulate page-specific knowledge and provide a clean API for test interactions. - -The Page Objects pattern was originally described by [Martin Fowler](https://martinfowler.com/bliki/PageObject.html) as a way to "wrap an HTML page, or fragment, with an application-specific API, allowing you to manipulate page elements without digging around in the HTML." - -## Decision - -We will adopt the Page Objects pattern for organizing E2E tests. Every page or major UI component should have a corresponding page object class that: - -1. **Encapsulates locators**: All element selectors are defined in one place -2. **Provides semantic methods**: Expose high-level actions like `login()`, `createPost()`, `navigateToSettings()` -3. **Abstracts implementation details**: Tests interact with business concepts, not DOM elements -4. **Centralizes page-specific logic**: Complex interactions and waits are handled within page objects -5. **Assertions live in test files**: Page Objects may include readiness guards (e.g., locator.waitFor({state: 'visible'})) before actions, business assertions (expect(...)) should be in tests -6. **Expose semantic locators, hide selectors**: Page Objects should surface public readonly Locators for tests to assert on, while keeping selector strings and construction internal - -## Guidelines - -Following both [Fowler's original principles](https://martinfowler.com/bliki/PageObject.html) and modern Playwright best practices: - -- ✅ **One page object per logical page or major component** (e.g., `LoginPage`, `PostEditor`, `AdminDashboard`) -- ✅ **Model the structure that makes sense to the user**: not necessarily the HTML structure -- ✅ **Use descriptive method names** that reflect user actions (e.g., `fillPostTitle()` not `typeInTitleInput()`) -- ✅ **Return elements or data**: for assertions in tests (e.g., `getErrorMessage()` returns locator) -- ✅ **Include wait methods**: for page readiness and async operations (e.g., `waitForErrorMessage()`) -- ✅ **Chain related actions**: in fluent interfaces where it makes sense -- ✅ **Keep assertions in test files**: page objects should return data/elements, tests should assert -- ✅ **Handle concurrency issues** within page objects (async operations, loading states) -- ✅ **Expose Locators (read-only), not raw selector strings**: you can tests assert against public locators (Playwright encourages it, with helpers on assertion) - - `loginPage.saveButton.click` instead of `page.locator('[data-testid="save-button"]')` -- ✅ **Selector priority: prefer getByRole / getByLabel / data-testid over CSS or XPath.**: add data-testid attributes where needed for stability -- ✅ **Use guards, not assertions, in POM**: prefer locator.waitFor({state:'visible'}) -- 🚫 **Don't include expectations/assertions** in page object methods (following Fowler's recommendation) -- 📁 **Organize in `/e2e/helpers/pages/` directory** with clear naming conventions - -## Example - -```ts -// e2e/helpers/pages/admin/LoginPage.ts -export class LoginPage extends BasePage { - public readonly emailInput = this.page.locator('[data-testid="email-input"]'); - public readonly passwordInput = this.page.locator('[data-testid="password-input"]'); - public readonly loginButton = this.page.locator('[data-testid="login-button"]'); - public readonly errorMessage = this.page.locator('[data-testid="login-error"]'); - - constructor(page: Page) { - super(page); - this.pageUrl = '/login'; - } - - async login(email: string, password: string) { - await this.emailInput.fill(email); - await this.passwordInput.fill(password); - await this.loginButton.click(); - } - - async waitForErrorMessage() { - await this.errorMessage.waitFor({ state: 'visible' }); - return this.errorMessage; - } - - getErrorMessage() { - return this.errorMessage; - } -} - -// In test file -test.describe('Login', () => { - test('invalid credentials', async ({page}) => { - // Arrange - const loginPage = new LoginPage(page); - - // Act - await loginPage.goto(); - await loginPage.login('invalid@email.com', 'wrongpassword'); - const errorMessage = await loginPage.waitForErrorMessage(); - - // Assert - await expect(errorMessage).toHaveText('Invalid credentials'); - }); -} -``` - -## References - -- [Page Object - Martin Fowler](https://martinfowler.com/bliki/PageObject.html) - Original pattern definition -- [Selenium Page Objects](https://selenium-python.readthedocs.io/page-objects.html) - Early implementation guidance -- [Playwright Page Object Model](https://playwright.dev/docs/pom) - Modern Playwright-specific approaches diff --git a/adr/README.md b/adr/README.md deleted file mode 100644 index fd1d936154a..00000000000 --- a/adr/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Architecture Decision Records (ADRs) - -This directory contains Architecture Decision Records (ADRs) specific to the E2E test suite. - -ADRs are short, version-controlled documents that capture important architectural and process decisions, along with the reasoning behind them. -They help document **why** something was decided — not just **what** was done — which improves transparency, consistency, and long-term maintainability. - -Each ADR includes the following sections: - -- `Status` – `Proposed`, `Accepted`, `Rejected`, etc. -- `Context` – Why the decision was needed -- `Decision` – What was decided and why -- `Guidelines` – (Optional) How the decision should be applied -- `Example` – (Optional) Minimal working example to clarify intent -- `References` - (Optional) - Lists documents, links, or resources that informed or support the decision - -## Guidelines for contributing - -- We follow a simplified and slightly adapted version of the [Michael Nygard ADR format](https://github.com/joelparkerhenderson/architecture-decision-record/tree/main/locales/en/templates/decision-record-template-by-michael-nygard) -- Keep ADRs focused, short, and scoped to one decision -- Start with `Status: Proposed` and update to `Status: Accepted` after code review -- Use sequential filenames with a descriptive slug, for example: `0002-page-objects-pattern.md` diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx index db5abefecac..126fb555377 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx @@ -15,6 +15,10 @@ const features: Feature[] = [{ title: 'Automations', description: 'Toggle the automations beta. Unexpected problems can occur if you turn this off after previously turning it on.', flag: 'automations' +}, { + title: 'Automation run analytics', + description: 'Track run-level analytics for automations.', + flag: 'automationRunAnalytics' }, { title: 'Stripe Automatic Tax (private beta)', description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe', diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx index 4fb74948047..3e21887985b 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx @@ -764,7 +764,11 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const formData = new FormData(); formData.append('file', blob, `${nextThemeName}.zip`); - const response = await fetch(`${getGhostPaths().apiRoot}/themes/upload/`, { + // when saving under a new name, carry over the original theme's + // settings so activating the copy keeps the site's design + const uploadQuery = isSaveAs ? `?copy_settings_from=${encodeURIComponent(previousThemeName)}` : ''; + + const response = await fetch(`${getGhostPaths().apiRoot}/themes/upload/${uploadQuery}`, { method: 'POST', credentials: 'include', headers: { diff --git a/apps/admin/src/settings/site/theme.acceptance.test.tsx b/apps/admin/src/settings/site/theme.acceptance.test.tsx index b178f3c938d..2858508bd12 100644 --- a/apps/admin/src/settings/site/theme.acceptance.test.tsx +++ b/apps/admin/src/settings/site/theme.acceptance.test.tsx @@ -241,7 +241,10 @@ describe("Theme settings", () => { fakeThemeWorld(); await fakeThemeDownload("casper"); await fakeThemeDownload("casper-edited"); - const uploadApi = fakeAdminEndpoint("POST", "/themes/upload/", { themes: [theme({ name: "casper-edited" })] }); + // saving under a new name carries over the original theme's settings + const uploadApi = fakeAdminEndpoint("POST", "/themes/upload/?copy_settings_from=casper", { + themes: [theme({ name: "casper-edited" })], + }); await renderAdminApp("/settings/theme/edit/casper"); const editor = await editorTextbox(); diff --git a/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx b/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx index ead657671a1..b3463d3afc7 100644 --- a/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx +++ b/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx @@ -133,6 +133,7 @@ describe('Tag detail (tagDetailsReact on)', () => { await expect.element(page.getByTestId('tag-detail-title')).toHaveTextContent('New tag'); const nameInput = page.getByLabelText('Name', {exact: true}); + await expect.element(nameInput).toBeVisible(); await userEvent.type(nameInput.element(), 'Weekly News'); await expect.element(page.getByLabelText('Slug', {exact: true})).toHaveValue('weekly-news'); diff --git a/apps/ember-admin/.ember-cli b/apps/ember-admin/.ember-cli index 53f3969d16d..83cbe4cd3eb 100644 --- a/apps/ember-admin/.ember-cli +++ b/apps/ember-admin/.ember-cli @@ -7,5 +7,14 @@ Setting `disableAnalytics` to true will prevent any data from being sent. */ - "disableAnalytics": true + "disableAnalytics": true, + + /** + Default to Node's native FS watcher instead of Watchman. Watchman + repeatedly trips its "MustScanSubDirs UserDropped" recrawl warning + in this monorepo (Docker bind mounts + Vite dev writes), spamming + ~6 lines per rebuild. The node watcher is quieter and good enough + for our tree size. + */ + "watcher": "node" } diff --git a/apps/ember-admin/lib/asset-delivery/index.js b/apps/ember-admin/lib/asset-delivery/index.js index 502cb1ad0e5..7a1ee3abaf9 100644 --- a/apps/ember-admin/lib/asset-delivery/index.js +++ b/apps/ember-admin/lib/asset-delivery/index.js @@ -101,7 +101,11 @@ module.exports = { } else { fs.ensureSymlinkSync(adminXPath, assetsAdminXPath); } - } else { + } else if (this.env === 'production') { + // In dev the admin-x apps may not have finished their first + // build yet and Nx will trigger another Ember rebuild once + // they do. Only flag a missing dist for production where it + // indicates a real pipeline failure. console.log(`${app} folder not found`); } } diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index c5977435038..b20705daab9 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.57.1-rc.0", + "version": "6.57.2-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", @@ -16,7 +16,7 @@ "test": "tests" }, "scripts": { - "dev": "SKIP_DEPENDENCY_CHECKER=true ember serve", + "dev": "SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve", "build": "ember build --environment=production --silent", "build:dev": "SKIP_DEPENDENCY_CHECKER=true pnpm build --environment=development", "test": "ember exam --split 2 --parallel", @@ -188,7 +188,7 @@ "executor": "nx:run-commands", "options": { "cwd": "apps/ember-admin", - "command": "JOBS=4 SKIP_DEPENDENCY_CHECKER=true ember serve" + "command": "JOBS=4 SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve" }, "dependsOn": [ "^build" diff --git a/apps/portal/src/app.jsx b/apps/portal/src/app.jsx index b4cd0744779..cb21601293b 100644 --- a/apps/portal/src/app.jsx +++ b/apps/portal/src/app.jsx @@ -672,8 +672,8 @@ export default class App extends React.Component { showPopup: true, page: 'feedback', pageData: { - uuid: member ? null : hashQuery.get('uuid'), - key: member ? null : hashQuery.get('key'), + uuid: hashQuery.get('uuid'), + key: hashQuery.get('key'), postId, score } diff --git a/apps/portal/src/components/pages/feedback-page.jsx b/apps/portal/src/components/pages/feedback-page.jsx index 1586cc8f5f1..60d70d611c3 100644 --- a/apps/portal/src/components/pages/feedback-page.jsx +++ b/apps/portal/src/components/pages/feedback-page.jsx @@ -311,9 +311,10 @@ export default function FeedbackPage() { const [score, setScore] = useState(initialScore); const positive = score === 1; const isLoggedIn = !!member; + const fromEmailLink = !!(uuid && key); - const [confirmed, setConfirmed] = useState(isLoggedIn); - const [loading, setLoading] = useState(isLoggedIn); + const [confirmed, setConfirmed] = useState(fromEmailLink && isLoggedIn); + const [loading, setLoading] = useState(fromEmailLink && isLoggedIn); const [error, setError] = useState(null); const doSendFeedback = async (selectedScore) => { diff --git a/apps/portal/test/feedback-flow.test.jsx b/apps/portal/test/feedback-flow.test.jsx index 685983d04c1..e681d6f0900 100644 --- a/apps/portal/test/feedback-flow.test.jsx +++ b/apps/portal/test/feedback-flow.test.jsx @@ -64,7 +64,7 @@ describe('Feedback Submission Flow', () => { within(popupIframeDocument).getByText('Your input helps shape what gets published.'); }); - test('Autosubmits feedback w/o uuid or key params', async () => { + test('Requires confirmation w/o uuid or key params', async () => { Object.defineProperty(window, 'location', { value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/`), writable: true @@ -72,9 +72,21 @@ describe('Feedback Submission Flow', () => { const {ghostApi, popupFrame, popupIframeDocument} = await setup(); expect(popupFrame).toBeInTheDocument(); + expect(within(popupIframeDocument).getByText('Give feedback on this post')).toBeInTheDocument(); + expect(within(popupIframeDocument).getByText('More like this')).toBeInTheDocument(); + expect(within(popupIframeDocument).getByText('Less like this')).toBeInTheDocument(); + expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0); + + const submitBtn = within(popupIframeDocument).getByText('Submit feedback'); + fireEvent.click(submitBtn); + expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1); - within(popupIframeDocument).getByText('Thanks for the feedback!'); - within(popupIframeDocument).getByText('Your input helps shape what gets published.'); + + // the re-render loop is slow to get to the final state + await waitFor(() => { + within(popupIframeDocument).getByText('Thanks for the feedback!'); + within(popupIframeDocument).getByText('Your input helps shape what gets published.'); + }); }); }); diff --git a/docs/README.md b/docs/README.md index 2e8c9c88bd6..031c7305e5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,8 +77,7 @@ Ghost/ ├── koenig/ # Ghost editor (Koenig) packages │ ├── koenig-lexical/ # Lexical-based rich text editor UI │ └── kg-*/ # Editor renderers, converters, and support packages -├── e2e/ # End-to-end tests -├── adr/ # Architecture Decision Records +└── e2e/ # End-to-end tests ``` ## Contributing @@ -109,10 +108,6 @@ Before contributing, please read: - **[API Documentation](https://ghost.org/docs/content-api/)** - Content and Admin API reference - **[Theme Documentation](https://ghost.org/docs/themes/)** - Theme development -## Architecture Decision Records - -The [adr/](../adr/) directory contains Architecture Decision Records (ADRs) that document significant architectural decisions made in the project. - ## Getting Help - **Forum**: [forum.ghost.org](https://forum.ghost.org) diff --git a/e2e/.claude/E2E_TEST_WRITING_GUIDE.md b/e2e/.claude/E2E_TEST_WRITING_GUIDE.md index 915c4bd2810..568be79c1ab 100644 --- a/e2e/.claude/E2E_TEST_WRITING_GUIDE.md +++ b/e2e/.claude/E2E_TEST_WRITING_GUIDE.md @@ -54,10 +54,13 @@ e2e/ ## Page Object Pattern ### Core Principles -1. **ALL selectors must be in Page Objects** - Never put selectors in test files -2. **Page Objects encapsulate page structure and interactions** -3. **Reuse existing Page Objects when possible** -4. **Create focused, single-responsibility Page Objects** +1. **Page Objects contain reusable page structure and interactions** +2. **Reuse existing Page Objects when possible** +3. **Create focused, single-responsibility Page Objects** +4. **Keep necessary structural selectors in Page Objects where practical** + +A direct semantic locator in a test is acceptable for a small, one-off assertion or +interaction when a Page Object would add indirection without reuse. ### Creating a Page Object @@ -77,19 +80,19 @@ export class FeaturePage extends AdminPage { this.pageUrl = '/ghost/#/[path]'; // Selector priority (use in this order): - // 1. data-testid - this.elementName = page.getByTestId('element-id'); - - // 2. ARIA roles with accessible names + // 1. ARIA roles with accessible names this.buttonName = page.getByRole('button', {name: 'Button Text'}); - // 3. Labels for form elements + // 2. Labels for form elements this.elementName = page.getByLabel('Field Label'); - // 4. Text content (for unique text) + // 3. Text content (for unique text) this.elementName = page.getByText('Unique text'); - // 5. Avoid CSS/XPath selectors unless absolutely necessary + // 4. Stable test IDs when semantic locators are unavailable + this.elementName = page.getByTestId('element-id'); + + // 5. Stable structural selectors only when necessary } // Action methods @@ -180,7 +183,7 @@ export class PublicHomePage extends BasePage { **Important: Write self-documenting tests without comments. Test names and method names should clearly express intent. If complex logic is needed, extract it to a well-named method in the Page Object.** -Tests should follow the **Arrange-Act-Assert (AAA)** pattern: +Use **Arrange–Act–Assert (AAA)** as a readability heuristic: - **Arrange**: Set up test data and page objects - **Act**: Perform the actions being tested - **Assert**: Verify the expected outcomes @@ -194,16 +197,13 @@ import {createPostFactory} from '../../data-factory'; test.describe('Feature Name', () => { test('should perform expected behavior', async ({page, ghostInstance}) => { - // Arrange const featurePage = new FeaturePage(page); const postFactory = createPostFactory(page.request); const post = await postFactory.create({title: 'Test Post'}); - // Act await featurePage.goto(); await featurePage.performAction(); - // Assert expect(await featurePage.isElementVisible()).toBe(true); expect(await featurePage.getResultText()).toContain('Expected text'); }); @@ -290,7 +290,7 @@ New factories are added as needed. When you need test data that doesn't have a f ## Best Practices ### DO's -✅ **Use Page Objects for all selectors** +✅ **Use Page Objects for reusable UI structure and interactions** ✅ **Write self-documenting tests** with clear method and test names ✅ **Check existing Page Objects before creating new ones** ✅ **Use proper waits** (`waitForLoadState`, `waitFor`, etc.) @@ -301,13 +301,13 @@ New factories are added as needed. When you need test data that doesn't have a f ✅ **Add meaningful assertions** beyond just visibility checks ### DON'Ts -❌ **Never put selectors in test files** +❌ **Don't duplicate reusable selectors and interactions across test files** ❌ **Don't write comments** - make code self-documenting instead ❌ **Don't use hardcoded waits** (`page.waitForTimeout`) ❌ **Don't use networkidle in waits** (`page.waitForLoadState('networkidle')`) - rely on web assertions to assess readiness instead ❌ **Don't depend on test execution order** ❌ **Don't manually log in** - use the pre-authenticated fixture -❌ **Avoid CSS/XPath selectors** - use semantic selectors +❌ **Avoid XPath and selectors coupled to styling or DOM position** ❌ **Don't create test data manually** if a factory exists ## Common Patterns @@ -451,8 +451,8 @@ You don't need to worry about: ## Validation Checklist Before submitting a test: -- [ ] All selectors are in Page Objects -- [ ] Test follows AAA pattern +- [ ] Reusable UI behavior is in Page Objects +- [ ] Arrange, Act, and Assert phases are easy to identify - [ ] Test is deterministic (not flaky) - [ ] Uses proper waits (no arbitrary timeouts) - [ ] Has meaningful assertions diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index f8820b41db7..0d387e61489 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -2,14 +2,17 @@ E2E testing guidance for AI assistants (Claude, Codex, etc.) working with Ghost tests. -**IMPORTANT**: When creating or modifying E2E tests, always refer to `./.claude/E2E_TEST_WRITING_GUIDE.md` for comprehensive testing guidelines and patterns. +**IMPORTANT**: `README.md` is the canonical human documentation for E2E testing. +When creating or modifying E2E tests, follow it first. Use +`./.claude/E2E_TEST_WRITING_GUIDE.md` for additional agent-oriented examples. ## Critical Rules -1. **Always follow ADRs** in `../adr/` folder (ADR-0001: AAA pattern, ADR-0002: Page Objects) -2. **Always use pnpm**, never npm -3. **Always run after changes**: `pnpm lint` and `pnpm test:types` -4. **Never use CSS/XPath selectors** - only semantic locators or data-testid -5. **Prefer less comments and giving things clear names** +1. **Always use pnpm**, never npm +2. **Always run after changes**: `pnpm lint` and `pnpm test:types` +3. **Prefer semantic locators**, then stable test IDs +4. **Keep reusable UI structure and interactions in Page Objects** +5. **Avoid selectors coupled to styling or DOM position** +6. **Prefer clear names over explanatory comments** ## Running E2E Tests @@ -74,7 +77,8 @@ export class AnalyticsPage extends AdminPage { ``` ### Rules -- Page Objects are located in `helpers/pages/` +- Put reusable page and major-component behavior in `helpers/pages/` +- Direct semantic locators are acceptable for small, one-off test interactions or assertions - Expose locators as `public readonly` when used with assertions - Methods use semantic names (`login()` not `clickLoginButton()`) - Use `waitFor()` for guards, never `expect()` in page objects @@ -91,7 +95,11 @@ export class AnalyticsPage extends AdminPage { - `getByTestId('analytics-card')` - Suggest adding `data-testid` to Ghost codebase when needed -3. **Never use**: CSS selectors, XPath, nth-child, class names +3. **Structural fallback**: stable attributes when semantic locators are unavailable + +Avoid XPath, `nth-child`, styling classes, and other selectors coupled to DOM +position or presentation. Keep necessary structural selectors in Page Objects where +practical. ### Playwright MCP Usage - Use `mcp__playwright__browser_snapshot` to find elements @@ -145,6 +153,6 @@ After writing tests, verify: 2. Linting passes: `pnpm lint` 3. Types check: `pnpm test:types` 4. Follows AAA pattern with clear sections -5. Uses page objects appropriately -6. Uses semantic locators or data-testid only -7. No hard-coded waits or CSS selectors +5. Uses Page Objects for reusable UI behavior +6. Prefers semantic locators, then stable test IDs +7. Has no hard-coded waits or selectors coupled to styling/DOM position diff --git a/e2e/README.md b/e2e/README.md index 302670b4881..f548fe1c24c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -138,19 +138,19 @@ e2e/ ### Writing Tests -Tests use [Playwright Test](https://playwright.dev/docs/writing-tests) framework with page objects. -Aim to format tests in Arrange Act Assert style - it will help you with directions when writing your tests. +Tests use [Playwright Test](https://playwright.dev/docs/writing-tests). Use +Arrange–Act–Assert (AAA) as a readability heuristic: set up the scenario, perform +the behavior under test, then verify the outcome. Keep those phases clear through +test structure and naming; comments are only useful when the boundaries would +otherwise be unclear. ```typescript test.describe('Ghost Homepage', () => { test('loads correctly', async ({page}) => { - // ARRANGE - setup fixtures, create helpers, prepare things that helps will need to be executed const homePage = new HomePage(page); - - // ACT - do the actions you need to do, to verify certain behaviour + await homePage.goto(); - - // ASSERT + await expect(homePage.title).toBeVisible(); }); }); @@ -158,15 +158,41 @@ test.describe('Ghost Homepage', () => { ### Using Page Objects -Page objects encapsulate page elements, and interactions. To read more about them, check [this link out](https://www.selenium.dev/documentation/test_practices/encouraged/page_object_models/) and [this link](https://martinfowler.com/bliki/PageObject.html). +Page Objects are the default home for reusable knowledge about a page or major UI +component. They encapsulate locators, readiness guards, and semantic interactions +so tests can describe behavior rather than DOM structure. Assertions stay in test +files. + +Prefer an existing Page Object when a test exercises reusable UI behavior. A direct +semantic locator in a test is acceptable for a small, one-off assertion or +interaction when creating a Page Object would add indirection without reuse. +Structural selectors sometimes remain necessary for iframes, editor internals, +generated theme markup, and elements without an accessible role. Keep those inside +Page Objects where practical and prefer, in order: + +1. Accessible roles, labels, and visible text +2. Stable test IDs +3. Stable structural selectors when no semantic locator exists + +Avoid selectors coupled to visual styling, DOM position, or incidental class names. +See [Playwright's locator guidance](https://playwright.dev/docs/locators) and +[Martin Fowler's Page Object description](https://martinfowler.com/bliki/PageObject.html) +for background. ```typescript // Create a page object for admin login +import type {Locator, Page} from '@playwright/test'; + export class AdminLoginPage { - private pageUrl:string; + private readonly pageUrl = '/ghost'; + public readonly emailInput: Locator; + public readonly passwordInput: Locator; + public readonly signInButton: Locator; - constructor(private page: Page) { - this.pageUrl = '/ghost' + constructor(private readonly page: Page) { + this.emailInput = page.getByLabel('Email address'); + this.passwordInput = page.getByLabel('Password'); + this.signInButton = page.getByRole('button', {name: 'Sign in'}); } async goto(urlToVisit = this.pageUrl) { @@ -174,9 +200,9 @@ export class AdminLoginPage { } async login(email: string, password: string) { - await this.page.fill('[name="identification"]', email); - await this.page.fill('[name="password"]', password); - await this.page.click('button[type="submit"]'); + await this.emailInput.fill(email); + await this.passwordInput.fill(password); + await this.signInButton.click(); } } ``` @@ -246,9 +272,9 @@ Modes: ### Best Practices -1. **Use page object patterns** to separate page elements, actions on the pages, complex logic from tests. They should help you make them more readable and UI elements reusable. +1. **Use Page Objects for reusable UI structure and interactions.** Direct semantic locators are fine for small, one-off assertions where a Page Object would not improve reuse or readability. 2. **Add meaningful assertions** beyond just page loads. Keep assertions in tests. -3. **Use `data-testid` attributes** for reliable element selection, in case you **cannot** locate elements in a simple way. Example: `page.getByLabel('User Name')`. Avoid, css, xpath locators - they make tests brittle. +3. **Prefer semantic locators**, such as `getByRole()` and `getByLabel()`. Use stable test IDs when semantic locators are unavailable. Avoid selectors coupled to styling or DOM position. 4. **Clean up test data** when tests modify Ghost state 5. **Group related tests** in describe blocks 6. **Do not use should to describe test scenarios** diff --git a/ghost/core/core/frontend/web/middleware/admin-toolbar.js b/ghost/core/core/frontend/web/middleware/admin-toolbar.js index 4d35a2a59d2..c391851f2d8 100644 --- a/ghost/core/core/frontend/web/middleware/admin-toolbar.js +++ b/ghost/core/core/frontend/web/middleware/admin-toolbar.js @@ -135,7 +135,7 @@ function getCleanRedirectUrl(req) { currentUrl.searchParams.delete(QUERY_PARAM); currentUrl.searchParams.delete(HIDE_QUERY_PARAM); - return `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`; + return `${urlUtils.createUrl(currentUrl.pathname)}${currentUrl.search}${currentUrl.hash}`; } function getQueryValue(value) { diff --git a/ghost/core/core/server/api/endpoints/gift-reminders.js b/ghost/core/core/server/api/endpoints/gifts.js similarity index 100% rename from ghost/core/core/server/api/endpoints/gift-reminders.js rename to ghost/core/core/server/api/endpoints/gifts.js diff --git a/ghost/core/core/server/api/endpoints/index.js b/ghost/core/core/server/api/endpoints/index.js index 9f649d1fe6a..48d63adff79 100644 --- a/ghost/core/core/server/api/endpoints/index.js +++ b/ghost/core/core/server/api/endpoints/index.js @@ -316,8 +316,8 @@ module.exports = { return apiFramework.pipeline(require('./gift-links'), localUtils); }, - get giftReminders() { - return apiFramework.pipeline(require('./gift-reminders'), localUtils); + get gifts() { + return apiFramework.pipeline(require('./gifts'), localUtils); }, get recommendationsPublic() { diff --git a/ghost/core/core/server/api/endpoints/themes.js b/ghost/core/core/server/api/endpoints/themes.js index 7b2cd4c19a9..817ef206ebe 100644 --- a/ghost/core/core/server/api/endpoints/themes.js +++ b/ghost/core/core/server/api/endpoints/themes.js @@ -106,6 +106,9 @@ const controller = { headers: { cacheInvalidate: false }, + options: [ + 'copy_settings_from' + ], permissions: { method: 'add' }, @@ -123,7 +126,9 @@ const controller = { name: frame.file.originalname }; - const {theme, themeOverridden} = await themeService.api.setFromZip(zip); + const {theme, themeOverridden} = await themeService.api.setFromZip(zip, { + copySettingsFrom: frame.options.copy_settings_from + }); if (themeOverridden) { frame.setHeader('X-Cache-Invalidate', '/*'); } diff --git a/ghost/core/core/server/models/base/plugins/overrides.js b/ghost/core/core/server/models/base/plugins/overrides.js index 6589d8bd9a7..bee74152372 100644 --- a/ghost/core/core/server/models/base/plugins/overrides.js +++ b/ghost/core/core/server/models/base/plugins/overrides.js @@ -96,11 +96,13 @@ module.exports = function (Bookshelf) { (value, key) => key.startsWith(PIVOT_PREFIX) ); - if (this.relationships) { - this.relationships.forEach((relation) => { - if (this._previousRelations && Object.prototype.hasOwnProperty.call(this._previousRelations, relation)) { - clonedModel.related(relation).models = this._previousRelations[relation].models; - } + // Iterate `_previousRelations` rather than `relationships` — a relation + // can carry previous state without being managed by bookshelf-relations + // (e.g. a member's stripeSubscriptions). Behaviour is unchanged for + // relations that are in both. + if (this._previousRelations) { + Object.keys(this._previousRelations).forEach((relation) => { + clonedModel.related(relation).models = this._previousRelations[relation].models; }); } diff --git a/ghost/core/core/server/services/adapter-manager/adapter-paths.ts b/ghost/core/core/server/services/adapter-manager/adapter-paths.ts index 66fd14ea4de..2da2f9c12c5 100644 --- a/ghost/core/core/server/services/adapter-manager/adapter-paths.ts +++ b/ghost/core/core/server/services/adapter-manager/adapter-paths.ts @@ -1,21 +1,26 @@ import config from '../../../shared/config'; +import type {ConfigInstance} from '../../../shared/config/loader'; /** * Where adapters are looked up, in order. Also read by bin/validate-adapters.ts, * which checks adapter implementations at build time - keep this the only place * the lookup order is declared, so a name resolves there exactly as it does here. */ -export const adapterPaths: string[] = Array.from(new Set([ - '', // A blank path will cause us to check node_modules for the adapter - config.get('paths').internalAdaptersPath, +export function buildAdapterPaths(configInstance: ConfigInstance): string[] { + return Array.from(new Set([ + '', // A blank path will cause us to check node_modules for the adapter + configInstance.get('paths').internalAdaptersPath, - // custom docker builds may install adapters in a separate path from content, - // since the content dir is often bind-mounted into the container. Offering - // an escape hatch here to allow for this - config.get('paths').installedAdaptersPath ?? '', + // custom docker builds may install adapters in a separate path from content, + // since the content dir is often bind-mounted into the container. Offering + // an escape hatch here to allow for this + configInstance.get('paths').installedAdaptersPath ?? '', - // load adapters from content last, so that they don't override any other - // internal or platform-installed adapters - // TODO: potentially deprecate/remove as part of Ghost 7.0 - config.getContentPath('adapters') -])); + // load adapters from content last, so that they don't override any other + // internal or platform-installed adapters + // TODO: potentially deprecate/remove as part of Ghost 7.0 + configInstance.getContentPath('adapters') + ])); +} + +export const adapterPaths: string[] = buildAdapterPaths(config); diff --git a/ghost/core/core/server/services/audience-feedback/audience-feedback-controller.js b/ghost/core/core/server/services/audience-feedback/audience-feedback-controller.js index aad613d44d4..ace2d6b064e 100644 --- a/ghost/core/core/server/services/audience-feedback/audience-feedback-controller.js +++ b/ghost/core/core/server/services/audience-feedback/audience-feedback-controller.js @@ -1,5 +1,6 @@ const Feedback = require('./feedback'); const errors = require('@tryghost/errors'); +const permissions = require('../../services/permissions'); const tpl = require('@tryghost/tpl'); const messages = { @@ -118,7 +119,8 @@ class AudienceFeedbackController { const postId = frame.data.id; const options = { limit: frame.options.limit || 10, - page: frame.options.page || 1 + page: frame.options.page || 1, + withMember: false }; // Add score filter if specified @@ -126,6 +128,13 @@ class AudienceFeedbackController { options.score = parseInt(frame.options.score); } + try { + await permissions.canThis(frame.options.context).browse.member(); + options.withMember = true; + } catch { + // permissions throws; we want to return data but without member info + } + const result = await this.#repository.getForPost(postId, options); return result; } diff --git a/ghost/core/core/server/services/audience-feedback/feedback-repository.js b/ghost/core/core/server/services/audience-feedback/feedback-repository.js index 56a7a105699..a4f4c069ddb 100644 --- a/ghost/core/core/server/services/audience-feedback/feedback-repository.js +++ b/ghost/core/core/server/services/audience-feedback/feedback-repository.js @@ -76,10 +76,13 @@ module.exports = class FeedbackRepository { limit: options.limit || 10, page: options.page || 1, order: 'created_at DESC', - withRelated: ['member'], filter: filter }; + if (options.withMember) { + findOptions.withRelated = ['member']; + } + // Use findPage with filter const results = await this.#MemberFeedback.findPage(findOptions); diff --git a/ghost/core/core/server/services/auth/session/session-service.js b/ghost/core/core/server/services/auth/session/session-service.js index 15a7bf64b32..35345aa999e 100644 --- a/ghost/core/core/server/services/auth/session/session-service.js +++ b/ghost/core/core/server/services/auth/session/session-service.js @@ -26,6 +26,7 @@ const AUTH_CODE_CHALLENGE_BYTES = 16; * @prop {string} user_agent * @prop {string} ip * @prop {boolean} verified + * @prop {string} [verified_user_id] * @prop {string} [auth_code_challenge] * @prop {number} [auth_code_generated_at] */ @@ -190,12 +191,14 @@ module.exports = function createSessionService({ if (isAuthCodeVerified) { session.verified = true; + session.verified_user_id = user.id; invalidateAuthCodeChallenge(session); } } if (isStaffDeviceVerificationDisabled()) { session.verified = true; + session.verified_user_id = user.id; } } @@ -214,6 +217,7 @@ module.exports = function createSessionService({ const { user_id: previousUserId, verified: previousVerified, + verified_user_id: previousVerifiedUserId, auth_code_challenge: previousAuthCodeChallenge, auth_code_generated_at: previousAuthCodeGeneratedAt } = previousSession; @@ -231,8 +235,11 @@ module.exports = function createSessionService({ const session = req.session; session.user_id = previousUserId; - // A different user doesn't inherit the previous user's verification - session.verified = previousUserId && previousUserId !== user.id ? undefined : previousVerified; + // Verification is bound to the user who completed it — any other user + // (including sessions with no verified_user_id) must verify again + const carryVerification = previousVerified === true && previousVerifiedUserId === user.id; + session.verified = carryVerification ? true : undefined; + session.verified_user_id = carryVerification ? previousVerifiedUserId : undefined; session.auth_code_challenge = previousAuthCodeChallenge; session.auth_code_generated_at = previousAuthCodeGeneratedAt; @@ -282,6 +289,7 @@ module.exports = function createSessionService({ }); session.verified = true; + session.verified_user_id = user.id; invalidateAuthCodeChallenge(session); } @@ -480,6 +488,7 @@ module.exports = function createSessionService({ async function verifySession(req, res) { const session = await getSession(req, res); session.verified = true; + session.verified_user_id = session.user_id; invalidateAuthCodeChallenge(session); } @@ -491,7 +500,12 @@ module.exports = function createSessionService({ */ async function isVerifiedSession(req, res) { const session = await getSession(req, res); - return session.verified; + // Verification is bound to a user; a session with no verified_user_id + // (e.g. logged out, or predating this field) fails closed rather than + // matching an absent user_id via undefined === undefined + return session.verified === true && + !!session.verified_user_id && + session.verified_user_id === session.user_id; } /** @@ -506,6 +520,7 @@ module.exports = function createSessionService({ if (isVerificationRequired()) { session.verified = undefined; + session.verified_user_id = undefined; } invalidateAuthCodeChallenge(session); diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index e9ba66bd877..1be49c07236 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -1256,6 +1256,11 @@ module.exports = class MemberRepository { }; let eventData = {}; + // A cancellation (or reactivation) changes no `members` column, so the member + // model event would be suppressed by `wasChanged()`. Remember the pre-update + // subscription so the event can be marked and carry its prior state. + let subscriptionBeforeCancelFlagChange = null; + const stripeCustomerSubscriptionModelShouldBeDeleted = stripeSubscriptionData.metadata && !!stripeSubscriptionData.metadata.ghost_migrated_to && stripeSubscriptionData.status === 'canceled'; if (stripeCustomerSubscriptionModelShouldBeDeleted) { logging.warn(`Subscription ${subscriptionData.subscription_id} is marked for deletion, skipping linking.`); @@ -1300,6 +1305,10 @@ module.exports = class MemberRepository { this.dispatchEvent(offerRedemptionEvent, options); } + if (stripeCustomerSubscriptionModel.get('cancel_at_period_end') !== updatedStripeCustomerSubscriptionModel.get('cancel_at_period_end')) { + subscriptionBeforeCancelFlagChange = stripeCustomerSubscriptionModel; + } + if (stripeCustomerSubscriptionModel.get('mrr') !== updatedStripeCustomerSubscriptionModel.get('mrr') || stripeCustomerSubscriptionModel.get('plan_id') !== updatedStripeCustomerSubscriptionModel.get('plan_id') || stripeCustomerSubscriptionModel.get('status') !== updatedStripeCustomerSubscriptionModel.get('status') || stripeCustomerSubscriptionModel.get('cancel_at_period_end') !== updatedStripeCustomerSubscriptionModel.get('cancel_at_period_end')) { const originalMrrDelta = stripeCustomerSubscriptionModel.get('mrr'); const updatedMrrDelta = updatedStripeCustomerSubscriptionModel.get('mrr'); @@ -1550,6 +1559,48 @@ module.exports = class MemberRepository { updatedMember = await this._Member.edit({status: status}, {...options, id: data.id}); } + // Cancelling (or reactivating) touches no `members` column, so mark the + // subscription relation as the change and carry its prior state. This lets the + // webhook payload express before/after — see `services/webhooks/serialize.js`. + // + // No explicit `emitChange` is needed, and adding one would emit twice: inside a + // transaction `emitChange` queues onto the `committed` handler and defers its + // `wasChanged()` check to commit time (see `models/base/plugins/events.js`), so + // the event already queued by `_Member.edit` above is still pending and setting + // `_changed` here is what lets it through. The e2e test asserting exactly one + // `member.edited` per cancellation guards this. + if (subscriptionBeforeCancelFlagChange && updatedMember) { + // Price/tier relations are needed for the serialized subscription shape + // to match the Admin API member resource (price, tier, plan) + await updatedMember.load([ + 'stripeSubscriptions', + 'stripeSubscriptions.stripePrice', + 'stripeSubscriptions.stripePrice.stripeProduct', + 'stripeSubscriptions.stripePrice.stripeProduct.product' + ], options); + await subscriptionBeforeCancelFlagChange.load([ + 'stripePrice', + 'stripePrice.stripeProduct', + 'stripePrice.stripeProduct.product' + ], options); + updatedMember._changed = { + ...updatedMember._changed, + stripeSubscriptions: { + cancel_at_period_end: subscriptionBeforeCancelFlagChange.get('cancel_at_period_end') + } + }; + // `previous` must be the full collection — a member can have multiple + // subscriptions — with only the changed one swapped for its prior state + updatedMember._previousRelations = { + ...updatedMember._previousRelations, + stripeSubscriptions: { + models: updatedMember.related('stripeSubscriptions').models.map((subscription) => { + return subscription.id === subscriptionBeforeCancelFlagChange.id ? subscriptionBeforeCancelFlagChange : subscription; + }) + } + }; + } + const newMemberProductIds = memberProducts.map(product => product.id); const oldMemberProductIds = oldMemberProducts.map(product => product.id); diff --git a/ghost/core/core/server/services/slack-notifications/slack-notifications.js b/ghost/core/core/server/services/slack-notifications/slack-notifications.js index 9fc539f36b6..bde0fad213c 100644 --- a/ghost/core/core/server/services/slack-notifications/slack-notifications.js +++ b/ghost/core/core/server/services/slack-notifications/slack-notifications.js @@ -1,6 +1,6 @@ -const got = require('got').default; const validator = require('@tryghost/validator'); const errors = require('@tryghost/errors'); +const externalRequest = require('../../lib/request-external'); const ghostVersion = require('@tryghost/version'); const moment = require('moment'); @@ -178,7 +178,7 @@ class SlackNotifications { }; } - return await got.post(url, requestOptions); + return await externalRequest.post(url, requestOptions); } /** diff --git a/ghost/core/core/server/services/slack-ping/index.ts b/ghost/core/core/server/services/slack-ping/index.ts index 40780777cb3..1ec1176b6f9 100644 --- a/ghost/core/core/server/services/slack-ping/index.ts +++ b/ghost/core/core/server/services/slack-ping/index.ts @@ -13,7 +13,7 @@ class SlackPingServiceWrapper { const {blogIcon} = require('../../lib/image'); const events = require('../../lib/common/events'); const logging = require('@tryghost/logging'); - const request = require('@tryghost/request'); + const request = require('../../lib/request-external'); const settingsCache = require('../../../shared/settings-cache'); const urlService = require('../url'); const urlUtils = require('../../../shared/url-utils').default; diff --git a/ghost/core/core/server/services/slack-ping/slack-ping-service.ts b/ghost/core/core/server/services/slack-ping/slack-ping-service.ts index 4fd0d4aa6d7..58ab89bef92 100644 --- a/ghost/core/core/server/services/slack-ping/slack-ping-service.ts +++ b/ghost/core/core/server/services/slack-ping/slack-ping-service.ts @@ -232,6 +232,7 @@ export class SlackPingService { } return this.request(slackSettings.url, { + method: 'POST', body: JSON.stringify(slackData), headers: { 'Content-type': 'application/json' diff --git a/ghost/core/core/server/services/stats/members-stats-service.js b/ghost/core/core/server/services/stats/members-stats-service.js index 95e24c3128d..54901602c26 100644 --- a/ghost/core/core/server/services/stats/members-stats-service.js +++ b/ghost/core/core/server/services/stats/members-stats-service.js @@ -111,13 +111,6 @@ class MembersStatsService { const startDateMoment = moment.utc(startDate).startOf('day'); const endDateMoment = moment.utc(today).startOf('day'); - // Create a map of events by date for fast lookup - const eventsMap = new Map(); - rows.forEach((row) => { - const date = moment(row.date).format('YYYY-MM-DD'); - eventsMap.set(date, row); - }); - // Sort rows chronologically to calculate historical totals rows.sort((a, b) => moment(a.date).valueOf() - moment(b.date).valueOf()); diff --git a/ghost/core/core/server/services/themes/storage.js b/ghost/core/core/server/services/themes/storage.js index ff854aa568b..345d31d7603 100644 --- a/ghost/core/core/server/services/themes/storage.js +++ b/ghost/core/core/server/services/themes/storage.js @@ -8,6 +8,7 @@ const errors = require('@tryghost/errors'); const validate = require('./validate'); const list = require('./list'); +const customThemeSettings = require('../custom-theme-settings'); const ThemeStorage = require('./theme-storage'); const themeLoader = require('./loader'); const activator = require('./activation-bridge'); @@ -24,7 +25,8 @@ const messages = { invalidThemeName: 'Please select a valid theme.', overrideDefaultTheme: 'Please rename your zip, it\'s not allowed to override the default theme.', destroyDefaultTheme: 'Deleting the default theme is not allowed.', - destroyActive: 'Deleting the active theme is not allowed.' + destroyActive: 'Deleting the active theme is not allowed.', + copySettingsFromDoesNotExist: 'Theme to copy settings from is not installed.' }; const INVALID_THEME_REGEX = /^[./]*$/; @@ -51,7 +53,7 @@ module.exports = { name: themeName }); }, - setFromZip: async (zip) => { + setFromZip: async (zip, {copySettingsFrom} = {}) => { const themeName = getStorage().getSanitizedFileName(zip.name.split('.zip')[0]); const backupName = `${themeName}_${ObjectID()}`; @@ -69,12 +71,27 @@ module.exports = { }); } + if (copySettingsFrom && !list.get(copySettingsFrom)) { + throw new errors.ValidationError({ + message: tpl(messages.copySettingsFromDoesNotExist) + }); + } + let checkedTheme; let overrideTheme; let renamedExisting = false; try { checkedTheme = await validate.checkSafe(themeName, zip, true); + + // CASE: theme uploaded as a copy of another theme, carry over that + // theme's settings so activating the copy keeps the site's design. + // Happens before any file changes so a failed copy leaves the + // installed themes untouched + if (copySettingsFrom) { + await customThemeSettings.api.copySettingsBetweenThemes(copySettingsFrom, themeName); + } + const themeExists = await getStorage().exists(themeName); // CASE: move the existing theme to a backup folder if (themeExists) { diff --git a/ghost/core/core/server/services/webhooks/serialize.js b/ghost/core/core/server/services/webhooks/serialize.js index 1fbc4bd20c0..134a2ea7b47 100644 --- a/ghost/core/core/server/services/webhooks/serialize.js +++ b/ghost/core/core/server/services/webhooks/serialize.js @@ -5,6 +5,15 @@ // already carries (e.g. authors) would strip its nested roles from the // payload. `getRequiredRelations()` is [] when the routing config reads // none, so this is a no-op on a default routes.yaml. +// `model._changed` carries raw model keys, but `previous` is picked off the +// API-serialized payload, where some keys are renamed (members `products` → `tiers`). +const SERIALIZED_KEYS = { + members: { + products: 'tiers', + stripeSubscriptions: 'subscriptions' + } +}; + const loadRequiredUrlRelations = async (model, urlService) => { const required = urlService.getRequiredRelations(); const missing = required.filter(relation => !model.relations[relation]); @@ -80,7 +89,7 @@ module.exports = ({urlService}) => async (event, model) => { .serializers .handle .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame); - previous = _.pick(frame.response[docName][0], changed); + previous = _.pick(frame.response[docName][0], changed.map(key => SERIALIZED_KEYS[docName]?.[key] ?? key)); } diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index b85df3f07f5..b57a10c687d 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -85,8 +85,8 @@ module.exports = function apiRoutes() { // ## Schedules router.put('/schedules/:resource/:id', mw.authAdminApiWithUrl, http(api.schedules.publish)); - // ## Gift Reminders - router.put('/gifts/flush_reminders', mw.authAdminApiWithUrl, http(api.giftReminders.flushReminders)); + // ## Gifts + router.put('/gifts/flush_reminders', mw.authAdminApiWithUrl, http(api.gifts.flushReminders)); // ## Settings router.get('/settings/routes/yaml', mw.authAdminApi, http(api.settings.download)); diff --git a/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-bread-service.js b/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-bread-service.js index dfeb634563a..6d90d10b312 100644 --- a/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-bread-service.js +++ b/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-bread-service.js @@ -26,4 +26,8 @@ module.exports = class CustomThemeSettingsBREADService { async destroy(data, options = {}) { return this.Model.destroy(data, options); } + + async transaction(fn) { + return this.Model.transaction(fn); + } }; diff --git a/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-service.js b/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-service.js index 3c909b02817..050c78e1e1f 100644 --- a/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-service.js +++ b/ghost/core/core/shared/custom-theme-settings-cache/custom-theme-settings-service.js @@ -164,6 +164,45 @@ module.exports = class CustomThemeSettingsService { return settingsObjects; } + /** + * Duplicate stored settings from one theme to another, e.g. when a theme + * is saved as a copy under a new name. + * + * No-ops if the destination theme already has stored settings so existing + * customisations are never overwritten. Values are copied verbatim - they + * are reconciled against the destination theme's settings definition by + * the sync that runs when that theme is activated. + * + * @param {string} fromThemeName + * @param {string} toThemeName + */ + async copySettingsBetweenThemes(fromThemeName, toThemeName) { + const sourceCollection = await this._repository.browse({filter: `theme:'${fromThemeName}'`}); + + // single transaction so a failure part-way leaves no partial copy + // behind that would make later attempts skip the copy. The + // destination check locks inside the same transaction so concurrent + // copies can't both see an empty destination and insert duplicates + await this._repository.transaction(async (transacting) => { + const destinationCollection = await this._repository.browse({filter: `theme:'${toThemeName}'`, transacting, forUpdate: true}); + + if (destinationCollection.toJSON().length > 0) { + debug(`Skipping copy of custom theme settings from '${fromThemeName}' to '${toThemeName}' - destination already has settings`); + return; + } + + for (const setting of sourceCollection.toJSON()) { + debug(`Copying custom theme setting '${fromThemeName}.${setting.key}' to '${toThemeName}'`); + await this._repository.add({ + theme: toThemeName, + key: setting.key, + type: setting.type, + value: setting.value + }, {transacting}); + } + }); + } + // Private ----------------------------------------------------------------- /** diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 1f90304d88e..f629ae07dcb 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -48,6 +48,7 @@ const PUBLIC_BETA_FEATURES = [ // Which is only visible if the developer experiments flag is enabled const PRIVATE_FEATURES = [ 'automations', + 'automationRunAnalytics', 'stripeAutomaticTax', 'importMemberTier', 'csvContentImporter', diff --git a/ghost/core/package.json b/ghost/core/package.json index e8ff42b3222..ed885e49510 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.57.1-rc.0", + "version": "6.57.2-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index 88f1f07547d..f63b2d1dfae 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -16,6 +16,7 @@ Object { "additionalPaymentMethods": true, "adminUIRefresh": true, "automationAnalytics": true, + "automationRunAnalytics": true, "automations": true, "commentsPinning": true, "commentsThreads": true, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/session.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/session.test.js.snap index 42e0239e621..c4402805f18 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/session.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/session.test.js.snap @@ -68,6 +68,24 @@ Object { } `; +exports[`Sessions API Staff 2FA requires 2FA again when a different user logs in after logout 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": "2FA_NEW_DEVICE_DETECTED", + "context": "A 6-digit sign-in verification code has been sent to your email to keep your account safe.", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "User must verify session to login.", + "property": null, + "type": "Needs2FAError", + }, + ], +} +`; + exports[`Sessions API Staff 2FA sends verification email if staffDeviceVerification is enabled 1: [body] 1`] = ` Object { "errors": Array [ diff --git a/ghost/core/test/e2e-api/admin/session.test.js b/ghost/core/test/e2e-api/admin/session.test.js index 14afd29ae94..e1e00f156c0 100644 --- a/ghost/core/test/e2e-api/admin/session.test.js +++ b/ghost/core/test/e2e-api/admin/session.test.js @@ -223,5 +223,75 @@ describe('Sessions API', function () { .expectStatus(401) .expectEmptyBody(); }); + + it('requires 2FA again when a different user logs in after logout', async function () { + // Seed staff users beyond the owner so we have a second account + await fixtureManager.init('users'); + + const owner = await fixtureManager.get('users', 0); + const otherUser = await fixtureManager.get('users', 1); + + // Establish the second user as having logged in before, so their + // later login is subject to device verification rather than the + // first-login skip + await agent + .post('session/') + .body({ + grant_type: 'password', + username: otherUser.email, + password: otherUser.password + }) + .expectStatus(201); + await agent + .delete('session/') + .expectStatus(204); + + // Owner logs in and completes device verification on this session + await agent + .post('session/') + .body({ + grant_type: 'password', + username: owner.email, + password: owner.password + }) + .expectStatus(403); + + const ownerEmail = assert.sentEmail({ + subject: /[0-9]{6} is your Ghost sign in verification code/ + }); + const ownerToken = ownerEmail.subject.match(/[0-9]{6}/)[0]; + + await agent + .put('session/verify') + .body({ + token: ownerToken + }) + .expectStatus(200); + + // Owner logs out — in trusted-device mode logout keeps the + // session's verified flag but clears the user + await agent + .delete('session/') + .expectStatus(204); + + // The second user logging in on the same session must verify again + // rather than inheriting the owner's verification + await agent + .post('session/') + .body({ + grant_type: 'password', + username: otherUser.email, + password: otherUser.password + }) + .expectStatus(403) + .matchBodySnapshot({ + errors: [{ + code: '2FA_NEW_DEVICE_DETECTED', + id: anyUuid, + message: 'User must verify session to login.', + type: 'Needs2FAError' + }] + }); + }); }); }); diff --git a/ghost/core/test/e2e-api/admin/themes.test.js b/ghost/core/test/e2e-api/admin/themes.test.js index 290f6ea54e1..7942e69d7ac 100644 --- a/ghost/core/test/e2e-api/admin/themes.test.js +++ b/ghost/core/test/e2e-api/admin/themes.test.js @@ -3,6 +3,7 @@ const {assertExists} = require('../../utils/assertions'); const sinon = require('sinon'); const path = require('path'); const fs = require('fs'); +const os = require('os'); const _ = require('lodash'); const supertest = require('supertest'); const nock = require('nock'); @@ -19,9 +20,10 @@ describe('Themes API', function () { const themePath = options.themePath; const fieldName = 'file'; const request = options.request || ownerRequest; + const query = options.query || ''; return request - .post(localUtils.API.getApiQuery('themes/upload')) + .post(localUtils.API.getApiQuery(`themes/upload${query}`)) .set('Origin', config.get('url')) .attach(fieldName, themePath); }; @@ -425,6 +427,75 @@ describe('Themes API', function () { mockManager.restoreLimitService(); }); + it('Can copy custom theme settings when uploading a theme under a new name', async function () { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-settings-copy-')); + + try { + // start from a known active theme and customise its settings + await ownerRequest + .put(localUtils.API.getApiQuery('themes/source/activate')) + .set('Origin', config.get('url')) + .expect(200); + + await ownerRequest + .put(localUtils.API.getApiQuery('custom_theme_settings/')) + .set('Origin', config.get('url')) + .send({custom_theme_settings: [ + {key: 'title_font', value: 'Elegant serif'}, + {key: 'site_background_color', value: '#123456'} + ]}) + .expect(200); + + // save a copy of the default theme under a new name, as the theme editor does + const zipPath = path.join(tmpDir, 'source-edited.zip'); + fs.copyFileSync(path.join(__dirname, '..', '..', 'utils', 'fixtures', 'themes', 'source.zip'), zipPath); + + const uploadRes = await uploadTheme({ + themePath: zipPath, + query: '?copy_settings_from=source' + }); + assert.equal(uploadRes.statusCode, 200); + assert.equal(uploadRes.body.themes[0].name, 'source-edited'); + + await ownerRequest + .put(localUtils.API.getApiQuery('themes/source-edited/activate')) + .set('Origin', config.get('url')) + .expect(200); + + // customised values survived the switch to the renamed copy + const settingsRes = await ownerRequest + .get(localUtils.API.getApiQuery('custom_theme_settings/')) + .set('Origin', config.get('url')) + .expect(200); + + const settingsByKey = Object.fromEntries(settingsRes.body.custom_theme_settings.map(setting => [setting.key, setting.value])); + assert.equal(settingsByKey.title_font, 'Elegant serif'); + assert.equal(settingsByKey.site_background_color, '#123456'); + } finally { + fs.rmSync(tmpDir, {recursive: true, force: true}); + + // best-effort restore of the pre-test theme state, no assertions so + // cleanup completes even when the test fails part-way through + await ownerRequest + .put(localUtils.API.getApiQuery('themes/source/activate')) + .set('Origin', config.get('url')); + + await ownerRequest + .del(localUtils.API.getApiQuery('themes/source-edited')) + .set('Origin', config.get('url')); + } + }); + + it('Errors when asked to copy settings from an unknown theme', async function () { + const res = await uploadTheme({ + themePath: path.join(__dirname, '..', '..', 'utils', 'fixtures', 'themes', 'valid.zip'), + query: '?copy_settings_from=unknown-theme' + }); + + assert.equal(res.statusCode, 422); + assert.equal(res.body.errors[0].type, 'ValidationError'); + }); + it('Can re-upload the active theme to override', async function () { // The tricky thing about this test is the default active theme is Source and you're not allowed to override it. // So we upload a valid theme, activate it, and then upload again. diff --git a/ghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snap index 7370722dc92..fadfd9af882 100644 --- a/ghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snap @@ -1499,7 +1499,7 @@ Snippet Docume", "frontmatter": null, "html": "

This is a post containing all media types for testing URL transformations. It includes images, galleries, files, videos, audio, and an inserted snippet.

\\"Inline
An inline image card
A gallery with three images

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +

Audio card:

\\"audio-thumbnail\\"
Test Audio
0:00
/180

Inserted snippet content below:

This snippet contains all media types for testing URL transformations in reusable content.

Image card:

\\"Snippet
Image in snippet

File card:

Video card:

- +
0:00
/
`); + expect(serializer.serialize(card.render(opts))).toBe(`
0:00
/
`); }); it('renders for email target', function () { @@ -41,6 +41,42 @@ describe('Video card', function () { expect(output).toContain('
+// element so no third-party request (e.g. spacergif.org) is needed. The
0:00
/1:00
Video caption
+
0:00
/1:00
Video caption
`); const nodes = $generateNodesFromDOM(editor, document) as VideoNode[]; expect(nodes.length).toBe(1); @@ -370,7 +370,7 @@ describe('VideoNode', function () { it('parses video card without caption', editorTest(function () { const document = createDocument(html` -
0:00
/1:00
+
0:00
/1:00
`); const nodes = $generateNodesFromDOM(editor, document) as VideoNode[]; expect(nodes.length).toBe(1); @@ -379,7 +379,7 @@ describe('VideoNode', function () { it('parses video card with custom thumbnail', editorTest(function () { const document = createDocument(html` -
0:00
/1:00
+
0:00
/1:00
`); const nodes = $generateNodesFromDOM(editor, document) as VideoNode[]; expect(nodes.length).toBe(1); diff --git a/koenig/kg-default-nodes/test/renderers/video-renderer.test.ts b/koenig/kg-default-nodes/test/renderers/video-renderer.test.ts index d156b9d5911..5fc12579ae5 100644 --- a/koenig/kg-default-nodes/test/renderers/video-renderer.test.ts +++ b/koenig/kg-default-nodes/test/renderers/video-renderer.test.ts @@ -36,7 +36,7 @@ describe('renderers/video-renderer', function () { assertPrettifiesTo(result.html, html`
- +