diff --git a/apps/admin-x-framework/src/test/vitest-config.ts b/apps/admin-x-framework/src/test/vitest-config.ts index c39e5295a4c..84ab02c0ac5 100644 --- a/apps/admin-x-framework/src/test/vitest-config.ts +++ b/apps/admin-x-framework/src/test/vitest-config.ts @@ -42,6 +42,10 @@ export function createVitestConfig(options: VitestConfigOptions = {}) { test: { globals: true, environment: 'jsdom', + // pool: 'forks' for process-level isolation in jsdom-heavy + // React suites; sidesteps Vitest threads-pool edge cases. + pool: 'forks', + isolate: true, setupFiles, include, silent, diff --git a/ghost/core/test/e2e-api/members/send-magic-link.test.js b/ghost/core/test/e2e-api/members/send-magic-link.test.js index afc67dc573a..ece3f1d5763 100644 --- a/ghost/core/test/e2e-api/members/send-magic-link.test.js +++ b/ghost/core/test/e2e-api/members/send-magic-link.test.js @@ -768,14 +768,24 @@ describe('sendMagicLink', function () { function assertNoOTCInEmailContent(mail) { const otcRegex = /\d{6}|\scode\s|\sotc\s/i; + // \d{6} would otherwise match digits inside the magic-link token. + const stripUrls = string => string.replace(/https?:\/\/\S+/g, ' '); + const subjectMatch = mail.subject.match(otcRegex); assert(!subjectMatch, `Email subject should not contain OTC. Found: "${subjectMatch?.[0]}" in subject: "${mail.subject}"`); - const textMatch = mail.text.match(otcRegex); - assert(!textMatch, `Email text should not contain OTC. Found: "${textMatch?.[0]}" near: "${mail.text.substring(mail.text.search(otcRegex) - 50, mail.text.search(otcRegex) + 100)}"`); - - // It's possible that there's an OTC-like in an href, so only check the rendered text. - const htmlText = cheerio.load(mail.html).text(); + const text = stripUrls(mail.text); + const textMatch = text.match(otcRegex); + assert(!textMatch, `Email text should not contain OTC. Found: "${textMatch?.[0]}" near: "${text.substring(text.search(otcRegex) - 50, text.search(otcRegex) + 100)}"`); + + // Per text node — cheerio's .text() concatenates without + // separators, so URLs could otherwise merge with adjacent digits. + const $ = cheerio.load(mail.html); + const htmlText = $('*').contents() + .toArray() + .filter(n => n.type === 'text') + .map(n => stripUrls(n.data)) + .join(' '); const htmlMatch = htmlText.match(otcRegex); assert(!htmlMatch, `Email HTML should not contain OTC. Found: "${htmlMatch?.[0]}" near: "${htmlText.substring(htmlText.search(otcRegex) - 50, htmlText.search(otcRegex) + 100)}"`); } diff --git a/ghost/core/test/unit/frontend/helpers/get.test.js b/ghost/core/test/unit/frontend/helpers/get.test.js index cd4ed36bec5..9124d4085f4 100644 --- a/ghost/core/test/unit/frontend/helpers/get.test.js +++ b/ghost/core/test/unit/frontend/helpers/get.test.js @@ -551,23 +551,29 @@ describe('{{#get}} helper', function () { }); it('should log an error and return safely if it hits the timeout threshold', async function () { - configUtils.set('optimization:getHelper:timeout:threshold', 1); - - const result = await get.call( - {}, - 'posts', - {hash: {}, data: locals, fn: fn, inverse: inverse} - ); - - assert(result.toString().includes('data-aborted-get-helper')); - // A log message will be output - sinon.assert.calledOnce(logging.error); - // The get helper gets called with an empty array of results - sinon.assert.calledOnce(fn); - const args = fn.firstCall.args[0]; - assert(args && typeof args === 'object'); - assert('posts' in args); - assert.deepEqual(args.posts, []); + const clock = sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + try { + configUtils.set('optimization:getHelper:timeout:threshold', 1); + + const resultPromise = get.call( + {}, + 'posts', + {hash: {}, data: locals, fn: fn, inverse: inverse} + ); + // 2 > threshold (1), < stub's 5 — fires only the helper's timer. + await clock.tickAsync(2); + const result = await resultPromise; + + assert(result.toString().includes('data-aborted-get-helper')); + sinon.assert.calledOnce(logging.error); + sinon.assert.calledOnce(fn); + const args = fn.firstCall.args[0]; + assert(args && typeof args === 'object'); + assert('posts' in args); + assert.deepEqual(args.posts, []); + } finally { + clock.restore(); + } }); }); diff --git a/ghost/core/test/unit/frontend/helpers/recommendations.test.js b/ghost/core/test/unit/frontend/helpers/recommendations.test.js index 7245f13b1d0..f333b1b7994 100644 --- a/ghost/core/test/unit/frontend/helpers/recommendations.test.js +++ b/ghost/core/test/unit/frontend/helpers/recommendations.test.js @@ -145,6 +145,8 @@ describe('{{#recommendations}} helper', function () { }); describe('when timeout is exceeded', function () { + let clock; + before(function () { sinon.stub(api, 'recommendationsPublic').get(() => { return { @@ -158,6 +160,15 @@ describe('{{#recommendations}} helper', function () { }; }); }); + + beforeEach(function () { + clock = sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + }); + + afterEach(function () { + clock.restore(); + }); + after(async function () { await configUtils.restore(); }); @@ -165,9 +176,12 @@ describe('{{#recommendations}} helper', function () { it('should log an error and return safely if it hits the timeout threshold', async function () { configUtils.set('optimization:getHelper:timeout:threshold', 1); - const response = await recommendations.call( + const responsePromise = recommendations.call( 'recommendations' ); + // 2 > threshold (1), < stub's 5 — fires only the helper's timer. + await clock.tickAsync(2); + const response = await responsePromise; // An error message is logged sinon.assert.calledOnce(logging.error); diff --git a/package.json b/package.json index 78d8d387b85..aefb7e1b909 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "form-data@<2.5.4": "^2.5.4", "growl@<1.10.0": "^1.10.0", "handlebars@>=4.0.0 <=4.7.8": "^4.7.9", + "ip-address@<=10.1.0": "^10.2.0", "js-yaml@>=4.0.0 <4.1.1": "^4.1.1", "json5@<1.0.2": "^1.0.2", "lodash@<4.18.0": "^4.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 734723602ba..0a82b89fe6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,7 @@ overrides: form-data@<2.5.4: ^2.5.4 growl@<1.10.0: ^1.10.0 handlebars@>=4.0.0 <=4.7.8: ^4.7.9 + ip-address@<=10.1.0: ^10.2.0 js-yaml@>=4.0.0 <4.1.1: ^4.1.1 json5@<1.0.2: ^1.0.2 lodash@<4.18.0: ^4.18.0 @@ -15110,8 +15111,8 @@ packages: resolution: {integrity: sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==} engines: {node: '>=6'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ip-regex@4.3.0: @@ -39287,7 +39288,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.1.0: {} + ip-address@10.2.0: {} ip-regex@4.3.0: {} @@ -45481,7 +45482,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):