Skip to content

Added unified pnpm test:watch across all Vitest packages#28038

Merged
9larsons merged 2 commits into
mainfrom
vitest-finish-unit-tests
May 21, 2026
Merged

Added unified pnpm test:watch across all Vitest packages#28038
9larsons merged 2 commits into
mainfrom
vitest-finish-unit-tests

Conversation

@9larsons
Copy link
Copy Markdown
Contributor

This branch finishes the ghost/core unit-test migration to Vitest and builds on it to deliver a unified pnpm test:watch across the monorepo.

Commits

  1. Moved ghost/core unit tests fully to vitest — retires the last 3 Mocha holdouts in test/unit; test:unit now runs Vitest and the separate CI step is gone.
  2. Added unified pnpm test:watch — the focus of this PR (below).

Unified test:watch

pnpm test:watch now runs a single Vitest watcher across all 14 Vitest packages (ghost/core + 13 apps) via a root vitest.config.mjs that registers each package as a Vitest project — each keeps its own environment, setup and pool. Previously only apps/portal and apps/stats had a watch script, each watching just itself.

  • pnpm test:watch — watch everything
  • pnpm test:watch apps/posts — path filter to scope
  • cd ghost/core && pnpm test:watch — a single package

Making ghost/core composable as a project

ghost/core could not run as a Vitest project alongside the apps until two things were fixed — both without touching production code:

  • cwd assumptions — several test helpers assumed process.cwd() was ghost/core (content paths in local-images-storage, knex-migrator's MigratorConfig.js, minifier glob output, importer fixtures). These now resolve paths explicitly, so the suite passes whether run standalone or from the repo root.
  • tsx loader — ghost/core needs tsx to require() .ts server source. A global NODE_OPTIONS='--import tsx' broke module resolution in the app projects, and worker threads cannot scope it via execArgv. tsx is now registered with require('tsx/cjs') inside ghost/core's Vitest setup (the pattern MigratorConfig.js already uses), scoping it to ghost/core's workers.

The apps' shared vitest-config also takes an explicit root instead of reading process.cwd(), keeping @src/@test aliases package-local under the unified run.

signup-form is excluded — its test:unit is a build, with no Vitest tests.

Testing

  • Full unified run: 771 files / 8908 tests pass (1 file / 11 tests skipped), no snapshot files mutated.
  • Every package also verified standalone in its own directory — all 13 Vitest packages green (~755 files / ~8,770 tests).

9larsons added 2 commits May 21, 2026 07:58
The last 3 mocha holdouts in test/unit now run on vitest, so the mocha
unit-test path is retired:

- scheduling-default.test.js: converted off mocha done() callbacks. The
  pingUrl tests do real HTTP (nock), which doesn't mix with the suite's
  sinon fake clock — they now restore real timers; the scheduling-logic
  tests keep the fake clock. The 503-retry test is driven by spying
  _pingUrl and awaiting each attempt instead of polling wall-clock time.
- notify.test.js / overrides.test.js needed no changes — vitest.config's
  include now covers all of test/unit (test/unit/**), and the
  scheduling-default exclude is gone.
- package.json: test:unit now runs vitest; the dead test:unit:base,
  test:unit:ci and test:unit:slow scripts are removed.
- ci.yml: the separate "Run vitest unit tests" step is gone (test:unit
  is vitest now), along with GHOST_UNIT_TEST_VARIANT and the unused
  unit-coverage artifact (it was uploaded but never consumed).

Integration, e2e-api and legacy suites still run on mocha by design.
- `pnpm test:watch` now runs a single Vitest watcher across ghost/core and
  every app instead of each package only watching itself, giving one
  consistent dev loop and a stable reference point for agents
- a root vitest.config.mjs wires each package up as a Vitest project; each
  keeps its own environment, setup and pool
- ghost/core's test helpers assumed process.cwd() was ghost/core (content
  paths, knex-migrator's MigratorConfig.js, minifier glob output); these are
  now resolved explicitly so the suite passes when run from the repo root as
  a project, with no change to production code
- tsx is registered via require('tsx/cjs') in ghost/core's Vitest setup
  rather than a global NODE_OPTIONS='--import tsx'; the global flag broke
  module resolution in the app projects and worker threads cannot scope it
- the apps' shared vitest-config resolves @src/@test from an explicit root
  instead of process.cwd(), keeping the aliases package-local
- signup-form is excluded — its test:unit is a build, with no Vitest tests
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Review Change Stack

Walkthrough

This PR consolidates Vitest as the unified unit test runner across the Ghost monorepo. It establishes root-level configuration (vitest.config.mjs) to run multiple Vitest-based packages under a single watcher, parameterizes app workspace configurations with explicit root settings for correct alias resolution, updates Ghost core test scripts to route through test:vitest, migrates test utilities and fixtures to use __dirname-relative paths for working-directory-independent execution, and refactors the scheduling adapter test suite from callback/done() patterns to async/await with managed fake-timer control.

Possibly related PRs

  • TryGhost/Ghost#27901: Adds a separate test:vitest CI step for Ghost unit tests; this PR consolidates that step into the unified test:unit target.
  • TryGhost/Ghost#28012: Modifies ghost/core/test/utils/vitest-setup.ts MySQL cleanup; this PR adds the tsx CommonJS loader to the same setup file.
  • TryGhost/Ghost#28030: Continues the Vitest migration for ghost/core by updating CI unit-test handling, package.json scripts, and scheduling test refactoring.

Suggested reviewers

  • rob-ghost
  • EvanHahn
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective: adding a unified pnpm test:watch command across all Vitest packages in the monorepo.
Description check ✅ Passed The description comprehensively explains the PR's purpose, implementation details, fixes made, and testing verification—all directly related to the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vitest-finish-unit-tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/stats/vitest.config.ts (1)

5-13: 💤 Low value

Redundant @src alias now that root is passed.

With root: __dirname, createVitestConfig already sets @srcpath.resolve(__dirname, './src'), so the explicit '@src': resolve(__dirname, './src') in aliases duplicates the default (the spread later just overwrites it with the same path). Safe to drop for clarity; the stats-specific aliases (@assets, @components, @hooks, @utils, @views) are the only ones that genuinely need to be here.

♻️ Proposed cleanup
 export default createVitestConfig({
     root: __dirname,
     aliases: {
-        '`@src`': resolve(__dirname, './src'),
         '`@assets`': resolve(__dirname, './src/assets'),
         '`@components`': resolve(__dirname, './src/components'),
         '`@hooks`': resolve(__dirname, './src/hooks'),
         '`@utils`': resolve(__dirname, './src/utils'),
         '`@views`': resolve(__dirname, './src/views')
     }
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/stats/vitest.config.ts` around lines 5 - 13, Remove the redundant '`@src`'
entry from the aliases object in the Vitest config: since root: __dirname is
passed to createVitestConfig which already maps '`@src`' → path.resolve(__dirname,
'./src'), delete the explicit "'`@src`': resolve(__dirname, './src')" line and
keep only the stats-specific aliases ('`@assets`', '`@components`', '`@hooks`',
'`@utils`', '`@views`') so the aliases object is concise and non-duplicative.
ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js (1)

321-360: 💤 Low value

Fire-and-forget call is fine here, but worth a small safety net.

scope.adapter._pingUrl(...) on line 345 isn't held in a variable or awaited; the same promise is reached later via pingSpy.returnValues[0] inside settle(0), so it does get awaited. However, if that first promise ever rejects (e.g., the implementation starts throwing instead of logging on 5xx), node would emit an unhandledRejection between the call and the await settle(0) resolution — and Ghost's CI runs with strict unhandled-rejection handling in places. Capturing the return value and pre-attaching a .catch(() => {}) (the assertion still comes from pingSpy.returnValues) keeps the test robust to future implementation changes:

Proposed tweak
-                scope.adapter._pingUrl({
-                    url: 'http://localhost:1111/ping',
-                    time: moment().valueOf(),
-                    extra: {
-                        httpMethod: 'PUT'
-                    }
-                });
+                // Fire-and-forget: settle(...) below awaits each attempt via pingSpy.returnValues.
+                // Swallow on the floating promise so a future reject() doesn't trip unhandledRejection
+                // before settle(0) gets a chance to surface it.
+                scope.adapter._pingUrl({
+                    url: 'http://localhost:1111/ping',
+                    time: moment().valueOf(),
+                    extra: {
+                        httpMethod: 'PUT'
+                    }
+                }).catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js`
around lines 321 - 360, The test currently calls scope.adapter._pingUrl(...)
without storing its promise which can produce an unhandled rejection if the
implementation starts throwing; capture the returned promise from
scope.adapter._pingUrl(...) into a variable (e.g., const firstPing =
scope.adapter._pingUrl(...)) and immediately attach a no-op rejection handler
(e.g., firstPing.catch(() => {})) so the test still awaits the original promise
via pingSpy.returnValues[0] in settle() but will not trigger unhandledRejection;
leave the rest of the test (settle, pingSpy, logging assertions) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/stats/vitest.config.ts`:
- Around line 5-13: Remove the redundant '`@src`' entry from the aliases object in
the Vitest config: since root: __dirname is passed to createVitestConfig which
already maps '`@src`' → path.resolve(__dirname, './src'), delete the explicit
"'`@src`': resolve(__dirname, './src')" line and keep only the stats-specific
aliases ('`@assets`', '`@components`', '`@hooks`', '`@utils`', '`@views`') so the aliases
object is concise and non-duplicative.

In `@ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js`:
- Around line 321-360: The test currently calls scope.adapter._pingUrl(...)
without storing its promise which can produce an unhandled rejection if the
implementation starts throwing; capture the returned promise from
scope.adapter._pingUrl(...) into a variable (e.g., const firstPing =
scope.adapter._pingUrl(...)) and immediately attach a no-op rejection handler
(e.g., firstPing.catch(() => {})) so the test still awaits the original promise
via pingSpy.returnValues[0] in settle() but will not trigger unhandledRejection;
leave the rest of the test (settle, pingSpy, logging assertions) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1e90af69-f6e2-446b-9105-a03401d348ce

📥 Commits

Reviewing files that changed from the base of the PR and between c3b163f and ee6928e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • AGENTS.md
  • apps/admin-x-framework/src/test/vitest-config.ts
  • apps/admin-x-settings/vitest.config.ts
  • apps/posts/vitest.config.ts
  • apps/stats/vitest.config.ts
  • ghost/core/package.json
  • ghost/core/test/unit/frontend/services/assets-minification/minifier.test.js
  • ghost/core/test/unit/server/adapters/scheduling/scheduling-default.test.js
  • ghost/core/test/unit/server/adapters/storage/local-images-storage.test.js
  • ghost/core/test/unit/server/data/importer/index.test.js
  • ghost/core/test/unit/server/web/admin/controller.test.js
  • ghost/core/test/utils/db-utils.js
  • ghost/core/test/utils/fixture-utils.js
  • ghost/core/test/utils/vitest-setup.ts
  • ghost/core/vitest.config.ts
  • package.json
  • vitest.config.mjs

@9larsons 9larsons enabled auto-merge (squash) May 21, 2026 15:27
@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.82%. Comparing base (f809c44) to head (ee6928e).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #28038      +/-   ##
==========================================
+ Coverage   73.81%   73.82%   +0.01%     
==========================================
  Files        1528     1528              
  Lines      129409   129417       +8     
  Branches    15506    15509       +3     
==========================================
+ Hits        95519    95538      +19     
- Misses      32910    32923      +13     
+ Partials      980      956      -24     
Flag Coverage Δ
admin-tests 54.04% <ø> (+<0.01%) ⬆️
e2e-tests 73.82% <ø> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@9larsons 9larsons merged commit 8288323 into main May 21, 2026
48 checks passed
@9larsons 9larsons deleted the vitest-finish-unit-tests branch May 21, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant