From 1568a7c939c6a85e3b1cb9864362ce26d4a593e0 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 1 Sep 2026 13:12:49 -0400 Subject: [PATCH 1/9] Added Node 24 to test matrix (#30393) no ref - add Node 24 to test leg now that sqlite3 is removed from the test matrix --- .github/workflows/ci.yml | 25 +++++++++++++++++++------ nx.json | 3 ++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc2b3662bf..6bc4563a65d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,9 +224,11 @@ jobs: - 'docker/tb-cli/**' - name: Define Node test matrix + # Node lines the unit / legacy / acceptance suites must keep passing on. + # test:unit's Nx cache is keyed on `node -v` (nx.json) so each leg runs. id: node_matrix run: | - echo 'matrix=["22.23.1"]' >> $GITHUB_OUTPUT + echo 'matrix=["22.23.1", "24.20.0"]' >> $GITHUB_OUTPUT - name: Start Nx Cloud CI run run: pnpm nx start-ci-run @@ -679,6 +681,7 @@ jobs: needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.unit_test_projects_str != '' strategy: + fail-fast: false matrix: node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} name: Unit tests (Node ${{ matrix.node }}) @@ -778,14 +781,20 @@ jobs: --health-interval=2s --health-timeout=5s --health-retries=60 + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} env: - COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled }} - name: Acceptance tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8) + # Only the primary Node leg is instrumented — the others cover the same + # code, and a second leg would collide on the e2e-coverage artifact name. + COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled == 'true' && matrix.node == needs.job_setup.outputs.node_version }} + name: Acceptance tests (Node ${{ matrix.node }}, mysql8) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: ./.github/actions/setup-node-pnpm with: - node-version: ${{ needs.job_setup.outputs.node_version }} + node-version: ${{ matrix.node }} trust-lockfile: 'true' - name: Set timezone (non-UTC) @@ -878,14 +887,18 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=12 - name: Legacy tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8) + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} + name: Legacy tests (Node ${{ matrix.node }}, mysql8) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: true - uses: ./.github/actions/setup-node-pnpm with: - node-version: ${{ needs.job_setup.outputs.node_version }} + node-version: ${{ matrix.node }} trust-lockfile: 'true' - name: Set env vars (MySQL) diff --git a/nx.json b/nx.json index bceb8446cbb..3cbf4c61317 100644 --- a/nx.json +++ b/nx.json @@ -39,7 +39,8 @@ }, "test:unit": { "cache": true, - "dependsOn": ["build"] + "dependsOn": ["build"], + "inputs": ["default", "^default", { "runtime": "node -v" }] }, "test:ci:*": { "cache": true, From ff748e1f507b9ca7deb4526e9598609752c3ec2b Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 1 Sep 2026 14:38:25 -0400 Subject: [PATCH 2/9] Fixed the Node test matrix running every leg on the same Node version (#30435) no ref pnpm installs the Node pinned by `devEngines.runtime` and links its bin ahead of the one setup-node installs, so both legs of the unit, legacy and acceptance matrices ran 22.23.1 and the Node 24 legs proved nothing. Passing `--no-runtime` to those installs skips the runtime dependency, so each leg runs the version its matrix entry asked for. That makes two genuine Node 24 failures visible, fixed here so the matrix goes green on both legs: - `res._headers` was removed in Node 24, so the mock-express helper handed every assertion an undefined headers object. - Node 24 adds a literal `module.exports` key to a CJS module's namespace. The external-globals plugin re-exports every key, emitting `export const module.exports = React.module.exports;`, which rolldown rejects and which failed every admin build. --- .github/workflows/ci.yml | 19 ++++++++++++------- apps/admin-x-framework/src/vite.ts | 3 +++ .../mock-express-style/utils/mock-express.js | 4 ++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc4563a65d..ec74bfa9f06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -554,8 +554,9 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} trust-lockfile: 'true' - # --force for the same reason as job_unit-tests: better-sqlite3 is an - # optionalDependency and boot uses it for the development database. + # better-sqlite3 is an optionalDependency that pnpm may skip when + # restoring from a cached store, and boot needs it for the + # development database. install-args: --force - name: Install hyperfine @@ -693,11 +694,9 @@ jobs: with: node-version: ${{ matrix.node }} trust-lockfile: 'true' - # better-sqlite3 is an optionalDependency. Without --force, pnpm may skip - # installing/linking it when restoring from a cached store. --force - # ensures all optional deps are installed regardless. - # (ghost core's test:unit job requires better-sqlite3) - install-args: --force + # Without --no-runtime, pnpm links the devEngines Node and every + # leg of this matrix runs that same version. + install-args: --no-runtime - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 @@ -796,6 +795,9 @@ jobs: with: node-version: ${{ matrix.node }} trust-lockfile: 'true' + # Without --no-runtime, pnpm links the devEngines Node and every + # leg of this matrix runs that same version. + install-args: --no-runtime - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 @@ -900,6 +902,9 @@ jobs: with: node-version: ${{ matrix.node }} trust-lockfile: 'true' + # Without --no-runtime, pnpm links the devEngines Node and every + # leg of this matrix runs that same version. + install-args: --no-runtime - name: Set env vars (MySQL) run: | diff --git a/apps/admin-x-framework/src/vite.ts b/apps/admin-x-framework/src/vite.ts index 7da2d5862f0..6240869bc6e 100644 --- a/apps/admin-x-framework/src/vite.ts +++ b/apps/admin-x-framework/src/vite.ts @@ -22,7 +22,10 @@ const externalPlugin = ({ externals }: { externals: Record }): P if (originalId) { const module = await import(originalId); + // Node 24 adds a literal `module.exports` key to a CJS namespace, which + // is not a valid identifier to re-export. return Object.keys(module) + .filter((key) => /^[A-Za-z_$][\w$]*$/.test(key)) .map((key) => key === 'default' ? `export default ${externalName};` diff --git a/ghost/core/test/legacy/mock-express-style/utils/mock-express.js b/ghost/core/test/legacy/mock-express-style/utils/mock-express.js index 8fa6fabc8f7..5a61ee66275 100644 --- a/ghost/core/test/legacy/mock-express-style/utils/mock-express.js +++ b/ghost/core/test/legacy/mock-express-style/utils/mock-express.js @@ -37,7 +37,7 @@ module.exports = { err: res.req.err, body: body, statusCode: res.statusCode, - headers: res._headers, + headers: res.getHeaders(), template: res._template, req: req, res: res, @@ -49,7 +49,7 @@ module.exports = { err: res.req.err, body: body, statusCode: res.statusCode, - headers: res._headers, + headers: res.getHeaders(), template: res._template, req: req, res: res, From dc02641ac113bd0c1d31e7f3682e66c1026c554a Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 1 Sep 2026 14:36:46 -0500 Subject: [PATCH 3/9] Added snippets API hooks to the shared admin framework (#30425) no ref The React editor will need the snippet save/insert feature that Ember Data currently serves in the Ember editor. This adds a snippets API module to `apps/admin-x-framework` with `useBrowseSnippets`, `useAddSnippet`, `useEditSnippet`, and `useDeleteSnippet`. --- apps/admin-x-framework/src/api/snippets.ts | 66 +++++++ .../test/unit/api/snippets.test.tsx | 183 ++++++++++++++++++ .../src/ember-bridge/ember-bridge.test.tsx | 28 +++ apps/admin/src/ember-bridge/ember-bridge.tsx | 1 + apps/ember-admin/app/services/state-bridge.js | 1 + .../tests/unit/services/state-bridge-test.js | 8 + 6 files changed, 287 insertions(+) create mode 100644 apps/admin-x-framework/src/api/snippets.ts create mode 100644 apps/admin-x-framework/test/unit/api/snippets.test.tsx diff --git a/apps/admin-x-framework/src/api/snippets.ts b/apps/admin-x-framework/src/api/snippets.ts new file mode 100644 index 00000000000..5c0b2a976a3 --- /dev/null +++ b/apps/admin-x-framework/src/api/snippets.ts @@ -0,0 +1,66 @@ +import { Meta, createMutation, createQuery } from '../utils/api/hooks'; + +// mobiledoc and lexical travel as JSON strings on the wire; callers parse/stringify +export type Snippet = { + id: string; + name: string; + mobiledoc: string; + lexical: string | null; + created_at: string; + updated_at: string | null; +}; + +// The add and edit schemas require name and mobiledoc on every item +export type SnippetEditableData = Pick & + Partial>; + +export interface SnippetsResponseType { + meta?: Meta; + snippets: Snippet[]; +} + +const dataType = 'SnippetsResponseType'; + +// Without `formats` the API strips `lexical` from responses (mobiledoc is the default format) +const formats = 'mobiledoc,lexical'; + +const useBrowseSnippetsQuery = createQuery({ + dataType, + path: '/snippets/', + defaultSearchParams: { limit: 'all', formats }, +}); + +export const useBrowseSnippets = ({ + searchParams, + ...args +}: Parameters[0] = {}) => + useBrowseSnippetsQuery({ + ...args, + // caller searchParams replace the defaults wholesale, so re-merge formats + searchParams: { limit: 'all', ...searchParams, formats }, + }); + +export const useAddSnippet = createMutation({ + method: 'POST', + path: () => '/snippets/', + searchParams: () => ({ formats }), + body: (snippet) => ({ snippets: [snippet] }), + invalidateQueries: { dataType }, +}); + +export const useEditSnippet = createMutation< + SnippetsResponseType, + SnippetEditableData & { id: string } +>({ + method: 'PUT', + path: ({ id }) => `/snippets/${id}/`, + searchParams: () => ({ formats }), + body: ({ id: _id, ...snippet }) => ({ snippets: [snippet] }), + invalidateQueries: { dataType }, +}); + +export const useDeleteSnippet = createMutation({ + method: 'DELETE', + path: (id) => `/snippets/${id}/`, + invalidateQueries: { dataType }, +}); diff --git a/apps/admin-x-framework/test/unit/api/snippets.test.tsx b/apps/admin-x-framework/test/unit/api/snippets.test.tsx new file mode 100644 index 00000000000..9024b925c54 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/snippets.test.tsx @@ -0,0 +1,183 @@ +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ValidationError } from '../../../src/utils/errors'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { + useAddSnippet, + useBrowseSnippets, + useDeleteSnippet, + useEditSnippet, +} from '../../../src/api/snippets'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const { mockSonnerError } = vi.hoisted(() => ({ + mockSonnerError: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { + error: mockSonnerError, + dismiss: vi.fn(), + }, +})); + +const existingSnippet = { + id: 'snippet-1', + name: 'Existing snippet', + mobiledoc: '{}', + lexical: '{"nodes":[]}', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', +}; + +const okResponse = (json: unknown) => ({ + json, + headers: { 'content-type': 'application/json' }, + ok: true, + status: 200, +}); + +const duplicateSnippetResponse = { + errors: [ + { + code: 'VALIDATION', + context: 'Snippet already exists.', + details: null, + ghostErrorCode: null, + help: null, + id: 'snippet-error-id', + message: 'Validation error, cannot save snippet.', + property: null, + type: 'ValidationError', + }, + ], +}; + +const mockErrorFetch = { + json: duplicateSnippetResponse, + headers: { 'content-type': 'application/json' }, + ok: false, + status: 422, +}; + +const findCall = (mock: { calls: unknown[][] }, path: string) => + mock.calls.find((call) => String(call[0]).includes(path)); + +describe('snippets api', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('browses all snippets in both formats', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useBrowseSnippets()); + + await waitFor(() => { + expect(result.current.data).toEqual({ snippets: [existingSnippet] }); + }); + + const call = findCall(mock, '/snippets/'); + const url = new URL(String(call![0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/'); + expect(url.searchParams.get('limit')).toBe('all'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + }); + }); + + it('keeps the formats param when a caller passes its own search params', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => + useBrowseSnippets({ searchParams: { filter: 'name:foo' } }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual({ snippets: [existingSnippet] }); + }); + + const url = new URL(String(findCall(mock, '/snippets/')![0])); + expect(url.searchParams.get('filter')).toBe('name:foo'); + expect(url.searchParams.get('limit')).toBe('all'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + }); + }); + + it('rejects duplicate creates with a validation error without reporting', async () => { + await withMockFetch(mockErrorFetch, async () => { + const { result } = renderHookWithProviders(() => useAddSnippet()); + + await act(async () => { + await expect( + result.current.mutateAsync({ name: 'Existing snippet', mobiledoc: '{}' }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + expect(mockSonnerError).not.toHaveBeenCalled(); + }); + }); + + it('adds a snippet and requests both formats back', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useAddSnippet()); + + await act(async () => { + await result.current.mutateAsync({ + name: 'Existing snippet', + lexical: '{"nodes":[]}', + mobiledoc: '{}', + }); + }); + + const call = findCall(mock, '/snippets/')!; + const url = new URL(String(call[0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + + const options = call[1] as RequestInit; + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body as string)).toEqual({ + snippets: [{ name: 'Existing snippet', lexical: '{"nodes":[]}', mobiledoc: '{}' }], + }); + }); + }); + + // The edit schema requires name and mobiledoc on every item, so edits send the full record + it('edits a snippet with the full record and requests both formats back', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useEditSnippet()); + + await act(async () => { + await result.current.mutateAsync({ + id: 'snippet-1', + name: 'Existing snippet', + mobiledoc: '{}', + lexical: '{"nodes":[]}', + }); + }); + + const call = findCall(mock, '/snippets/snippet-1/')!; + const url = new URL(String(call[0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/snippet-1/'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + + const options = call[1] as RequestInit; + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body as string)).toEqual({ + snippets: [{ name: 'Existing snippet', mobiledoc: '{}', lexical: '{"nodes":[]}' }], + }); + }); + }); + + it('deletes a snippet by id', async () => { + await withMockFetch({ ok: true, status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useDeleteSnippet()); + + await act(async () => { + await result.current.mutateAsync('snippet-1'); + }); + + const call = findCall(mock, '/snippets/snippet-1/')!; + expect(String(call[0])).toBe('http://localhost:3000/ghost/api/admin/snippets/snippet-1/'); + expect((call[1] as RequestInit).method).toBe('DELETE'); + }); + }); +}); diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index c302973f4fe..6331d027279 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -250,6 +250,34 @@ describe('useEmberDataSync', () => { }); }); + queryTest('invalidates snippets when Ember saves one', async ({ queryClient, wrapper }) => { + const mock = createMockStateBridge(); + window.EmberBridge = { state: mock.stateBridge }; + const snippetsKey = ['SnippetsResponseType', '/snippets']; + + queryClient.setQueryDefaults(snippetsKey, { gcTime: Infinity }); + queryClient.setQueryData(snippetsKey, { snippets: [] }); + + renderHook(() => useEmberDataSync(), { wrapper }); + + await waitFor(() => { + expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function)); + }); + + act(() => { + mock.emit('emberDataChange', { + operation: 'update', + modelName: 'snippet', + id: 'snippet-1', + data: null, + }); + }); + + await waitFor(() => { + expect(queryClient.getQueryState(snippetsKey)?.isInvalidated).toBe(true); + }); + }); + queryTest( 'invalidates the sidebar member count query for Ember member changes', async ({ queryClient, wrapper }) => { diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index d5d646971b5..c5f7cdf59de 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -105,6 +105,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { member: 'MembersResponseType', tag: 'TagsResponseType', label: 'LabelsResponseType', + snippet: 'SnippetsResponseType', }; /** diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 8a57d0abb5b..3cad2e20c87 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -19,6 +19,7 @@ const emberDataTypeMapping = { NewslettersResponseType: {type: 'newsletter'}, RecommendationResponseType: {type: 'recommendation'}, SettingsResponseType: {type: 'setting', singleton: true}, + SnippetsResponseType: {type: 'snippet'}, TagsResponseType: {type: 'tag'}, ThemesResponseType: {type: 'theme'}, TiersResponseType: {type: 'tier'}, diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index a74065d8ba0..62bf771f690 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -306,6 +306,14 @@ describe('Unit: Service: state-bridge', function () { expect(store.unloadAll.calledWith('integration')).to.be.true; }); + it('unloads snippets when React snippet queries are invalidated', function () { + run(() => { + service.onInvalidate('SnippetsResponseType'); + }); + + expect(store.unloadAll.calledOnceWith('snippet')).to.be.true; + }); + it('unloads all tags when tag queries are invalidated', function () { run(() => { service.onInvalidate('TagsResponseType'); From fd8026ec9a93043517358933c81103b93784df10 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 1 Sep 2026 15:45:12 -0400 Subject: [PATCH 4/9] Changed the acceptance coverage lanes to vitest's v8 provider (#30438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref The acceptance lanes (test:ci:e2e, test:ci:integration) wrapped vitest in c8, which reports coverage V8 can describe but c8 cannot interpret. Two measured problems, both on the real e2e suite: - Branch and function counts are inflated. V8 emits block ranges; without an AST c8 cannot see an `if` with no `else`, a ternary, or a `||`/`??` short-circuit as a branch point at all, so it scores them covered. e2e branches read 80.48% under c8 against 58.00% under the v8 provider, on near-identical denominators (20811 vs 21926) — the gap is entirely in the numerator. - Denominators collapse for files a run never loads. c8 credits each such file exactly 1 branch and 1 function regardless of content. The integration lane leaves most of core/ unloaded, so its branch denominator falls to 12532 against the v8 provider's 21926, and an untested file added today barely moves the gate. Vitest 4's v8 provider does AST-aware remapping, so both go away. Coverage is now configured in vitest.config.db.ts and the lanes just pass --coverage; cobertura still lands at coverage-e2e/ and coverage-integration/, so ci.yml and the nx outputs are unchanged. Thresholds are re-baselined from measured runs against MySQL 8.0 with Redis and MinIO up, minus roughly 2pt of headroom. The branch gates drop hardest (e2e 75 to 56, integration 73 to 32). No test changed and none started failing — the old numbers were simply not measuring what they claimed. Both lanes' gates and report directories live in one COVERAGE_LANES table in the config, selected by the COVERAGE_LANE env var the test:ci:* scripts set, rather than split between the config and a long CLI override. The lookup uses Object.hasOwn, so 'constructor' and the rest of Object.prototype are rejected instead of resolving to inherited members and silently skipping the gate, and an empty string is a bad value rather than "unset". An unset lane reports without gating, so ad-hoc local --coverage runs no longer inherit e2e's thresholds. Vitest 4 has no coverage.all — `include` is what reports never-loaded files. Verified: identical counts with and without it, so it was dead config implying a behaviour `include` already provided. Two entries from .c8rc.e2e.json did not carry over: the `!core/server/data/migrations/utils.js` negation, which matched nothing (utils is a directory), and .c8rc.json, which nothing referenced. --- ghost/core/.c8rc.e2e.json | 21 -- ghost/core/.c8rc.json | 25 -- ghost/core/package.json | 5 +- ghost/core/vitest.config.db.ts | 58 ++++ pnpm-lock.yaml | 521 +++++++++++++++++++++++++++++---- pnpm-workspace.yaml | 1 - 6 files changed, 525 insertions(+), 106 deletions(-) delete mode 100644 ghost/core/.c8rc.e2e.json delete mode 100644 ghost/core/.c8rc.json diff --git a/ghost/core/.c8rc.e2e.json b/ghost/core/.c8rc.e2e.json deleted file mode 100644 index 0ac3e794ff5..00000000000 --- a/ghost/core/.c8rc.e2e.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "all": true, - "check-coverage": true, - "reporter": ["text-summary", "cobertura"], - "reportsDir": "./coverage-e2e", - "statements": 54, - "branches": 75, - "functions": 79, - "lines": 54, - "include": ["core/{*.js,frontend,server,shared}"], - "exclude": [ - "core/frontend/src/**", - "core/frontend/public/**", - "core/frontend/helpers/**", - "core/server/data/migrations/**", - "core/server/data/schema/schema.js", - "!core/server/data/migrations/utils.js", - "core/server/web/api/testmode/**", - "core/server/services/koenig/**" - ] -} diff --git a/ghost/core/.c8rc.json b/ghost/core/.c8rc.json deleted file mode 100644 index e89306f676a..00000000000 --- a/ghost/core/.c8rc.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "all": true, - "check-coverage": false, - "reporter": ["html-spa", "text-summary", "cobertura"], - "include": ["core/{*.js,frontend,server,shared}"], - "exclude": [ - "core/frontend/src/**", - "core/frontend/public/**", - "core/server/data/migrations/**", - "core/server/data/schema/schema.js", - "!core/server/data/migrations/utils.js", - "core/frontend/web/**", - "!core/frontend/web/middleware/**", - "core/server/web/**/app.js", - "core/server/web/api/testmode/**", - "core/server/web/parent/**", - "core/server/api/endpoints/**", - "!core/server/web/**/middleware/**", - "!core/server/api/endpoints/utils", - "core/server/services/email-analytics/jobs/**", - "core/server/services/members/jobs/**", - "core/server/services/email-service/wrapper.js", - "core/server/services/**/service.js" - ] -} diff --git a/ghost/core/package.json b/ghost/core/package.json index 458c182f1e0..e77811acecb 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -63,8 +63,8 @@ "test:integration": "vitest run -c vitest.config.db.ts --project integration", "test:e2e": "vitest run -c vitest.config.db.ts --project e2e --project e2e-api --project e2e-isolated", "test:legacy": "vitest run -c vitest.config.db.ts --project legacy", - "test:ci:e2e": "c8 -c ./.c8rc.e2e.json -o coverage-e2e pnpm test:e2e", - "test:ci:integration": "c8 -c ./.c8rc.e2e.json -o coverage-integration --lines 52 --functions 47 --branches 73 --statements 52 pnpm test:integration", + "test:ci:e2e": "COVERAGE_LANE=e2e pnpm test:e2e --coverage", + "test:ci:integration": "COVERAGE_LANE=integration pnpm test:integration --coverage", "test:int:slow": "pnpm test:integration", "test:e2e:slow": "vitest run -c vitest.config.db.ts --project e2e --project e2e-api --reporter=verbose", "test:leg:slow": "vitest run -c vitest.config.db.ts --project legacy --reporter=verbose", @@ -279,7 +279,6 @@ "@types/supertest": "6.0.3", "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", - "c8": "catalog:", "chai": "catalog:", "cli-progress": "3.12.0", "cssnano": "7.1.9", diff --git a/ghost/core/vitest.config.db.ts b/ghost/core/vitest.config.db.ts index 2c0ad07aca3..d05a5f774dd 100644 --- a/ghost/core/vitest.config.db.ts +++ b/ghost/core/vitest.config.db.ts @@ -94,6 +94,43 @@ const sharedDbConfig = { hookTimeout: 60000, }; +// Coverage gates per acceptance lane, selected by COVERAGE_LANE (set in the +// test:ci:* scripts). Baselines measured against MySQL 8.0 + Redis + MinIO, +// minus ~2pt of headroom. Unset means no gate — ad-hoc local --coverage runs +// report without failing. +type CoverageLane = { + reportsDirectory: string; + thresholds: { + statements: number; + branches: number; + functions: number; + lines: number; + }; +}; + +const COVERAGE_LANES: Record = { + e2e: { + reportsDirectory: 'coverage-e2e', + thresholds: { statements: 70, branches: 56, functions: 74, lines: 70 }, + }, + integration: { + reportsDirectory: 'coverage-integration', + thresholds: { statements: 47, branches: 32, functions: 47, lines: 47 }, + }, +}; + +// Object.hasOwn, not a truthy lookup: 'constructor' and the rest of +// Object.prototype would otherwise resolve to inherited members and silently +// skip the gate. An empty string is a bad value too, not "unset". +const laneName = process.env.COVERAGE_LANE; +if (laneName !== undefined && !Object.hasOwn(COVERAGE_LANES, laneName)) { + // eslint-disable-next-line ghost/ghost-custom/no-native-error + throw new Error( + `Unknown COVERAGE_LANE '${laneName}' — expected one of: ${Object.keys(COVERAGE_LANES).join(', ')}`, + ); +} +const coverageLane = laneName === undefined ? undefined : COVERAGE_LANES[laneName]; + export default defineConfig({ test: { resolveSnapshotPath, @@ -109,6 +146,27 @@ export default defineConfig({ // Local runs use the compact `dot` reporter. CI uses `default` plus // `github-actions` for inline annotations (mirrors vitest.config.ts). reporters: process.env.GITHUB_ACTIONS ? ['default', 'github-actions'] : ['dot'], + // Coverage for the CI acceptance lanes (test:ci:e2e / test:ci:integration), + // which pass --coverage and pick their lane via COVERAGE_LANE. `include` + // is what reports never-loaded files under vitest 4 (there is no `all`). + coverage: { + provider: 'v8', + include: ['core/*.js', 'core/{frontend,server,shared}/**/*.{js,cjs,mjs,ts}'], + exclude: [ + 'core/frontend/src/**', + 'core/frontend/public/**', + 'core/frontend/helpers/**', + 'core/server/data/migrations/**', + 'core/server/data/schema/schema.js', + 'core/server/web/api/testmode/**', + 'core/server/services/koenig/**', + // Type-only declarations: no runtime code, and the remapper's parser + // rejects them ('Expected `from` but found `{`' on `import type`). + '**/*.d.ts', + ], + reporter: ['text-summary', 'cobertura'], + ...coverageLane, + }, projects: [ { ssr: sharedSsrConfig, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce2134e2a0c..8c2e92539c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,9 +383,6 @@ catalogs: bson-objectid: specifier: 2.0.4 version: 2.0.4 - c8: - specifier: 11.0.0 - version: 11.0.0 chai: specifier: 4.5.0 version: 4.5.0 @@ -1111,7 +1108,7 @@ importers: version: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) apps/admin-x-framework: dependencies: @@ -2699,7 +2696,7 @@ importers: version: 2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2) knex-migrator: specifier: 'catalog:' - version: 5.4.1(@types/node@22.20.1)(supports-color@10.2.2) + version: 5.4.1(@types/node@22.20.1)(bluebird@3.7.2)(supports-color@10.2.2) leaky-bucket: specifier: 2.2.0 version: 2.2.0 @@ -2929,9 +2926,6 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - c8: - specifier: 'catalog:' - version: 11.0.0 chai: specifier: 'catalog:' version: 4.5.0 @@ -6090,6 +6084,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@glimmer/component@1.1.2': resolution: {integrity: sha512-XyAsEEa4kWOPy+gIdMjJ8XlzA3qrGH55ZDv6nA16ibalCR17k74BI0CztxuRds+Rm6CtbUVgheCVlcCULuqD7A==} engines: {node: 6.* || 8.* || >= 10.*} @@ -6849,6 +6846,9 @@ packages: resolution: {integrity: sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw==} engines: {node: ^16.14.0 || >=18.0.0} + '@npmcli/fs@1.1.1': + resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} + '@npmcli/git@5.0.8': resolution: {integrity: sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6857,6 +6857,11 @@ packages: resolution: {integrity: sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + '@npmcli/move-file@1.1.2': + resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} + engines: {node: '>=10'} + deprecated: This functionality has been moved to @npmcli/fs + '@npmcli/name-from-folder@2.0.0': resolution: {integrity: sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10757,6 +10762,14 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + ajv-errors@1.0.1: resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==} peerDependencies: @@ -10922,6 +10935,11 @@ packages: resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==} deprecated: This package is no longer supported. + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -11982,19 +12000,13 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - c8@11.0.0: - resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} - engines: {node: 20 || >=22} - hasBin: true - peerDependencies: - monocart-coverage-reports: ^2 - peerDependenciesMeta: - monocart-coverage-reports: - optional: true - cacache@12.0.4: resolution: {integrity: sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==} + cacache@15.3.0: + resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} + engines: {node: '>= 10'} + cache-manager-ioredis@2.1.0: resolution: {integrity: sha512-TCxbp9ceuFveTKWuNaCX8QjoC41rAlHen4s63u9Yd+iXlw3efYmimc/u935PKPxSdhkXpnMes4mxtK3/yb0L4g==} engines: {node: '>=6.0.0'} @@ -12222,6 +12234,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -12427,6 +12443,10 @@ packages: resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} engines: {node: '>=18'} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + color@3.2.1: resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} @@ -15045,6 +15065,10 @@ packages: fs-merger@3.2.1: resolution: {integrity: sha512-AN6sX12liy0JE7C2evclwoo0aCG3PFulLjrTLsJpWh/2mM+DinhpSGqYLbHBBbIW1PLRNcFhJG8Axtz8mQW3ug==} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs-mkdirp-stream@2.0.1: resolution: {integrity: sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==} engines: {node: '>=10.13.0'} @@ -15101,6 +15125,11 @@ packages: resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==} deprecated: This package is no longer supported. + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + gelf-stream@1.1.1: resolution: {integrity: sha512-kCzCfI6DJ8+aaDhwMcsNm2l6CsBj6y4Is6CCxH2W9sYnZGcXg9WmJ/iZMoJVO6uTwTRL7dbIioAS8lCuGUXSFA==} @@ -15676,6 +15705,9 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -15900,6 +15932,10 @@ packages: resolution: {integrity: sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==} engines: {node: '>=6'} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + ip-regex@4.3.0: resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} engines: {node: '>=8'} @@ -16099,6 +16135,9 @@ packages: resolution: {integrity: sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==} engines: {node: '>=0.10.0'} + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-language-code@2.0.0: resolution: {integrity: sha512-6xKmRRcP2YdmMBZMVS3uiJRPQgcMYolkD6hFw2Y4KjqyIyaJlCGxUt56tuu0iIV8q9r8kMEo0Gjd/GFwKrgjbw==} @@ -17416,6 +17455,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-fetch-happen@9.1.0: + resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} + engines: {node: '>= 10'} + make-iterator@1.0.1: resolution: {integrity: sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==} engines: {node: '>=0.10.0'} @@ -17921,9 +17964,33 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@1.4.1: + resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} + engines: {node: '>=8'} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + minipass@2.9.0: resolution: {integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + minipass@4.2.8: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} @@ -17932,6 +17999,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -18167,6 +18238,9 @@ packages: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-dir@0.1.17: resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} engines: {node: '>= 0.10.5'} @@ -18193,6 +18267,11 @@ packages: engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true + node-gyp@8.4.1: + resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} + engines: {node: '>= 10.12.0'} + hasBin: true + node-html-markdown@2.0.0: resolution: {integrity: sha512-DqUC3GGP7pwSYxS93SwHoP+qCw78xcMP6C6H2DuC8rPD2AweJRjBzQb5SdXpKtDlqAQ7hVotJcfhgU7hU5Gthw==} engines: {node: '>=20.0.0'} @@ -18393,6 +18472,11 @@ packages: resolution: {integrity: sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==} hasBin: true + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + nopt@7.2.1: resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -18487,6 +18571,11 @@ packages: resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} deprecated: This package is no longer supported. + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -18751,6 +18840,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + p-map@7.0.6: resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} engines: {node: '>=18'} @@ -20990,6 +21083,10 @@ packages: slick@1.12.2: resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smartquotes@2.3.2: resolution: {integrity: sha512-0R6YJ5hLpDH4mZR7N5eZ12oCMLspvGOHL9A9SEm2e3b/CQmQidekW4SWSKEmor/3x6m3NCBBEqLzikcZC9VJNQ==} engines: {node: '>=4.0.0'} @@ -21020,6 +21117,14 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + socks-proxy-agent@6.2.1: + resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} + engines: {node: '>= 10'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -21120,6 +21225,9 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sqlite3@5.1.7: + resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} + ssh2@1.17.0: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} @@ -21127,6 +21235,10 @@ packages: ssri@6.0.2: resolution: {integrity: sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==} + ssri@8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} + stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' @@ -21608,10 +21720,6 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - test-exclude@8.0.0: - resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} - engines: {node: 20 || >=22} - testem@3.19.1: resolution: {integrity: sha512-h9LKg7pAF3B0aqp3V3Kx8vFlB/ocB1xXvRY1YxZyIKvV/3ZUXqYadi8OD2T15VoFjUZlRrm39s9e7N8A+gYBfA==} engines: {node: '>= 7.*'} @@ -24822,10 +24930,7 @@ snapshots: resolve: 1.22.12 semver: 7.8.5 transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - utf-8-validate '@embroider/macros@0.41.0(supports-color@10.2.2)': dependencies: @@ -25256,6 +25361,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@gar/promisify@1.1.3': + optional: true + '@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@glimmer/di': 0.1.11 @@ -26310,6 +26418,12 @@ snapshots: transitivePeerDependencies: - bluebird + '@npmcli/fs@1.1.1': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.8.5 + optional: true + '@npmcli/git@5.0.8(bluebird@3.7.2)': dependencies: '@npmcli/promise-spawn': 7.0.2 @@ -26331,6 +26445,12 @@ snapshots: minimatch: 9.0.9 read-package-json-fast: 3.0.2 + '@npmcli/move-file@1.1.2': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + optional: true + '@npmcli/name-from-folder@2.0.0': {} '@npmcli/package-json@5.2.1(bluebird@3.7.2)': @@ -30470,6 +30590,20 @@ snapshots: - vite optional: true + '@vitest/browser-playwright@4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(playwright@1.61.1)(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.1.10(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 @@ -30523,6 +30657,24 @@ snapshots: - vite optional: true + '@vitest/browser@4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -30535,9 +30687,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) '@vitest/expect@3.2.4': dependencies: @@ -30583,6 +30735,15 @@ snapshots: msw: 2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2) vite: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/mocker@4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@26.0.0)(typescript@5.9.3) + vite: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -30618,7 +30779,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -30972,6 +31133,17 @@ snapshots: transitivePeerDependencies: - supports-color + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + optional: true + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + optional: true + ajv-errors@1.0.1(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -31131,6 +31303,12 @@ snapshots: delegates: 1.0.0 readable-stream: 2.3.8 + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + arg@5.0.2: {} argparse@1.0.10: @@ -31376,7 +31554,7 @@ snapshots: babel-generator: 6.26.1 babel-helpers: 6.24.1(supports-color@10.2.2) babel-messages: 6.23.0 - babel-register: 6.26.0(supports-color@10.2.2) + babel-register: 6.26.0 babel-runtime: 6.26.0 babel-template: 6.26.0(supports-color@10.2.2) babel-traverse: 6.26.0(supports-color@10.2.2) @@ -31950,7 +32128,7 @@ snapshots: babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) optional: true - babel-register@6.26.0(supports-color@10.2.2): + babel-register@6.26.0: dependencies: babel-core: 6.26.3(supports-color@10.2.2) babel-runtime: 6.26.0 @@ -31959,8 +32137,6 @@ snapshots: lodash: 4.18.1 mkdirp: 0.5.6 source-map-support: 0.4.18 - transitivePeerDependencies: - - supports-color babel-runtime@6.26.0: dependencies: @@ -32930,20 +33106,6 @@ snapshots: bytes@3.1.2: {} - c8@11.0.0: - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@istanbuljs/schema': 0.1.6 - find-up: 5.0.0 - foreground-child: 3.3.1 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - test-exclude: 8.0.0 - v8-to-istanbul: 9.3.0 - yargs: 17.7.3 - yargs-parser: 21.1.1 - cacache@12.0.4: dependencies: bluebird: 3.7.2 @@ -32962,6 +33124,30 @@ snapshots: unique-filename: 1.1.1 y18n: 4.0.3 + cacache@15.3.0(bluebird@3.7.2): + dependencies: + '@npmcli/fs': 1.1.1 + '@npmcli/move-file': 1.1.2 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 7.2.3 + infer-owner: 1.0.4 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1(bluebird@3.7.2) + rimraf: 3.0.2 + ssri: 8.0.1 + tar: 7.5.16 + unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird + optional: true + cache-manager-ioredis@2.1.0(supports-color@10.2.2): dependencies: ioredis: 4.31.0(supports-color@10.2.2) @@ -33260,6 +33446,9 @@ snapshots: chownr@1.1.4: {} + chownr@2.0.0: + optional: true + chownr@3.0.0: {} chrome-trace-event@1.0.4: {} @@ -33451,6 +33640,9 @@ snapshots: dependencies: color-name: 2.1.0 + color-support@1.1.3: + optional: true + color@3.2.1: dependencies: color-convert: 1.9.3 @@ -35551,10 +35743,7 @@ snapshots: optionalDependencies: ember-mocha: 0.16.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - utf-8-validate ember-export-application-global@2.0.1: {} @@ -37698,6 +37887,11 @@ snapshots: transitivePeerDependencies: - supports-color + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + optional: true + fs-mkdirp-stream@2.0.1: dependencies: graceful-fs: 4.2.11 @@ -37785,6 +37979,18 @@ snapshots: strip-ansi: 3.0.1 wide-align: 1.1.5 + gauge@4.0.4: + dependencies: + aproba: 1.2.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + gelf-stream@1.1.1: dependencies: gelfling: 0.3.1 @@ -38526,6 +38732,11 @@ snapshots: human-signals@8.0.1: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + optional: true + husky@9.1.7: {} i18n-iso-countries@7.14.0: @@ -38781,6 +38992,9 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.7.0: + optional: true + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -38962,6 +39176,9 @@ snapshots: dependencies: is-glob: 2.0.1 + is-lambda@1.0.1: + optional: true + is-language-code@2.0.0: {} is-map@2.0.3: {} @@ -39970,7 +40187,7 @@ snapshots: kleur@4.1.5: {} - knex-migrator@5.4.1(@types/node@22.20.1)(supports-color@10.2.2): + knex-migrator@5.4.1(@types/node@22.20.1)(bluebird@3.7.2)(supports-color@10.2.2): dependencies: '@tryghost/database-info': 0.3.35 '@tryghost/errors': 3.3.9 @@ -39987,9 +40204,11 @@ snapshots: resolve: 1.22.12 optionalDependencies: better-sqlite3: 12.11.1 + sqlite3: 5.1.7(bluebird@3.7.2)(supports-color@10.2.2) transitivePeerDependencies: - '@75lb/nature' - '@types/node' + - bluebird - mysql - pg - pg-native @@ -40614,6 +40833,29 @@ snapshots: dependencies: semver: 7.8.5 + make-fetch-happen@9.1.0(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + agentkeepalive: 4.6.0 + cacache: 15.3.0(bluebird@3.7.2) + http-cache-semantics: 4.2.0 + http-proxy-agent: 4.0.1(supports-color@10.2.2) + https-proxy-agent: 5.0.1(supports-color@10.2.2) + is-lambda: 1.0.1 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 1.4.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 6.2.1(supports-color@10.2.2) + ssri: 8.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + make-iterator@1.0.1: dependencies: kind-of: 6.0.3 @@ -41436,15 +41678,55 @@ snapshots: minimist@1.2.8: {} + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-fetch@1.4.1: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + optional: true + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + optional: true + minipass@2.9.0: dependencies: safe-buffer: 5.2.1 yallist: 3.1.1 + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + optional: true + minipass@4.2.8: {} minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + optional: true + minizlib@3.1.0: dependencies: minipass: 7.1.3 @@ -41606,6 +41888,32 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@26.0.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + multer@2.2.0: dependencies: append-field: 1.0.0 @@ -41750,6 +42058,9 @@ snapshots: semver: 7.8.5 optional: true + node-addon-api@7.1.1: + optional: true + node-dir@0.1.17: dependencies: minimatch: 3.1.5 @@ -41782,6 +42093,23 @@ snapshots: undici: 8.10.0 which: 7.0.0 + node-gyp@8.4.1(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + env-paths: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 9.1.0(bluebird@3.7.2)(supports-color@10.2.2) + nopt: 5.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.8.5 + tar: 7.5.16 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + node-html-markdown@2.0.0: dependencies: node-html-parser: 6.1.13 @@ -41884,6 +42212,11 @@ snapshots: dependencies: abbrev: 1.1.1 + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + nopt@7.2.1: dependencies: abbrev: 2.0.0 @@ -41981,6 +42314,14 @@ snapshots: gauge: 2.7.4 set-blocking: 2.0.0 + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + optional: true + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -42476,6 +42817,11 @@ snapshots: p-map@2.1.0: {} + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + optional: true + p-map@7.0.6: {} p-timeout@3.2.0: @@ -45116,6 +45462,9 @@ snapshots: slick@1.12.2: {} + smart-buffer@4.2.0: + optional: true + smartquotes@2.3.2: {} smol-toml@1.6.1: {} @@ -45159,6 +45508,21 @@ snapshots: - supports-color - utf-8-validate + socks-proxy-agent@6.2.1(supports-color@10.2.2): + dependencies: + agent-base: 6.0.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.9: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + optional: true + sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -45251,6 +45615,19 @@ snapshots: sql-escaper@1.3.3: {} + sqlite3@5.1.7(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + bindings: 1.5.0 + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + tar: 7.5.16 + optionalDependencies: + node-gyp: 8.4.1(bluebird@3.7.2)(supports-color@10.2.2) + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + ssh2@1.17.0: dependencies: asn1: 0.2.6 @@ -45263,6 +45640,11 @@ snapshots: dependencies: figgy-pudding: 3.5.2 + ssri@8.0.1: + dependencies: + minipass: 3.3.6 + optional: true + stable@0.1.8: {} stack-utils@2.0.6: @@ -45960,12 +46342,6 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 - test-exclude@8.0.0: - dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 13.0.6 - minimatch: 10.2.5 - testem@3.19.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@xmldom/xmldom': 0.9.10 @@ -46781,6 +47157,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + optional: true v8flags@3.2.0: dependencies: @@ -47162,6 +47539,38 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.0.0 + '@vitest/browser-playwright': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(playwright@1.61.1)(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + '@vitest/ui': 4.1.10(vitest@4.1.10) + jsdom: 30.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + vm-browserify@1.1.2: {} vscode-uri@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19fb5ef1ae7..c120e7254b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -122,7 +122,6 @@ catalog: ajv-formats: 3.0.1 better-sqlite3: 12.11.1 bson-objectid: 2.0.4 - c8: 11.0.0 chai: 4.5.0 chalk: 4.1.2 clsx: 2.1.1 From 5584454222d34597b16a6725978c6b0beb5c1b2e Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 1 Sep 2026 15:18:16 -0500 Subject: [PATCH 5/9] Added post and page write hooks to the framework data layer (#30426) no ref The React editor work needs create/edit mutations for posts and pages whose requests match what the Ember editor sends today, so the API behaves identically for both clients. --- .../src/api/content-types.ts | 237 +++++++++ apps/admin-x-framework/src/api/pages.ts | 112 +++- .../src/api/post-contract.ts | 170 ++++++ apps/admin-x-framework/src/api/posts.ts | 169 +++--- .../src/hooks/use-koenig-link-suggestions.ts | 2 +- .../test/unit/api/pages.test.tsx | 136 +++++ .../test/unit/api/post-contract.test.ts | 498 ++++++++++++++++++ .../test/unit/api/posts.test.tsx | 277 +++++++++- .../components/post-analytics-header.tsx | 2 +- .../providers/post-analytics-context.ts | 8 +- .../src/posts/list/hooks/use-posts-list.ts | 8 +- apps/admin/src/posts/list/post-row-copy.ts | 6 +- 12 files changed, 1511 insertions(+), 114 deletions(-) create mode 100644 apps/admin-x-framework/src/api/content-types.ts create mode 100644 apps/admin-x-framework/src/api/post-contract.ts create mode 100644 apps/admin-x-framework/test/unit/api/pages.test.tsx create mode 100644 apps/admin-x-framework/test/unit/api/post-contract.test.ts diff --git a/apps/admin-x-framework/src/api/content-types.ts b/apps/admin-x-framework/src/api/content-types.ts new file mode 100644 index 00000000000..64c206100aa --- /dev/null +++ b/apps/admin-x-framework/src/api/content-types.ts @@ -0,0 +1,237 @@ +/** Shared Admin API type contracts for posts and pages. */ + +type Override = Omit & Changes; + +export type Email = { + opened_count: number; + email_count: number; + status?: string; + track_opens?: boolean; + track_clicks?: boolean; +}; + +// Every field is optional because list and analytics endpoints return different +// projections of these relations. +export type PostAuthor = { + id?: string; + name?: string; + email?: string; + slug?: string; + profile_image?: string | null; +}; + +export type PostTag = { + id?: string; + name?: string; + slug?: string; + visibility?: string; +}; + +export type PostStatus = 'published' | 'draft' | 'scheduled' | 'sent'; +export type PageStatus = Exclude; + +type AtLeastOne = { + [Key in keyof T]-?: Required> & Partial>; +}[keyof T]; + +export type PostAuthorInput = string | AtLeastOne<{ id: string; slug: string; email: string }>; + +export type PostTagInput = + | string + | ({ id: string } & Partial<{ name: string; slug: string | null }>) + | ({ name: string } & Partial<{ id: string; slug: string | null }>) + | ({ slug: string } & Partial<{ id: string; name: string }>); + +export type PostTierInput = { id: string }; + +export type PostTier = { + id: string; + name?: string; + slug?: string | null; +}; + +export type PostRevision = { + id?: string; + post_id?: string; + lexical?: string | null; + title?: string | null; + feature_image?: string | null; + feature_image_alt?: string | null; + feature_image_caption?: string | null; + custom_excerpt?: string | null; + post_status?: string | null; + reason?: string | null; + created_at?: string; + author?: PostAuthor | null; +}; + +/** Fields shared by post and page list responses. */ +export type ContentListFields = { + featured?: boolean; + updated_at?: string | null; + created_at?: string; + excerpt?: string | null; + custom_excerpt?: string | null; + authors?: PostAuthor[]; + primary_author?: PostAuthor | null; + tags?: PostTag[]; + primary_tag?: PostTag | null; + tiers?: object[]; +}; + +/** Fields shared by post and page editor responses. */ +export type ContentEditorFields = { + lexical?: string | null; + mobiledoc?: string | null; + meta_title?: string | null; + meta_description?: string | null; + canonical_url?: string | null; + custom_template?: string | null; + codeinjection_head?: string | null; + codeinjection_foot?: string | null; + og_image?: string | null; + og_title?: string | null; + og_description?: string | null; + twitter_image?: string | null; + twitter_title?: string | null; + twitter_description?: string | null; + feature_image_alt?: string | null; + feature_image_caption?: string | null; + post_revisions?: PostRevision[]; +}; + +export type PostCount = { + clicks?: number; + conversions?: number; + signups?: number; + paid_conversions?: number; + positive_feedback?: number; + negative_feedback?: number; +}; + +export type PageCount = { + signups?: number; + paid_conversions?: number; +}; + +/** Broad response record shared by the posts and pages endpoints. */ +export type ContentRecord = { + id: string; + url: string; + slug: string; + title: string; + visibility?: string; + uuid?: string; + feature_image?: string | null; + published_at?: string | null; +} & ContentListFields & + ContentEditorFields; + +export type PostEmailFields = { + email?: Email | null; + email_subject?: string | null; + newsletter?: object | null; + email_only?: boolean; + email_segment?: string | null; +}; + +export type Post = Override< + ContentRecord, + { + uuid: string; + status?: PostStatus; + count?: PostCount; + } +> & + PostEmailFields; + +export type Page = Override< + ContentRecord, + { + status?: PageStatus; + count?: PageCount; + show_title_and_feature_image?: boolean; + } +>; + +// Write access stays opt-in: a new response field must not silently become +// writable. Pick supplies each allowed field's value type from ContentRecord. +type ContentWritableKey = + | 'title' + | 'slug' + | 'mobiledoc' + | 'lexical' + | 'feature_image' + | 'feature_image_alt' + | 'feature_image_caption' + | 'featured' + | 'meta_title' + | 'meta_description' + | 'updated_at' + | 'published_at' + | 'custom_excerpt' + | 'codeinjection_head' + | 'codeinjection_foot' + | 'og_image' + | 'og_title' + | 'og_description' + | 'twitter_image' + | 'twitter_title' + | 'twitter_description' + | 'custom_template' + | 'canonical_url'; + +type ContentEditableScalars = Partial>; + +/** Shared post/page input, including fields whose input shape differs from output. */ +export type ContentEditableData = Override< + ContentEditableScalars, + { + html?: string | null; + locale?: string | null; + // The serializer treats null visibility as "leave visibility unchanged". + visibility?: string | null; + visibility_filter?: string | null; + authors?: PostAuthorInput[]; + tags?: PostTagInput[]; + tiers?: PostTierInput[]; + } +>; + +export type PostEditableData = ContentEditableData & + Partial>; + +export type PageEditableData = ContentEditableData & + Partial>; + +type EditorRelations = { + updated_at: string | null; + authors?: Array; + tags?: Array; + tiers?: PostTier[]; +}; + +/** A single editor response has the relations required for a safe round-trip edit. */ +export type EditorRecord = Override; + +export type PostEditorRecord = EditorRecord; +export type PageEditorRecord = EditorRecord; + +export type CreateContentData = Override; + +export type EditContentData = Override< + Data, + { id: string; updated_at: string | null } +>; + +export type PostBulkAction = + | { type: 'feature' } + | { type: 'unfeature' } + | { type: 'unpublish' } + | { type: 'unschedule' } + | { type: 'addTag'; meta: { tags: { id?: string; name: string; slug?: string }[] } } + | { type: 'access'; meta: { visibility: string; tiers?: { id: string }[] } }; + +// Compatibility aliases for existing imports from api/posts. +export type PostListFields = ContentListFields; +export type PostEditorFields = ContentEditorFields & Pick; diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts index 33ce11511dc..d8e3a649224 100644 --- a/apps/admin-x-framework/src/api/pages.ts +++ b/apps/admin-x-framework/src/api/pages.ts @@ -1,35 +1,40 @@ import { InfiniteData } from '@tanstack/react-query'; -import { Meta, createInfiniteQuery, createMutation, createQuery } from '../utils/api/hooks'; -import type { Email, PostBulkAction, PostListFields } from './posts'; - -// A page is a post with `displayName: 'page'` server-side, so the list screens -// read the same fields off both. -export type Page = { - id: string; - title: string; - slug: string; - url: string; - status?: string; - published_at?: string; - visibility?: string; - uuid?: string; - feature_image?: string; - email?: Email; - count?: { - clicks?: number; - }; - // Pages are never emailed, but the list reads these off both resources - // through one type, so they have to be addressable here too. - email_only?: boolean; - email_segment?: string; - newsletter?: object; -} & PostListFields; +import { + Meta, + createInfiniteQuery, + createMutation, + createQuery, + createQueryWithId, +} from '../utils/api/hooks'; +import { + PageWriteOptions, + PostCreateOptions, + buildPostEditorReadParams, + buildPageWriteParams, + buildPostReadParams, + serializePostPayload, +} from './post-contract'; +import type { + CreateContentData, + EditContentData, + Page, + PageEditableData, + PageEditorRecord, + PostBulkAction, +} from './content-types'; + +export type { Page, PageEditableData, PageEditorRecord, PageStatus } from './content-types'; export interface PagesResponseType { meta?: Meta; pages: Page[]; } +export interface PageResponseType { + meta?: Meta; + pages: PageEditorRecord[]; +} + const dataType = 'PagesResponseType'; export const useBrowsePages = createQuery({ @@ -63,6 +68,63 @@ export const useBrowsePagesInfinite = createInfiniteQuery({ + dataType, + path: (id) => `/pages/${id}/`, +}); + +export const usePage = (id: string, options: Parameters[1] = {}) => { + const { searchParams, ...queryOptions } = options; + return usePageQuery(id, { + ...queryOptions, + searchParams: { ...buildPostReadParams(), ...searchParams }, + }); +}; + +const useEditorPageQuery = createQueryWithId({ + dataType, + path: (id) => `/pages/${id}/`, +}); + +export const useEditorPage = ( + id: string, + options: Parameters[1] = {}, +) => { + const { searchParams, ...queryOptions } = options; + return useEditorPageQuery(id, { + ...queryOptions, + searchParams: { ...searchParams, ...buildPostEditorReadParams() }, + }); +}; + +// The create endpoint only accepts include/formats/source - revision and +// email delivery options are update-only +export interface AddPagePayload { + page: CreateContentData; + options?: PostCreateOptions; +} + +export interface EditPagePayload { + page: EditContentData; + options?: PageWriteOptions; +} + +export const useAddPage = createMutation({ + method: 'POST', + path: () => '/pages/', + searchParams: ({ options }) => buildPageWriteParams(options), + body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), + invalidateQueries: { dataType }, +}); + +export const useEditPage = createMutation({ + method: 'PUT', + path: ({ page }) => `/pages/${page.id}/`, + searchParams: ({ options }) => buildPageWriteParams(options), + body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), + invalidateQueries: { dataType }, +}); + /** Duplicate a page. As with posts, the copy is always a draft. */ export const useCopyPage = createMutation({ method: 'POST', diff --git a/apps/admin-x-framework/src/api/post-contract.ts b/apps/admin-x-framework/src/api/post-contract.ts new file mode 100644 index 00000000000..ee7050b6a81 --- /dev/null +++ b/apps/admin-x-framework/src/api/post-contract.ts @@ -0,0 +1,170 @@ +/** + * Request contract for post/page reads and writes against the Admin API. + * + * The Ember editor's adapters and serializers are the spec here — these + * builders must produce the same query params and payload shapes as + * `apps/ember-admin/app/adapters/post.js`, `adapters/page.js` and + * `serializers/post.js`/`page.js` so the API sees identical requests from + * either client. + */ + +/** + * Posts/pages include all relations by default on reads, but create/update + * responses only include what is explicitly requested — so writes re-request + * everything, including `post_revisions` for the client-side revision compare. + */ +export const ALL_POST_INCLUDES = [ + 'tags', + 'authors', + 'authors.roles', + 'email', + 'tiers', + 'newsletter', + 'count.clicks', + 'post_revisions', + 'post_revisions.author', +].join(','); + +/** Every post/page request asks for both content formats. */ +export const POST_FORMATS = 'mobiledoc,lexical'; + +// The publish flow's "everyone" segment; the API expects it spelled `all` +const ALL_MEMBERS_SEGMENT = 'status:free,status:-free'; + +export interface PostCreateOptions { + /** Convert an HTML payload to lexical content. */ + source?: 'html'; +} + +export interface PageWriteOptions extends PostCreateOptions { + /** Force the server to store a post revision (explicit save, leaving the editor). */ + saveRevision?: boolean; + /** Ask the server to convert the record's mobiledoc content to lexical. */ + convertToLexical?: boolean; +} + +export interface PostWriteOptions extends PageWriteOptions { + /** Newsletter slug. Presence means "email this publish". */ + newsletter?: string; + /** NQL member filter for the email. Only sent alongside `newsletter`. */ + emailSegment?: string; +} + +export function buildPostReadParams(): Record { + return { formats: POST_FORMATS }; +} + +/** Editor reads additionally need revision history and its author relation. */ +export function buildPostEditorReadParams(): Record { + return { formats: POST_FORMATS, include: ALL_POST_INCLUDES }; +} + +/** Query params for post create/update requests. */ +export function buildPostWriteParams(options: PostWriteOptions = {}): Record { + const params: Record = { formats: POST_FORMATS }; + + if (options.source) { + params.source = options.source; + } + + if (options.newsletter) { + params.newsletter = options.newsletter; + + if (options.emailSegment) { + params.email_segment = + options.emailSegment === ALL_MEMBERS_SEGMENT ? 'all' : options.emailSegment; + } + } + + if (options.saveRevision) { + params.save_revision = 'true'; + } + + if (options.convertToLexical) { + params.convert_to_lexical = 'true'; + } + + params.include = ALL_POST_INCLUDES; + + return params; +} + +/** Query params for page create/update requests — as posts, minus email delivery. */ +export function buildPageWriteParams(options: PageWriteOptions = {}): Record { + const params: Record = { formats: POST_FORMATS }; + + if (options.source) { + params.source = options.source; + } + + if (options.saveRevision) { + params.save_revision = 'true'; + } + + if (options.convertToLexical) { + params.convert_to_lexical = 'true'; + } + + params.include = ALL_POST_INCLUDES; + + return params; +} + +// Read-only/virtual fields the API must not receive back on writes +const READ_ONLY_POST_FIELDS = [ + 'author_id', + 'uuid', + 'url', + 'send_email_when_published', + 'email_recipient_filter', + 'email', + 'newsletter', + 'post_revisions', + // deprecated single-author field, replaced by `authors` + 'author', +] as const; + +// Pages additionally never send email fields +const POST_ONLY_FIELDS = ['email_subject', 'email_only', 'email_id'] as const; + +/** + * Shape an editable post/page into the exact payload the API expects: strips + * read-only and virtual fields and resolves the visibility/tiers coupling + * (tiers only accompany `visibility: 'tiers'`, and an empty tiers selection + * means "leave visibility unchanged"). + */ +export function serializePostPayload( + data: object, + resource: 'post' | 'page' = 'post', +): Record { + const json: Record = { ...data }; + + for (const field of READ_ONLY_POST_FIELDS) { + delete json[field]; + } + + if (resource === 'page') { + for (const field of POST_ONLY_FIELDS) { + delete json[field]; + } + } else { + delete json.show_title_and_feature_image; + } + + if (json.visibility === null) { + delete json.visibility; + delete json.visibility_filter; + delete json.tiers; + } + + if (json.visibility === 'tiers') { + delete json.visibility_filter; + } + + if (json.visibility === 'tiers' && !(json.tiers as unknown[] | undefined)?.length) { + delete json.visibility; + delete json.tiers; + } + + return json; +} diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index e17bc6442a1..86c7702b54d 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -6,80 +6,51 @@ import { createQueryWithId, createMutation, } from '../utils/api/hooks'; - -export type Email = { - opened_count: number; - email_count: number; - status?: string; - track_opens?: boolean; - track_clicks?: boolean; -}; - -// Every field optional: these are supertypes of the narrower author/tag shapes -// already declared around the analytics screens, so widening `Post` doesn't -// invalidate them. The list only reads names and slugs. -export type PostAuthor = { - id?: string; - name?: string; - email?: string; - slug?: string; -}; - -export type PostTag = { - id?: string; - name?: string; - slug?: string; - visibility?: string; -}; - -/** - * Fields the list screens need on top of the analytics-shaped core. All - * optional: the analytics endpoints don't return them, and the list gets them - * from the server's default relations rather than an explicit `include`. - */ -export type PostListFields = { - featured?: boolean; - updated_at?: string; - created_at?: string; - excerpt?: string; - custom_excerpt?: string; - authors?: PostAuthor[]; - primary_author?: PostAuthor | null; - tags?: PostTag[]; - primary_tag?: PostTag | null; - tiers?: object[]; -}; - -export type Post = { - id: string; - url: string; - slug: string; - title: string; - visibility?: string; - uuid: string; - feature_image?: string; - count?: { - clicks?: number; - positive_feedback?: number; - negative_feedback?: number; - }; - email?: Email; - status?: string; - published_at?: string; - newsletter_id?: string; - newsletter?: object; - email_only?: boolean; - email_segment?: string; - email_recipient_filter?: string; - send_email_when_published?: boolean; - email_stats?: object; -} & PostListFields; +import { + PostWriteOptions, + PostCreateOptions, + buildPostEditorReadParams, + buildPostReadParams, + buildPostWriteParams, + serializePostPayload, +} from './post-contract'; +import type { + CreateContentData, + EditContentData, + Post, + PostBulkAction, + PostEditableData, + PostEditorRecord, +} from './content-types'; + +export type { + Email, + Post, + PostAuthor, + PostAuthorInput, + PostBulkAction, + PostEditableData, + PostEditorFields, + PostEditorRecord, + PostListFields, + PostRevision, + PostStatus, + PostTag, + PostTagInput, + PostTier, + PostTierInput, +} from './content-types'; export interface PostsResponseType { meta?: Meta; posts: Post[]; } +export interface PostResponseType { + meta?: Meta; + posts: PostEditorRecord[]; +} + const dataType = 'PostsResponseType'; export const useBrowsePosts = createQuery({ @@ -113,24 +84,68 @@ export const useBrowsePostsInfinite = createInfiniteQuery({ +const usePostQuery = createQueryWithId({ + dataType, + path: (id) => `/posts/${id}/`, +}); + +export const usePost = (id: string, options: Parameters[1] = {}) => { + const { searchParams, ...queryOptions } = options; + return usePostQuery(id, { + ...queryOptions, + searchParams: { ...buildPostReadParams(), ...searchParams }, + }); +}; + +const useEditorPostQuery = createQueryWithId({ dataType, path: (id) => `/posts/${id}/`, }); +export const useEditorPost = ( + id: string, + options: Parameters[1] = {}, +) => { + const { searchParams, ...queryOptions } = options; + return useEditorPostQuery(id, { + ...queryOptions, + searchParams: { ...searchParams, ...buildPostEditorReadParams() }, + }); +}; + +// The create endpoint only accepts include/formats/source - revision and +// email delivery options are update-only +export interface AddPostPayload { + post: CreateContentData; + options?: PostCreateOptions; +} + +export interface EditPostPayload { + post: EditContentData; + options?: PostWriteOptions; +} + +export const useAddPost = createMutation({ + method: 'POST', + path: () => '/posts/', + searchParams: ({ options }) => buildPostWriteParams(options), + body: ({ post }) => ({ posts: [serializePostPayload(post)] }), + invalidateQueries: { dataType }, +}); + +export const useEditPost = createMutation({ + method: 'PUT', + path: ({ post }) => `/posts/${post.id}/`, + searchParams: ({ options }) => buildPostWriteParams(options), + body: ({ post }) => ({ posts: [serializePostPayload(post)] }), + invalidateQueries: { dataType }, +}); + export const useDeletePost = createMutation({ method: 'DELETE', path: (id) => `/posts/${id}/`, }); -export type PostBulkAction = - | { type: 'feature' } - | { type: 'unfeature' } - | { type: 'unpublish' } - | { type: 'unschedule' } - | { type: 'addTag'; meta: { tags: { id?: string; name: string; slug?: string }[] } } - | { type: 'access'; meta: { visibility: string; tiers?: { id: string }[] } }; - /** * Bulk-edit posts matching an NQL filter. * diff --git a/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts b/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts index 96972beeff1..fb2a93dfe41 100644 --- a/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts +++ b/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts @@ -69,7 +69,7 @@ export const useKoenigLinkSuggestions = ({ title: post.title, url: post.url, visibility: post.visibility, - publishedAt: post.published_at, + publishedAt: post.published_at ?? undefined, })); return [ diff --git a/apps/admin-x-framework/test/unit/api/pages.test.tsx b/apps/admin-x-framework/test/unit/api/pages.test.tsx new file mode 100644 index 00000000000..ec907df2cf8 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/pages.test.tsx @@ -0,0 +1,136 @@ +import { act, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { useAddPage, useEditPage, useEditorPage, usePage } from '../../../src/api/pages'; +import { withMockFetch } from '../../utils/mock-fetch'; + +// The Ember editor's exact include list — page writes re-request it too +const ALL_INCLUDES = + 'tags,authors,authors.roles,email,tiers,newsletter,count.clicks,post_revisions,post_revisions.author'; + +const requestUrl = (mock: any) => new URL(mock.calls[0][0] as string); + +const requestParams = (mock: any) => Object.fromEntries(requestUrl(mock).searchParams.entries()); + +const requestBody = (mock: any) => JSON.parse(mock.calls[0][1].body as string); + +describe('pages api', () => { + it('reads a single page', async () => { + await withMockFetch( + { + json: { + pages: [{ id: 'page-1', title: 'About', slug: 'about', url: '/about/' }], + // the permissions gate fetches the current user through the same mock + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => usePage('page-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const pagesCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/pages/'), + ); + const pagesUrl = new URL(pagesCall[0] as string); + expect(pagesUrl.pathname).toBe('/ghost/api/admin/pages/page-1/'); + expect(Object.fromEntries(pagesUrl.searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + }); + }, + ); + }); + + it('reads an editor page with revision history', async () => { + await withMockFetch( + { + json: { + pages: [{ id: 'page-1', title: 'About', slug: 'about', url: '/about/' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEditorPage('page-1', { + searchParams: { formats: 'html', include: 'tags' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const pagesCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/pages/page-1/'), + ); + expect(Object.fromEntries(new URL(pagesCall[0] as string).searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + }, + ); + }); + + it('creates a page through the pages endpoint with the write contract params', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useAddPage()); + + await act(async () => { + await result.current.mutateAsync({ + page: { title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/pages/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + pages: [{ title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }], + }); + }); + }); + + it('saves a page with a revision and without email delivery params', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPage()); + + await act(async () => { + await result.current.mutateAsync({ + page: { + id: 'page-1', + title: 'About', + status: 'draft', + lexical: '{"root":{}}', + show_title_and_feature_image: false, + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { saveRevision: true }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/pages/page-1/'); + expect(mock.calls[0][1].method).toBe('PUT'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + save_revision: 'true', + include: ALL_INCLUDES, + }); + // pages keep show_title_and_feature_image - it's a page-only field + expect(requestBody(mock)).toEqual({ + pages: [ + { + id: 'page-1', + title: 'About', + status: 'draft', + lexical: '{"root":{}}', + show_title_and_feature_image: false, + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/post-contract.test.ts b/apps/admin-x-framework/test/unit/api/post-contract.test.ts new file mode 100644 index 00000000000..28a7aec0fce --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/post-contract.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, it } from 'vitest'; +import type { + AddPagePayload, + EditPagePayload, + Page, + PageEditableData, + PageResponseType, +} from '../../../src/api/pages'; +import type { + AddPostPayload, + EditPostPayload, + Post, + PostEditableData, + PostResponseType, + PostRevision, +} from '../../../src/api/posts'; +import { + ALL_POST_INCLUDES, + POST_FORMATS, + buildPageWriteParams, + buildPostEditorReadParams, + buildPostReadParams, + buildPostWriteParams, + serializePostPayload, +} from '../../../src/api/post-contract'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +type ExpectedContentEditableKey = + | 'title' + | 'slug' + | 'mobiledoc' + | 'lexical' + | 'html' + | 'locale' + | 'feature_image' + | 'feature_image_alt' + | 'feature_image_caption' + | 'featured' + | 'meta_title' + | 'meta_description' + | 'updated_at' + | 'published_at' + | 'custom_excerpt' + | 'codeinjection_head' + | 'codeinjection_foot' + | 'og_image' + | 'og_title' + | 'og_description' + | 'twitter_image' + | 'twitter_title' + | 'twitter_description' + | 'custom_template' + | 'canonical_url' + | 'visibility' + | 'visibility_filter' + | 'authors' + | 'tags' + | 'tiers'; + +const sharedTypeAssertions: [ + Assert>, + Assert>, + Assert< + Equal< + keyof PostEditableData, + ExpectedContentEditableKey | 'status' | 'email_subject' | 'email_only' + > + >, + Assert< + Equal< + keyof PageEditableData, + ExpectedContentEditableKey | 'status' | 'show_title_and_feature_image' + > + >, +] = [true, true, true, true]; + +// Compile-time cases: the build failing is the assertion. Each `@ts-expect-error` +// fails the build if the request and response types drift from the Admin API schema. +const postEditWithoutUpdatedAt: EditPostPayload = { + // @ts-expect-error post edits require the collision token + post: { id: 'post-1' }, +}; + +const pageEditWithoutUpdatedAt: EditPagePayload = { + // @ts-expect-error page edits require the collision token + page: { id: 'page-1' }, +}; + +const postCreateWithoutTitle: AddPostPayload = { + // @ts-expect-error post creates require a title + post: { status: 'draft' }, +}; + +const pageCreateWithoutTitle: AddPagePayload = { + // @ts-expect-error page creates require a title + page: { status: 'draft' }, +}; + +// The API accepts updated_at on creates, even though clients normally omit it. +const postCreateWithUpdatedAt: AddPostPayload = { + post: { title: 'Hello', updated_at: '2026-01-01T00:00:00.000Z' }, +}; + +const pageCreateWithUpdatedAt: AddPagePayload = { + page: { title: 'About', updated_at: '2026-01-01T00:00:00.000Z' }, +}; + +const postReadResponse: PostResponseType = { + posts: [ + { + id: 'post-1', + uuid: 'uuid-1', + url: '/hello/', + slug: 'hello', + title: 'Hello', + updated_at: null, + }, + ], +}; + +const pageReadResponse: PageResponseType = { + pages: [ + { + id: 'page-1', + url: '/about/', + slug: 'about', + title: 'About', + updated_at: null, + }, + ], +}; + +const nullablePostReadResponse: PostResponseType = { + posts: [ + { + id: 'post-2', + uuid: 'uuid-2', + url: '/empty/', + slug: 'empty', + title: 'Empty', + updated_at: null, + published_at: null, + feature_image: null, + excerpt: null, + custom_excerpt: null, + email: null, + newsletter: null, + email_segment: null, + }, + ], +}; + +const postWithAllCounts: Post = { + id: 'post-3', + uuid: 'uuid-3', + url: '/counts/', + slug: 'counts', + title: 'Counts', + count: { + clicks: 1, + conversions: 2, + signups: 3, + paid_conversions: 4, + positive_feedback: 5, + negative_feedback: 6, + }, +}; + +const pageWithPageCounts: Page = { + id: 'page-2', + url: '/page-counts/', + slug: 'page-counts', + title: 'Page counts', + count: { signups: 1, paid_conversions: 2 }, +}; + +const pageWithPostCount: Page = { + id: 'page-3', + url: '/invalid-count/', + slug: 'invalid-count', + title: 'Invalid count', + count: { + // @ts-expect-error click counts are not exposed by the pages endpoint + clicks: 1, + }, +}; + +const postWithInputOnlyResponseField: Post = { + id: 'post-4', + uuid: 'uuid-4', + url: '/input-only/', + slug: 'input-only', + title: 'Input only', + // @ts-expect-error visibility_filter is accepted on writes but not emitted in responses + visibility_filter: 'status:paid', +}; + +// Single-resource reads can flow directly into their edit mutations. +const postEditFromRead: EditPostPayload = { post: postReadResponse.posts[0] }; +const pageEditFromRead: EditPagePayload = { page: pageReadResponse.pages[0] }; + +const revisionWithImageMetadata: PostRevision = { + feature_image_alt: 'A ghost sign', + feature_image_caption: 'Photo by Ghost', + author: { profile_image: '/content/images/author.png' }, +}; + +const schemaAlignedPostEdits: PostEditableData = { + html: '

Hello

', + feature_image: null, + published_at: null, + visibility: null, + locale: null, +}; + +const schemaAlignedPageEdits: AddPagePayload['page'] = { + title: 'About', + html: '

About

', + feature_image: null, + published_at: null, + visibility: null, + locale: null, +}; + +const schemaAlignedRelations: PostEditableData = { + authors: [{ email: 'author@example.com' }], + tags: [{ name: 'News' }, { id: 'tag-1', slug: null }], + tiers: [{ id: 'tier-1' }], +}; + +const postWithPageOnlyField: AddPostPayload = { + post: { + title: 'Hello', + // @ts-expect-error page presentation settings are never writable on posts + show_title_and_feature_image: false, + }, +}; + +const pageWithPostOnlyFields: AddPagePayload = { + page: { + title: 'About', + // @ts-expect-error pages are never sent as email-only posts + email_only: true, + }, +}; + +const pageWithEmailSubject: AddPagePayload = { + page: { + title: 'About', + // @ts-expect-error pages have no email subject + email_subject: 'About us', + }, +}; + +const sentPage: AddPagePayload = { + page: { + title: 'About', + // @ts-expect-error sent is a post-only status + status: 'sent', + }, +}; + +const postWithReadOnlyFields: AddPostPayload = { + post: { + title: 'Hello', + // @ts-expect-error response URLs are not writable + url: '/hello/', + }, +}; + +const postWithRevisions: AddPostPayload = { + post: { + title: 'Hello', + // @ts-expect-error revision history is not writable through the post payload + post_revisions: [], + }, +}; + +const invalidRelations: PostEditableData = { + authors: [ + // @ts-expect-error author objects require id, slug, or email + {}, + ], + tags: [ + // @ts-expect-error tag objects require id, name, or a non-null slug + { slug: null }, + ], + tiers: [ + // @ts-expect-error tier objects require an id + { name: 'Supporters' }, + // @ts-expect-error tier strings are discarded by the model relation handler + 'tier-slug', + ], +}; + +describe('post request contract', () => { + it('keeps the compile-time request and response contracts exercised', () => { + for (const value of [ + postEditWithoutUpdatedAt, + pageEditWithoutUpdatedAt, + postCreateWithoutTitle, + pageCreateWithoutTitle, + postCreateWithUpdatedAt, + pageCreateWithUpdatedAt, + postEditFromRead, + pageEditFromRead, + nullablePostReadResponse, + postWithAllCounts, + pageWithPageCounts, + pageWithPostCount, + postWithInputOnlyResponseField, + revisionWithImageMetadata, + schemaAlignedPostEdits, + schemaAlignedPageEdits, + schemaAlignedRelations, + invalidRelations, + postWithPageOnlyField, + pageWithPostOnlyFields, + pageWithEmailSubject, + sentPage, + postWithReadOnlyFields, + postWithRevisions, + sharedTypeAssertions, + ]) { + expect(value).toBeTruthy(); + } + }); + + describe('query params', () => { + it('requests both content formats on reads', () => { + expect(buildPostReadParams()).toEqual({ + formats: 'mobiledoc,lexical', + }); + expect(buildPostEditorReadParams()).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_POST_INCLUDES, + }); + }); + + it('re-requests the full include list on writes', () => { + expect(buildPostWriteParams()).toEqual({ + formats: POST_FORMATS, + include: + 'tags,authors,authors.roles,email,tiers,newsletter,count.clicks,post_revisions,post_revisions.author', + }); + }); + + it('adds save_revision and convert_to_lexical only when requested', () => { + expect(buildPostWriteParams({ saveRevision: true, convertToLexical: true })).toEqual({ + formats: POST_FORMATS, + save_revision: 'true', + convert_to_lexical: 'true', + include: ALL_POST_INCLUDES, + }); + + expect(buildPostWriteParams({ saveRevision: false, convertToLexical: false })).toEqual({ + formats: POST_FORMATS, + include: ALL_POST_INCLUDES, + }); + }); + + it('requests HTML-to-lexical conversion when sending an HTML source', () => { + expect(buildPostWriteParams({ source: 'html' })).toEqual({ + formats: POST_FORMATS, + source: 'html', + include: ALL_POST_INCLUDES, + }); + + expect(buildPageWriteParams({ source: 'html' })).toEqual({ + formats: POST_FORMATS, + source: 'html', + include: ALL_POST_INCLUDES, + }); + }); + + it('sends newsletter and email segment when emailing a publish', () => { + expect(buildPostWriteParams({ newsletter: 'weekly', emailSegment: 'status:free' })).toEqual({ + formats: POST_FORMATS, + newsletter: 'weekly', + email_segment: 'status:free', + include: ALL_POST_INCLUDES, + }); + }); + + it('rewrites the "everyone" segment to all', () => { + expect( + buildPostWriteParams({ newsletter: 'weekly', emailSegment: 'status:free,status:-free' }), + ).toEqual({ + formats: POST_FORMATS, + newsletter: 'weekly', + email_segment: 'all', + include: ALL_POST_INCLUDES, + }); + }); + + it('only sends an email segment alongside a newsletter', () => { + expect(buildPostWriteParams({ emailSegment: 'status:free' })).toEqual({ + formats: POST_FORMATS, + include: ALL_POST_INCLUDES, + }); + }); + + it('never sends email delivery params for pages', () => { + expect(buildPageWriteParams({ saveRevision: true })).toEqual({ + formats: POST_FORMATS, + save_revision: 'true', + include: ALL_POST_INCLUDES, + }); + }); + }); + + describe('payload shaping', () => { + it('strips read-only and virtual fields from post payloads', () => { + expect( + serializePostPayload({ + id: 'post-1', + title: 'Hello', + lexical: '{"root":{}}', + author_id: 'author-1', + author: { id: 'author-1' }, + uuid: 'uuid-1', + url: 'https://example.com/hello/', + send_email_when_published: true, + email_recipient_filter: 'all', + email: { email_count: 1 }, + newsletter: { id: 'newsletter-1' }, + post_revisions: [{ id: 'revision-1' }], + }), + ).toEqual({ + id: 'post-1', + title: 'Hello', + lexical: '{"root":{}}', + }); + }); + + it('strips the page-only title/feature-image toggle from post payloads but keeps it for pages', () => { + const data = { title: 'Hello', show_title_and_feature_image: false }; + + expect(serializePostPayload(data)).toEqual({ title: 'Hello' }); + expect(serializePostPayload(data, 'page')).toEqual(data); + }); + + it('strips email fields from page payloads', () => { + expect( + serializePostPayload( + { title: 'Hello', email_subject: 'Subject', email_only: false, email_id: 'email-1' }, + 'page', + ), + ).toEqual({ title: 'Hello' }); + }); + + it('drops visibility, filter and tiers when visibility is null', () => { + expect( + serializePostPayload({ + title: 'Hello', + visibility: null, + visibility_filter: 'label:vip', + tiers: [{ id: 'tier-1' }], + }), + ).toEqual({ title: 'Hello' }); + }); + + it('drops the visibility filter when visibility is tiers', () => { + expect( + serializePostPayload({ + title: 'Hello', + visibility: 'tiers', + visibility_filter: 'label:vip', + tiers: [{ id: 'tier-1' }], + }), + ).toEqual({ + title: 'Hello', + visibility: 'tiers', + tiers: [{ id: 'tier-1' }], + }); + }); + + it('treats tiers visibility without tiers as unchanged visibility', () => { + expect(serializePostPayload({ title: 'Hello', visibility: 'tiers', tiers: [] })).toEqual({ + title: 'Hello', + }); + }); + + it('keeps other visibility values untouched', () => { + expect(serializePostPayload({ title: 'Hello', visibility: 'members', tiers: [] })).toEqual({ + title: 'Hello', + visibility: 'members', + tiers: [], + }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/posts.test.tsx b/apps/admin-x-framework/test/unit/api/posts.test.tsx index dbe4ffd35cf..d32dae1e46f 100644 --- a/apps/admin-x-framework/test/unit/api/posts.test.tsx +++ b/apps/admin-x-framework/test/unit/api/posts.test.tsx @@ -1,10 +1,111 @@ -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; -import { useImportContentCSV } from '../../../src/api/posts'; +import { + useAddPost, + useEditPost, + useEditorPost, + useImportContentCSV, + usePost, +} from '../../../src/api/posts'; import { withMockFetch } from '../../utils/mock-fetch'; +// The Ember editor's exact include list — writes must re-request everything +const ALL_INCLUDES = + 'tags,authors,authors.roles,email,tiers,newsletter,count.clicks,post_revisions,post_revisions.author'; + +const requestUrl = (mock: any) => new URL(mock.calls[0][0] as string); + +const requestParams = (mock: any) => Object.fromEntries(requestUrl(mock).searchParams.entries()); + +const requestBody = (mock: any) => JSON.parse(mock.calls[0][1].body as string); + describe('posts api', () => { + it('reads a single post with both content formats', async () => { + await withMockFetch( + { + json: { + posts: [{ id: 'post-1', title: 'Hello', slug: 'hello', url: '/hello/' }], + // the permissions gate fetches the current user through the same mock + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => usePost('post-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const postsCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/posts/post-1/'), + ); + const postsUrl = new URL(postsCall[0] as string); + expect(postsUrl.pathname).toBe('/ghost/api/admin/posts/post-1/'); + expect(Object.fromEntries(postsUrl.searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + }); + }, + ); + }); + + it('reads an editor post with revision history', async () => { + await withMockFetch( + { + json: { + posts: [{ id: 'post-1', title: 'Hello', slug: 'hello', url: '/hello/' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEditorPost('post-1', { + searchParams: { formats: 'html', include: 'tags' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const postsCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/posts/post-1/'), + ); + expect(Object.fromEntries(new URL(postsCall[0] as string).searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + }, + ); + }); + + it('preserves explicit params on generic post reads', async () => { + await withMockFetch( + { + json: { + posts: [{ id: 'post-1', title: 'Hello', slug: 'hello', url: '/hello/' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + usePost('post-1', { + searchParams: { formats: 'html', include: 'count.positive_feedback' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const postsCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/posts/post-1/'), + ); + expect(Object.fromEntries(new URL(postsCall[0] as string).searchParams.entries())).toEqual({ + formats: 'html', + include: 'count.positive_feedback', + }); + }, + ); + }); + it('imports CSV content via the posts upload endpoint', async () => { const file = new File(['title\nHello'], 'posts.csv', { type: 'text/csv' }); @@ -56,6 +157,178 @@ describe('posts api', () => { } }); + it('creates a draft through the posts endpoint with the write contract params', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useAddPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/posts/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + posts: [{ title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }], + }); + }); + }); + + it('autosaves a draft in the background without forcing a revision', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + title: 'Draft in progress', + status: 'draft', + lexical: '{"root":{}}', + updated_at: '2026-01-01T00:00:00.000Z', + }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/posts/post-1/'); + expect(mock.calls[0][1].method).toBe('PUT'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + posts: [ + { + id: 'post-1', + title: 'Draft in progress', + status: 'draft', + lexical: '{"root":{}}', + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + }); + + it('forces a revision on explicit saves', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + title: 'Saved', + status: 'draft', + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { saveRevision: true }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + save_revision: 'true', + include: ALL_INCLUDES, + }); + }); + }); + + it('publishes to a newsletter segment and strips read-only fields from the payload', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + // read-only relations come back on fetched posts - they must never be sent back + const fetchedPost = { + id: 'post-1', + title: 'Published', + status: 'published' as const, + published_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + email: { email_count: 0, opened_count: 0 }, + newsletter: { id: 'newsletter-1' }, + post_revisions: [{ id: 'revision-1' }], + }; + + await act(async () => { + await result.current.mutateAsync({ + post: fetchedPost, + options: { newsletter: 'weekly', emailSegment: 'label:vip' }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + newsletter: 'weekly', + email_segment: 'label:vip', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + posts: [ + { + id: 'post-1', + title: 'Published', + status: 'published', + published_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + }); + + it('publishes to everyone as the all segment', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + status: 'published', + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { newsletter: 'weekly', emailSegment: 'status:free,status:-free' }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + newsletter: 'weekly', + email_segment: 'all', + include: ALL_INCLUDES, + }); + }); + }); + + it('requests a mobiledoc conversion with convert_to_lexical', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + status: 'draft', + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { convertToLexical: true }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + convert_to_lexical: 'true', + include: ALL_INCLUDES, + }); + }); + }); + it('uploads a mapped CSV ZIP through the posts endpoint', async () => { const file = new File(['PK'], 'posts.zip', { type: 'application/zip' }); diff --git a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx index fa3866d427f..ae7635ebe2c 100644 --- a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx +++ b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx @@ -203,7 +203,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c canShareAsGift={canManageGiftLink} description="" faviconURL={site?.icon || ''} - featureImageURL={post?.feature_image} + featureImageURL={post?.feature_image ?? undefined} giftAccessLabel={giftAccessLabel(post?.visibility)} open={isShareOpen} postExcerpt={post?.excerpt || ''} diff --git a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts index d844a5b5389..2d0d82178d5 100644 --- a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts +++ b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts @@ -3,8 +3,8 @@ import { createContext, useContext } from 'react'; // Comprehensive Post type with all the includes we fetch in PostAnalytics export interface Post extends PostBase { - published_at?: string; - excerpt?: string; + published_at?: string | null; + excerpt?: string | null; // `name` optional, matching the framework's PostAuthor: invited staff have // an email but no name yet. authors?: { @@ -14,10 +14,10 @@ export interface Post extends PostBase { opened_count: number; email_count: number; status?: string; - }; + } | null; newsletter?: { feedback_enabled?: boolean; - }; + } | null; count?: { positive_feedback?: number; negative_feedback?: number; diff --git a/apps/admin/src/posts/list/hooks/use-posts-list.ts b/apps/admin/src/posts/list/hooks/use-posts-list.ts index 97f47742417..f8927e12ad3 100644 --- a/apps/admin/src/posts/list/hooks/use-posts-list.ts +++ b/apps/admin/src/posts/list/hooks/use-posts-list.ts @@ -16,8 +16,12 @@ import type { Post } from '@tryghost/admin-x-framework/api/posts'; import type { PostBucket, PostFilterContext, PostListParams } from '@/posts/list/post-query-params'; import type { PostResource } from '@/posts/list/post-resource'; -/** A row in the list. Pages are posts with a different `displayName`. */ -export type PostListItem = Post | Page; +/** + * A row in the shared list. Page API records never contain email data, but the + * resource-neutral rendering helpers need those properties to be addressable. + */ +export type PostListItem = (Post | Page) & + Partial>; export interface UsePostsListOptions { resource: PostResource; diff --git a/apps/admin/src/posts/list/post-row-copy.ts b/apps/admin/src/posts/list/post-row-copy.ts index e37f20453f9..9f688c505be 100644 --- a/apps/admin/src/posts/list/post-row-copy.ts +++ b/apps/admin/src/posts/list/post-row-copy.ts @@ -16,7 +16,7 @@ import type { PostResource } from '@/posts/list/post-resource'; type PostStatus = 'draft' | 'scheduled' | 'published' | 'sent'; function statusOf(post: PostListItem): PostStatus { - return (post.status ?? 'draft') as PostStatus; + return post.status ?? 'draft'; } /** @@ -73,7 +73,9 @@ export function getPostDateField(post: PostListItem): 'updated_at' | 'published_ } export function getPostDate(post: PostListItem): string | undefined { - return getPostDateField(post) === 'updated_at' ? post.updated_at : post.published_at; + return ( + (getPostDateField(post) === 'updated_at' ? post.updated_at : post.published_at) ?? undefined + ); } export interface PostMetaLine { From 19c48912e9d2167ef298884e90d92d9079f2e773 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 1 Sep 2026 16:01:55 -0500 Subject: [PATCH 6/9] Added an editorReact flag gating /editor between Ember and a React placeholder (#30427) no ref Adds the cutover seam for serving the editor from the React admin with a placeholder and labs flag for future work. This is deliberately 'empty'/non-functional. --- .../stats/overview/components/latest-post.tsx | 3 +- .../stats/overview/components/top-posts.tsx | 9 +- apps/admin/src/editor-gate.test.tsx | 137 ++++++++++++++++++ apps/admin/src/editor-gate.tsx | 17 +++ apps/admin/src/editor/api.ts | 9 ++ apps/admin/src/editor/editor-screen.tsx | 33 +++++ .../src/editor/editor.acceptance.test.tsx | 58 ++++++++ .../layout/editor-sidebar.acceptance.test.tsx | 9 ++ .../components/post-analytics-header.tsx | 6 +- apps/admin/src/routes.tsx | 42 +++--- .../advanced/advanced.acceptance.test.tsx | 26 ++++ .../advanced/labs/private-features.tsx | 6 + apps/ember-admin/app/routes/lexical-editor.js | 93 ++++++++++++ apps/ember-admin/app/services/feature.js | 1 + .../acceptance/editor-react-flag-test.js | 129 +++++++++++++++++ ghost/core/core/shared/labs.js | 1 + 16 files changed, 555 insertions(+), 24 deletions(-) create mode 100644 apps/admin/src/editor-gate.test.tsx create mode 100644 apps/admin/src/editor-gate.tsx create mode 100644 apps/admin/src/editor/api.ts create mode 100644 apps/admin/src/editor/editor-screen.tsx create mode 100644 apps/admin/src/editor/editor.acceptance.test.tsx create mode 100644 apps/ember-admin/tests/acceptance/editor-react-flag-test.js diff --git a/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx b/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx index 1062c7bd3c1..257fc7a5cf2 100644 --- a/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx +++ b/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx @@ -88,7 +88,8 @@ const LatestPost: React.FC = ({ latestPostStats, isLoading }) = analytics: { webAnalytics, membersTrackSources }, }); const shouldGoToEditor = postDestination.startsWith('/editor/'); - // Editor destinations are still Ember-owned and need a hash navigation. + // Editor destinations need a hash navigation while Ember serves them + // (the `editorReact` flag decides which side does). const destinationIsEmberOwned = useIsEmberOwnedRoute(postDestination); return ( diff --git a/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx b/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx index 6ed62f1bfb1..40ba9654195 100644 --- a/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx +++ b/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx @@ -21,6 +21,7 @@ import { getPeriodText } from '@/shared/analytics/chart-helpers'; import { getPostDestination } from '@/analytics/utils/url-helpers'; import { getPostStatusText } from '@tryghost/admin-x-framework/utils/post-utils'; import { getSiteTimezone } from '@tryghost/admin-x-framework/utils/get-site-timezone'; +import { useIsEmberOwnedRoute } from '@/routes'; import { useNavigate } from '@tryghost/admin-x-framework'; import { useEmailTrackClicks, @@ -81,6 +82,9 @@ interface TopPostsProps { const TopPosts: React.FC = ({ topPostsData, isLoading }) => { const navigate = useNavigate(); + // Whether `/editor/*` needs a hash navigation depends on the `editorReact` + // flag; ownership is the same for every post, so one probe path suffices. + const editorIsEmberOwned = useIsEmberOwnedRoute('/editor'); const { range } = useAnalytics(); const { settings } = useAnalyticsData(); const paidMembersEnabled = usePaidMembersEnabled(); @@ -126,8 +130,9 @@ const TopPosts: React.FC = ({ topPostsData, isLoading }) => { membersTrackSources, }, }); - // `/editor/*` is still Ember-owned (EMBER_ROUTES) and needs a hash navigation. - navigate(destination, { crossApp: destination.startsWith('/editor/') }); + navigate(destination, { + crossApp: destination.startsWith('/editor/') && editorIsEmberOwned, + }); }} > {post.feature_image ? ( diff --git a/apps/admin/src/editor-gate.test.tsx b/apps/admin/src/editor-gate.test.tsx new file mode 100644 index 00000000000..95b56eda49f --- /dev/null +++ b/apps/admin/src/editor-gate.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { EditorGate } from './editor-gate'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +const { mockUseBrowseConfig } = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig, +})); + +// `useEmberFeatureFlag` is the ownership authority when Ember is present. +// Mirroring the real reader (window.EmberBridge, undefined without it) lets +// tests cover both the standalone config path and the integrated Ember path. +vi.mock('./ember-bridge', () => ({ + EmberFallback: () => React.createElement('div', { 'data-testid': 'ember-fallback' }), + useEmberFeatureFlag: (flag: string) => { + const stateBridge = window.EmberBridge?.state; + if (!stateBridge?.isFeatureEnabled) { + return undefined; + } + return stateBridge.isFeatureEnabled(flag) ?? null; + }, +})); + +// Stand in for the real lazy screen module so the test asserts the wiring +// without pulling in the editor chunk. +vi.mock('./editor/editor-screen', () => ({ + default: () => React.createElement('div', { 'data-testid': 'react-editor' }), +})); + +const configResult = (overrides: Record) => ({ + data: undefined, + isError: false, + isLoading: false, + ...overrides, +}); + +const withLabs = (labs: Record) => configResult({ data: { config: { labs } } }); + +describe('EditorGate', () => { + beforeEach(() => { + mockUseBrowseConfig.mockReset(); + delete window.EmberBridge; + }); + + it('renders the Ember editor while the flag is off', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: false })); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders the Ember editor when Ember reports the flag off', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: false })); + window.EmberBridge = { + state: { + isFeatureEnabled: () => false, + }, + } as unknown as typeof window.EmberBridge; + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders the Ember editor when the flag is absent', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({})); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders the Ember editor when the config query fails', () => { + mockUseBrowseConfig.mockReturnValue(configResult({ isError: true })); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + // Ember present but its Labs settings still loading: React must not claim + // the route yet, or the two implementations could briefly split-brain. + it('renders nothing while Ember reports the flag as still loading', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: true })); + window.EmberBridge = { + state: { + isFeatureEnabled: () => null, + }, + } as unknown as typeof window.EmberBridge; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + // Ember's synchronously exposed feature state is the ownership authority, + // even over a config query that disagrees. + it('renders the React editor when Ember reports the flag on', async () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: false })); + window.EmberBridge = { + state: { + isFeatureEnabled: () => true, + }, + } as unknown as typeof window.EmberBridge; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('react-editor')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); + + it('renders nothing while config is loading', () => { + mockUseBrowseConfig.mockReturnValue(configResult({ isLoading: true })); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the React editor while the flag is on', async () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: true })); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('react-editor')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/editor-gate.tsx b/apps/admin/src/editor-gate.tsx new file mode 100644 index 00000000000..62738db0817 --- /dev/null +++ b/apps/admin/src/editor-gate.tsx @@ -0,0 +1,17 @@ +import { FlagGatedRoute } from './flag-gated-route'; +import { lazy } from 'react'; +import { lazyEditorScreen } from './editor/api'; + +/** + * Serves `/editor/*` — new (`:type`) and edit (`:type/:postId`) — from the + * React editor screen when the `editorReact` Labs flag is on, and from Ember + * otherwise. The gating semantics (loading, error, and flag branching) live + * in FlagGatedRoute. + */ +const EditorReact = lazy(lazyEditorScreen); + +export function EditorGate() { + return ; +} + +export default EditorGate; diff --git a/apps/admin/src/editor/api.ts b/apps/admin/src/editor/api.ts new file mode 100644 index 00000000000..fe4fcf128c6 --- /dev/null +++ b/apps/admin/src/editor/api.ts @@ -0,0 +1,9 @@ +/** + * Public surface of the editor domain, consumed by the admin shell + * (apps/admin/src/routes.tsx via the editor gate). Everything else in this + * domain is internal. + */ + +// Lazy entry, not a component re-export: the shell mounts this behind +// `lazy()`, so a static re-export would pull the chunk into the shell bundle. +export const lazyEditorScreen = () => import('./editor-screen'); diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx new file mode 100644 index 00000000000..34571c94ef6 --- /dev/null +++ b/apps/admin/src/editor/editor-screen.tsx @@ -0,0 +1,33 @@ +import { AdminLink } from '@/shared/admin-link'; +import { useParams } from '@tryghost/admin-x-framework'; +import { Button } from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; + +/** + * Placeholder for the React editor, served behind the `editorReact` Labs + * flag. It only proves the EditorGate cutover seam end to end; the editor + * itself still lives in Ember while the flag is off. + */ +export default function EditorScreen() { + const editorPath = useParams()['*']; + const isPage = editorPath?.split('/')[0] === 'page'; + const listPath = isPage ? '/pages' : '/posts'; + const listLabel = isPage ? 'Back to pages' : 'Back to posts'; + + return ( + + + The React editor is under construction. Turn off the “React editor” flag in Labs to use the + editor. + + + + ); +} diff --git a/apps/admin/src/editor/editor.acceptance.test.tsx b/apps/admin/src/editor/editor.acceptance.test.tsx new file mode 100644 index 00000000000..b2c6cefa0a3 --- /dev/null +++ b/apps/admin/src/editor/editor.acceptance.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; + +import { renderAdminApp } from '@test-utils/acceptance'; + +const FLAG_ON = { labs: { editorReact: true } }; +const FLAG_OFF = { labs: { editorReact: false } }; + +/** + * Proves the `editorReact` flag swap end-to-end in the real admin app: the + * React placeholder appears only when the flag is on, and the Ember side of + * the URL is delegated to otherwise. + * + * There is no Ember app in this harness, so "Ember serves it" shows up as the + * React placeholder being absent rather than as an Ember editor being present + * — the Ember half of the handshake (the lexical-editor route aborting its + * transition) is covered in + * apps/ember-admin/tests/acceptance/editor-react-flag-test.js. + */ +describe('Editor flag', () => { + const placeholder = () => page.getByTestId('editor-react-placeholder'); + + it('renders the React placeholder when the flag is on', async () => { + await renderAdminApp('/editor/post/abc123', FLAG_ON); + + await expect.element(placeholder()).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Back to posts' })) + .toHaveAttribute('href', '#/posts'); + }); + + it('serves a new-post URL too', async () => { + await renderAdminApp('/editor/post', FLAG_ON); + + await expect.element(placeholder()).toBeVisible(); + }); + + it('returns page editors to the pages list', async () => { + await renderAdminApp('/editor/page/abc123', FLAG_ON); + + await expect.element(placeholder()).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Back to pages' })) + .toHaveAttribute('href', '#/pages'); + }); + + it('defers to Ember when the flag is off', async () => { + await renderAdminApp('/editor/post/abc123', FLAG_OFF); + + await expect(placeholder()).toHaveCount(0); + }); + + it('defers to Ember when the flag is absent entirely', async () => { + await renderAdminApp('/editor/post/abc123'); + + await expect(placeholder()).toHaveCount(0); + }); +}); diff --git a/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx b/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx index 8e1bd8a5cba..2c4cca1bb79 100644 --- a/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx +++ b/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx @@ -39,6 +39,15 @@ describe('Editor chrome', () => { await expect(sidebar()).toHaveCount(0); }); + // The decision lives on the route handle, so it must hold on both sides of + // the `editorReact` gate — here the React placeholder serves the route. + it('hides it with editorReact on', async () => { + await renderAdminApp('/editor/post/abc123', { labs: { editorReact: true } }); + + await expect.element(page.getByTestId('editor-react-placeholder')).toBeVisible(); + await expect(sidebar()).toHaveCount(0); + }); + // ...and still shows it everywhere else, or this would be a worse bug than // the one it fixes. it('leaves the sidebar alone on the posts list', async () => { diff --git a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx index ae7635ebe2c..1e8134521ff 100644 --- a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx +++ b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx @@ -35,6 +35,7 @@ import { formatNumber, } from '@tryghost/shade/utils'; import { useAnalyticsData } from '@/shared/analytics/use-analytics-data'; +import { useIsEmberOwnedRoute } from '@/routes'; import { usePostAnalytics } from '@/posts/analytics/providers/post-analytics-context'; import { getSiteTimezone } from '@tryghost/admin-x-framework/utils/get-site-timezone'; import { giftAccessLabel } from '@/posts/analytics/utils/gift-link'; @@ -72,6 +73,9 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c const { settings, site, statsConfig } = useAnalyticsData(); const { post, isPostLoading, postId } = usePostAnalytics(); const canManageGiftLink = useCanManageGiftLink(post); + const editorPath = `/editor/post/${postId}`; + // Whether the editor needs a hash navigation depends on the `editorReact` flag. + const editorIsEmberOwned = useIsEmberOwnedRoute(editorPath); const siteTimezone = getSiteTimezone(settings); @@ -238,7 +242,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c { - navigate(`/editor/post/${postId}`, { crossApp: true }); + navigate(editorPath, { crossApp: editorIsEmberOwned }); }} > diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 2a33a8c0fdc..b683f07c076 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -18,6 +18,7 @@ import MyProfileRedirect from './my-profile-redirect'; import { EmberFallback, ForceUpgradeGuard } from './ember-bridge'; import HomeRedirect from './home-redirect'; import { EmberListWithGiftLinks } from './gift-link-modal-host'; +import { EditorGate } from './editor-gate'; import { PagesListGate, PostsListGate } from './posts-list-gate'; import { TagDetailGate } from './tag-detail-gate'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; @@ -50,35 +51,16 @@ const EMBER_ROUTES: string[] = [ '/pro/*', '/posts/analytics/:postId/debug', '/restore', - '/editor/*', '/migrate/*', '/members-activity', ]; const emberFallbackHandle = { allowInForceUpgrade: true } satisfies AdminRouteHandle; -/** - * Ember routes that hide the nav sidebar. - * - * The editor is a focused writing surface and has always hidden it. Ember - * arranges that by setting `ui.isFullScreen` when the editor route *activates* — - * but with `postsListReact` on, the posts route aborts its transition, so the - * editor route never deactivates, and a second visit is a model change on an - * already-active route where `activate()` does not run again. The sidebar came - * back from the second post onwards. - * - * Deciding it from the route makes React the authority and removes the - * cross-implementation handshake, which had already caused the mirror-image bug - * (the sidebar going *missing* on returning from the editor). - */ -const EMBER_ROUTES_HIDING_SIDEBAR = new Set(['/editor/*']); - const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map((path) => ({ path, Component: EmberFallback, - handle: EMBER_ROUTES_HIDING_SIDEBAR.has(path) - ? ({ ...emberFallbackHandle, hideAdminSidebar: true } satisfies AdminRouteHandle) - : emberFallbackHandle, + handle: emberFallbackHandle, })); const appRoutes: RouteObject[] = [ @@ -194,6 +176,22 @@ const appRoutes: RouteObject[] = [ // on both sides of the flag. { path: '/posts', Component: PostsListGate, handle: emberFallbackHandle }, { path: '/pages', Component: PagesListGate, handle: emberFallbackHandle }, + { + // Served by React or Ember depending on the `editorReact` Labs flag. + // + // The editor is a focused writing surface and has always hidden the nav + // sidebar. Ember arranges that by setting `ui.isFullScreen` when the + // editor route *activates* — but with `postsListReact` on, the posts + // route aborts its transition, so the editor route never deactivates, + // and a second visit is a model change on an already-active route where + // `activate()` does not run again. The sidebar came back from the second + // post onwards. Deciding it from the route handle makes React the + // authority, removes the cross-implementation handshake, and applies to + // both sides of the flag. + path: '/editor/*', + Component: EditorGate, + handle: { ...emberFallbackHandle, hideAdminSidebar: true } satisfies AdminRouteHandle, + }, // Ember-handled routes ...emberFallbackRoutes, { @@ -228,6 +226,7 @@ const EMBER_ROUTE_COMPONENTS = new Set([EmberFallback, EmberListWithGif export function useIsEmberOwnedRoute(pathname: string): boolean { const tagDetailOwner = useFlagGatedRouteOwner('tagDetailsReact'); const postsListOwner = useFlagGatedRouteOwner('postsListReact'); + const editorOwner = useFlagGatedRouteOwner('editorReact'); const leaf = matchRoutes(routes, pathname)?.at(-1)?.route; if (!leaf) { return true; @@ -238,5 +237,8 @@ export function useIsEmberOwnedRoute(pathname: string): boolean { if (leaf.Component === PostsListGate || leaf.Component === PagesListGate) { return postsListOwner !== 'react'; } + if (leaf.Component === EditorGate) { + return editorOwner !== 'react'; + } return EMBER_ROUTE_COMPONENTS.has(leaf.Component); } diff --git a/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx b/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx index 4747a170680..538bc4d527e 100644 --- a/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx +++ b/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx @@ -90,6 +90,32 @@ describe('Advanced settings', () => { ]); }); + it('treats an absent React editor flag as off and allows enabling it', async () => { + fakeSettingsScreens(); + const settingsApi = fakeEditSettings(); + const response = configResponse(); + response.config.enableDeveloperExperiments = true; + await renderAdminApp('/settings/labs', { labs: {}, boot: { browseConfig: { response } } }); + + const section = settingsScreen.section('labs'); + await section.getByRole('button', { name: 'Open' }).click(); + await section.getByRole('tab', { name: 'Private features' }).click(); + const toggle = section.getByRole('switch', { name: 'React editor' }); + await expect.element(toggle).not.toBeChecked(); + await toggle.click(); + await expect(settingsApi).toHaveEditedSettings([ + { + key: 'labs', + value: String( + settingsResponse({ labs: { editorReact: true } }).settings.find((setting) => { + return setting.key === 'labs'; + })!.value, + ), + }, + ]); + await expect.element(toggle).toBeChecked(); + }); + it('saves header and footer code injection', async () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); diff --git a/apps/admin/src/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx index 3e67973300b..86f5e36c550 100644 --- a/apps/admin/src/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -112,6 +112,12 @@ const features: Feature[] = [ 'Renders the posts (/posts) and pages (/pages) list screens from the React app instead of the Ember screens. Gates the migration behind a runtime toggle so we can compare both implementations.', flag: 'postsListReact', }, + { + title: 'React editor', + description: + 'Serves the editor (/editor) from the React app instead of the Ember editor. Gates the migration behind a runtime toggle; the React side is an early placeholder.', + flag: 'editorReact', + }, { title: 'Self-serve archives', description: diff --git a/apps/ember-admin/app/routes/lexical-editor.js b/apps/ember-admin/app/routes/lexical-editor.js index d194bab2a8f..2e7a4311a52 100644 --- a/apps/ember-admin/app/routes/lexical-editor.js +++ b/apps/ember-admin/app/routes/lexical-editor.js @@ -69,6 +69,35 @@ export default AuthenticatedRoute.extend({ classNames: ['editor'], + // React owns /editor/* when the flag is on. Aborting keeps the Ember + // editor subtree unrendered and skips `activate()`, so full-screen state + // is never set for a screen nobody sees. + beforeModel(transition) { + this._super(...arguments); + + // Strictly boolean: a non-boolean labs value must not hand the route + // to React. + if (this.feature.editorReact !== true) { + return; + } + + transition.abort(); + const reactRouteUrl = this._reactRouteUrl(transition); + + // Ember and React share window.location.hash, and an aborted + // transition never reaches updateURL. A URL intent (cold load, hash + // change, React-driven navigation) already has the browser URL + // pointing here, so React renders and there is nothing to do. A + // named intent (post list title links, Cmd-K search results, the + // post-success modal's revert-to-draft) has no URL yet — without + // writing one the click is a silent no-op. + if (!transition.intent?.url) { + this._navigateToReactRoute(reactRouteUrl); + } + + this._parkOnReactFallback(reactRouteUrl); + }, + activate() { this._super(...arguments); this.ui.set('isFullScreen', true); @@ -124,6 +153,70 @@ export default AuthenticatedRoute.extend({ }; }, + // Built by hand rather than with `router.urlFor`, whose output depends + // on the configured location — it returns `/ghost/editor/...` under the + // `none` location used in tests but `#/editor/...` under `trailing-hash` + // in the app. Unlike the list routes the editor has dynamic segments, so + // the path comes from the target route's own params. Every Ember-initiated + // transition into the editor passes string params, so `transition.to` has + // them serialized already. + _reactRouteUrl(transition) { + const {name, params} = transition.to ?? {}; + + if (name === 'lexical-editor.edit' && params?.type && params?.post_id) { + return `/editor/${params.type}/${params.post_id}`; + } + if (name === 'lexical-editor.new' && params?.type) { + return `/editor/${params.type}`; + } + + return '/editor'; + }, + + // Aborting stops Ember rendering this screen, but it also leaves the + // router believing it is still on the route we came from. That desync is + // only invisible until you navigate back to the very same URL: Ember + // compares it against the route it thinks it is on, finds no difference, + // and runs no transition at all. Park on `react-fallback` — the empty + // catch-all Ember already uses for URLs React owns — to keep the + // router's state honest. The fallback must use the real editor path: + // parking on `lexical-editor` briefly sends React to an unknown URL, and + // restoring the hash with replaceState does not notify React Router. That + // leaves the browser showing React's 404 until the next reload. + // See PostsRoute#_parkOnReactFallback for the full rationale (replace + // semantics, the parked-path guard, and URL restoration). + _parkOnReactFallback(reactRouteUrl) { + const fallbackPath = reactRouteUrl.replace(/^\//, ''); + const parkedPath = this.router.currentRouteName === 'react-fallback' + ? this.router.currentRoute?.params?.path + : null; + + if (parkedPath === fallbackPath) { + return; + } + + const url = window.location.hash; + const state = window.history.state; + + this.router.replaceWith('react-fallback', fallbackPath) + .finally(() => this._restoreUrl(url, state)); + }, + + // Parking writes the fallback route's own path, so the captured URL goes + // back afterwards. `replaceState`: no history entry, and no `hashchange` + // to re-enter routing. The captured history state goes back too — + // react-router keeps `{usr, key, idx}` there and a `null` state breaks + // its back/forward index and useBlocker. + _restoreUrl(url, state) { + window.history.replaceState(state, '', url); + }, + + // Seam so tests can assert the navigation without a real hash location — + // Ember acceptance tests run with `location: 'none'`. + _navigateToReactRoute(url) { + window.location.hash = url; + }, + _blurAndScheduleAction(func) { let selectedElement = $(document.activeElement); diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index b009d885eea..95c7e32b29a 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -85,6 +85,7 @@ export default class FeatureService extends Service { @feature('automations') automations; @feature('csvContentImporter') csvContentImporter; @feature('postsListReact') postsListReact; + @feature('editorReact') editorReact; _user = null; @computed('settings.labs') diff --git a/apps/ember-admin/tests/acceptance/editor-react-flag-test.js b/apps/ember-admin/tests/acceptance/editor-react-flag-test.js new file mode 100644 index 00000000000..6a6fe6db0f5 --- /dev/null +++ b/apps/ember-admin/tests/acceptance/editor-react-flag-test.js @@ -0,0 +1,129 @@ +import sinon from 'sinon'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {authenticateSession} from 'ember-simple-auth/test-support'; +import {enableLabsFlag} from '../helpers/labs-flag'; +import {expect} from 'chai'; +import {find, settled, visit} from '@ember/test-helpers'; +import {setupApplicationTest} from 'ember-mocha'; +import {setupMirage} from 'ember-cli-mirage/test-support'; +import {titleSelector} from '../helpers/editor'; + +// The `editorReact` flag hands /editor/* to the React app. Ember's side of +// that handshake is the lexical-editor route's beforeModel: it aborts so the +// Ember editor stays unrendered, and drives window.location.hash so +// navigations Ember itself starts (post list title links, Cmd-K search, the +// post-success modal) still land somewhere — an aborted transition never +// reaches updateURL, and the two apps share the hash. + +// `visit()` rejects with TransitionAborted whenever the route aborts, which is +// the whole point of the flag being on. Swallow only that rejection so the +// assertions below can run; anything else still fails the test. +async function visitExpectingAbort(url) { + try { + await visit(url); + } catch (error) { + if (error?.message !== 'TransitionAborted' && error?.name !== 'TransitionAborted') { + throw error; + } + } + await settled(); +} + +describe('Acceptance: editor React flag', function () { + let hooks = setupApplicationTest(); + setupMirage(hooks); + + beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + + let role = this.server.create('role', {name: 'Administrator'}); + let user = this.server.create('user', {roles: [role]}); + this.server.create('post', {authors: [user]}); + + return await authenticateSession(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('when the flag is off', function () { + it('renders the Ember editor', async function () { + await visit('/editor/post/1'); + + expect(find(titleSelector), 'Ember editor title input').to.exist; + }); + }); + + describe('when the flag is on', function () { + beforeEach(function () { + enableLabsFlag(this.server, 'editorReact'); + }); + + it('does not render the Ember editor', async function () { + await visitExpectingAbort('/editor/post/1'); + + expect(find(titleSelector), 'Ember editor title input').to.not.exist; + }); + + it('does not render the Ember editor for a new post', async function () { + await visitExpectingAbort('/editor/post'); + + expect(find(titleSelector), 'Ember editor title input').to.not.exist; + }); + + // The regression this guards: post list title links, Cmd-K search + // results, and the post-success modal's revert-to-draft all + // transition by route name. Without supplying a URL they are silent + // no-ops and the user is stranded on the previous screen. + it('navigates React when Ember initiates an edit transition', async function () { + const route = this.owner.lookup('route:lexical-editor'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('lexical-editor.edit', 'post', '1'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/editor/post/1'); + }); + + it('navigates React when Ember initiates a new-post transition', async function () { + const route = this.owner.lookup('route:lexical-editor'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('lexical-editor.new', 'post'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/editor/post'); + }); + + // A URL that already points at the editor must be left exactly as it + // is — React is already rendering it. + it('does not rewrite a URL-initiated navigation', async function () { + const route = this.owner.lookup('route:lexical-editor'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/editor/post/1'); + + expect(navigate.called, '_navigateToReactRoute called').to.be.false; + }); + + // Aborting alone leaves the router still reporting the route it came + // from, so returning to that same URL later would be a no-op + // transition that renders nothing. Parking on the catch-all at the + // actual editor path keeps both routers truthful. Using the Ember + // route name here sent React to its 404 until a reload. + it('parks the router on the React fallback at the editor path', async function () { + const router = this.owner.lookup('service:router'); + + await visitExpectingAbort('/editor/post/1'); + + expect(router.currentRouteName, 'currentRouteName after aborting').to.equal('react-fallback'); + expect(router.currentRoute?.params?.path, 'fallback path after aborting').to.equal('editor/post/1'); + }); + }); +}); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 8c3b27b9c3a..62d0d5fa85d 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -59,6 +59,7 @@ const PRIVATE_FEATURES = [ 'selfServeArchives', 'machinePayments', 'postsListReact', + 'editorReact', ]; module.exports.GA_KEYS = [...GA_FEATURES]; From 45610b17a2c72f291f9c10082c107ccea5a32cc2 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Tue, 1 Sep 2026 21:42:20 +0100 Subject: [PATCH 7/9] Fixed the members page looping when custom field definitions fail to load The definitions hook suppressed the error toast but still let react-query retry the failed request whenever a new consumer mounted. While a retry is in flight the hook reports neither data nor an error, so every consumer saw the definitions disappear and reappear on each mount. On the members page that swing rebuilt the field catalog, rewrote the URL, remounted the filter bar, and kicked off another retry, so a single failed fetch turned into an endless loop. Holding the failure for the life of the cache entry keeps the page in its degraded-but-stable state with one attempt per visit. --- .../shared/member-custom-fields/use-definitions.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/shared/member-custom-fields/use-definitions.ts b/apps/admin/src/shared/member-custom-fields/use-definitions.ts index 7ba446a1a01..de794f81999 100644 --- a/apps/admin/src/shared/member-custom-fields/use-definitions.ts +++ b/apps/admin/src/shared/member-custom-fields/use-definitions.ts @@ -12,11 +12,18 @@ import { * custom fields, the member page shows no custom fields section, a filtered column falls * back to an unnamed header, and the import dialog maps onto Ghost's built-in member * fields alone. None of that is what the publisher came to the screen to do. + * + * A failed fetch is held for the life of the cache entry rather than retried whenever + * another consumer mounts. A retry in flight reports neither data nor an error, so every + * consumer would watch the definitions vanish and return on each mount. On the members + * page that swing rebuilt the field catalog, rewrote the URL, remounted the filter bar and + * retried again, without end. One attempt per visit settles it. */ +const quietlyDegrading = { defaultErrorHandler: false, retryOnMount: false } as const; + export const useCustomFieldDefinitions: typeof useBrowseMemberCustomFields = (options) => - useBrowseMemberCustomFields({ ...options, defaultErrorHandler: false }); + useBrowseMemberCustomFields({ ...options, ...quietlyDegrading }); /** As above, and also lists fields the publisher has archived. */ export const useCustomFieldDefinitionsIncludingArchived: typeof useBrowseMemberCustomFieldsIncludingArchived = - (options) => - useBrowseMemberCustomFieldsIncludingArchived({ ...options, defaultErrorHandler: false }); + (options) => useBrowseMemberCustomFieldsIncludingArchived({ ...options, ...quietlyDegrading }); From df6d65d47c424980a3d5d6a44c9aca9a090cc1a6 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Tue, 1 Sep 2026 21:47:50 +0100 Subject: [PATCH 8/9] Added an acceptance test for adding a members filter against an older Core An Admin deployed ahead of a Core without the custom field definitions endpoint has to keep filtering intact. Adding a filter swaps the filter bar between its two placements, and each placement asks for the definitions; if the failed answer does not hold across that swap, every swap asks again, the answer flips the page's field catalog, and the URL is rewritten in a loop that never settles. The previous commit fixed that loop but nothing pinned it, so this test drives the filter UI against a 404 definitions endpoint and asserts no further definitions requests are made and the route stays put. --- .../members-filtering.acceptance.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/admin/src/members/members-filtering.acceptance.test.tsx b/apps/admin/src/members/members-filtering.acceptance.test.tsx index 996ee06f78f..f7231d3932c 100644 --- a/apps/admin/src/members/members-filtering.acceptance.test.tsx +++ b/apps/admin/src/members/members-filtering.acceptance.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { page } from 'vitest/browser'; import { + currentRoute, + fakeAdminEndpoint, fakeMemberCustomFields, fakeMembers, label, @@ -125,6 +127,42 @@ describe('Members list', () => { ); }); + // The deploy-compatibility rule in apps/admin/README.md: an Admin deployed ahead of a + // Core without the definitions endpoint must keep filtering intact. Adding a filter swaps + // the filter bar between its two placements, and each one asks for the definitions; the + // failed answer has to hold across that, or every swap asks again, the answer flips the + // page's field catalog, and the URL is rewritten in a loop that never settles. + it('adds a filter against a Core without the definitions endpoint', async () => { + const membersApi = fakeMembers(({ filter }) => + filter + ? [member({ name: 'Alice Alpha' })] + : [member({ name: 'Alice Alpha' }), member({ name: 'Bob Beta' })], + ); + // After fakeMembers, which serves an empty definitions list on behalf of specs that never + // mention custom fields: a handler registered later wins. + const definitionsApi = fakeAdminEndpoint( + 'GET', + /^\/members\/metafields\/custom\/(\?|$)/, + { errors: [{ type: 'NotFoundError', message: 'Resource not found error.' }] }, + { status: 404 }, + ); + await renderAdminApp('/members'); + + await expect(membersScreen.memberRows()).toHaveCount(2); + const requestsBeforeFilter = definitionsApi.requests.length; + + await membersScreen.addFilter('Name', 'Alice'); + + await expect(membersApi).toHaveSentFilter(/name:~'Alice'/); + await expect(membersScreen.memberRows()).toHaveCount(1); + // The loop asks again roughly every round trip, so a short pause is enough to catch it. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + expect(definitionsApi.requests.length).toBe(requestsBeforeFilter); + expect(currentRoute()).toMatch(/^\/members\?filter=/); + }); + it('builds a name filter through the filters UI', async () => { const membersApi = fakeMembers(({ filter }) => filter From c57f7a64b0df689c8e0f6a8369f798558dc50f20 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 1 Sep 2026 17:27:01 -0400 Subject: [PATCH 9/9] Changed the Ghost-CLI job to run each scenario on its own Node version (#30440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @acburdine @claude Changed the Ghost-CLI job to run each scenario on its own Node version cbfd782 no ref The job runs two scenarios: installing this build's tarball, and upgrading to it from the newest Ghost on npm. They don't need the same Node version, and once NODE_VERSION moves ahead of what the published release supports they can't share one — Ghost-CLI reads that release's `engines` and refuses, which is correct behaviour and not something the branch can fix. A matrix keeps the job id, so the required-checks aggregator needs no change. The clean-install leg follows NODE_VERSION, since it is what proves this build installs and boots there. The latest-release leg stays on the Node version the published release declares, and moves up once a release ships supporting a newer line. The debug-log artifact is named per scenario, since both legs upload on failure and upload-artifact rejects a duplicate name within a run. --- .github/workflows/ci.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec74bfa9f06..d00c9a93251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1066,16 +1066,29 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_ghost-cli: - name: Ghost-CLI tests + name: Ghost-CLI tests (${{ matrix.scenario }}) needs: [job_setup, job_pack] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # This build, installed from the tarball this run produced. + - scenario: clean-install + node: ${{ needs.job_setup.outputs.node_version }} + # Upgrade from the newest Ghost on npm, which is pinned to the Node + # version whose `engines` that release declared — it can't be raised + # until a release ships supporting the newer line. Move this to + # node_version once the published release supports it. + - scenario: latest-release + node: '22.23.1' steps: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: - node-version: ${{ env.NODE_VERSION }} + node-version: ${{ matrix.node }} - name: Install Ghost-CLI run: npm install -g ghost-cli@latest @@ -1097,10 +1110,11 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ghost-cli-debug-logs + name: ghost-cli-debug-logs-${{ matrix.scenario }} path: /home/runner/.ghost/logs/ - name: Clean Install + if: matrix.scenario == 'clean-install' run: | DIR=$(mktemp -d) ghost install local -d "$DIR" --archive "$(pwd)/ghost.tgz" @@ -1109,6 +1123,7 @@ jobs: ghost stop -d "$DIR" - name: Latest Release + if: matrix.scenario == 'latest-release' # --force skips Ghost-CLI's version comparison between the archive and # the installed release. Without it this step is coupled to how far the # branch has drifted from main: a PR branched before the last release