From 6a43ca06d3c5bf2aa519455b683874b1dfd43e71 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 18 Feb 2026 16:35:09 +0000 Subject: [PATCH 01/24] Replaced `offer_id` guards with `hasActiveOffer` check in retention flow (#26438) ref https://linear.app/ghost/issue/BER-3295 Members with expired offer discounts were blocked from seeing or claiming retention offers because the guards only checked for `offer_id` presence. The new `hasActiveOffer` utility checks `discount_start`/`discount_end`, active trials, and falls back to offer duration lookup for legacy data --- .../controllers/router-controller.js | 11 +- .../repositories/member-repository.js | 14 +- .../members-api/utils/has-active-offer.js | 60 ++++++ .../e2e-api/members/member-offers.test.js | 115 +++++++++++- .../controllers/router-controller.test.js | 73 ++++++-- .../utils/has-active-offer.test.js | 177 ++++++++++++++++++ 6 files changed, 407 insertions(+), 43 deletions(-) create mode 100644 ghost/core/core/server/services/members/members-api/utils/has-active-offer.js create mode 100644 ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js diff --git a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js index 6d37f7793e7..a1c93c5fa04 100644 --- a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js +++ b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js @@ -6,6 +6,7 @@ const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureErr const errors = require('@tryghost/errors'); const {isEmail} = require('@tryghost/validator'); const normalizeEmail = require('../utils/normalize-email'); +const hasActiveOffer = require('../utils/has-active-offer'); const {getInboxLinks} = require('../../../../lib/get-inbox-links'); const {SIGNUP_CONTEXTS} = require('../../../lib/member-signup-contexts'); /** @typedef {import('../../../lib/member-signup-contexts').SignupContext} SignupContext */ @@ -1024,14 +1025,8 @@ module.exports = class RouterController { return sendNoOffersAvailable(); } - // If subscription already has an offer applied (e.g. signup offer), don't show retention offers - if (activeSubscription.get('offer_id')) { - return sendNoOffersAvailable(); - } - - // If subscription is in a trial period (either offer-based or tier-based), don't show retention offers - const trialEndAt = activeSubscription.get('trial_end_at'); - if (trialEndAt && trialEndAt > new Date()) { + // If subscription has an active offer, don't show retention offers + if (await hasActiveOffer(activeSubscription, this._offersAPI)) { return sendNoOffersAvailable(); } diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index f967ecba3d8..6f1432fb9cf 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -9,6 +9,7 @@ const {NotFoundError} = require('@tryghost/errors'); const validator = require('@tryghost/validator'); const crypto = require('crypto'); const addCalendarMonths = require('../utils/add-calendar-months'); +const hasActiveOffer = require('../utils/has-active-offer'); const StartOutboxProcessingEvent = require('../../../outbox/events/start-outbox-processing-event'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../member-welcome-emails/constants'); const messages = { @@ -32,7 +33,6 @@ const messages = { offerAlreadyRedeemed: 'This offer has already been redeemed on this subscription', subscriptionNotActive: 'Cannot apply offer to an inactive subscription', subscriptionHasOffer: 'Subscription already has an offer applied', - subscriptionInTrial: 'Cannot apply offer to a subscription in a trial period', subscriptionCancelling: 'Cannot apply retention offer to a subscription that is already cancelling' }; @@ -1730,21 +1730,13 @@ module.exports = class MemberRepository { }); } - // Check subscription doesn't already have an offer - if (subscriptionModel.get('offer_id')) { + // Check subscription doesn't already have an active offer + if (await hasActiveOffer(subscriptionModel, this._offersAPI)) { throw new errors.BadRequestError({ message: tpl(messages.subscriptionHasOffer) }); } - // Check subscription is not in a trial period - const trialEndAt = subscriptionModel.get('trial_end_at'); - if (trialEndAt && trialEndAt > new Date()) { - throw new errors.BadRequestError({ - message: tpl(messages.subscriptionInTrial) - }); - } - // Get tier and cadence from subscription const stripePrice = subscriptionModel.related('stripePrice'); const stripeProduct = stripePrice.related('stripeProduct'); diff --git a/ghost/core/core/server/services/members/members-api/utils/has-active-offer.js b/ghost/core/core/server/services/members/members-api/utils/has-active-offer.js new file mode 100644 index 00000000000..a0de4f4680c --- /dev/null +++ b/ghost/core/core/server/services/members/members-api/utils/has-active-offer.js @@ -0,0 +1,60 @@ +/** + * Determines if a subscription currently has an active offer. + * Uses discount_start/discount_end (synced from Stripe) when available, + * falls back to offer duration lookup for legacy data (pre-6.16). + * + * @param {object} subscriptionModel - Bookshelf model for members_stripe_customers_subscriptions + * @param {object} offersAPI - OffersAPI instance with getOffer() + * @returns {Promise} + */ +module.exports = async function hasActiveOffer(subscriptionModel, offersAPI) { + const discountStart = subscriptionModel.get('discount_start'); + const discountEnd = subscriptionModel.get('discount_end'); + const trialEndAt = subscriptionModel.get('trial_end_at'); + + // Check for active Stripe discount (post-6.16 data) + if (discountStart) { + return !discountEnd || new Date(discountEnd) > new Date(); + } + + // Check for active trial (trial/free_months offers) + if (trialEndAt && new Date(trialEndAt) > new Date()) { + return true; + } + + // Fallback: legacy data where discount_start was never populated + const offerId = subscriptionModel.get('offer_id'); + if (!offerId) { + return false; + } + + // Look up the offer to determine if it's still active based on duration + try { + const offer = await offersAPI.getOffer({id: offerId}); + if (!offer) { + return false; + } + + if (offer.duration === 'forever') { + return true; + } + + if (offer.duration === 'once') { + return false; // once = already applied and expired + } + + if (offer.duration === 'repeating' && offer.duration_in_months > 0) { + const startDate = new Date(subscriptionModel.get('start_date')); + const end = new Date(startDate); + + end.setUTCMonth(end.getUTCMonth() + offer.duration_in_months); + + return new Date() < end; + } + } catch (e) { + // If we can't look up the offer, err on the side of blocking + return true; + } + + return false; +}; diff --git a/ghost/core/test/e2e-api/members/member-offers.test.js b/ghost/core/test/e2e-api/members/member-offers.test.js index f18419d8c5a..509f5da4ca2 100644 --- a/ghost/core/test/e2e-api/members/member-offers.test.js +++ b/ghost/core/test/e2e-api/members/member-offers.test.js @@ -238,7 +238,7 @@ describe('Members API - Member Offers', function () { } }); - it('returns empty offers if subscription already has an offer applied', async function () { + it('returns empty offers if subscription has an active discount', async function () { // Get the paid member's subscription const member = await models.Member.findOne({email: 'paid@test.com'}, { withRelated: [ @@ -273,7 +273,7 @@ describe('Members API - Member Offers', function () { redemption_type: 'retention' }); - // Create a signup offer and apply it to the subscription + // Create a signup offer and apply it with an active discount window const signupOffer = await models.Offer.add({ name: 'Signup Offer', code: 'signup-offer', @@ -289,8 +289,15 @@ describe('Members API - Member Offers', function () { redemption_type: 'signup' }); - // Set the offer_id on the subscription - await subscription.save({offer_id: signupOffer.id}, {patch: true}); + // CASE: Set offer_id AND discount_start/end to simulate an active discount + const now = new Date(); + const discountStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + const discountEnd = new Date(now.getTime() + 23 * 24 * 60 * 60 * 1000); // 23 days from now + await subscription.save({ + offer_id: signupOffer.id, + discount_start: discountStart, + discount_end: discountEnd + }, {patch: true}); try { const token = await getIdentityToken('paid@test.com'); @@ -300,16 +307,96 @@ describe('Members API - Member Offers', function () { .body({identity: token}) .expectStatus(200); - // Should not return retention offers if subscription already has an offer + // Should not return retention offers if subscription has an active discount assert.deepEqual(body, {offers: []}); } finally { // Clean up - await subscription.save({offer_id: null}, {patch: true}); + await subscription.save({offer_id: null, discount_start: null, discount_end: null}, {patch: true}); await models.Offer.destroy({id: offer.id}); await models.Offer.destroy({id: signupOffer.id}); } }); + it('returns retention offers if subscription has an expired discount', async function () { + // Get the paid member's subscription + const member = await models.Member.findOne({email: 'paid@test.com'}, { + withRelated: [ + 'stripeSubscriptions', + 'stripeSubscriptions.stripePrice', + 'stripeSubscriptions.stripePrice.stripeProduct', + 'stripeSubscriptions.stripePrice.stripeProduct.product' + ] + }); + + const subscription = member.related('stripeSubscriptions').models[0]; + const stripePrice = subscription.related('stripePrice'); + const stripeProduct = stripePrice.related('stripeProduct'); + const product = stripeProduct.related('product'); + + const tierId = product.id; + const cadence = stripePrice.get('interval'); + + // Create a retention offer + const retentionOffer = await models.Offer.add({ + name: 'Test Retention Expired', + code: 'test-retention-expired', + portal_title: '20% off', + portal_description: 'Stay with us!', + discount_type: 'percent', + discount_amount: 20, + duration: 'once', + interval: cadence, + product_id: null, + currency: null, + active: true, + redemption_type: 'retention' + }); + + // Create a signup offer with an expired discount window + const signupOffer = await models.Offer.add({ + name: 'Expired Signup Offer', + code: 'expired-signup-offer', + portal_title: '10% off', + portal_description: 'Welcome!', + discount_type: 'percent', + discount_amount: 10, + duration: 'once', + interval: cadence, + product_id: tierId, + currency: null, + active: true, + redemption_type: 'signup' + }); + + // CASE: Set offer_id with expired discount_start/end + const now = new Date(); + const discountStart = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); // 60 days ago + const discountEnd = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + await subscription.save({ + offer_id: signupOffer.id, + discount_start: discountStart, + discount_end: discountEnd + }, {patch: true}); + + try { + const token = await getIdentityToken('paid@test.com'); + + const {body} = await membersAgent + .post('/api/member/offers') + .body({identity: token}) + .expectStatus(200); + + // Should return retention offers since the old discount has expired + assert.equal(body.offers.length, 1); + assert.equal(body.offers[0].id, retentionOffer.id); + } finally { + // Clean up + await subscription.save({offer_id: null, discount_start: null, discount_end: null}, {patch: true}); + await models.Offer.destroy({id: retentionOffer.id}); + await models.Offer.destroy({id: signupOffer.id}); + } + }); + it('returns empty offers if subscription is already set to cancel', async function () { const {subscription} = await getMemberSubscription('paid@test.com'); const stripePrice = subscription.related('stripePrice'); @@ -400,7 +487,7 @@ describe('Members API - Member Offers', function () { .expectStatus(404); }); - it('returns 400 when subscription already has an offer', async function () { + it('returns 400 when subscription has an active discount', async function () { const {subscription} = await getMemberSubscription('paid@test.com'); const stripePrice = subscription.related('stripePrice'); const stripeProduct = stripePrice.related('stripeProduct'); @@ -426,7 +513,7 @@ describe('Members API - Member Offers', function () { redemption_type: 'retention' }); - // Create another offer and apply it to subscription + // Create another offer and apply it with an active discount window const existingOffer = await models.Offer.add({ name: 'Existing Offer', code: 'existing-offer', @@ -442,7 +529,15 @@ describe('Members API - Member Offers', function () { redemption_type: 'signup' }); - await subscription.save({offer_id: existingOffer.id}, {patch: true}); + // CASE: Set offer_id AND discount_start/end to simulate an active discount + const now = new Date(); + const discountStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const discountEnd = new Date(now.getTime() + 23 * 24 * 60 * 60 * 1000); + await subscription.save({ + offer_id: existingOffer.id, + discount_start: discountStart, + discount_end: discountEnd + }, {patch: true}); try { const token = await getIdentityToken('paid@test.com'); @@ -452,7 +547,7 @@ describe('Members API - Member Offers', function () { .body({identity: token, offer_id: retentionOffer.id}) .expectStatus(400); } finally { - await subscription.save({offer_id: null}, {patch: true}); + await subscription.save({offer_id: null, discount_start: null, discount_end: null}, {patch: true}); await models.Offer.destroy({id: retentionOffer.id}); await models.Offer.destroy({id: existingOffer.id}); } diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js index 1b52cbd41b3..77136191e9c 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js @@ -1513,20 +1513,19 @@ describe('RouterController', function () { let res; let responseData; - function createMockSubscription({id = 'sub_123', status = 'active', offerId = null, trialEndAt = null} = {}) { + function createMockSubscription({id = 'sub_123', status = 'active', offerId = null, trialEndAt = null, discountStart = null, discountEnd = null, cancelAtPeriodEnd = false} = {}) { return { id, get: sinon.stub().callsFake((key) => { - if (key === 'status') { - return status; - } - if (key === 'offer_id') { - return offerId; - } - if (key === 'trial_end_at') { - return trialEndAt; - } - return null; + const values = { + status, + offer_id: offerId, + trial_end_at: trialEndAt, + discount_start: discountStart, + discount_end: discountEnd, + cancel_at_period_end: cancelAtPeriodEnd + }; + return values[key] ?? null; }), related: sinon.stub().withArgs('stripePrice').returns(mockStripePrice) }; @@ -1555,7 +1554,8 @@ describe('RouterController', function () { beforeEach(function () { mockOffersAPI = { - listOffersAvailableToSubscription: sinon.stub().resolves([]) + listOffersAvailableToSubscription: sinon.stub().resolves([]), + getOffer: sinon.stub().resolves(null) }; tokenService = { @@ -1603,9 +1603,13 @@ describe('RouterController', function () { assert(offersAPIWithError.listOffersAvailableToSubscription.calledOnce); }); - it('returns empty offers when subscription already has an offer applied', async function () { + it('returns empty offers when subscription has an active discount', async function () { const routerController = createRouterController({ - subscriptions: createMockSubscription({offerId: 'existing_offer_123'}) + subscriptions: createMockSubscription({ + offerId: 'existing_offer_123', + discountStart: new Date('2026-01-01') + // discountEnd: null means forever + }) }); await routerController.getMemberOffers({ @@ -1616,6 +1620,47 @@ describe('RouterController', function () { assert(mockOffersAPI.listOffersAvailableToSubscription.notCalled); }); + it('returns offers when subscription has an expired discount', async function () { + const mockOffer = {id: 'retention_offer', name: 'Stay with us'}; + mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); + + const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const routerController = createRouterController({ + subscriptions: createMockSubscription({ + offerId: 'expired_offer_123', + discountStart: new Date('2025-01-01'), + discountEnd: pastDate + }) + }); + + await routerController.getMemberOffers({ + body: {identity: 'valid-token'} + }, res); + + assert.deepEqual(responseData, {offers: [mockOffer]}); + assert(mockOffersAPI.listOffersAvailableToSubscription.calledOnce); + }); + + it('returns offers when subscription has expired once offer (legacy data, no discount_start)', async function () { + const mockOffer = {id: 'retention_offer', name: 'Stay with us'}; + mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); + mockOffersAPI.getOffer.resolves({duration: 'once'}); + + const routerController = createRouterController({ + subscriptions: createMockSubscription({ + offerId: 'expired_once_offer' + // No discountStart — legacy data + }) + }); + + await routerController.getMemberOffers({ + body: {identity: 'valid-token'} + }, res); + + assert.deepEqual(responseData, {offers: [mockOffer]}); + assert(mockOffersAPI.listOffersAvailableToSubscription.calledOnce); + }); + it('returns empty offers when member has multiple active subscriptions', async function () { const routerController = createRouterController({ subscriptions: [ diff --git a/ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js b/ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js new file mode 100644 index 00000000000..6edecd74e9d --- /dev/null +++ b/ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js @@ -0,0 +1,177 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const hasActiveOffer = require('../../../../../../../core/server/services/members/members-api/utils/has-active-offer'); + +describe('hasActiveOffer', function () { + afterEach(function () { + sinon.restore(); + }); + + function createSubscriptionModel({discountStart = null, discountEnd = null, trialEndAt = null, offerId = null, startDate = null} = {}) { + return { + get: sinon.stub().callsFake((key) => { + const values = { + discount_start: discountStart, + discount_end: discountEnd, + trial_end_at: trialEndAt, + offer_id: offerId, + start_date: startDate + }; + return values[key] ?? null; + }) + }; + } + + function createOffersAPI(offer = null) { + return { + getOffer: sinon.stub().resolves(offer) + }; + } + + // Post-6.16 data: discount_start is populated + + it('returns true when discount_start is set and discount_end is null (forever discount)', async function () { + const model = createSubscriptionModel({ + discountStart: new Date('2026-01-01') + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, true); + }); + + it('returns true when discount_start is set and discount_end is in the future', async function () { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + discountStart: new Date('2026-01-01'), + discountEnd: futureDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, true); + }); + + it('returns false when discount_start is set and discount_end is in the past', async function () { + const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + discountStart: new Date('2025-01-01'), + discountEnd: pastDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, false); + }); + + // Trial-based offers (free_months, trial) + + it('returns true when trial_end_at is in the future', async function () { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + trialEndAt: futureDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, true); + }); + + it('returns false when trial_end_at is in the past', async function () { + const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + trialEndAt: pastDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, false); + }); + + // Legacy data: discount_start is null, fall back to offer duration lookup + + it('returns false when no discount_start, no trial, and no offer_id', async function () { + const model = createSubscriptionModel(); + const result = await hasActiveOffer(model, createOffersAPI()); + assert.equal(result, false); + }); + + it('returns true for a forever offer (legacy data)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = createOffersAPI({duration: 'forever'}); + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, true); + }); + + it('returns false for a once offer (legacy data)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = createOffersAPI({duration: 'once'}); + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, false); + }); + + it('returns true for a repeating offer still within duration (legacy data)', async function () { + const threeMonthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + offerId: 'offer_123', + startDate: threeMonthsAgo + }); + const offersAPI = createOffersAPI({duration: 'repeating', duration_in_months: 6}); + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, true); + }); + + it('returns false for a repeating offer past its duration (legacy data)', async function () { + const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + offerId: 'offer_123', + startDate: oneYearAgo + }); + const offersAPI = createOffersAPI({duration: 'repeating', duration_in_months: 6}); + + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, false); + }); + + it('returns true when offer lookup throws (errs on the side of blocking)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = { + getOffer: sinon.stub().rejects(new Error('Database error')) + }; + + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, true); + }); + + it('returns false when offer lookup returns null (offer deleted)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = createOffersAPI(null); + + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, false); + }); + + // Priority: discount_start takes precedence over trial and legacy fallback + + it('discount_start takes precedence over trial_end_at', async function () { + const pastDiscountEnd = new Date(Date.now() - 1000); + const futureTrialEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + discountStart: new Date('2025-01-01'), + discountEnd: pastDiscountEnd, + trialEndAt: futureTrialEnd + }); + + // discount_start is set, so discount_end in the past means expired + // even though trial_end_at is in the future + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, false); + }); +}); From 476e468b16fb50607fa434a156e3c6235e555727 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 18 Feb 2026 11:29:29 -0600 Subject: [PATCH 02/24] Updated stripe cleanup job logic with dry run (#26472) ref https://ghost.slack.com/archives/C02G9E68C/p1771428472410089 - improved looping logic and resilience to errors - added dry run option --- .../cleanup-stripe-test-accounts.yml | 87 +++++++++++++++++-- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cleanup-stripe-test-accounts.yml b/.github/workflows/cleanup-stripe-test-accounts.yml index 3b022645830..2f50a286125 100644 --- a/.github/workflows/cleanup-stripe-test-accounts.yml +++ b/.github/workflows/cleanup-stripe-test-accounts.yml @@ -5,6 +5,15 @@ on: # Run twice daily at 3 AM and 3 PM UTC - cron: '0 3,15 * * *' workflow_dispatch: # Allow manual trigger + inputs: + dry_run: + description: "Preview deletions without deleting accounts" + required: false + default: "true" + type: choice + options: + - "true" + - "false" jobs: cleanup: @@ -15,12 +24,19 @@ jobs: - name: Cleanup old Stripe Connect test accounts env: STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} run: | set -euo pipefail if [ -z "${STRIPE_SECRET_KEY:-}" ]; then echo "STRIPE_SECRET_KEY is not set" exit 1 fi + + DRY_RUN_NORMALIZED=$(echo "${DRY_RUN:-false}" | tr '[:upper:]' '[:lower:]') + if [ "$DRY_RUN_NORMALIZED" = "true" ]; then + echo "Running in dry-run mode (no accounts will be deleted)" + fi + # Delete test accounts older than 24 hours # Accounts are named like: test-{runId}-{parallelIndex}@example.com @@ -28,6 +44,9 @@ jobs: MAX_AGE_SECONDS=$((MAX_AGE_HOURS * 60 * 60)) NOW=$(date +%s) DELETED=0 + WOULD_DELETE=0 + FAILED_DELETES=0 + DELETE_QUEUE="" echo "Fetching Stripe Connect test accounts..." @@ -37,9 +56,15 @@ jobs: while [ "$HAS_MORE" = "true" ]; do if [ -z "$STARTING_AFTER" ]; then - RESPONSE=$(curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100") + if ! RESPONSE=$(curl -sS -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100"); then + echo "Failed to fetch Stripe accounts" + exit 1 + fi else - RESPONSE=$(curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100&starting_after=$STARTING_AFTER") + if ! RESPONSE=$(curl -sS -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100&starting_after=$STARTING_AFTER"); then + echo "Failed to fetch Stripe accounts (starting_after=$STARTING_AFTER)" + exit 1 + fi fi # Check for API errors @@ -52,6 +77,7 @@ jobs: # Extract account data - handle null/missing data array gracefully ACCOUNTS=$(echo "$RESPONSE" | jq -c '.data // [] | .[]') HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more // false') + PAGE_LAST_ID=$(echo "$RESPONSE" | jq -r '.data[-1].id // empty') while IFS= read -r account; do [ -z "$account" ] && continue @@ -60,18 +86,63 @@ jobs: EMAIL=$(echo "$account" | jq -r '.email // ""') CREATED=$(echo "$account" | jq -r '.created') - # Check if this is a test account (matches our naming pattern: test-{runId}-{attempt}-{index}@example.com) + # Check if this is a test account (matches our naming pattern) if [[ "$EMAIL" =~ ^test-.*@example\.com$ ]]; then + if ! [[ "$CREATED" =~ ^[0-9]+$ ]]; then + echo "Skipping $ID ($EMAIL): invalid created timestamp '$CREATED'" + continue + fi + AGE=$((NOW - CREATED)) if [ "$AGE" -gt "$MAX_AGE_SECONDS" ]; then - echo "Deleting $ID ($EMAIL) - age: $((AGE / 3600)) hours" - curl -s -X DELETE -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts/$ID" > /dev/null - DELETED=$((DELETED + 1)) + DELETE_QUEUE="${DELETE_QUEUE}${ID}|${EMAIL}|$((AGE / 3600))"$'\n' fi fi - - STARTING_AFTER="$ID" done <<< "$ACCOUNTS" + + if [ "$HAS_MORE" = "true" ]; then + if [ -z "$PAGE_LAST_ID" ]; then + echo "Pagination indicated more results, but no last account id was returned" + exit 1 + fi + STARTING_AFTER="$PAGE_LAST_ID" + fi done + while IFS='|' read -r ID EMAIL AGE_HOURS; do + [ -z "${ID:-}" ] && continue + + if [ "$DRY_RUN_NORMALIZED" = "true" ]; then + echo "Would delete $ID ($EMAIL) - age: ${AGE_HOURS} hours" + WOULD_DELETE=$((WOULD_DELETE + 1)) + continue + fi + + echo "Deleting $ID ($EMAIL) - age: ${AGE_HOURS} hours" + if ! DELETE_RESPONSE=$(curl -sS -X DELETE -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts/$ID"); then + echo "Failed to delete $ID ($EMAIL): request failed" + FAILED_DELETES=$((FAILED_DELETES + 1)) + continue + fi + + DELETE_ERROR=$(echo "$DELETE_RESPONSE" | jq -r '.error.message // empty') + DELETED_FLAG=$(echo "$DELETE_RESPONSE" | jq -r '.deleted // false') + if [ -n "$DELETE_ERROR" ] || [ "$DELETED_FLAG" != "true" ]; then + echo "Failed to delete $ID ($EMAIL): ${DELETE_ERROR:-unexpected response}" + FAILED_DELETES=$((FAILED_DELETES + 1)) + continue + fi + + DELETED=$((DELETED + 1)) + done <<< "$DELETE_QUEUE" + + if [ "$DRY_RUN_NORMALIZED" = "true" ]; then + echo "Dry run complete. Would delete $WOULD_DELETE old test accounts" + exit 0 + fi + echo "Deleted $DELETED old test accounts" + if [ "$FAILED_DELETES" -gt 0 ]; then + echo "Failed to delete $FAILED_DELETES accounts" + exit 1 + fi From 867ab460b4154688ee0ee32cded987ad69d9119a Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 18 Feb 2026 12:02:22 -0600 Subject: [PATCH 03/24] Bumped Portal to 2.64.5 (#26475) ref https://github.com/TryGhost/Ghost/pull/26449/ This PR was intended to bump Portal, however the CI job that ran to validate the Portal bump ran before the bump happened, leading to a false pass. --- apps/portal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index aca281ed1f0..56a4de7ed5e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.4", + "version": "2.64.5", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", From 25cb0bd943004abc71697a251a17fc1fe82f1f48 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 18 Feb 2026 12:14:56 -0600 Subject: [PATCH 04/24] Fixed flaky comments API test ordering (#26476) ref https://github.com/TryGhost/Ghost/actions/runs/22150423065/job/64040429159 The "Can browse reporters for a comment" and "Can browse comment likes" tests were flaky because they created multiple reports/likes without explicit timestamps. When both were created in the same millisecond, the `order: 'created_at desc'` query returned results in non-deterministic order, causing snapshot mismatches. Added explicit created_at timestamps using db.knex().update() to ensure deterministic ordering, matching the pattern already used in the "Orders reports/likes by created_at desc" tests. --- ghost/core/test/e2e-api/admin/comments.test.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ghost/core/test/e2e-api/admin/comments.test.js b/ghost/core/test/e2e-api/admin/comments.test.js index 8d4b2d0c737..e4b96142c7b 100644 --- a/ghost/core/test/e2e-api/admin/comments.test.js +++ b/ghost/core/test/e2e-api/admin/comments.test.js @@ -1675,14 +1675,16 @@ describe(`Admin Comments API`, function () { html: '

Reported comment

' }); - // Add reports from different members + // Add reports from different members with explicit timestamps to ensure deterministic ordering await models.CommentReport.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 1).id + member_id: fixtureManager.get('members', 1).id, + created_at: new Date('2023-06-01') }); await models.CommentReport.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 2).id + member_id: fixtureManager.get('members', 2).id, + created_at: new Date('2023-01-01') }); await adminApi.get(`/comments/${comment.id}/reports/`) @@ -1798,14 +1800,16 @@ describe(`Admin Comments API`, function () { html: '

Liked comment

' }); - // Add likes from different members + // Add likes from different members with explicit timestamps to ensure deterministic ordering await models.CommentLike.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 1).id + member_id: fixtureManager.get('members', 1).id, + created_at: new Date('2023-06-01') }); await models.CommentLike.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 2).id + member_id: fixtureManager.get('members', 2).id, + created_at: new Date('2023-01-01') }); await adminApi.get(`/comments/${comment.id}/likes/`) From eb2e9aa63809576476d8bfe8547823ffa151d4db Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 12:34:14 -0600 Subject: [PATCH 05/24] Removed Should.js from preview routes tests (#26479) This test-only change should have no user impact. --- .../test/e2e-frontend/preview-routes.test.js | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/ghost/core/test/e2e-frontend/preview-routes.test.js b/ghost/core/test/e2e-frontend/preview-routes.test.js index d3ee8d2751b..8a4e5bf8bdd 100644 --- a/ghost/core/test/e2e-frontend/preview-routes.test.js +++ b/ghost/core/test/e2e-frontend/preview-routes.test.js @@ -4,7 +4,6 @@ // But then again testing real code, rather than mock code, might be more useful... const assert = require('node:assert/strict'); const {assertExists} = require('../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); @@ -22,15 +21,15 @@ function assertCorrectFrontendHeaders(res) { } function assertPaywallRendered(res) { - res.text.should.match(/Before paywall/, 'Content before paywall should be rendered'); - res.text.should.not.match(/After paywall/, 'Content after paywall should not be rendered'); - res.text.should.match(/This post is for/, 'Paywall should be rendered'); + assert.match(res.text, /Before paywall/, 'Content before paywall should be rendered'); + assert.doesNotMatch(res.text, /After paywall/, 'Content after paywall should not be rendered'); + assert.match(res.text, /This post is for/, 'Paywall should be rendered'); } function assertNoPaywallRendered(res) { - res.text.should.match(/Before paywall/, 'Content before paywall should be rendered'); - res.text.should.match(/After paywall/, 'Content after paywall should be rendered'); - res.text.should.not.match(/This post is for/, 'Paywall should not be rendered'); + assert.match(res.text, /Before paywall/, 'Content before paywall should be rendered'); + assert.match(res.text, /After paywall/, 'Content after paywall should be rendered'); + assert.doesNotMatch(res.text, /This post is for/, 'Paywall should not be rendered'); } describe('Frontend Routing: Preview Routes', function () { @@ -74,10 +73,10 @@ describe('Frontend Routing: Preview Routes', function () { assert.equal($('meta[name="description"]').attr('content'), 'meta description for draft post'); // @TODO: use theme from fixtures and don't rely on content/themes/casper - // $('.content .post').length.should.equal(1); - // $('.poweredby').text().should.equal('Proudly published with Ghost'); - // $('body.post-template').length.should.equal(1); - // $('article.post').length.should.equal(1); + // assert.equal($('.content .post').length, 1); + // assert.equal($('.poweredby').text(), 'Proudly published with Ghost'); + // assert.equal($('body.post-template').length, 1); + // assert.equal($('article.post').length, 1); }); }); From 80c74b1c69913b73d07616fdde29b65f5278238d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 12:38:29 -0600 Subject: [PATCH 06/24] Removed Should.js from `{{cancel_link}}` helper tests (#26480) This test-only change should have no user impact. --- .../test/unit/frontend/helpers/cancel-link.test.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ghost/core/test/unit/frontend/helpers/cancel-link.test.js b/ghost/core/test/unit/frontend/helpers/cancel-link.test.js index 2fa9175fe4b..1ff5650e8f7 100644 --- a/ghost/core/test/unit/frontend/helpers/cancel-link.test.js +++ b/ghost/core/test/unit/frontend/helpers/cancel-link.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const hbs = require('../../../../core/frontend/services/theme-engine/engine'); const cancel_link = require('../../../../core/frontend/helpers/cancel_link'); @@ -52,11 +51,11 @@ describe('{{cancel_link}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(defaultLinkClass); + assert.match(rendered.string, defaultLinkClass); assert.match(rendered.string, /data-members-cancel-subscription="sub_cancel"/); - rendered.string.should.match(defaultCancelLinkText); + assert.match(rendered.string, defaultCancelLinkText); - rendered.string.should.match(defaultErrorElementClass); + assert.match(rendered.string, defaultErrorElementClass); }); it('can render continue subscription link', function () { @@ -66,9 +65,9 @@ describe('{{cancel_link}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(defaultLinkClass); + assert.match(rendered.string, defaultLinkClass); assert.match(rendered.string, /data-members-continue-subscription="sub_continue"/); - rendered.string.should.match(defaultContinueLinkText); + assert.match(rendered.string, defaultContinueLinkText); }); it('can render custom link class', function () { From 2246435ad722539a6b79be2c675a74a7c80f126b Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 12:45:51 -0600 Subject: [PATCH 07/24] Removed Should.js from `{{pagination}}` helper tests (#26481) no ref This test-only change should have no user impact. --- .../unit/frontend/helpers/pagination.test.js | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/ghost/core/test/unit/frontend/helpers/pagination.test.js b/ghost/core/test/unit/frontend/helpers/pagination.test.js index 4b678bbb479..7f165746746 100644 --- a/ghost/core/test/unit/frontend/helpers/pagination.test.js +++ b/ghost/core/test/unit/frontend/helpers/pagination.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); -const should = require('should'); const hbs = require('../../../../core/frontend/services/theme-engine/engine'); const configUtils = require('../../../utils/config-utils'); const path = require('path'); @@ -46,11 +45,11 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); // strip out carriage returns and compare. - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); assert.match(rendered.string, /Page 1 of 1/); - rendered.string.should.not.match(newerRegex); - rendered.string.should.not.match(olderRegex); + assert.doesNotMatch(rendered.string, newerRegex); + assert.doesNotMatch(rendered.string, olderRegex); }); it('can render first page of many with older posts link', function () { @@ -59,11 +58,11 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); - rendered.string.should.match(olderRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); + assert.match(rendered.string, olderRegex); assert.match(rendered.string, /Page 1 of 3/); - rendered.string.should.not.match(newerRegex); + assert.doesNotMatch(rendered.string, newerRegex); }); it('can render middle pages of many with older and newer posts link', function () { @@ -72,10 +71,10 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); - rendered.string.should.match(olderRegex); - rendered.string.should.match(newerRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); + assert.match(rendered.string, olderRegex); + assert.match(rendered.string, newerRegex); assert.match(rendered.string, /Page 2 of 3/); }); @@ -85,11 +84,11 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); - rendered.string.should.match(newerRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); + assert.match(rendered.string, newerRegex); assert.match(rendered.string, /Page 3 of 3/); - rendered.string.should.not.match(olderRegex); + assert.doesNotMatch(rendered.string, olderRegex); }); it('validates values', function () { From b5fec755b2d5f37bf34ba2d5a6397b842dbffeeb Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 18 Feb 2026 18:48:35 +0000 Subject: [PATCH 08/24] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20settings=20page=20?= =?UTF-8?q?not=20scrollable=20on=20mobile=20(#26477)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/BER-3350/ closes https://github.com/TryGhost/Ghost/issues/26473 - Fixed the settings page not being scrollable at mobile sizes (below 860px) when the sidebar is hidden - The page wrapper only applied `fixed` positioning and `h-full` at the `tablet` breakpoint, so on smaller screens the content area had no height constraint and `overflow-y-scroll` had no effect - Applied `fixed left-0 top-0 flex h-full` at all sizes so the content scroller works on mobile too --- apps/admin-x-settings/src/main-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/admin-x-settings/src/main-content.tsx b/apps/admin-x-settings/src/main-content.tsx index ec58ad46342..1a3f2209abc 100644 --- a/apps/admin-x-settings/src/main-content.tsx +++ b/apps/admin-x-settings/src/main-content.tsx @@ -14,7 +14,7 @@ const Page: React.FC<{children: ReactNode}> = ({children}) => {
-
+
{children}
; From b74a0b58d041251350e3d48b21aebcf9df44cde1 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 12:53:31 -0600 Subject: [PATCH 09/24] Replaced `.should.startWith()` with `node:assert` (#26483) This test-only change should have no user impact. --- .../scheduling/post-scheduling/post-scheduler.test.js | 3 +-- .../core/test/unit/server/services/stats/content.test.js | 2 +- .../unit/server/services/stats/utils/tinybird.test.js | 9 ++++----- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js b/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js index de89e860ded..0bc869c1695 100644 --- a/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js +++ b/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); -const should = require('should'); const sinon = require('sinon'); const moment = require('moment'); const testUtils = require('../../../../../utils'); @@ -79,7 +78,7 @@ describe('Scheduling: Post Scheduler', function () { assert.equal(adapter.schedule.calledOnce, true); assert.equal(adapter.schedule.args[0][0].time, moment(post.get('published_at')).valueOf()); - adapter.schedule.args[0][0].url.should.startWith(urlUtils.urlJoin('http://scheduler.local:1111/', 'schedules', 'posts', post.get('id'), '?token=')); + assert(adapter.schedule.args[0][0].url.startsWith(urlUtils.urlJoin('http://scheduler.local:1111/', 'schedules', 'posts', post.get('id'), '?token='))); assert.equal(adapter.schedule.args[0][0].extra.httpMethod, 'PUT'); assert.equal(null, adapter.schedule.args[0][0].extra.oldTime); }); diff --git a/ghost/core/test/unit/server/services/stats/content.test.js b/ghost/core/test/unit/server/services/stats/content.test.js index 9ff3c8ef4cf..bce5d43dd72 100644 --- a/ghost/core/test/unit/server/services/stats/content.test.js +++ b/ghost/core/test/unit/server/services/stats/content.test.js @@ -71,7 +71,7 @@ describe('ContentStatsService', function () { const result = mockTinybirdClient.buildRequest('api_top_pages', options); assertExists(result.url); - result.url.should.startWith('https://api.tinybird.co/v0/pipes/api_top_pages.json?'); + assert(result.url.startsWith('https://api.tinybird.co/v0/pipes/api_top_pages.json?')); assert(result.url.includes('site_uuid=site-id')); assert(result.url.includes('date_from=2023-01-01')); assert(result.url.includes('date_to=2023-01-31')); diff --git a/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js b/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js index a07807d0ba8..bd0e86e183f 100644 --- a/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js +++ b/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../../../utils/assertions'); const sinon = require('sinon'); -const should = require('should'); const tinybird = require('../../../../../../core/server/services/stats/utils/tinybird'); describe('Tinybird Client', function () { @@ -60,7 +59,7 @@ describe('Tinybird Client', function () { }); assertExists(url); - url.should.startWith('https://api.tinybird.co/v0/pipes/test_pipe.json?'); + assert(url.startsWith('https://api.tinybird.co/v0/pipes/test_pipe.json?')); assert(url.includes('site_uuid=931ade9e-a4f1-4217-8625-34bd34250c16')); assert(url.includes('date_from=2023-01-01')); assert(url.includes('date_to=2023-01-31')); @@ -85,7 +84,7 @@ describe('Tinybird Client', function () { dateTo: '2023-01-31' }); - url.should.startWith('https://api.tinybird.co/v0/pipes/test_pipe_v2.json?'); + assert(url.startsWith('https://api.tinybird.co/v0/pipes/test_pipe_v2.json?')); }); it('overrides defaults with provided options', function () { @@ -115,7 +114,7 @@ describe('Tinybird Client', function () { const {url, options} = tinybirdClient.buildRequest('test_pipe', {}); - url.should.startWith('http://localhost:8000/v0/pipes/test_pipe.json?'); + assert(url.startsWith('http://localhost:8000/v0/pipes/test_pipe.json?')); assert.equal(options.headers.Authorization, 'Bearer mock-jwt-token'); }); }); @@ -227,7 +226,7 @@ describe('Tinybird Client', function () { // Verify request was called with correct parameters assert.equal(mockRequest.get.calledOnce, true); const [url, options] = mockRequest.get.firstCall.args; - url.should.startWith('https://api.tinybird.co/v0/pipes/test_pipe.json?'); + assert(url.startsWith('https://api.tinybird.co/v0/pipes/test_pipe.json?')); assert.equal(options.headers.Authorization, 'Bearer mock-jwt-token'); }); From 8b12475bba7ff5e1db7c4758199e8356e3314d92 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 18 Feb 2026 12:56:01 -0600 Subject: [PATCH 10/24] Added additional functionality to welcome email editor (#26450) ref https://linear.app/ghost/issue/NY-1044/ ref https://github.com/TryGhost/Koenig/pull/1736 - added additional plugins and nodes behind feature flag This change requires either yarn link'ing with the above PR or bumping Koenig in Ghost after merging the PR. We have a few rough edges with styles (bookmarks, callouts) and a few pieces that don't fully work (image + file cards require fileUpload hook), snippets (require cardConfig) that we can bite off sequentially. --- .../src/global/form/koenig-editor-base.tsx | 5 +-- .../member-emails/member-email-editor.tsx | 35 ++++++++++++++----- apps/admin/package.json | 2 +- ghost/admin/package.json | 2 +- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx b/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx index 21f1769e46b..600c519110f 100644 --- a/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx +++ b/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx @@ -6,7 +6,7 @@ import ErrorBoundary from '../error-boundary'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FetchKoenigLexical = () => Promise -export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES'; +export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES' | 'EMAIL_EDITOR_NODES'; export interface KoenigEditorBaseProps { onBlur?: () => void @@ -126,7 +126,8 @@ export const KoenigWrapper: React.FC = ({ DEFAULT_NODES: koenig.DEFAULT_TRANSFORMERS, BASIC_NODES: koenig.BASIC_TRANSFORMERS, MINIMAL_NODES: koenig.MINIMAL_TRANSFORMERS, - EMAIL_NODES: koenig.EMAIL_TRANSFORMERS + EMAIL_NODES: koenig.EMAIL_TRANSFORMERS, + EMAIL_EDITOR_NODES: koenig.EMAIL_TRANSFORMERS }; const defaultNodes = nodes || 'DEFAULT_NODES'; diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx index 9e8c4f63699..5c7d0999479 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx @@ -1,11 +1,11 @@ import React, {useCallback} from 'react'; -import {KoenigEditorBase, type KoenigInstance, LoadingIndicator, type NodeType} from '@tryghost/admin-x-design-system'; +import useFeatureFlag from '../../../../hooks/use-feature-flag'; +import {KoenigEditorBase, type KoenigInstance, LoadingIndicator} from '@tryghost/admin-x-design-system'; import {cn} from '@tryghost/shade'; export interface MemberEmailsEditorProps { value?: string; placeholder?: string; - nodes?: NodeType; singleParagraph?: boolean; className?: string; onChange?: (value: string) => void; @@ -14,11 +14,11 @@ export interface MemberEmailsEditorProps { const MemberEmailsEditor: React.FC = ({ value, placeholder, - nodes = 'EMAIL_NODES', singleParagraph = false, className, onChange }) => { + const welcomeEmailEditorEnabled = useFeatureFlag('welcomeEmailEditor'); const baseEditorStyles = cn( // Base typography 'text-[1.6rem] leading-[1.6] tracking-[-0.01em]', @@ -60,20 +60,39 @@ const MemberEmailsEditor: React.FC = ({
} - nodes={nodes} - placeholder={placeholder} + nodes={welcomeEmailEditorEnabled ? 'EMAIL_EDITOR_NODES' : 'EMAIL_NODES'} + placeholder={placeholder} singleParagraph={singleParagraph} onChange={handleChange} > {(koenig: KoenigInstance) => ( <> - - + + + + + {welcomeEmailEditorEnabled && ( + <> + + + + + + + + {/* TODO: we need to wire up card config to enable snippets */} + {/* */} + {/* TODO: we need to wire up a fileUploader prop + fileUploadHook to enable files+images */} + {/* */} + {/* */} + + )} + )} diff --git a/apps/admin/package.json b/apps/admin/package.json index 82da3936493..db452023315 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,7 +15,7 @@ "@tryghost/activitypub": "*", "@tryghost/admin-x-framework": "*", "@tryghost/admin-x-settings": "*", - "@tryghost/koenig-lexical": "1.7.12", + "@tryghost/koenig-lexical": "1.7.13", "@tryghost/posts": "*", "@tryghost/shade": "*", "@tryghost/stats": "*", diff --git a/ghost/admin/package.json b/ghost/admin/package.json index c81d40d9f79..e437c6b3696 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -49,7 +49,7 @@ "@tryghost/helpers": "1.1.97", "@tryghost/kg-clean-basic-html": "4.2.13", "@tryghost/kg-converters": "1.1.13", - "@tryghost/koenig-lexical": "1.7.12", + "@tryghost/koenig-lexical": "1.7.13", "@tryghost/limit-service": "1.4.1", "@tryghost/members-csv": "2.0.3", "@tryghost/nql": "0.12.8", From 963fa5ce0dcab4ee3afaae4e251ac0ecacd0b595 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 13:08:42 -0600 Subject: [PATCH 11/24] Removed Should.js from schema validator tests (#26482) no ref This test-only change should have no user impact. --- ghost/core/test/unit/server/data/schema/validator.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ghost/core/test/unit/server/data/schema/validator.test.js b/ghost/core/test/unit/server/data/schema/validator.test.js index 9837704ecc9..ba3561d4854 100644 --- a/ghost/core/test/unit/server/data/schema/validator.test.js +++ b/ghost/core/test/unit/server/data/schema/validator.test.js @@ -1,5 +1,4 @@ const assert = require('node:assert/strict'); -const should = require('should'); const _ = require('lodash'); const ObjectId = require('bson-objectid').default; const testUtils = require('../../../../utils'); @@ -32,7 +31,7 @@ describe('Validate Schema', function () { // NOTE: Some of these fields are auto-filled in the model layer (e.g. created_at, created_at etc.) ['id', 'uuid', 'slug', 'title', 'created_at'].forEach(function (attr) { - errorMessages.should.match(new RegExp('posts.' + attr)); + assert.match(errorMessages, RegExp('posts.' + attr)); }); }); }); From 8adc6a9865f22b4b50b8861a80e7680b1718c6e8 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 13:31:20 -0600 Subject: [PATCH 12/24] Removed Should.js from member attribution service tests (#26485) no ref This test-only change should have no user impact. --- .../services/member-attribution.test.js | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/ghost/core/test/e2e-server/services/member-attribution.test.js b/ghost/core/test/e2e-server/services/member-attribution.test.js index ffadf5cccf3..ed9d213911d 100644 --- a/ghost/core/test/e2e-server/services/member-attribution.test.js +++ b/ghost/core/test/e2e-server/services/member-attribution.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); +const {assertObjectMatches} = require('../../utils/assertions'); const {agentProvider, fixtureManager, configUtils} = require('../../utils/e2e-framework'); -const should = require('should'); const models = require('../../../core/server/models'); const urlService = require('../../../core/server/services/url'); const memberAttributionService = require('../../../core/server/services/member-attribution'); @@ -28,18 +28,18 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: subdomainRelative, type: 'url' - })); + }); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: subdomainRelative - })); + }); }); it('resolves posts', async function () { @@ -53,20 +53,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url, type: 'post' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'post', title: post.get('title') - })); + }); }); it('resolves removed resources', async function () { @@ -84,21 +84,21 @@ describe('Member Attribution Service', function () { ]); // Without subdirectory - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'post' - })); + }); // Unpublish this post await models.Post.edit({status: 'draft'}, {id}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: urlWithoutSubdirectory - })); + }); await models.Post.edit({status: 'published'}, {id}); }); @@ -116,20 +116,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url, type: 'page' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'page', title: post.get('title') - })); + }); }); it('resolves tags', async function () { @@ -143,20 +143,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: tag.id, url, type: 'tag' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: tag.id, url: absoluteUrl, type: 'tag', title: tag.get('name') - })); + }); }); it('resolves authors', async function () { @@ -170,20 +170,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: author.id, url, type: 'author' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: author.id, url: absoluteUrl, type: 'author', title: author.get('name') - })); + }); }); }); @@ -207,18 +207,18 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: subdomainRelative, type: 'url' - })); + }); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: subdomainRelative - })); + }); }); it('resolves posts', async function () { @@ -238,20 +238,20 @@ describe('Member Attribution Service', function () { ]); // Without subdirectory - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'post' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'post', title: post.get('title') - })); + }); }); it('resolves removed resources', async function () { @@ -272,21 +272,21 @@ describe('Member Attribution Service', function () { ]); // Without subdirectory - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'post' - })); + }); // Unpublish this post await models.Post.edit({status: 'draft'}, {id}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: urlWithoutSubdirectory - })); + }); }); it('resolves pages', async function () { @@ -303,20 +303,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'page' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'page', title: post.get('title') - })); + }); }); it('resolves tags', async function () { @@ -331,20 +331,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: tag.id, url: urlWithoutSubdirectory, type: 'tag' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: tag.id, url: absoluteUrl, type: 'tag', title: tag.get('name') - })); + }); }); it('resolves authors', async function () { @@ -359,20 +359,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: author.id, url: urlWithoutSubdirectory, type: 'author' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: author.id, url: absoluteUrl, type: 'author', title: author.get('name') - })); + }); }); }); }); @@ -392,14 +392,14 @@ describe('Member Attribution Service', function () { referrerUrl: null } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: '/', type: 'url', referrerSource: 'Ghost Explore', referrerMedium: 'Ghost Network', referrerUrl: null - })); + }); }); it('resolves Portal signup URLs', async function () { @@ -412,14 +412,14 @@ describe('Member Attribution Service', function () { referrerSource: 'casper' } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: '/', type: 'url', referrerSource: 'casper', referrerMedium: null, referrerUrl: null - })); + }); }); }); -}); \ No newline at end of file +}); From 8cdaf6fd214b60d787f78f7fa7f897a941c436c4 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 13:31:39 -0600 Subject: [PATCH 13/24] Removed Should.js from DB API tests (#26486) no ref This test-only change should have no user impact. --- ghost/core/test/legacy/api/admin/db.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ghost/core/test/legacy/api/admin/db.test.js b/ghost/core/test/legacy/api/admin/db.test.js index ea8b2463572..230827a228f 100644 --- a/ghost/core/test/legacy/api/admin/db.test.js +++ b/ghost/core/test/legacy/api/admin/db.test.js @@ -4,7 +4,6 @@ const path = require('path'); const os = require('os'); const fs = require('fs-extra'); const crypto = require('crypto'); -const should = require('should'); const supertest = require('supertest'); const sinon = require('sinon'); const config = require('../../../../core/shared/config'); @@ -373,7 +372,7 @@ describe('DB API', function () { assert.equal(post.get('email_recipient_filter'), 'status:-free'); // Check this post is connected to the imported product - post.relations.tiers.models.map(m => m.id).should.match([product.id]); + assert.deepEqual(post.relations.tiers.models.map(m => m.id), [product.id]); // Check stripe prices const monthlyPrice = await models.StripePrice.findOne({id: product.get('monthly_price_id')}); @@ -475,7 +474,7 @@ describe('DB API (cleaned)', function () { assert.equal(post.get('email_recipient_filter'), 'status:-free'); // Check this post is connected to the imported product - post.relations.tiers.models.map(m => m.id).should.match([product.id]); + assert.deepEqual(post.relations.tiers.models.map(m => m.id), [product.id]); // Check stripe prices const monthlyPrice = await models.StripePrice.findOne({stripe_price_id: 'price_a425520db0'}); From e3430c07c5f0b1a256259183eaa535884a6762d5 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 13:31:47 -0600 Subject: [PATCH 14/24] Removed Should.js from web utils tests (#26488) no ref This test-only change should have no user impact. --- .../unit/server/web/api/middleware/upload.test.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ghost/core/test/unit/server/web/api/middleware/upload.test.js b/ghost/core/test/unit/server/web/api/middleware/upload.test.js index e736201ff99..3c90e1570c4 100644 --- a/ghost/core/test/unit/server/web/api/middleware/upload.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/upload.test.js @@ -1,4 +1,3 @@ -const should = require('should'); const validation = require('../../../../../../core/server/web/api/middleware/upload')._test; const imageFixturePath = ('../../../../../utils/fixtures/images/'); const fs = require('fs'); @@ -12,11 +11,11 @@ describe('web utils', function () { }); it('should return false if file does not exist in input', function () { - validation.checkFileExists({}).should.be.false; + assert.equal(validation.checkFileExists({}), false); }); it('should return false if file is incorrectly structured', function () { - validation.checkFileExists({type: 'file'}).should.be.false; + assert.equal(validation.checkFileExists({type: 'file'}), false); }); }); @@ -36,12 +35,12 @@ describe('web utils', function () { }); it('returns false if file has invalid extension', function () { - validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']).should.be.false; - validation.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']).should.be.false; + assert.equal(validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']), false); + assert.equal(validation.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']), false); }); it('returns false if file has invalid type', function () { - validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']).should.be.false; + assert.equal(validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']), false); }); }); From 743345d2470f9e32815612264b0f94993a7d7b1c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 13:32:06 -0600 Subject: [PATCH 15/24] Removed Should.js from default frontend routing tests (#26489) no ref This test-only change should have no user impact. --- ghost/core/test/e2e-frontend/default-routes.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ghost/core/test/e2e-frontend/default-routes.test.js b/ghost/core/test/e2e-frontend/default-routes.test.js index 998e6e39913..48c057e1c85 100644 --- a/ghost/core/test/e2e-frontend/default-routes.test.js +++ b/ghost/core/test/e2e-frontend/default-routes.test.js @@ -7,7 +7,6 @@ // But then again testing real code, rather than mock code, might be more useful... const assert = require('node:assert/strict'); const {assertExists} = require('../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); const moment = require('moment'); @@ -529,7 +528,7 @@ describe('Default Frontend routing', function () { .expect(200) .expect(assertCorrectFrontendHeaders) .expect((res) => { - res.text.should.match('User-agent: *\nDisallow: /'); + assert(res.text.includes('User-agent: *\nDisallow: /')); }); }); }); From c80cbaca22e0800b8c60cc7eb3cc540f43153f32 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 13:33:36 -0600 Subject: [PATCH 16/24] Removed Should.js from `{{next_post}}` helper tests (#26490) no ref This test-only change should have no user impact. --- .../unit/frontend/helpers/next-post.test.js | 249 ++++++++++++------ 1 file changed, 163 insertions(+), 86 deletions(-) diff --git a/ghost/core/test/unit/frontend/helpers/next-post.test.js b/ghost/core/test/unit/frontend/helpers/next-post.test.js index 3710f33a7b6..26f898fcdf6 100644 --- a/ghost/core/test/unit/frontend/helpers/next-post.test.js +++ b/ghost/core/test/unit/frontend/helpers/next-post.test.js @@ -1,10 +1,8 @@ -const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); const sinon = require('sinon'); const markdownToMobiledoc = require('../../../utils/fixtures/data-generator').markdownToMobiledoc; const next_post = require('../../../../core/frontend/helpers/prev_post'); const api = require('../../../../core/frontend/services/proxy').api; -const should = require('should'); const logging = require('@tryghost/logging'); describe('{{next_post}} helper', function () { @@ -56,14 +54,20 @@ describe('{{next_post}} helper', function () { published_at: new Date(0), url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); + + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly(browsePostsStub, sinon.match({include: 'author,authors,tags,tiers'})); }); }); @@ -93,12 +97,17 @@ describe('{{next_post}} helper', function () { published_at: new Date(0), url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); - assert.equal(inverse.firstCall.args.length, 2); - inverse.firstCall.args[0].should.have.properties('slug', 'title'); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); + sinon.assert.notCalled(fn); + + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); }); }); @@ -118,9 +127,10 @@ describe('{{next_post}} helper', function () { await next_post .call({}, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); - assert.equal(browsePostsStub.called, false); + + sinon.assert.notCalled(fn); + sinon.assert.calledOnce(inverse); + sinon.assert.notCalled(browsePostsStub); }); }); @@ -156,8 +166,9 @@ describe('{{next_post}} helper', function () { url: '/current/', page: true }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); + + sinon.assert.notCalled(fn); + sinon.assert.calledOnce(inverse); }); }); @@ -191,8 +202,9 @@ describe('{{next_post}} helper', function () { created_at: new Date(0), url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); + + sinon.assert.notCalled(fn); + sinon.assert.calledOnce(inverse); }); }); @@ -224,15 +236,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_tag:test/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_tag:test/) + }) + ); }); it('shows \'if\' template with prev post data with primary_author set', async function () { @@ -252,15 +274,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_author:hans/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_author:hans/) + }) + ); }); it('shows \'if\' template with prev post data with author set', async function () { @@ -280,15 +312,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+author:author-name/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+author:author-name/) + }) + ); }); it('shows \'if\' template with prev post data & ignores in author if author isnt present', async function () { @@ -307,15 +349,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+author:/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+author:/.test(filter)) + }) + ); }); it('shows \'if\' template with prev post data & ignores unknown in value', async function () { @@ -335,15 +387,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+magic/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+magic/.test(filter)) + }) + ); }); }); @@ -371,13 +433,19 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.calledOnce, true); - assert.equal(loggingStub.calledOnce, true); + sinon.assert.notCalled(fn); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); - inverse.firstCall.args[1].data.should.be.an.Object().and.have.property('error'); - assert.match(inverse.firstCall.args[1].data.error, /^Something wasn't found/); + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match.any, + sinon.match({ + data: sinon.match({ + error: sinon.match(/^Something wasn't found/) + }) + }) + ); + + sinon.assert.calledOnce(loggingStub); }); it('should show warning for call without any options', async function () { @@ -391,8 +459,8 @@ describe('{{next_post}} helper', function () { optionsData ); - assert.equal(fn.called, false); - assert.equal(inverse.called, false); + sinon.assert.notCalled(fn); + sinon.assert.notCalled(inverse); }); }); @@ -431,17 +499,26 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - - // Check context passed - assert.equal(browsePostsStub.firstCall.args[0].context.member, member); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + // Check context passed + context: {member} + }) + ); }); }); }); From 556dec0e71d544fa6d5084232801671268802473 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 18 Feb 2026 13:47:34 -0600 Subject: [PATCH 17/24] Updated yarn lock (#26491) ref https://github.com/TryGhost/Ghost/commit/8b12475bba7ff5e1db7c4758199e8356e3314d92 This was supposed to contain the lock file. --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7693d836a04..ae5fcb42aad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9141,10 +9141,10 @@ dependencies: semver "^7.7.0" -"@tryghost/koenig-lexical@1.7.12": - version "1.7.12" - resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.7.12.tgz#d546b1149b268bd0e52d3eb7e7198ad50da64a10" - integrity sha512-8JJxtrVFqmyfsr2igg38O1s2zKDrpDtM8GW6eW8pmYIX66qedqsZOFv4cg/rYevZeO1wNejxr4+cME4y97gnXA== +"@tryghost/koenig-lexical@1.7.13": + version "1.7.13" + resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.7.13.tgz#9ea9374f4d40fc683164fc36f459e50194943a08" + integrity sha512-0ctOjb3eZbpP/2VJkPk3iREyyZnMWuWXteRRGoz8tyXptqaR/yzYEBd94Zh6Ug1UlCJawY80vVsMFrLydZyDCQ== "@tryghost/limit-service@1.4.1": version "1.4.1" From f14c28fb9acc79842b8eb793a40fda066e6ed481 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:59:46 -0800 Subject: [PATCH 18/24] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20member=20filters?= =?UTF-8?q?=20with=20multiple=20date=20based=20filters=20returning=20incor?= =?UTF-8?q?rect=20results=20(#26458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-1446/member-filtering-issue This commit updates @tryghost/nql to v0.12.10, which includes a [fix](https://github.com/TryGhost/NQL/commit/90479359184e3fd7284c7fa6708bc7a03a215fcd) for a bug in Ghost Admin's member filtering when more than one date based filter is applied. ## Summary When filtering members in Admin using the following filters, Ghost was returning members that should not have been included in the results: - Member status: paid - Stripe Subscription Status: active - Billing Period: Monthly - Next Billing Date: on or after Feb 1, 2026 - Next Billing Date: on or before Feb 28, 2026 The filtering was also order dependent - if you swapped the two filters on Billing Date, the results could be completely different. ## Root cause NQL would treat the above filters as two separate sub queries: 1. Member status: paid, subscription status: active, billing period: monthly, next billing date >= Feb 1, 2026 2. Next billing date <= Feb 28, 2026 The additional date-based filter was being split into its own subquery, such that any member with any subscription with a next billing date prior to Feb 28, 2026 would be included in the results - regardless of whether the matching subscription was active or billed monthly. The ordering was also important, because it's always the second condition that gets orphaned into its own subquery, and would return different results. ## Fix The fix for this bug was in NQL [here](https://github.com/TryGhost/NQL/commit/90479359184e3fd7284c7fa6708bc7a03a215fcd). Ultimately it ensures that range based filters, like our next billing date filter, are grouped into the same subquery, rather than split out into its own subquery. This prevents members with e.g. cancelled subscriptions with a next billing date in the past from being included in the example query above. --- apps/admin-x-settings/package.json | 2 +- ghost/admin/package.json | 2 +- ghost/core/package.json | 2 +- yarn.lock | 48 +++++++++++++++--------------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 5e175371a04..9c9009810ab 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -44,7 +44,7 @@ "@tryghost/i18n": "0.0.0", "@tryghost/kg-unsplash-selector": "0.3.12", "@tryghost/limit-service": "1.4.1", - "@tryghost/nql": "0.12.8", + "@tryghost/nql": "0.12.10", "@tryghost/timezone-data": "0.4.12", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/ghost/admin/package.json b/ghost/admin/package.json index e437c6b3696..43fcac0c8d1 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -52,7 +52,7 @@ "@tryghost/koenig-lexical": "1.7.13", "@tryghost/limit-service": "1.4.1", "@tryghost/members-csv": "2.0.3", - "@tryghost/nql": "0.12.8", + "@tryghost/nql": "0.12.10", "@tryghost/nql-lang": "0.6.4", "@tryghost/string": "0.2.17", "@tryghost/timezone-data": "0.4.12", diff --git a/ghost/core/package.json b/ghost/core/package.json index 0aa8b874574..ddf078e8b3a 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -105,7 +105,7 @@ "@tryghost/mw-error-handler": "1.0.7", "@tryghost/mw-vhost": "1.0.1", "@tryghost/nodemailer": "0.3.48", - "@tryghost/nql": "0.12.8", + "@tryghost/nql": "0.12.10", "@tryghost/parse-email-address": "0.0.0", "@tryghost/pretty-cli": "1.2.47", "@tryghost/prometheus-metrics": "1.0.2", diff --git a/yarn.lock b/yarn.lock index ae5fcb42aad..c63707f99e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8839,7 +8839,7 @@ resolved "https://registry.yarnpkg.com/@tryghost/database-info/-/database-info-0.3.30.tgz#550902da9bfa6e7f9adc145e2cc2a51a5c7e1ad5" integrity sha512-WX8PlkHRxiLV6PwXRoRgJi7sGx4taWhHfm285oZPWzsNhvyDvJpeacikaXgbvOPBMdxEfB+ZpIF6XYlLPGdN0Q== -"@tryghost/debug@0.1.35", "@tryghost/debug@^0.1.13", "@tryghost/debug@^0.1.26", "@tryghost/debug@^0.1.35": +"@tryghost/debug@0.1.35": version "0.1.35" resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.35.tgz#6bff0a16b946b25cb14a75942c2f17bd8c993aa2" integrity sha512-NNKMKV6xuaOaXjTJ/NBMWEzfSkFLahtxARlyYbFuxb9y95jhyJb2+mu9Zsd+gKWZZIkP7ACkWqyooTm4rr9eCQ== @@ -8847,7 +8847,7 @@ "@tryghost/root-utils" "^0.3.33" debug "^4.3.1" -"@tryghost/debug@^0.1.36": +"@tryghost/debug@^0.1.13", "@tryghost/debug@^0.1.26", "@tryghost/debug@^0.1.35", "@tryghost/debug@^0.1.36": version "0.1.36" resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.36.tgz#54561ffad4d24632406824aa8b78148a59a826e2" integrity sha512-6B3zrO7Y3SWlxTB9M+dwhj63whE2R4ktmDYI7r1L1Ao9S//Y0XYOPVVlqUDxHfig7T29JliZfbhZw9VdSEUYjg== @@ -9200,10 +9200,10 @@ mobiledoc-dom-renderer "0.7.0" mobiledoc-text-renderer "0.4.0" -"@tryghost/mongo-knex@^0.9.1", "@tryghost/mongo-knex@^0.9.2": - version "0.9.2" - resolved "https://registry.yarnpkg.com/@tryghost/mongo-knex/-/mongo-knex-0.9.2.tgz#3bd9c96ec1ced10253fac778b811613e3942e583" - integrity sha512-9YA1wpPwAgAT75YAMX46dGp9HpNS7uwoz8d8o88DS3pE1N6uTJUJPzcqcj6gXcXB0tAnJu+cmr1BdTA2YeAYvw== +"@tryghost/mongo-knex@^0.9.1", "@tryghost/mongo-knex@^0.9.4": + version "0.9.4" + resolved "https://registry.yarnpkg.com/@tryghost/mongo-knex/-/mongo-knex-0.9.4.tgz#bd92d191c28d95367d4898aefc57cf82a1d95304" + integrity sha512-R5mqpuQ1nN4qWAii9Tvg5POZ0nLO7Voy7QaQF+95sseUpfe4XEUsr3+mm4HYPC8MNKginlUm/unLf5QFzFQl5w== dependencies: debug "^4.3.3" lodash "^4.17.21" @@ -9251,6 +9251,16 @@ dependencies: date-fns "^2.28.0" +"@tryghost/nql@0.12.10", "@tryghost/nql@^0.12.5": + version "0.12.10" + resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.12.10.tgz#3050f24203e8c3946568f6cb03e7371ac10cf4e5" + integrity sha512-kpj2ICTBmkz5Uet7Z/J61C/EEBTfa55np6LnbqW6N8g33uvCh9NkAsM2WgV1NK2lffpQT3Cs/qA2ymzHAguvoA== + dependencies: + "@tryghost/mongo-knex" "^0.9.4" + "@tryghost/mongo-utils" "^0.6.3" + "@tryghost/nql-lang" "^0.6.4" + mingo "^2.2.2" + "@tryghost/nql@0.12.6": version "0.12.6" resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.12.6.tgz#18c8b57f73d37269e2c0ab23b6c3f4f4030804b4" @@ -9261,16 +9271,6 @@ "@tryghost/nql-lang" "^0.6.2" mingo "^2.2.2" -"@tryghost/nql@0.12.8", "@tryghost/nql@^0.12.5": - version "0.12.8" - resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.12.8.tgz#b753603126e4f7e9f211e77485d8be19bec4fad0" - integrity sha512-88P6IijqjeFyIeDU2WdJPE9SVAZF8MpL53JIsNf1aViqVRAnfVqC/e6UmBsIoHJfFgSJsNdW7Jtznu7BYYvPSA== - dependencies: - "@tryghost/mongo-knex" "^0.9.2" - "@tryghost/mongo-utils" "^0.6.3" - "@tryghost/nql-lang" "^0.6.4" - mingo "^2.2.2" - "@tryghost/pretty-cli@1.2.47", "@tryghost/pretty-cli@^1.2.38": version "1.2.47" resolved "https://registry.yarnpkg.com/@tryghost/pretty-cli/-/pretty-cli-1.2.47.tgz#314f06b12c486ecdd6547f0ffaf2274e02047531" @@ -9330,7 +9330,7 @@ got "13.0.0" lodash "^4.17.21" -"@tryghost/root-utils@0.3.33", "@tryghost/root-utils@^0.3.24", "@tryghost/root-utils@^0.3.33": +"@tryghost/root-utils@0.3.33": version "0.3.33" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.33.tgz#208e3d15520131c2d4157c7e62fe74771a7a110f" integrity sha512-Gmc/TrKtiRT7PV9JOPoSZ7jAOl/jJDWJFKNaLZbDQaiJIBP5C6PucqEfRqGb2Ko/S9j73HzEEBu6B7+qZMvbBg== @@ -9338,7 +9338,7 @@ caller "^1.0.1" find-root "^1.1.0" -"@tryghost/root-utils@^0.3.34": +"@tryghost/root-utils@^0.3.24", "@tryghost/root-utils@^0.3.33", "@tryghost/root-utils@^0.3.34": version "0.3.34" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.34.tgz#db131568cf04069929a27fb8ed519b16a2226a5d" integrity sha512-RH3nPr5/1tK/nQTZMSO9rDeEelFZbY0gB0HInbhXl7995cgR/yuOnTPWa3CCQT3m/cYPZOPFhlInO8evke9rDg== @@ -9386,14 +9386,14 @@ resolved "https://registry.yarnpkg.com/@tryghost/timezone-data/-/timezone-data-0.4.12.tgz#c8a63979a073fe7ca9a85af4fcf255ff73a13859" integrity sha512-oUDQyYP3sxpC1/ndT15tfGHx50YqIC1ovktyEkS/1f04+H5+bPiCgCLu/Dvi63u4Jc1GTEcTvHoRqTngpA+mZw== -"@tryghost/tpl@0.1.35", "@tryghost/tpl@^0.1.35": +"@tryghost/tpl@0.1.35": version "0.1.35" resolved "https://registry.yarnpkg.com/@tryghost/tpl/-/tpl-0.1.35.tgz#7ad6b84f94529b2e709046255706dcda083267c9" integrity sha512-U6zWUnxDgw2nHZc5DTI0JuqYsytK76BVfIB3hz2rYrCKL4O6JL76F25Jr6I+A8cEIfL5GKLSG8/tWmEAHLy0Mg== dependencies: lodash.template "^4.5.0" -"@tryghost/tpl@^0.1.36": +"@tryghost/tpl@^0.1.35", "@tryghost/tpl@^0.1.36": version "0.1.36" resolved "https://registry.yarnpkg.com/@tryghost/tpl/-/tpl-0.1.36.tgz#1c8c5b23b8948d58f207fccb7c707658e8a166fa" integrity sha512-1bgvGE06ABEuXQqVeuYK3i58YiEFf6fHiXSCq4CzA+MIBUoUs4ID9oQLkdPJwPQ9TsuuJt3z4DPiG8KnLt06fg== @@ -9413,7 +9413,7 @@ remark-footnotes "1.0.0" unist-util-visit "^2.0.0" -"@tryghost/validator@0.2.17", "@tryghost/validator@^0.2.17": +"@tryghost/validator@0.2.17": version "0.2.17" resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.17.tgz#51732e93677f3ee10e9c641c5d2cc93f8e038934" integrity sha512-MsiF8tkZmsUmWmtDCr8oMua/Lyk0b16zJrT/tDHaERi2eKKgOqf0RwLRdIS/droqX1uOZCX+cLX3xSYbKhh4cA== @@ -9424,7 +9424,7 @@ moment-timezone "^0.5.23" validator "7.2.0" -"@tryghost/validator@^0.2.18": +"@tryghost/validator@^0.2.17", "@tryghost/validator@^0.2.18": version "0.2.18" resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.18.tgz#de9a2fbec5c81024a745fc4b4cbfdaaff9a68587" integrity sha512-ua345mKqmOQvykwSEH25dYSyTe1bndS8/mwr/n/mRREW+p1a0dhuTuS21Z0s2TkYMoyMTcXL50XC3v8jBapk/Q== @@ -24467,12 +24467,12 @@ lodash.upperfirst@4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@4.17.21, lodash@^4.0.0, lodash@^4.14.2, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.5.1, lodash@^4.7.0, lodash@~4.17.21: +lodash@4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -lodash@4.17.23: +lodash@4.17.23, lodash@^4.0.0, lodash@^4.14.2, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.5.1, lodash@^4.7.0, lodash@~4.17.21: version "4.17.23" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== From f1321fb6715245cbbac2c487400e155d16ca06da Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 18 Feb 2026 14:13:57 -0600 Subject: [PATCH 19/24] Removed dev.js and legacy scripts (#26442) no ref With the update to the React-based Admin app, we've overhauled the dev setup to lean fully into Docker. This commit removes the old/legacy setup to prevent confusion on what's the intended way to develop with Ghost. The `dev.js` script has handled many tasks in the past. It appears to me as if the browser tests are the only necessary use case right now, so I've stripped away the unnecessary pieces and moved/renamed it to a dedicated script describing its purpose. --- .env.example | 7 +- .github/scripts/dev-with-tinybird.js | 147 -------- .github/scripts/dev.js | 317 ----------------- .vscode/launch.json | 58 ---- AGENTS.md | 17 +- compose.object-storage.yml | 64 ---- compose.yml | 328 ------------------ e2e/package.json | 2 +- .../core/test/scripts/browser-test-runner.js | 65 ++++ package.json | 37 +- 10 files changed, 80 insertions(+), 962 deletions(-) delete mode 100755 .github/scripts/dev-with-tinybird.js delete mode 100644 .github/scripts/dev.js delete mode 100644 compose.object-storage.yml delete mode 100644 compose.yml create mode 100644 ghost/core/test/scripts/browser-test-runner.js diff --git a/.env.example b/.env.example index 8001b9354cf..3b6e8ef7ed2 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,7 @@ # Debug level to pass to Ghost # DEBUG= -# App flags to pass to the dev command -## Run `yarn dev --show-flags` to see all available app flags - -# GHOST_DEV_APP_FLAGS= - -# Stripe keys - used to forward Stripe webhooks to the Ghost instance in `dev.js` script +# Stripe keys - used to forward Stripe webhooks to Ghost ## Stripe Secret Key: sk_test_******* # STRIPE_SECRET_KEY= ## Stripe Publishable Key: pk_test_******* diff --git a/.github/scripts/dev-with-tinybird.js b/.github/scripts/dev-with-tinybird.js deleted file mode 100755 index f05422aa2e0..00000000000 --- a/.github/scripts/dev-with-tinybird.js +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env node - -const {spawn, execSync} = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// Check if analytics containers are running -function checkAnalyticsRunning() { - try { - const output = execSync('docker ps --format "{{.Names}}"', {encoding: 'utf8'}); - return output.includes('ghost-tinybird-local'); - } catch (error) { - return false; - } -} - -// Extract Tinybird configuration from Docker volume -function extractTinybirdConfig() { - try { - console.log('📊 Extracting Tinybird configuration from Docker...'); - - // Wait for tb-cli to complete if needed - try { - execSync('docker wait ghost-tb-cli', {encoding: 'utf8', stdio: 'pipe'}); - } catch (e) { - // Container might have already completed - } - - // Extract configuration from shared volume - const config = execSync( - 'docker run --rm -v ghost_shared-config:/mnt/shared-config alpine cat /mnt/shared-config/.env.tinybird', - {encoding: 'utf8', stdio: 'pipe'} - ); - - if (!config) { - throw new Error('Could not read Tinybird configuration'); - } - - // Parse the configuration - const lines = config.split('\n').filter(line => line.includes('=')); - const configObj = {}; - - lines.forEach(line => { - const [key, ...valueParts] = line.split('='); - configObj[key] = valueParts.join('='); - }); - - if (!configObj.TINYBIRD_WORKSPACE_ID || !configObj.TINYBIRD_ADMIN_TOKEN) { - throw new Error('Invalid Tinybird configuration'); - } - - return configObj; - } catch (error) { - console.error('❌ Failed to extract Tinybird configuration:', error.message); - console.error(' Make sure Docker analytics containers are running properly'); - process.exit(1); - } -} - -// Setup Tinybird environment variables -function setupTinybirdEnv() { - const config = extractTinybirdConfig(); - - // Set environment variables for the child process - const tinybirdEnv = { - tinybird__workspaceId: config.TINYBIRD_WORKSPACE_ID, - tinybird__adminToken: config.TINYBIRD_ADMIN_TOKEN, - tinybird__stats__endpoint: 'http://localhost:7181', - tinybird__stats__endpointBrowser: 'http://localhost:7181', - tinybird__tracker__endpoint: 'http://localhost:3000/api/v1/page_hit', - TINYBIRD_TRACKER_TOKEN: config.TINYBIRD_TRACKER_TOKEN - }; - - console.log('✅ Tinybird configuration loaded'); - return tinybirdEnv; -} - -// Main function -async function main() { - // Check if we should use Tinybird - const useTinybird = process.argv.includes('--tinybird') || process.env.GHOST_USE_TINYBIRD === 'true'; - - let extraEnv = {}; - - if (useTinybird) { - console.log('🚀 Starting Ghost with Tinybird analytics...\n'); - - // Check if analytics containers are running - if (!checkAnalyticsRunning()) { - console.log('📦 Analytics containers not running, starting them...'); - try { - execSync('docker compose --profile analytics up -d --wait', { - stdio: 'inherit' - }); - console.log('✅ Analytics containers started\n'); - } catch (error) { - console.error('❌ Failed to start analytics containers'); - process.exit(1); - } - } else { - console.log('✅ Analytics containers already running\n'); - } - - // Setup Tinybird environment - extraEnv = setupTinybirdEnv(); - } - - // Get the original dev script arguments - const devScriptPath = path.join(__dirname, 'dev.js'); - const devArgs = process.argv.slice(2).filter(arg => arg !== '--tinybird'); - - console.log('\n🏃 Starting Ghost development server...\n'); - - // Spawn the original dev script with Tinybird environment - const child = spawn('node', [devScriptPath, ...devArgs], { - stdio: 'inherit', - env: { - ...process.env, - ...extraEnv - } - }); - - child.on('error', (error) => { - console.error('Failed to start:', error); - process.exit(1); - }); - - child.on('exit', (code) => { - process.exit(code); - }); -} - -// Handle process termination -process.on('SIGINT', () => { - console.log('\n👋 Shutting down...'); - process.exit(0); -}); - -process.on('SIGTERM', () => { - console.log('\n👋 Shutting down...'); - process.exit(0); -}); - -main().catch(error => { - console.error('Error:', error); - process.exit(1); -}); \ No newline at end of file diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js deleted file mode 100644 index 5150bc9830e..00000000000 --- a/.github/scripts/dev.js +++ /dev/null @@ -1,317 +0,0 @@ -const path = require('path'); -const util = require('util'); -const exec = util.promisify(require('child_process').exec); -const debug = require('debug')('ghost:dev'); - -const chalk = require('chalk'); -const concurrently = require('concurrently'); - - -debug('loading config'); -const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ - customConfigPath: path.join(__dirname, '../../ghost/core') -}); -debug('config loaded'); - -debug('loading live reload base url'); -const liveReloadBaseUrl = config.getSubdir() || '/ghost/'; -debug('live reload base url loaded'); - -debug('loading site url'); -const siteUrl = config.getSiteUrl(); -debug('site url loaded'); - -// Pass flags using GHOST_DEV_APP_FLAGS env var or --flag -debug('loading app flags') -const availableAppFlags = { - 'show-flags': 'Show available app flags, then exit', - stripe: 'Run `stripe listen` to forward Stripe webhooks to the Ghost instance', - all: 'Run all apps', - ghost: 'Run only Ghost', - admin: 'Run only Admin', - 'browser-tests': 'Run browser tests', - announcementBar: 'Run Announcement Bar', - announcementbar: 'Run Announcement Bar', - 'announcement-bar': 'Run Announcement Bar', - portal: 'Run Portal', - signup: 'Run Signup Form', - search: 'Run Sodo Search', - lexical: 'Use your local instance of the Lexical editor running in a separate process', - comments: 'Run Comments UI', - https: 'Serve apps using HTTPS', - offline: 'Run in offline mode (no Stripe webhooks will be forwarded)' -} - -// Split args on '--' separator to separate app flags from pass-through args -const doubleDashIndex = process.argv.lastIndexOf('--'); -const devArgs = doubleDashIndex === -1 ? process.argv : process.argv.slice(0, doubleDashIndex); -const passThroughArgs = doubleDashIndex === -1 ? [] : process.argv.slice(doubleDashIndex + 1); - -const DASH_DASH_ARGS = devArgs.filter(a => a.startsWith('--')).map(a => a.slice(2)); -const ENV_ARGS = process.env.GHOST_DEV_APP_FLAGS?.split(',') || []; -const GHOST_APP_FLAGS = [...ENV_ARGS, ...DASH_DASH_ARGS].filter(flag => flag.trim().length > 0); - -// Format pass-through args for command usage -const PASS_THROUGH_FLAGS = passThroughArgs.join(' '); - -function showAvailableAppFlags() { - console.log(chalk.blue('App flags can be enabled by setting the GHOST_DEV_APP_FLAGS environment variable to a comma separated list of flags.')); - console.log(chalk.blue('Alternatively, flags can be passed directly to `yarn dev`, i.e. `yarn dev --portal')); - console.log(chalk.blue('Note: the `yarn docker:dev` command only supports the GHOST_DEV_APP_FLAGS environment variable, as --flags cannot be passed to the docker container.\n')); - console.log(chalk.blue('Available app flags:')); - for (const [flag, description] of Object.entries(availableAppFlags)) { - console.log(chalk.blue(` ${flag}: ${description}`)); - } -} - -if (GHOST_APP_FLAGS.includes('show-flags')) { - showAvailableAppFlags(); - process.exit(0); -} - -// Check for invalid flags -debug('checking for invalid flags', GHOST_APP_FLAGS); -const invalidFlags = GHOST_APP_FLAGS.filter(flag => !Object.keys(availableAppFlags).includes(flag)); -if (invalidFlags.length > 0) { - console.error(chalk.red(`Error: Invalid app flag(s): ${invalidFlags.join(', ')}`)); - showAvailableAppFlags(); - process.exit(1); -} -debug('invalid flags check passed'); - - -debug('app flags loaded'); - -debug('loading commands'); -let commands = []; - -const COMMAND_GHOST = { - name: 'ghost', - command: 'nx run ghost:dev', - prefixColor: 'blue', - env: { - // In development mode, we allow self-signed certificates (for sending webmentions and oembeds) - NODE_TLS_REJECT_UNAUTHORIZED: '0', - } -}; - -const COMMAND_ADMIN = { - name: 'admin', - command: `nx run ghost-admin:dev --live-reload-base-url=${liveReloadBaseUrl} --live-reload-port=4201`, - prefixColor: 'green', - env: {} -}; - -const COMMAND_BROWSERTESTS = { - name: 'browser-tests', - command: `nx run ghost:test:browser${PASS_THROUGH_FLAGS ? ` -- ${PASS_THROUGH_FLAGS}`: ''}`, - prefixColor: 'blue', - env: {} -}; - -const adminXApps = '@tryghost/admin-x-settings,@tryghost/activitypub,@tryghost/posts,@tryghost/stats'; - -const COMMANDS_ADMINX = [{ - name: 'adminXDeps', - command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework,apps/shade,apps/stats -- nx run \\$NX_PROJECT_NAME:build; done', - prefixColor: '#C72AF7', - env: {} -}, { - name: 'adminX', - command: `nx run-many --projects=${adminXApps} --parallel=${adminXApps.length} --targets=dev`, - prefixColor: '#C72AF7', - env: {} -}]; - -if (GHOST_APP_FLAGS.includes('ghost')) { - commands = [COMMAND_GHOST]; -} else if (GHOST_APP_FLAGS.includes('admin')) { - commands = [COMMAND_ADMIN, ...COMMANDS_ADMINX]; -} else if (GHOST_APP_FLAGS.includes('browser-tests')) { - commands = [COMMAND_BROWSERTESTS]; -} else { - commands = [COMMAND_GHOST, COMMAND_ADMIN, ...COMMANDS_ADMINX]; -} - -if (GHOST_APP_FLAGS.includes('portal') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'portal', - command: 'nx run @tryghost/portal:dev', - prefixColor: 'magenta', - env: {} - }); - - if (GHOST_APP_FLAGS.includes('https')) { - // Safari needs HTTPS for it to work - // To make this work, you'll need a CADDY server running in front - // Note the port is different because of this extra layer. Use the following Caddyfile: - // https://localhost:4176 { - // reverse_proxy http://localhost:4175 - // } - - COMMAND_GHOST.env['portal__url'] = 'https://localhost:4176/portal.min.js'; - } else { - COMMAND_GHOST.env['portal__url'] = 'http://localhost:4175/portal.min.js'; - } -} - -if (GHOST_APP_FLAGS.includes('signup') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'signup-form', - command: GHOST_APP_FLAGS.includes('signup') ? 'nx run @tryghost/signup-form:dev' : 'nx run @tryghost/signup-form:preview', - prefixColor: 'magenta', - env: {} - }); - COMMAND_GHOST.env['signupForm__url'] = 'http://localhost:6174/signup-form.min.js'; -} - -if (GHOST_APP_FLAGS.includes('announcement-bar') || GHOST_APP_FLAGS.includes('announcementBar') || GHOST_APP_FLAGS.includes('announcementbar') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'announcement-bar', - command: 'nx run @tryghost/announcement-bar:dev', - prefixColor: '#DC9D00', - env: {} - }); - COMMAND_GHOST.env['announcementBar__url'] = 'http://localhost:4177/announcement-bar.min.js'; -} - -if (GHOST_APP_FLAGS.includes('search') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'search', - command: 'nx run @tryghost/sodo-search:dev', - prefixColor: '#23de43', - env: {} - }); - COMMAND_GHOST.env['sodoSearch__url'] = 'http://localhost:4178/sodo-search.min.js'; - COMMAND_GHOST.env['sodoSearch__styles'] = 'http://localhost:4178/main.css'; -} - -if (GHOST_APP_FLAGS.includes('lexical')) { - if (GHOST_APP_FLAGS.includes('https')) { - // Safari needs HTTPS for it to work - // To make this work, you'll need a CADDY server running in front - // Note the port is different because of this extra layer. Use the following Caddyfile: - // https://localhost:41730 { - // reverse_proxy http://127.0.0.1:4173 - // } - - COMMAND_ADMIN.env['EDITOR_URL'] = 'https://localhost:41730/'; - } else { - COMMAND_ADMIN.env['EDITOR_URL'] = 'http://localhost:4173/'; - } -} - -if (GHOST_APP_FLAGS.includes('comments') || GHOST_APP_FLAGS.includes('all')) { - if (GHOST_APP_FLAGS.includes('https')) { - // Safari needs HTTPS for it to work - // To make this work, you'll need a CADDY server running in front - // Note the port is different because of this extra layer. Use the following Caddyfile: - // https://localhost:7174 { - // reverse_proxy http://127.0.0.1:7173 - // } - COMMAND_GHOST.env['comments__url'] = 'https://localhost:7174/comments-ui.min.js'; - } else { - COMMAND_GHOST.env['comments__url'] = 'http://localhost:7173/comments-ui.min.js'; - } - - commands.push({ - name: 'comments', - command: 'nx run @tryghost/comments-ui:dev', - prefixColor: '#E55137', - env: {} - }); -} - -async function handleStripe() { - if (GHOST_APP_FLAGS.includes('stripe') || GHOST_APP_FLAGS.includes('all')) { - debug('stripe flag found'); - if (GHOST_APP_FLAGS.includes('offline') || GHOST_APP_FLAGS.includes('browser-tests')) { - debug('offline or browser-tests flag found, skipping stripe'); - return; - } - debug('stripe flag found, proceeding'); - - console.log('Fetching Stripe webhook secret...'); - let stripeSecret; - const stripeSecretKey = process.env.STRIPE_SECRET_KEY; - const apiKeyFlag = stripeSecretKey ? `--api-key ${stripeSecretKey}` : ''; - try { - debug('fetching stripe secret'); - const stripeListenCommand = `stripe listen --print-secret ${apiKeyFlag}`; - debug('stripe listen command', stripeListenCommand); - stripeSecret = await Promise.race([ - exec(stripeListenCommand), - new Promise((_, reject) => setTimeout(() => reject(new Error('Stripe listen command timed out after 5 seconds')), 5000)) - ]); - debug('stripe secret fetched'); - } catch (err) { - console.error('Failed to fetch Stripe secret token. Please ensure either STRIPE_SECRET_KEY is set or you are logged in to the Stripe CLI by running `stripe login`.'); - console.error(err); - process.exit(1); - } - - if (!stripeSecret || !stripeSecret.stdout) { - debug('no stripe secret found'); - console.error('No Stripe secret was present'); - console.error('Please ensure either STRIPE_SECRET_KEY is set or you are logged in to Stripe CLI by running `stripe login`.'); - return; - } - - COMMAND_GHOST.env['WEBHOOK_SECRET'] = stripeSecret.stdout.trim(); - commands.push({ - name: 'stripe', - command: `stripe listen --forward-to ${siteUrl}members/webhooks/stripe/ ${apiKeyFlag}`, - prefixColor: 'yellow', - env: {} - }); - } -} - -(async () => { - debug('starting with commands', commands); - debug('handling stripe'); - await handleStripe(); - debug('stripe handled'); - - if (!commands.length) { - debug('no commands provided'); - console.log(`No commands provided`); - process.exit(0); - } - debug('at least one command provided'); - - debug('resetting nx'); - await exec("yarn nx reset --onlyDaemon"); - debug('nx reset'); - await exec("yarn nx daemon --start"); - debug('nx daemon started'); - - // Wait for daemon to be fully ready by verifying it responds - debug('verifying daemon is ready'); - await new Promise(resolve => setTimeout(resolve, 200)); - await exec("yarn nx daemon --version"); - debug('daemon verified ready'); - - console.log(`Running projects: ${commands.map(c => chalk.green(c.name)).join(', ')}`); - - debug('creating concurrently promise'); - const {result} = concurrently(commands, { - prefix: 'name', - killOthers: ['failure', 'success'], - successCondition: 'first' - }); - - try { - debug('running commands concurrently'); - await result; - debug('commands completed'); - } catch (err) { - debug('concurrently result error', err); - console.error(); - console.error(chalk.red(`Executing dev command failed:`) + `\n`); - console.error(chalk.red(`If you've recently done a \`yarn main\`, dependencies might be out of sync. Try running \`${chalk.green('yarn fix')}\` to fix this.`)); - console.error(chalk.red(`If not, something else went wrong. Please report this to the Ghost team.`)); - console.error(); - process.exit(1); - } -})(); diff --git a/.vscode/launch.json b/.vscode/launch.json index 698afa15d2b..398e21df654 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,64 +4,6 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Backend", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "args": [ - "--ghost" - ], - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, - { - "type": "node", - "request": "launch", - "name": "Ghost core + Admin", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, - { - "type": "node", - "request": "launch", - "name": "Full Dev", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "args": [ - "--all" - ], - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, - { - "type": "node", - "request": "launch", - "name": "Full Offline Dev", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "args": [ - "--all", - "--offline" - ], - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, { "args": [ "--require", diff --git a/AGENTS.md b/AGENTS.md index e5923674e90..552f6a91b67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,6 @@ Two categories of apps: yarn # Install dependencies yarn setup # First-time setup (installs deps + submodules) yarn dev # Start development (Docker backend + host frontend dev servers) -yarn dev:legacy # Local dev with legacy admin and without Docker (deprecated) -yarn dev:legacy:debug # yarn dev:legacy with DEBUG=@tryghost*,ghost:* enabled ``` ### Building @@ -86,18 +84,15 @@ cd ghost/admin && yarn lint # Lint Ember admin ### Database ```bash yarn knex-migrator migrate # Run database migrations -yarn reset:data # Reset database with test data (1000 members, 100 posts) -yarn reset:data:empty # Reset database with no data +yarn reset:data # Reset database with test data (1000 members, 100 posts) (requires yarn dev running) +yarn reset:data:empty # Reset database with no data (requires yarn dev running) ``` ### Docker ```bash -yarn docker:build # Build Docker images and delete ephemeral volumes -yarn docker:dev # Start Ghost in Docker with hot reload -yarn docker:shell # Open shell in Ghost container -yarn docker:mysql # Open MySQL CLI -yarn docker:test:unit # Run unit tests in Docker -yarn docker:reset # Reset all Docker volumes (including database) and restart +yarn docker:build # Build Docker images +yarn docker:clean # Stop containers, remove volumes and local images +yarn docker:down # Stop containers ``` ### How yarn dev works @@ -217,7 +212,7 @@ Users requested ability to switch themes for better accessibility - **Legacy:** `admin-x-design-system` (being phased out, avoid for new work) ### Analytics (Tinybird) -- **Local development:** `yarn docker:dev:analytics` (starts Tinybird + MySQL) +- **Local development:** `yarn dev:analytics` (starts Tinybird + MySQL) - **Config:** Add Tinybird config to `ghost/core/config.development.json` - **Scripts:** `ghost/core/core/server/data/tinybird/scripts/` - **Datafiles:** `ghost/core/core/server/data/tinybird/` diff --git a/compose.object-storage.yml b/compose.object-storage.yml deleted file mode 100644 index fcc84d82213..00000000000 --- a/compose.object-storage.yml +++ /dev/null @@ -1,64 +0,0 @@ -services: - ghost: - extends: - file: compose.yml - service: ghost - profiles: [object-storage] - environment: - - storage__media__adapter=S3Storage - - storage__media__staticFileURLPrefix=content/media - - storage__files__adapter=S3Storage - - storage__files__staticFileURLPrefix=content/files - - storage__S3Storage__bucket=ghost-dev - - storage__S3Storage__region=us-east-1 - - storage__S3Storage__tenantPrefix=ab/ab1234567890abcdef1234567890abcd - - storage__S3Storage__forcePathStyle=true - - storage__S3Storage__cdnUrl=http://127.0.0.1:9000/ghost-dev - - storage__S3Storage__staticFileURLPrefix=content/images - - storage__S3Storage__endpoint=http://minio:9000 - - storage__S3Storage__accessKeyId=minio-user - - storage__S3Storage__secretAccessKey=minio-pass - - urls__media=http://127.0.0.1:9000/ghost-dev/ab/ab1234567890abcdef1234567890abcd - - urls__files=http://127.0.0.1:9000/ghost-dev/ab/ab1234567890abcdef1234567890abcd - depends_on: - minio: - condition: service_healthy - minio-setup: - condition: service_completed_successfully - - minio: - profiles: [object-storage] - image: minio/minio:RELEASE.2024-12-13T22-19-12Z - container_name: ghost-minio - command: server /data --console-address ':9001' - restart: always - environment: - - MINIO_ROOT_USER=minio-user - - MINIO_ROOT_PASSWORD=minio-pass - ports: - - '9000:9000' - - '9001:9001' - volumes: - - minio-data:/data - healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/ready'] - interval: 1s - retries: 120 - - minio-setup: - profiles: [object-storage] - image: minio/mc - entrypoint: ['/bin/sh', '/setup.sh'] - environment: - - MINIO_ROOT_USER=minio-user - - MINIO_ROOT_PASSWORD=minio-pass - - MINIO_BUCKET=ghost-dev - volumes: - - ./docker/minio/setup.sh:/setup.sh:ro - depends_on: - minio: - condition: service_healthy - restart: 'no' - -volumes: - minio-data: {} diff --git a/compose.yml b/compose.yml deleted file mode 100644 index d62cc0280ff..00000000000 --- a/compose.yml +++ /dev/null @@ -1,328 +0,0 @@ -# Development Docker Compose configuration for Ghost Monorepo -# Not intended for production use. See https://github.com/tryghost/ghost-docker for production-ready self-hosting setup. -name: ghost - -# Template to share volumes and environment variable between all services running the same base image -x-service-template: &service-template - volumes: - - .:/home/ghost - - ${SSH_AUTH_SOCK}:/ssh-agent - - ${HOME}/.gitconfig:/root/.gitconfig:ro - - ${HOME}/.yalc:/root/.yalc - - shared-config:/mnt/shared-config:ro - - node_modules_yarn_lock_hash:/home/ghost/.yarnhash:delegated - - node_modules_ghost_root:/home/ghost/node_modules:delegated - - node_modules_ghost_admin:/home/ghost/ghost/admin/node_modules:delegated - - node_modules_ghost_core:/home/ghost/ghost/core/node_modules:delegated - - node_modules_ghost_i18n:/home/ghost/ghost/i18n/node_modules:delegated - - node_modules_e2e:/home/ghost/e2e/node_modules:delegated - - node_modules_apps_activitypub:/home/ghost/apps/activitypub/node_modules:delegated - - node_modules_apps_admin-x-design-system:/home/ghost/apps/admin-x-design-system/node_modules:delegated - - node_modules_apps_admin-x-framework:/home/ghost/apps/admin-x-framework/node_modules:delegated - - node_modules_apps_admin-x-settings:/home/ghost/apps/admin-x-settings/node_modules:delegated - - node_modules_apps_announcement-bar:/home/ghost/apps/announcement-bar/node_modules:delegated - - node_modules_apps_comments-ui:/home/ghost/apps/comments-ui/node_modules:delegated - - node_modules_apps_portal:/home/ghost/apps/portal/node_modules:delegated - - node_modules_apps_posts:/home/ghost/apps/posts/node_modules:delegated - - node_modules_apps_shade:/home/ghost/apps/shade/node_modules:delegated - - node_modules_apps_signup-form:/home/ghost/apps/signup-form/node_modules:delegated - - node_modules_apps_sodo-search:/home/ghost/apps/sodo-search/node_modules:delegated - - node_modules_apps_stats:/home/ghost/apps/stats/node_modules:delegated - environment: - - DEBUG=${DEBUG:-} - - SSH_AUTH_SOCK=/ssh-agent - - NX_DAEMON=${NX_DAEMON:-true} - - GHOST_DEV_IS_DOCKER=true - - GHOST_DEV_APP_FLAGS=${GHOST_DEV_APP_FLAGS:-} - - GHOST_UPSTREAM=${GHOST_UPSTREAM:-} - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} - -services: - server: - <<: *service-template - image: ghost-monorepo:latest - build: - target: development - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - working_dir: /home/ghost/ghost/core - command: [ "yarn", "dev" ] - ports: - - "2368:2368" - profiles: [ split, all ] - tty: true - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - tinybird-local: - condition: service_healthy - required: false - analytics: - condition: service_healthy - required: false - tb-cli: - condition: service_completed_successfully - required: false - stripe: - condition: service_healthy - required: false - environment: - - DEBUG=${DEBUG:-} - - SSH_AUTH_SOCK=/ssh-agent - - NX_DAEMON=false - - GHOST_DEV_IS_DOCKER=true - - GHOST_DEV_APP_FLAGS=${GHOST_DEV_APP_FLAGS:-} - - GHOST_UPSTREAM=${GHOST_UPSTREAM:-} - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} - - TB_HOST=${TB_HOST:-http://tinybird-local:7181} - - TB_LOCAL_HOST=${TB_LOCAL_HOST:-tinybird-local} - - tinybird__stats__endpoint=http://tinybird-local:7181 - - tinybird__stats__endpointBrowser=http://localhost:7181 - - tinybird__tracker__endpoint=http://localhost/.ghost/analytics/api/v1/page_hit - - admin: - <<: *service-template - image: ghost-monorepo:latest - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - working_dir: /home/ghost/ghost/admin - command: [ "yarn", "dev" ] - ports: - - "4200:4200" - - "4201:4201" - profiles: [ split, all ] - tty: true - - admin-apps: - <<: *service-template - image: ghost-monorepo:latest - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - working_dir: /home/ghost - command: [ "node", "/home/ghost/docker/watch-admin-apps.js" ] - profiles: [ split, all ] - tty: true - restart: always - environment: - - CHOKIDAR_USEPOLLING=true - - CHOKIDAR_INTERVAL=1000 - - FORCE_COLOR=1 - - caddy: - image: caddy:latest - container_name: ghost-caddy - profiles: [ split, all ] - ports: - - "80:80" - - "443:443" - volumes: - - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - environment: - - ANALYTICS_PROXY_TARGET=${ANALYTICS_PROXY_TARGET:-analytics:3000} - restart: always - - ghost: - <<: *service-template - image: ghost-monorepo:latest - build: - target: development - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - command: [ "yarn", "dev" ] - ports: - - "2368:2368" # Ghost - - "4200:4200" # Admin - - "4201:4201" # Admin tests - - "4175:4175" # Portal - - "4176:4176" # Portal HTTPS - - "4177:4177" # Announcement bar - - "4178:4178" # Search - - "6174:6174" # Signup form - - "7173:7173" # Comments - - "7174:7174" # Comments HTTPS - profiles: [ ghost, all] - tty: true - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - - mysql: - image: mysql:8.4.5 - container_name: ghost-mysql - command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT - ports: - - 3306:3306 - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: ghost - restart: always - volumes: - - ./docker/mysql-preload:/docker-entrypoint-initdb.d - - mysql-data:/var/lib/mysql - healthcheck: - test: mysql -uroot -proot ghost -e 'select 1' - interval: 1s - retries: 120 - - redis: - image: redis:7.0 - container_name: ghost-redis - ports: - - 6379:6379 - restart: always - volumes: - - redis-data:/data - healthcheck: - test: - - CMD - - redis-cli - - --raw - - incr - - ping - interval: 1s - retries: 120 - - analytics: - profiles: [ analytics, all ] - image: ghost/traffic-analytics:1.0.44 - platform: linux/amd64 - command: ["node", "--enable-source-maps", "dist/server.js"] - entrypoint: [ "/app/entrypoint.sh" ] - container_name: ghost-analytics - ports: - - "3000:3000" - healthcheck: - # Simpler: use Node's global fetch (Node 18+) - test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))\"" ] - interval: 1s - retries: 120 - volumes: - - ./docker/analytics/entrypoint.sh:/app/entrypoint.sh:ro - - shared-config:/mnt/shared-config:ro - environment: - - PROXY_TARGET=http://tinybird-local:7181/v0/events - depends_on: - tinybird-local: - condition: service_healthy - tb-cli: - condition: service_completed_successfully - - tb-cli: - build: - context: . - dockerfile: ./docker/tb-cli/Dockerfile - profiles: [ analytics, all ] - working_dir: /home/tinybird - tty: true - container_name: ghost-tb-cli - environment: - - TB_HOST=http://tinybird-local:7181 - - TB_LOCAL_HOST=tinybird-local - volumes: - - ./ghost/core/core/server/data/tinybird:/home/tinybird - - shared-config:/mnt/shared-config - depends_on: - tinybird-local: - condition: service_healthy - - tinybird-local: - profiles: [ analytics, all ] - image: tinybirdco/tinybird-local:latest - platform: linux/amd64 - ports: - - "7181:7181" - stop_grace_period: 2s - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:7181/v0/health" ] - interval: 1s - timeout: 5s - retries: 120 - - prometheus: - profiles: [ monitoring, all ] - image: prom/prometheus:v2.55.1 - container_name: ghost-prometheus - ports: - - 9090:9090 - restart: always - volumes: - - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - grafana: - profiles: [ monitoring, all ] - image: grafana/grafana:8.5.27 - container_name: ghost-grafana - ports: - - 3000:3000 - restart: always - environment: - - GF_AUTH_ANONYMOUS_ENABLED=true - - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin - volumes: - - ./docker/grafana/datasources:/etc/grafana/provisioning/datasources - - ./docker/grafana/dashboard.yml:/etc/grafana/provisioning/dashboards/main.yaml - - ./docker/grafana/dashboards:/var/lib/grafana/dashboards - - pushgateway: - profiles: [ monitoring, all ] - image: prom/pushgateway:v1.11.1 - container_name: ghost-pushgateway - ports: - - 9091:9091 - - mailpit: - image: axllent/mailpit - platform: linux/amd64 - container_name: ghost-mailpit - profiles: [ ghost, split, all ] - ports: - - "1025:1025" # SMTP server - - "8025:8025" # Web interface - restart: always - - stripe: - image: stripe/stripe-cli:latest - container_name: ghost-stripe - profiles: [ stripe, all ] - entrypoint: [ "/entrypoint.sh" ] - volumes: - - ./docker/stripe/entrypoint.sh:/entrypoint.sh:ro - - shared-config:/mnt/shared-config - environment: - - GHOST_URL=http://server:2368 - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} - healthcheck: - test: ["CMD", "test", "-f", "/mnt/shared-config/.env.stripe"] - interval: 1s - retries: 120 - -volumes: - mysql-data: {} - redis-data: {} - shared-config: {} - caddy_data: {} - node_modules_yarn_lock_hash: {} - node_modules_ghost_root: {} - node_modules_ghost_admin: {} - node_modules_ghost_core: {} - node_modules_ghost_i18n: {} - node_modules_e2e: {} - node_modules_apps_activitypub: {} - node_modules_apps_admin-x-design-system: {} - node_modules_apps_admin-x-framework: {} - node_modules_apps_admin-x-settings: {} - node_modules_apps_announcement-bar: {} - node_modules_apps_comments-ui: {} - node_modules_apps_portal: {} - node_modules_apps_posts: {} - node_modules_apps_shade: {} - node_modules_apps_signup-form: {} - node_modules_apps_sodo-search: {} - node_modules_apps_stats: {} diff --git a/e2e/package.json b/e2e/package.json index 6e66567e4de..b838966750f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,7 +12,7 @@ "docker:build:ghost": "cd .. && docker build -t ghost-monorepo:latest -f Dockerfile .", "docker:update": "docker compose pull && docker compose up -d --force-recreate", "prepare": "tsc --noEmit", - "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || docker compose -f ../compose.yml build ghost tb-cli", + "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || yarn docker:build:ghost", "test": "playwright test --project=main", "test:analytics": "playwright test --project=analytics", "test:all": "playwright test --project=main --project=analytics", diff --git a/ghost/core/test/scripts/browser-test-runner.js b/ghost/core/test/scripts/browser-test-runner.js new file mode 100644 index 00000000000..b1831c76cf6 --- /dev/null +++ b/ghost/core/test/scripts/browser-test-runner.js @@ -0,0 +1,65 @@ +/** + * Browser Test Runner + * + * Starts the frontend dev servers (Portal, Comments UI, etc.) and runs + * Playwright browser tests concurrently. The browser tests expect these + * apps to be available on specific ports. + */ +const concurrently = require('concurrently'); + +// Pass-through args (everything after --) +const doubleDashIndex = process.argv.lastIndexOf('--'); +const passThroughArgs = doubleDashIndex === -1 ? [] : process.argv.slice(doubleDashIndex + 1); +const PASS_THROUGH_FLAGS = passThroughArgs.join(' '); + +// Frontend dev servers needed by browser tests +// These ports are hardcoded in ghost/core/test/e2e-browser/fixtures/ghost-test.js +const commands = [ + { + name: 'browser-tests', + command: `nx run ghost:test:browser${PASS_THROUGH_FLAGS ? ` -- ${PASS_THROUGH_FLAGS}` : ''}`, + prefixColor: 'blue' + }, + { + name: 'portal', + command: 'nx run @tryghost/portal:dev', + prefixColor: 'magenta' + }, + { + name: 'comments', + command: 'nx run @tryghost/comments-ui:dev', + prefixColor: '#E55137' + }, + { + name: 'signup-form', + command: 'nx run @tryghost/signup-form:preview', + prefixColor: 'magenta' + }, + { + name: 'announcement-bar', + command: 'nx run @tryghost/announcement-bar:dev', + prefixColor: '#DC9D00' + }, + { + name: 'search', + command: 'nx run @tryghost/sodo-search:dev', + prefixColor: '#23de43' + } +]; + +(async () => { + // eslint-disable-next-line no-console + console.log(`Starting browser tests with frontend dev servers...`); + + const {result} = concurrently(commands, { + prefix: 'name', + killOthers: ['failure', 'success'], + successCondition: 'first' + }); + + try { + await result; + } catch (err) { + process.exit(1); + } +})(); diff --git a/package.json b/package.json index 78e6690ac6f..b3c25702da9 100644 --- a/package.json +++ b/package.json @@ -26,46 +26,23 @@ "build:clean": "nx reset && rimraf -g 'ghost/*/build' && rimraf -g 'ghost/*/tsconfig.tsbuildinfo'", "clean:hard": "node ./.github/scripts/clean.js", "dev": "nx run ghost-monorepo:docker:dev", - "dev:forward": "echo '***********************************************************************************************\n* Deprecation warning: This command will be removed in a future release, use yarn dev instead *\n***********************************************************************************************' && sleep 5 && yarn dev", "dev:lexical": "EDITOR_URL=http://localhost:2368/ghost/assets/koenig-lexical/ yarn dev", "dev:analytics": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml' nx run ghost-monorepo:docker:dev", "dev:storage": "DEV_COMPOSE_FILES='-f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", "dev:all": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml -f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", - "dev:ghost": "node .github/scripts/dev.js --ghost", - "dev:legacy": "node .github/scripts/dev.js", - "dev:legacy:debug": "DEBUG_COLORS=true DEBUG=@tryghost*,ghost:* yarn dev:legacy", - "dev:legacy:admin": "node .github/scripts/dev.js --admin", - "dev:tinybird": "node .github/scripts/dev-with-tinybird.js --tinybird", "fix": "yarn cache clean && rimraf -g '**/node_modules' && yarn && yarn nx reset", "knex-migrator": "yarn workspace ghost run knex-migrator", "setup": "yarn && git submodule update --init && NODE_ENV=development node .github/scripts/setup.js", - "reset:data": "cd ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123", - "reset:data:empty": "cd ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123", - "reset:data:xxl": "cd ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123", - "docker:reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", - "docker": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run --rm -it ghost", - "docker:dev": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose up --attach=ghost --force-recreate --no-log-prefix", - "docker:dev:object-storage": "docker compose -f compose.yml -f compose.object-storage.yml --profile object-storage up --attach=ghost --force-recreate --no-log-prefix", - "docker:dev:analytics": "docker compose --profile analytics up -d --wait", - "docker:dev:analytics:stop": "docker compose --profile analytics down", - "docker:dev:analytics:logs": "docker compose --profile analytics logs -f", - "docker:build": "yarn docker:clean && yarn build:clean && docker compose --profile all build", - "docker:clean": "echo \"Deleting node_modules volumes...\" && docker compose --profile all down --remove-orphans && docker volume ls -q -f name=ghost_node_modules | xargs -I{} docker volume rm {}", - "docker:shell": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run --rm -it ghost /bin/bash", - "docker:mysql": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose up mysql -d --wait && docker compose exec mysql mysql -u root -proot ghost", - "docker:sleep": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run -d --name ghost-devcontainer --rm -it ghost /bin/bash -c 'sleep infinity'", - "docker:sleep:stop": "docker stop ghost-devcontainer", - "docker:test:unit": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} NX_DAEMON=false docker compose run --rm --no-deps ghost yarn test:unit", - "docker:test:browser": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run --rm ghost yarn test:browser", - "docker:test:all": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} NX_DAEMON=false docker compose run --rm ghost yarn nx run ghost:test:all", - "docker:test:e2e": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} NX_DAEMON=false docker compose run --rm ghost yarn test:e2e", - "docker:reset": "docker compose --profile all down -v && docker compose up -d --wait", - "docker:restart": "docker compose down && docker compose up -d --wait", - "docker:down": "docker compose --profile all down", + "reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", + "reset:data:empty": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123'", + "reset:data:xxl": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123'", + "docker:build": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} build", + "docker:clean": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} --profile all down -v --remove-orphans --rmi local", + "docker:down": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} down", "lint": "nx run-many -t lint", "test": "nx run-many -t test", "test:unit": "nx run-many -t test:unit", - "test:browser": "node .github/scripts/dev.js --browser-tests --all --", + "test:browser": "node ghost/core/test/scripts/browser-test-runner.js --", "test:e2e": "yarn workspace @tryghost/e2e test", "test:e2e:analytics": "yarn workspace @tryghost/e2e test:analytics", "test:e2e:all": "yarn workspace @tryghost/e2e test:all", From 677c6b70e31ba36df518fccd0268c854f93c9a15 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 14:33:05 -0600 Subject: [PATCH 20/24] Removed `.should.match()` (#26492) no ref This test-only change should have no user impact. --- .../test/e2e-frontend/helpers/get.test.js | 6 +-- .../services/stats/mrr-stats-service.test.js | 4 +- .../email-service/email-renderer.test.js | 39 +++++-------------- 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/ghost/core/test/e2e-frontend/helpers/get.test.js b/ghost/core/test/e2e-frontend/helpers/get.test.js index 45fc927817a..35316ea8d25 100644 --- a/ghost/core/test/e2e-frontend/helpers/get.test.js +++ b/ghost/core/test/e2e-frontend/helpers/get.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); -const {assertExists} = require('../../utils/assertions'); -const should = require('should'); +const {assertExists, assertObjectMatches} = require('../../utils/assertions'); const sinon = require('sinon'); const testUtils = require('../../utils'); const models = require('../../../core/server/models/index'); @@ -39,8 +38,7 @@ function testPosts(posts, map) { const post = posts.find(p => p.id === postID); assertExists(post); - - post.should.match(expectData); + assertObjectMatches(post, expectData); } } diff --git a/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js b/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js index 58c73183e5c..a18d5509ebe 100644 --- a/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js +++ b/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const statsService = require('../../../../core/server/services/stats'); const {agentProvider, fixtureManager, mockManager} = require('../../../utils/e2e-framework'); -require('should'); const {stripeMocker} = require('../../../utils/e2e-framework-mock-manager'); const moment = require('moment'); @@ -175,8 +174,7 @@ describe('MRR Stats Service', function () { await createMemberWithSubscription('month', 2, 'usd', moment(today).toISOString()); const results = await statsService.api.mrr.fetchAllDeltas(); - assert.equal(results.length, 3); - results.should.match([ + assert.deepEqual(results, [ { date: ninetyDaysAgo, delta: 500, diff --git a/ghost/core/test/unit/server/services/email-service/email-renderer.test.js b/ghost/core/test/unit/server/services/email-service/email-renderer.test.js index 30c11e603eb..914b0e16264 100644 --- a/ghost/core/test/unit/server/services/email-service/email-renderer.test.js +++ b/ghost/core/test/unit/server/services/email-service/email-renderer.test.js @@ -1,4 +1,3 @@ -require('should'); const EmailRenderer = require('../../../../../core/server/services/email-service/email-renderer'); const assert = require('node:assert/strict'); const {assertExists} = require('../../../../utils/assertions'); @@ -1399,20 +1398,11 @@ describe('Email renderer', function () { // Unsubscribe button included assert(response.plaintext.includes('Unsubscribe [%%{unsubscribe_url}%%]')); assert(response.html.includes('Unsubscribe')); - assert.equal(response.replacements.length, 4); - response.replacements.should.match([ - { - id: 'uuid' - }, - { - id: 'key' - }, - { - id: 'unsubscribe_url' - }, - { - id: 'list_unsubscribe' - } + assert.deepEqual(response.replacements.map(r => r.id), [ + 'uuid', + 'key', + 'unsubscribe_url', + 'list_unsubscribe' ]); assert(response.plaintext.includes('http://example.com')); @@ -2007,20 +1997,11 @@ describe('Email renderer', function () { assert(response.html.includes('Unsubscribe')); assert(response.html.includes('http://example.com')); - assert.equal(response.replacements.length, 4); - response.replacements.should.match([ - { - id: 'uuid' - }, - { - id: 'key' - }, - { - id: 'unsubscribe_url' - }, - { - id: 'list_unsubscribe' - } + assert.deepEqual(response.replacements.map(r => r.id), [ + 'uuid', + 'key', + 'unsubscribe_url', + 'list_unsubscribe' ]); assert(!response.html.includes('members only section')); assert(response.html.includes('some text for both')); From 5c1f357c7a2bf4eca2f1d585fa7850baee9955a0 Mon Sep 17 00:00:00 2001 From: Weyland Swart <49831538+weylandswart@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:44:08 +0000 Subject: [PATCH 21/24] Updated popover margins and improved user details layout (#26493) No ref. Our popovers had excessive top margin so I removed it. Also improved the ordering of the elements in the user details modal, made the delete action destructive and updated the delete modal phrasing to make sense. | Before | After | |--------|--------| | Screenshot 2026-02-18 at 20 17 28 | Screenshot 2026-02-18 at 20 17
06 | --- .../src/global/popover.tsx | 2 +- .../settings/general/user-detail-modal.tsx | 33 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/admin-x-design-system/src/global/popover.tsx b/apps/admin-x-design-system/src/global/popover.tsx index 7e35ae50551..c6b1b72855b 100644 --- a/apps/admin-x-design-system/src/global/popover.tsx +++ b/apps/admin-x-design-system/src/global/popover.tsx @@ -40,7 +40,7 @@ const Popover: React.FC = ({ {trigger} - {children} diff --git a/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx b/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx index feb5708535a..ed9debda825 100644 --- a/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx @@ -282,7 +282,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { title: 'Are you sure you want to delete this user?', prompt: ( <> -

{_user.name || _user.email} will be permanently deleted and all their posts will be automatically assigned to the {owner.name}.

+

{_user.name || _user.email} will be permanently deleted and all their posts will be automatically assigned to {owner.name}.

To make these easy to find in the future, each post will be given an internal tag of #{user.slug}

), @@ -377,6 +377,15 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { }); } + menuItems.push({ + id: 'view-user-activity', + label: 'View user activity', + onClick: () => { + mainModal.remove(); + updateRoute(`history/view/${formState.id}`); + } + }); + if (formState.id !== currentUser.id && ( (hasAdminAccess(currentUser) && !isOwnerUser(user)) || (isEditorUser(currentUser) && isAuthorOrContributor(user)) @@ -384,29 +393,21 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { let suspendUserLabel = formState.status === 'inactive' ? 'Un-suspend user' : 'Suspend user'; menuItems.push({ - id: 'delete-user', - label: 'Delete user', - onClick: () => { - confirmDelete(user, {owner: ownerUser}); - } - }, { id: 'suspend-user', label: suspendUserLabel, onClick: () => { confirmSuspend(formState); } + }, { + id: 'delete-user', + label: 'Delete user', + destructive: true, + onClick: () => { + confirmDelete(user, {owner: ownerUser}); + } }); } - menuItems.push({ - id: 'view-user-activity', - label: 'View user activity', - onClick: () => { - mainModal.remove(); - updateRoute(`history/view/${formState.id}`); - } - }); - const noCoverButtonClasses = 'rounded text-sm flex flex-nowrap items-center justify-center px-3 h-8 transition-all cursor-pointer font-medium border border-grey-300 bg-transparent text-black dark:border-grey-800 dark:text-white'; const coverButtonClasses = 'flex flex-nowrap items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white transition-all cursor-pointer font-medium nowrap'; From d46506dfbe0ccc6088a8e3988f7c06df6aa8a6e8 Mon Sep 17 00:00:00 2001 From: Yovko Lambrev Date: Wed, 18 Feb 2026 23:01:35 +0200 Subject: [PATCH 22/24] =?UTF-8?q?=F0=9F=8C=90=20Updated=20Bulgarian=20tran?= =?UTF-8?q?slations=20(#26287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Changes:** Updated the Bulgarian translation of `comments.json` and `portal.json` to include recently added strings. Refined some existing translations for better clarity, conciseness, and improved UI consistency. --- > [!NOTE] > **Low Risk** > Translation-only JSON string updates with no logic changes; risk is limited to minor UI text regressions or missing translations. > > **Overview** > **Updates Bulgarian (`bg`) UI translations** for comments and Portal by filling in previously empty strings and refining wording for consistency. > > This adds missing labels/messages (e.g., commenting restrictions, admin view link, billing/next payment, “open mail provider” actions, and billing-update failures) and tweaks several existing phrases from “актуализиран/актуализиране” to “обновен/обновяване” and similar copy edits. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5f64dd7f951f5c6a5e4f10d4bdc2875ee70ad2d1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- ghost/i18n/locales/bg/comments.json | 10 ++++---- ghost/i18n/locales/bg/portal.json | 38 ++++++++++++++--------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/ghost/i18n/locales/bg/comments.json b/ghost/i18n/locales/bg/comments.json index 3d75d236042..9648904c303 100644 --- a/ghost/i18n/locales/bg/comments.json +++ b/ghost/i18n/locales/bg/comments.json @@ -17,9 +17,9 @@ "Best": "Най-добрите", "Cancel": "Отказ", "Comment": "Коментар", - "Commenting disabled": "", + "Commenting disabled": "Коментирането е изключено", "Complete your profile": "Попълнете профила си", - "Contact support": "", + "Contact support": "Връзка с поддръжката", "Delete": "Изтриване", "Deleted": "Изтрито", "Deleted member": "Изтрит абонат", @@ -30,7 +30,7 @@ "edited": "редактиран", "Enter your name": "Попълнете името си", "Expertise": "Компетенции", - "for more information.": "", + "for more information.": "за повече информация.", "Founder @ Acme Inc": "Основател на Компания ООД", "Full-time parent": "Родител на пълно работно време", "Head of Marketing at Acme, Inc": "Директор маркетинг в Компания ООД", @@ -73,8 +73,8 @@ "This comment has been hidden.": "Този коментар е скрит.", "This comment has been removed.": "Този коментар е премахнат.", "Upgrade now": "Надградете сега", - "View in admin": "", + "View in admin": "Преглед в админ панела", "Yesterday": "Вчера", - "You can't post comments in this publication.": "", + "You can't post comments in this publication.": "Не може да коментирате под тази публикация.", "Your request will be sent to the owner of this site.": "Искането ще бъде изпратено до собственика на сайта." } diff --git a/ghost/i18n/locales/bg/portal.json b/ghost/i18n/locales/bg/portal.json index e596448a716..4dc5347d6df 100644 --- a/ghost/i18n/locales/bg/portal.json +++ b/ghost/i18n/locales/bg/portal.json @@ -23,7 +23,7 @@ "An unexpected error occured. Please try again or contact support if the error persists.": "Възникна неочаквана грешка. Моля, опитайте отново или потърсете поддръжката ако това се повтаря.", "Back": "Обратно", "Back to Log in": "Обратно към формуляра за влизане", - "Billing info & receipts": "", + "Billing info & receipts": "Платежна информация", "Black Friday": "Черен петък", "Cancel anytime.": "Отказване по всяко време.", "Cancel subscription": "Откажи абонамент", @@ -80,11 +80,11 @@ "Failed to send magic link email": "Неуспешно изпращане на линк за влизане по имейл", "Failed to send verification email": "Неуспешно изпращане на имейл за проверка", "Failed to sign up, please try again": "Неуспешна регистрация, опитайте отново", - "Failed to update account data": "Неуспешно актуализиране на данните", - "Failed to update account details": "Неуспешно актуализиране на детайлите", - "Failed to update billing information, please try again": "", - "Failed to update newsletter settings": "Неуспешно актуализиране на настройките на бюлетина", - "Failed to update subscription, please try again": "Не успяхте да актуализирате абонамента, опитайте отново", + "Failed to update account data": "Неуспешно обновяване на данните", + "Failed to update account details": "Неуспешно обновяване на детайлите", + "Failed to update billing information, please try again": "Неуспешно обновяване на данните за плащане, опитайте отново", + "Failed to update newsletter settings": "Неуспешно обновяване на настройките на бюлетина", + "Failed to update subscription, please try again": "Не успяхте да обновите абонамента, опитайте отново", "Failed to verify code, please try again": "Неуспешна проверка на кода, опитайте отново", "Forever": "Завинаги", "Free Trial – Ends {trialEnd}": "Безплатен тест – до {trialEnd}", @@ -119,21 +119,21 @@ "Name": "Име", "Need more help? Contact support": "Още имате нужда от помощ? Потърсете поддръжката", "Newsletters can be disabled on your account for two reasons: A previous email was marked as spam, or attempting to send an email resulted in a permanent failure (bounce).": "Бюлетините могат да бъдат деактивирани в профила ви по две причини: предишен имейл е бил маркиран като спам или опитът за изпращане на имейл е довел до траен неуспех (отказ).", - "Next payment": "", + "Next payment": "Следващо плащане", "No member exists with this e-mail address.": "Няма абонат с такъв имейл адрес.", "No member exists with this e-mail address. Please sign up first.": "Няма абонат с такъв имейл адрес. Моля, първо се регистрирайте.", "Not receiving emails?": "Не получавате поща?", "Now check your email!": "Проверете имейла си!", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "След като се абонирате отново, ако все още не получавате имейли, проверете папката за спам. Някои доставчици пазят история с предишни оплаквания за спам и ще продължат да маркират имейлите. Ако вашият случай е такъв, маркирайте последния бюлетин като 'Не е спам', за да го преместите обратно в основната си папка.", - "Open AOL Mail": "", - "Open email": "", - "Open Gmail": "", - "Open Hey": "", - "Open iCloud Mail": "", - "Open Mail.ru": "", - "Open Outlook": "", - "Open Proton Mail": "", - "Open Yahoo Mail": "", + "Open AOL Mail": "Отворете AOL Mail", + "Open email": "Отворете имейла си", + "Open Gmail": "Отворете Gmail", + "Open Hey": "Отворете Hey", + "Open iCloud Mail": "Отворете iCloud Mail", + "Open Mail.ru": "Отворете Mail.ru", + "Open Outlook": "Отворете Outlook", + "Open Proton Mail": "Отворете Proton Mail", + "Open Yahoo Mail": "Отворете Yahoo Mail", "Permanent failure (bounce)": "Постоянен проблем (отказ)", "Phone number": "Телефонен номер", "Plan": "План", @@ -168,18 +168,18 @@ "Submit feedback": "Изпратете отзив", "Subscribe": "Абонамент", "Subscribed": "Абониран", - "Subscription plan updated successfully": "Абонаментният план е актуализиран успешно", + "Subscription plan updated successfully": "Абонаментният план е обновен успешно", "Success": "Чудесно", "Success! Check your email for magic link to sign-in.": "Чудесно! Проверете имейла си за своя магически линк за влизане.", "Success! Your account is fully activated, you now have access to all content.": "Чудесно! Вашият профил е активиран и вече имате достъп до цялото съдържание.", - "Success! Your email is updated.": "Чудесно! Вашият имейл е актуализиран.", + "Success! Your email is updated.": "Чудесно! Вашият имейл е обновен.", "Successfully unsubscribed": "Успешно отписване", "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "Благодарим ви за абонамента. Преди да започнете да четете, още няколко сайта, които може да ви харесат.", "Thank you for your support": "Благодарности за подкрепата ви", "Thank you for your support!": "Благодарности за подкрепата ви!", "Thanks for the feedback!": "Благодарности за обратната връзка!", "That didn't go to plan": "Не се получи както трябва", - "The email address we have for you is {memberEmail} — if that's not correct, you can update it in your .": "Имейлът, с който сте регистрирани, е {memberEmail} — ако това не е вярно, актуализирайте го в .", + "The email address we have for you is {memberEmail} — if that's not correct, you can update it in your .": "Имейлът, с който сте регистрирани, е {memberEmail} — ако това не е вярно, обновете го в .", "There was a problem submitting your feedback. Please try again a little later.": "Имаше проблем при изпращането на обратната връзка. Моля, опитайте отново малко по-късно.", "There was an error cancelling your subscription, please try again.": "Възникна грешка при отмяната на абонамента ви, опитайте отново.", "There was an error continuing your subscription, please try again.": "Възникна грешка при продължаването на абонамента ви, опитайте отново.", From 12364bd2f8a44943c981d6adc06735d2fee1bbd6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 15:15:09 -0600 Subject: [PATCH 23/24] Removed Should.js from front-end members tests (#26496) no ref This test-only change should have no user impact. --- ghost/core/test/e2e-frontend/members.test.js | 96 ++++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/ghost/core/test/e2e-frontend/members.test.js b/ghost/core/test/e2e-frontend/members.test.js index 1c06cc81f5d..3d0945f705a 100644 --- a/ghost/core/test/e2e-frontend/members.test.js +++ b/ghost/core/test/e2e-frontend/members.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); const moment = require('moment'); @@ -232,19 +231,19 @@ describe('Front-end members behavior', function () { const getJsonResponse = getRes.body; assertExists(getJsonResponse); - getJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']); + assert('email' in getJsonResponse); + assert('uuid' in getJsonResponse); + assert('status' in getJsonResponse); + assert('name' in getJsonResponse); + assert('newsletters' in getJsonResponse); assert(!('id' in getJsonResponse)); assert.equal(getJsonResponse.newsletters.length, 1); // NOTE: these should be snapshots not code - assert.equal(Object.keys(getJsonResponse.newsletters[0]).length, 5); - getJsonResponse.newsletters[0].should.have.properties([ - 'id', - 'uuid', - 'name', - 'description', - 'sort_order' - ]); + assert.deepEqual( + new Set(Object.keys(getJsonResponse.newsletters[0])), + new Set(['id', 'uuid', 'name', 'description', 'sort_order']) + ); // Can update newsletter subscription const originalNewsletters = getJsonResponse.newsletters; @@ -259,7 +258,11 @@ describe('Front-end members behavior', function () { const jsonResponse = res.body; assertExists(jsonResponse); - jsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']); + assert('email' in jsonResponse); + assert('uuid' in jsonResponse); + assert('status' in jsonResponse); + assert('name' in jsonResponse); + assert('newsletters' in jsonResponse); assert(!('id' in jsonResponse)); assert.equal(jsonResponse.newsletters.length, 0); @@ -271,18 +274,18 @@ describe('Front-end members behavior', function () { const restoreJsonResponse = resRestored.body; assertExists(restoreJsonResponse); - restoreJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']); + assert('email' in restoreJsonResponse); + assert('uuid' in restoreJsonResponse); + assert('status' in restoreJsonResponse); + assert('name' in restoreJsonResponse); + assert('newsletters' in restoreJsonResponse); assert(!('id' in restoreJsonResponse)); assert.equal(restoreJsonResponse.newsletters.length, 1); // @NOTE: this seems like too much exposed information, needs a review - assert.equal(Object.keys(restoreJsonResponse.newsletters[0]).length, 5); - restoreJsonResponse.newsletters[0].should.have.properties([ - 'id', - 'uuid', - 'name', - 'description', - 'sort_order' - ]); + assert.deepEqual( + new Set(Object.keys(restoreJsonResponse.newsletters[0])), + new Set(['id', 'uuid', 'name', 'description', 'sort_order']) + ); assert.equal(restoreJsonResponse.newsletters[0].name, originalNewsletterName); }); @@ -875,37 +878,34 @@ describe('Front-end members behavior', function () { assertExists(memberData); // @NOTE: this should be a snapshot test not code - memberData.should.have.properties([ - 'uuid', - 'email', - 'name', - 'firstname', - 'expertise', - 'avatar_image', - 'subscribed', - 'subscriptions', - 'paid', - 'created_at', - 'enable_comment_notifications', - 'can_comment', - 'commenting', - 'newsletters', - 'email_suppression', - 'unsubscribe_url' - ]); - assert.equal(Object.keys(memberData).length, 16); - assert(!('id' in memberData)); + assert.deepEqual( + new Set(Object.keys(memberData)), + new Set([ + 'uuid', + 'email', + 'name', + 'firstname', + 'expertise', + 'avatar_image', + 'subscribed', + 'subscriptions', + 'paid', + 'created_at', + 'enable_comment_notifications', + 'can_comment', + 'commenting', + 'newsletters', + 'email_suppression', + 'unsubscribe_url' + ]) + ); assert.equal(memberData.newsletters.length, 1); // @NOTE: this should be a snapshot test not code - assert.equal(Object.keys(memberData.newsletters[0]).length, 5); - memberData.newsletters[0].should.have.properties([ - 'id', - 'uuid', - 'name', - 'description', - 'sort_order' - ]); + assert.deepEqual( + new Set(Object.keys(memberData.newsletters[0])), + new Set(['id', 'uuid', 'name', 'description', 'sort_order']) + ); }); it('can read public post content', async function () { From 6c1227cdf26eb13558ec60677b565907f0f692bc Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 18 Feb 2026 15:51:40 -0600 Subject: [PATCH 24/24] Removed last Should.js invocations (#26499) no ref This test-only change should have no user impact. `git grep -F .should.` returns no results after this change. --- .../e2e-api/admin/members-importer.test.js | 6 +- .../test/e2e-frontend/default-routes.test.js | 2 +- .../test/legacy/models/model-posts.test.js | 2 +- .../unit/frontend/helpers/prev-post.test.js | 221 ++++++++++++------ .../routing/static-routes-router.test.js | 6 +- .../theme-engine/handlebars/template.test.js | 3 +- .../unit/server/data/exporter/index.test.js | 4 +- .../server/services/mail/ghost-mailer.test.js | 2 +- .../services/geolocation-service.test.js | 4 +- .../server/services/stats/content.test.js | 3 - 10 files changed, 158 insertions(+), 95 deletions(-) diff --git a/ghost/core/test/e2e-api/admin/members-importer.test.js b/ghost/core/test/e2e-api/admin/members-importer.test.js index 9903e1efa9c..d603ffd1f31 100644 --- a/ghost/core/test/e2e-api/admin/members-importer.test.js +++ b/ghost/core/test/e2e-api/admin/members-importer.test.js @@ -118,7 +118,7 @@ describe('Members Importer API', function () { // assertExists(jsonResponse.meta.stats); // assertExists(jsonResponse.meta.import_label); - // jsonResponse.meta.stats.imported.should.equal(8); + // assert.equal(jsonResponse.meta.stats.imported, 8); // return jsonResponse.meta.import_label; // }) @@ -134,7 +134,7 @@ describe('Members Importer API', function () { // const jsonResponse = res.body; // assertExists(jsonResponse); // assertExists(jsonResponse.members); - // jsonResponse.members.should.have.length(8); + // assert.equal(jsonResponse.members.length, 8); // }) // .then(() => importLabel); // }) @@ -168,7 +168,7 @@ describe('Members Importer API', function () { // const jsonResponse = res.body; // assertExists(jsonResponse); // assertExists(jsonResponse.members); - // jsonResponse.members.should.have.length(0); + // assert.equal(jsonResponse.members.length, 0); // }); // }); // }); diff --git a/ghost/core/test/e2e-frontend/default-routes.test.js b/ghost/core/test/e2e-frontend/default-routes.test.js index 48c057e1c85..6c280ae786b 100644 --- a/ghost/core/test/e2e-frontend/default-routes.test.js +++ b/ghost/core/test/e2e-frontend/default-routes.test.js @@ -128,7 +128,7 @@ describe('Default Frontend routing', function () { assert(res.text.includes('Start here for a quick overview of everything you need to know')); assert.match(res.text, /]*?>Start here for a quick overview of everything you need to know<\/h1>/); // We should write a single test for this, or encapsulate it as an assertion - // E.g. res.text.should.not.containInvalidUrls() + // E.g. assertDoesNotContainInvalidUrls(res.text) assert(!res.text.includes('__GHOST_URL__')); }); }); diff --git a/ghost/core/test/legacy/models/model-posts.test.js b/ghost/core/test/legacy/models/model-posts.test.js index ac67ac49d6d..00e925ef444 100644 --- a/ghost/core/test/legacy/models/model-posts.test.js +++ b/ghost/core/test/legacy/models/model-posts.test.js @@ -814,7 +814,7 @@ describe('Post Model', function () { assert.equal(createdPost.get('html'), newPostDB.html); assert.equal(createdPost.has('plaintext'), true); assert.match(createdPost.get('plaintext'), /^testing/); - // createdPost.get('slug').should.equal(newPostDB.slug + '-3'); + // assert.equal(createdPost.get('slug'), newPostDB.slug + '-3'); assert.equal((!!createdPost.get('featured')), false); assert.equal((!!createdPost.get('page')), false); diff --git a/ghost/core/test/unit/frontend/helpers/prev-post.test.js b/ghost/core/test/unit/frontend/helpers/prev-post.test.js index 47997d9dc16..8a86c562423 100644 --- a/ghost/core/test/unit/frontend/helpers/prev-post.test.js +++ b/ghost/core/test/unit/frontend/helpers/prev-post.test.js @@ -4,7 +4,6 @@ const sinon = require('sinon'); const markdownToMobiledoc = require('../../../utils/fixtures/data-generator').markdownToMobiledoc; const prev_post = require('../../../../core/frontend/helpers/prev_post'); const api = require('../../../../core/frontend/services/proxy').api; -const should = require('should'); const logging = require('@tryghost/logging'); describe('{{prev_post}} helper', function () { @@ -57,14 +56,21 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({include: 'author,authors,tags,tiers'}) + ); }); }); @@ -93,12 +99,16 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); + sinon.assert.notCalled(fn); - assert.equal(inverse.firstCall.args.length, 2); - inverse.firstCall.args[0].should.have.properties('slug', 'title'); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); }); }); @@ -228,15 +238,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_tag:test/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_tag:test/) + }) + ); }); it('shows \'if\' template with prev post data with primary_author set', async function () { @@ -256,15 +275,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_author:hans/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_author:hans/) + }) + ); }); it('shows \'if\' template with prev post data with author set', async function () { @@ -284,15 +312,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+author:author-name/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+author:author-name/) + }) + ); }); it('shows \'if\' template with prev post data & ignores in author if author isnt present', async function () { @@ -311,15 +348,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+author:/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+author:/.test(filter)) + }) + ); }); it('shows \'if\' template with prev post data & ignores unknown in value', async function () { @@ -339,15 +385,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+magic/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+magic/.test(filter)) + }) + ); }); }); @@ -375,13 +430,19 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.calledOnce, true); - assert.equal(loggingStub.calledOnce, true); + sinon.assert.notCalled(fn); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); - inverse.firstCall.args[1].data.should.be.an.Object().and.have.property('error'); - assert.match(inverse.firstCall.args[1].data.error, /^Something wasn't found/); + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match.any, + sinon.match({ + data: sinon.match({ + error: sinon.match(/^Something wasn't found/) + }) + }) + ); + + sinon.assert.calledOnce(loggingStub); }); it('should show warning for call without any options', async function () { @@ -435,17 +496,25 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - - // Check context passed - assert.equal(browsePostsStub.firstCall.args[0].context.member, member); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + // Check context passed + context: sinon.match({member}) + }) + ); }); }); }); diff --git a/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js b/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js index be41ac3896c..2c987c1d549 100644 --- a/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js +++ b/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js @@ -82,13 +82,12 @@ describe('UNIT - services/routing/StaticRoutesRouter', function () { staticRoutesRouter._prepareStaticRouteContext(req, res, next); assert.equal(next.called, true); - res.routerOptions.should.have.properties('type', 'templates', 'defaultTemplate', 'context', 'data', 'contentType'); assert.equal(res.routerOptions.type, 'custom'); assert.deepEqual(res.routerOptions.templates, []); assert.equal(typeof res.routerOptions.defaultTemplate, 'function'); assert.deepEqual(res.routerOptions.context, ['about']); assert.deepEqual(res.routerOptions.data, {}); - + assert('contentType' in res.routerOptions); assert.equal(res.routerOptions.contentType, undefined); assert.equal(res.locals.slug, undefined); }); @@ -99,13 +98,12 @@ describe('UNIT - services/routing/StaticRoutesRouter', function () { staticRoutesRouter._prepareStaticRouteContext(req, res, next); assert.equal(next.called, true); - res.routerOptions.should.have.properties('type', 'templates', 'defaultTemplate', 'context', 'data', 'contentType'); assert.equal(res.routerOptions.type, 'custom'); assert.deepEqual(res.routerOptions.templates, []); assert.equal(typeof res.routerOptions.defaultTemplate, 'function'); assert.deepEqual(res.routerOptions.context, ['index']); assert.deepEqual(res.routerOptions.data, {}); - + assert('contentType' in res.routerOptions); assert.equal(res.locals.slug, undefined); }); }); diff --git a/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js b/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js index 08ffa310cdb..54e22bff777 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../../../utils/assertions'); -const should = require('should'); const errors = require('@tryghost/errors'); const {hbs, templates} = require('../../../../../../core/frontend/services/handlebars'); @@ -11,7 +10,7 @@ describe('Helpers Template', function () { const safeString = templates.execute('test', {name: 'world'}); assertExists(safeString); - safeString.should.have.property('string').and.equal('

Hello world

'); + assert.equal(safeString.string, '

Hello world

'); }); it('will throw an IncorrectUsageError if the partial does not exist', function () { diff --git a/ghost/core/test/unit/server/data/exporter/index.test.js b/ghost/core/test/unit/server/data/exporter/index.test.js index 5aa15faa636..51c1f8c1bee 100644 --- a/ghost/core/test/unit/server/data/exporter/index.test.js +++ b/ghost/core/test/unit/server/data/exporter/index.test.js @@ -52,7 +52,7 @@ describe('Exporter', function () { assert.equal(db.knex.called, true); assert.equal(knexMock.callCount, expectedCallCount); - queryMock.select.callCount.should.have.eql(expectedCallCount); + sinon.assert.callCount(queryMock.select, expectedCallCount); const expectedTables = new Set([ 'posts', @@ -99,7 +99,7 @@ describe('Exporter', function () { assert.equal(queryMock.select.called, true); assert.equal(knexMock.callCount, expectedCallCount); - queryMock.select.callCount.should.have.eql(expectedCallCount); + sinon.assert.callCount(queryMock.select, expectedCallCount); const expectedTables = new Set([ 'posts', diff --git a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js index 164871b0cc8..bb0f3de3ab1 100644 --- a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js +++ b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js @@ -58,7 +58,7 @@ describe('Mail: Ghostmailer', function () { mailer = new mail.GhostMailer(); assertExists(mailer); - mailer.should.have.property('send').and.be.a.Function(); + assert.equal(typeof mailer.send, 'function'); }); it('should setup SMTP transport on initialization', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js index d736d0ba0d0..05d90cfa566 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js @@ -40,7 +40,7 @@ describe('lib/geolocation', function () { assert.equal(scope.isDone(), true, 'request was not made'); assertExists(result, 'nothing was returned'); - result.should.deepEqual(RESPONSE, 'result didn\'t match expected response'); + assert.deepEqual(result, RESPONSE, 'result didn\'t match expected response'); }); it('fetches from geojs.io with IPv6 address', async function () { @@ -52,7 +52,7 @@ describe('lib/geolocation', function () { assert.equal(scope.isDone(), true, 'request was not made'); assertExists(result, 'nothing was returned'); - result.should.deepEqual(RESPONSE, 'result didn\'t match expected response'); + assert.deepEqual(result, RESPONSE, 'result didn\'t match expected response'); }); it('handles non-IP addresses', async function () { diff --git a/ghost/core/test/unit/server/services/stats/content.test.js b/ghost/core/test/unit/server/services/stats/content.test.js index bce5d43dd72..c13754da50e 100644 --- a/ghost/core/test/unit/server/services/stats/content.test.js +++ b/ghost/core/test/unit/server/services/stats/content.test.js @@ -148,7 +148,6 @@ describe('ContentStatsService', function () { const result = await service.lookupPostTitles(['post-1', 'post-2']); assertExists(result); - result.should.have.properties(['post-1', 'post-2']); assert.equal(result['post-1'].title, 'Test Post 1'); assert.equal(result['post-1'].id, 'post-id-1'); assert.equal(result['post-2'].title, 'Test Post 2'); @@ -183,7 +182,6 @@ describe('ContentStatsService', function () { const result = service.getResourceTitle('/about/'); assertExists(result); - result.should.have.properties(['title', 'resourceType']); assert.equal(result.title, 'About Us'); assert.equal(result.resourceType, 'page'); }); @@ -198,7 +196,6 @@ describe('ContentStatsService', function () { const result = service.getResourceTitle('/tag/news/'); assertExists(result); - result.should.have.properties(['title', 'resourceType']); assert.equal(result.title, 'News'); assert.equal(result.resourceType, 'tag'); });