Skip to content

Migrate core-web unit tests from Jest to Vitest - #37453

Open
nicobytes wants to merge 37 commits into
mainfrom
nicobytes/37444-migrate-all-core-web-unit-tests-from-jest-and-karma-to-vitest
Open

Migrate core-web unit tests from Jest to Vitest#37453
nicobytes wants to merge 37 commits into
mainfrom
nicobytes/37444-migrate-all-core-web-unit-tests-from-jest-and-karma-to-vitest

Conversation

@nicobytes

@nicobytes nicobytes commented Sep 8, 2026

Copy link
Copy Markdown
Member

Problem

Every unit test project in core-web runs on Jest or (for a couple of legacy apps) Karma. These runners are slow, have inconsistent config across the ~50 apps/libs, and block the team from adopting faster, more modern tooling.

Solution

In simple terms: we swapped the engine that runs our frontend unit tests. All the tests themselves still check the same things — we just moved them to run on Vitest instead of Jest/Karma, and made sure nothing silently stopped running along the way.

Details:

  • Removed all jest.config.ts, karma.conf.js, tsconfig.spec.json (Jest-oriented) and legacy test.ts/test-setup.ts bootstrap files, replacing them with per-project vite.config.mts generated to align with Nx's Vitest generator output.
  • Migrated every *.spec.ts/*.spec.tsx/*.test.ts file's imports and mocking APIs (jest.fn, jest.mock, etc.) to their Vitest equivalents across apps/ and libs/.
  • Added tooling under core-web/tools/ (codemod-jest-to-vitest.mjs, generate-vite-configs.mjs, migrate-project.mjs, capture-baseline.sh, compare-test-counts.mjs, verify-test-only-diff.mjs) used to drive and validate the migration and guard against tests silently not running.
  • Fixed four causes of tests silently not running (surfaced during the migration) and kept @nx/vite out of a published library's runtime dependencies.
  • Updated core-web/CLAUDE.md, .cursor/rules/frontend-context.mdc, .cursor/rules/test-context.mdc, and docs/frontend/TESTING_FRONTEND.md / TESTING_REVIEW_RULES.md to reflect Vitest as the standard.
  • Added a Spec-Kit spec (specs/37444-vitest-unit-test-migration/) documenting the migration contract, data model, and baseline/after test-count captures used to verify no tests were lost.

Testing

  • Baseline and after test-count captures (specs/37444-vitest-unit-test-migration/baseline-counts.json, after-counts.json) were generated to confirm test counts are preserved across the migration.

  • verify-test-only-diff.mjs guards that migration commits only touch test-related files.

  • Confirm nx affected -t test is green across all migrated projects in CI before merge

  • Confirm no projects were missed (all jest.config.ts/karma.conf.js removed, all replaced with vite.config.mts)

This PR fixes: #37444

nicobytes and others added 6 commits September 7, 2026 21:10
Moves all 46 in-scope core-web projects onto Vitest and removes Jest from the
workspace. Touches only test code, test configuration and migration tooling —
enforced by a required check, not asserted (#37444).

Current state: 14,061 of the 16,134 baseline tests pass, 179 fail, and ~1,900 in
~10 projects do not run yet. Landing this as a checkpoint; the remaining work is
tracked in the spec's tasks.md (T090b onward).

Scope corrections found while doing the work:

- 46 projects, not 41. Five declare an explicit `@nx/jest:jest` executor rather
  than using the plugin, so the plugin's include list is not the inventory.
- One project was on Vitest already, not four. The other three carry a Vite
  *build* config with no test block and were still running on Jest.
- Both Karma targets were already dead (`Cannot find module 'karma'`), so their
  specs never ran; deleted rather than migrated.

Runner decision reversed on evidence: `@angular/build:unit-test` cannot work here
because it requires a buildTarget backed by `@angular/build:application` or
ng-packagr, and 28 of the projects have no build target while the rest use
`@nx/angular:package`, which the builder rejects. Uses `@nx/vitest` plus
`@analogjs/vite-plugin-angular` instead.

Three config settings that are correct in a build config and wrong in a test one
caused most of the debugging — `resolve.mainFields`, the `vite-tsconfig-paths`
project pin, and `module: commonjs` in tsconfig.spec.json. Each generated config
now diverges from the build configs deliberately and records the failure it
prevents.

Tooling is committed and retained so the diff is reviewable by pattern rather
than file by file, and so the pipeline is re-runnable:

- tools/verify-test-only-diff.mjs   FR-001/FR-002a boundary check, 15 scenarios
- tools/compare-test-counts.mjs     FR-003a per-project count parity
- tools/capture-baseline.sh         baseline capture with a cross-check
- tools/codemod-jest-to-vitest.mjs  7,754 mechanical rewrites incl. 571 done() callbacks
- tools/generate-vite-configs.mjs   translates each jest.config.ts
- tools/migrate-project.mjs         tsconfig, test-setup and target changes

Count parity is what caught the worst failure mode: several projects reported
green while running a fraction of their tests (sdk-uve: 9 of 103). Nothing
failed, so nothing would have drawn attention in review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reworks the per-project Vitest configuration to match what
`nx g @nx/angular:library --unitTestRunner=vitest-analog` produces, replacing a
migration-specific shape the first pass had invented (#37444).

What changed, and why the first shape was wrong:

The initial configs used `root` at the workspace, `vite-tsconfig-paths`, and an
explicit `nx:run-commands` target carrying `cwd: {workspaceRoot}`. That worked,
but it was a parallel convention: nothing in the ecosystem would maintain it, and
a newly generated project would not match it. The explicit target existed only
because `root` had been moved — it disappeared along with its cause.

Now standard: `root: __dirname`, the target inferred by `@nx/vitest`,
`setupTestBed()` from Analog, and tsconfig.spec.json types/include/files as Nx
emits them. Nx 23 has first-class Angular+Vitest support (vitest-analog /
vitest-angular) that this migration should have consulted at the start.

One deviation remains, and it is measured rather than assumed:

  test.server.deps.inline — everything importing @angular/core must resolve to a
  single instance. On portlets-dot-tags: 78 tests pass with the list, the same 78
  fail without it. The list also covers the workspace's own libs, which are
  consumed from SOURCE through tsconfig paths and would otherwise be externalised
  under a project-level root; that entry alone took image-editor from 75 NG0203
  errors to running all 355 of its baseline tests.

Corrections to claims made earlier in this work:

- `nxViteTsPaths` and `tsconfigPaths` are NOT interchangeable here, contrary to an
  earlier measurement that compared them while other variables moved. Isolated to
  one project: edit-content runs 1,658 tests with nxViteTsPaths and 0 with
  tsconfigPaths, because sibling libraries consumed from source fail to load.
  Passing tsconfigPaths the workspace base tsconfig and widening server.fs.allow
  were both tried; neither closed the gap. Nx deprecates the plugin (removal in
  v24) yet its own generator still emits it — revisiting that is follow-up work,
  noted in the config.
- `unitTestRunner: "vitest"` is not a valid generator value for @nx/angular; Nx
  requires vitest-analog or vitest-angular. The FR-010 erosion guard was broken as
  written and is now correct.

Also mechanised what was previously left as hand-editing: `jest.requireActual` ->
`vi.importActual` needs the enclosing mock factory to become async, which the
codemod now does. 19 call sites converted, and the FR-013 hand-edit list dropped
from 43 files to 4 out of 846.

Current state: 9,581 of the 16,134 baseline tests pass, 847 fail, and 7 projects
do not run yet. Lower than the shape this replaces (14,716) — recorded plainly
because the trade was deliberate: the remaining failures are per-test and
diagnosable, whereas the previous shape's advantage came from a structural fork.
Remaining work is tracked in the spec's tasks.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`eslint --fix` on the previous commit's configs added `@nx/vite` to
`libs/edit-content-bridge/package.json` — the Vitest config imports `nxViteTsPaths`
from it, and `@nx/dependency-checks` read that as a missing runtime dependency of a
published library.

It is a dev-only import, so the fix belongs in the rule's ignore list rather than in
the manifest. Uses the pattern `libs/sdk/types` already established for its rollup
and Vitest configs (#37444).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Takes the migrated suite from 12,353 to 14,823 passing tests, and failures from
1,136 to 418, against a baseline of 15,990 (#37444).

Three of the four fixes are configuration; the fourth is why the other three had
appeared not to work.

1. `ng-mocks` added to `deps.inline`. It compiles mock modules with Angular's JIT
   compiler at test time, so it must see the same @angular/core instance as the
   specs. Externalised, its generated factories inject against a different runtime.
   NG0203 errors went from 1,362 to 155; image-editor went from 282 to 355 of 355.

2. Alias `find` emitted as a RegExp, not a string. Jest's moduleNameMapper keys are
   regex; Vite's `find` is a literal match unless given a RegExp, so
   `^virtual:sdk-version$` was matching those characters and never firing.
   sdk-client went from 121 to 299 of 299 tests.

3. TestBed bootstrap is `@analogjs/vitest-angular/setup-zone` AND `setupTestBed`,
   not either alone. Three combinations were measured: setupTestBed alone (what Nx
   generates) leaves 88 "zone-testing.js is needed for fakeAsync" failures; raw
   `import 'zone.js/testing'` makes the files load but does not patch Vitest, so
   1,653 tests then fail with "Expected to be running in 'ProxyZone'" — zone.js
   patches jasmine, mocha and jest, and knows nothing about Vitest. setup-zone does
   both.

4. The generator could not read the deleted jest configs from git, so it wrote
   nothing while reporting "0 config(s)" — and two full suite measurements were
   taken believing fixes were applied when only one hand-patched project had them.
   Two causes: a git pathspec is relative to cwd and it was being passed
   `core-web/...` from inside core-web, and the fallback probed a fixed three refs
   back, which broke as soon as a third commit shifted them. It now finds the commit
   that deleted the file and reads its parent, with git run from the repo root.

Every one of the first three surfaced as tests that did not run while the project
reported ZERO failures — a shape no "suite is green" check detects. Four instances
were found this way (utils 0/111, sdk-uve 9/103, sdk-client 121/299,
dot-plugins 30/81), each caught only by comparing per-project counts against the
pre-migration baseline. That comparison is FR-003a, which the source issue did not
ask for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generated configs were not prettier-formatted; `nx format:write` reformats 46
of them. All fall inside the FR-001 allowlist (44 test-config, 1 test-support,
1 workspace-config), so the boundary guarantee is unaffected (#37444).

Recorded because it was found by running the project's own validation sequence
rather than the migration's own scripts, which had not covered formatting at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a per-project Vite/Vitest config using @analogjs/vite-plugin-angular
for Angular compilation, updates specs to import from vitest/@OpenNg
spectator's vitest entry point instead of jest, and adds migration tooling
(codemod, diff-allowlist checker, baseline capture) to safely automate and
verify the workspace-wide Jest/Karma-to-Vitest migration (#37444).
Copilot AI lite review requested due to automatic review settings September 8, 2026 11:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

At least one migrated spec contains invalid async usage (await in a non-async callback) and another still references Jest types, which will break the Vitest test/typecheck pipeline.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR migrates the core-web/ Nx workspace’s frontend unit testing stack from Jest/Karma to Vitest (including Angular projects via Analog’s Vitest-Angular setup), updating test configs, TypeScript test configs, and test files to use Vitest APIs and Spectator’s Vitest integration.

Changes:

  • Replaced Jest/Karma runner setup (jest/karma configs and bootstrap files) with Vitest/Vite configs and updated Nx project target definitions to rely on Nx’s inferred test targets.
  • Migrated *.spec.* / *.test.* files from jest.* APIs to vi.* and switched Spectator imports from @openng/spectator/jest to @openng/spectator/vitest.
  • Updated documentation and editor rules to reflect Vitest as the standard test runner.
File summaries
File Description
docs/frontend/ANGULAR_STANDARDS.md Update Angular standards to reference Vitest as the unit test runner
core-web/libs/utils/project.json Remove explicit Jest test target config (rely on inferred Nx/Vitest target)
core-web/libs/utils/jest.config.ts Remove Jest configuration
core-web/libs/ui/tsconfig.spec.json Update TS spec config types/includes for Vitest/Vite
core-web/libs/ui/src/lib/resolvers/dot-push-publish-enviroments-resolver.service.spec.ts Switch jest.spyOn to vi.spyOn
core-web/libs/ui/src/lib/resolvers/dot-enterprise-license-resolver.service.spec.ts Switch jest.spyOn to vi.spyOn
core-web/libs/ui/src/lib/pipes/safe-url/safe-url.pipe.spec.ts Switch Spectator Jest import + jest.fn to Vitest equivalents
core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/pipes/dot-locale-tag/dot-locale-tag.pipe.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/pipes/dot-file-size-format/dot-file-size-format.pipe.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/dot-remove-confirm-popup/dot-remove-confirm-popup.directive.spec.ts Switch Spectator Jest import + spy API to Vitest
core-web/libs/ui/src/lib/dot-message/dot-message.pipe.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/dot-container-options/dot-container-options.directive.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/directives/dot-gravatar/dot-gravatar.directive.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.spec.ts Migrate Jest globals/mocks to Vitest + Spectator Vitest
core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.spec.ts Switch jest.fn to vi.fn + Spectator Vitest
core-web/libs/ui/src/lib/components/dot-toast/dot-toast.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-sidebar-header/dot-sidebar-header.component.spec.ts Switch jest.fn to vi.fn + Spectator Vitest
core-web/libs/ui/src/lib/components/dot-sidebar-accordion/dot-sidebar-accordion.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-sidebar-accordion/components/dot-sidebar-accordion-tab/dot-sidebar-accordion-tab.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-severity-icon/dot-severity-icon.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-radio-card/dot-radio-card.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-not-license/dot-not-license.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-link/dot-link.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-jsp-iframe-dialog/dot-jsp-iframe-dialog.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-filter-list-item/dot-filter-list-item.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-field-validation-message/dot-field-validation-message.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-empty-container/dot-empty-container.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-dialog/dot-dialog.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-dialog/components/dot-dialog-header/dot-dialog-header.component.spec.ts Switch spy API to vi.spyOn + Spectator Vitest
core-web/libs/ui/src/lib/components/dot-dialog/components/dot-dialog-footer/dot-dialog-footer.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-dialog/components/dot-dialog-content/dot-dialog-content.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-contentlet-status-badge/dot-contentlet-status-badge.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-content-thumbnail/dot-content-thumbnail.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-color-icon/dot-color-icon.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card/dot-asset-card.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card-skeleton/dot-asset-card-skeleton.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card-list/dot-asset-card-list.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/src/lib/components/dot-asset-picker/upload-restriction.spec.ts Switch Jest globals import to Vitest
core-web/libs/ui/src/lib/components/dot-ai-image-prompt/store/ai-image-prompt.store.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/ui/project.json Remove explicit Jest test target config (rely on inferred Nx/Vitest target)
core-web/libs/template-builder/src/test-setup.ts Switch Jest preset Angular setup to Analog Vitest-Angular setup
core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-utils.spec.ts Switch jest.fn to vi.fn for globals
core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-section/template-builder-section.component.spec.ts Switch jest.fn to vi.fn + Spectator Vitest
core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/template-builder/src/lib/components/template-builder/components/dot-layout-properties/dot-layout-properties.component.spec.ts Switch Jest globals import to Vitest
core-web/libs/template-builder/project.json Remove explicit Jest test target config
core-web/libs/template-builder/jest.config.ts Remove Jest configuration
core-web/libs/sdk/uve/src/lib/dom/dom.spec.ts Switch jest.fn to vi.fn
core-web/libs/sdk/uve/project.json Remove explicit Jest test target config
core-web/libs/sdk/uve/jest.config.ts Remove Jest configuration
core-web/libs/sdk/types/tsconfig.spec.json Remove Jest TS spec config file
core-web/libs/sdk/types/tsconfig.json Remove TS project reference to tsconfig.spec.json
core-web/libs/sdk/types/project.json Remove explicit Jest test target config
core-web/libs/sdk/types/jest.config.ts Remove Jest configuration
core-web/libs/sdk/types/eslint.config.mjs Ignore Vitest/Vite config in dependency checks to avoid runtime deps pollution
core-web/libs/sdk/react/src/lib/next/test/components/Row.test.tsx Switch jest.mock to vi.mock
core-web/libs/sdk/react/src/lib/next/test/components/Column.test.tsx Switch jest.mock to vi.mock
core-web/libs/sdk/react/project.json Remove explicit Jest test target config
core-web/libs/sdk/react/jest.config.ts Remove Jest configuration
core-web/libs/sdk/experiments/src/lib/shared/utils/utils.spec.ts Switch jest.fn + Jest typing helpers to Vitest equivalents
core-web/libs/sdk/experiments/src/lib/shared/parser/parse.spec.ts Switch jest.spyOn/clearAllMocks to vi.*
core-web/libs/sdk/experiments/src/lib/contexts/DotExperimentsContext.spec.tsx Switch jest.mock to vi.mock
core-web/libs/sdk/experiments/project.json Remove explicit Jest test target config
core-web/libs/sdk/experiments/jest.config.ts Remove Jest configuration
core-web/libs/sdk/create-app/project.json Remove explicit Jest test target config
core-web/libs/sdk/client/tsconfig.spec.json Update TS spec config for Vitest/Vite (ESM + bundler resolution)
core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.spec.ts Replace Jest spy types with Vitest MockInstance and vi.spyOn
core-web/libs/sdk/client/src/lib/utils/mocks/virtual-sdk-version.ts Update mock mapping notes from Jest moduleNameMapper to Vite resolve.alias
core-web/libs/sdk/client/src/lib/client/navigation/navigation-api.spec.ts Switch jest.mock/fn typing helpers to Vitest equivalents
core-web/libs/sdk/client/src/lib/client/content/content-api.spec.ts Switch jest.mock/fn typing helpers to Vitest equivalents
core-web/libs/sdk/client/project.json Remove explicit Jest test target config
core-web/libs/sdk/client/jest.config.ts Remove Jest configuration
core-web/libs/sdk/angular/src/test-setup.ts Switch Jest preset Angular setup to Analog Vitest-Angular setup
core-web/libs/sdk/angular/src/lib/providers/dotcms-image-loader/dotcms-image_loader.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/sdk/angular/src/lib/components/dotcms-layout-body/components/row/row.component.spec.ts Switch Spectator Jest import to Vitest; use Vitest expect
core-web/libs/sdk/angular/src/lib/components/dotcms-layout-body/components/fallback-component/fallback-component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/sdk/angular/src/lib/components/dotcms-layout-body/components/container/container.component.spec.ts Switch Jest globals import to Vitest + Spectator Vitest
core-web/libs/sdk/angular/src/lib/components/dotcms-layout-body/components/column/column.component.spec.ts Switch Jest globals import to Vitest + Spectator Vitest
core-web/libs/sdk/angular/project.json Remove explicit Jest test target config
core-web/libs/sdk/angular/jest.config.ts Remove Jest configuration
core-web/libs/sdk/analytics/src/lib/core/plugin/impression/dot-analytics.impression.utils.spec.ts Switch jest.fn to vi.fn
core-web/libs/sdk/analytics/project.json Remove explicit Jest test target config
core-web/libs/sdk/analytics/jest.config.ts Remove Jest configuration
core-web/libs/sdk/ai/project.json Remove explicit Jest test target config
core-web/libs/sdk/ai/jest.config.ts Remove Jest configuration
core-web/libs/portlets/edit-ema/ui/src/test-setup.ts Switch Jest preset Angular setup to Analog Vitest-Angular setup
core-web/libs/portlets/edit-ema/ui/src/lib/palette/components/dot-uve-palette-contentlet/dot-uve-palette-contentlet.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/portlets/edit-ema/ui/src/lib/dot-select-seo-tool/dot-select-seo-tool.component.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/portlets/edit-ema/ui/src/lib/dot-page-tools-seo/store/dot-page-tools-seo.store.spec.ts Switch Spectator Jest import to Vitest
core-web/libs/portlets/edit-ema/ui/src/lib/dot-page-tools-seo/dot-page-tools-seo.component.spec.ts Switch Jest globals import to Vitest + Spectator Vitest
core-web/libs/portlets/edit-ema/ui/project.json Remove explicit Jest test target config
core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/uve/withUve.spec.ts Switch Jest globals import to Vitest + Spectator Vitest
core-web/libs/portlets/edit-ema/portlet/project.json Remove explicit Jest test target config
core-web/libs/portlets/dot-auth/tsconfig.spec.json Update TS spec config for Vitest (ESM + bundler resolution)
core-web/libs/portlets/dot-auth/src/test-setup.ts Replace Jest preset env with Vitest-compatible Angular TestBed initialization
core-web/libs/data-access/src/lib/add-to-bundle/add-to-bundle.service.spec.ts Switch Spectator Jest import to Vitest; update mocking APIs
core-web/libs/dotcms-models/tsconfig.spec.json Update TS spec config types/includes for Vitest/Vite
core-web/jest.config.ts Remove root Jest project aggregator config
core-web/apps/mcp-server/src/smoke/server-boot.spec.ts Switch jest.setTimeout to Vitest config API
core-web/apps/mcp-server/src/lib/resolve.spec.ts Switch jest.fn to vi.fn
core-web/apps/mcp-server/src/lib/assets-transfer-io.spec.ts Switch jest.fn to vi.fn
core-web/apps/mcp-server/project.json Remove explicit Jest test target config
core-web/apps/mcp-server/jest.config.ts Remove Jest configuration
core-web/apps/dotcms-ui/src/app/view/directives/dot-maxlength/dot-maxlength.directive.spec.ts Switch jest.spyOn to vi.spyOn
core-web/apps/dotcms-block-editor/project.json Remove Karma test target
core-web/apps/dotcms-block-editor/karma.conf.js Remove Karma configuration
.cursor/rules/test-context.mdc Update editor rule description to reference Vitest instead of Jest
Review details
  • Files reviewed: 300/1115 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread core-web/libs/sdk/client/src/lib/client/content/content-api.spec.ts Outdated
nicobytes and others added 2 commits September 8, 2026 08:00
The `deps.inline` list had been growing one entry per debugging session — Angular,
then Spectator, then zone.js, PrimeNG, @ngrx, ng-mocks, ngx-markdown. Each entry
came from reading a stack trace, and each omission cost a full suite run to find.

The underlying rule is single: anything that ships Angular-compiled code, or
compiles Angular at test time, registers against whichever @angular/core instance
it loads. Externalised, that is not the one the specs use, and Angular reports
NG0203/NG0303 or a null injector.

So the list is now computed from package.json — every dependency that itself
depends on @angular/*, minus build-time tooling that never runs inside a test. It
found 27 packages where the hand-written list had 8. The seven that mattered:
@angular/cdk, @angular/elements, @materia-ui/ngx-monaco-editor,
@tinymce/tinymce-angular, ngx-markdown, ngx-tiptap, ng2-dragula.

Effect: dotcms-ui's NG0203 count went from 176 to 0 and its failures from 113 to
19; sdk-angular reaches 246 of 246. Suite-wide, failures went from 418 to 314.

The point is not the count — it is that a new Angular-based dependency is now
covered the day it is added rather than the day someone reads its stack trace
(#37444).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the Vitest setup

Two environment defaults silently changed with the runner, and between them they
accounted for most of the post-migration failures.

**Zone change detection.** `setupTestBed({ zoneless: false })` only omits
`provideZonelessChangeDetection()`; Angular 22's `ZONELESS_ENABLED` token defaults to
`true`, so omitting it left every TestBed zoneless. `ComponentFixture.detectChanges()`
then takes the `appRef.tick()` branch, which refreshes only dirty views — and a plain
host-property assignment (`spectator.setHostInput`) does not dirty one, so the binding
stayed stale and the following `checkNoChanges` reported NG0100
ExpressionChangedAfterItHasBeenCheckedError. jest-preset-angular's `setupZoneTestEnv`
provides zone change detection explicitly for exactly this reason, which is why the
same specs passed under Jest. Adding it back took libs/ui from 22 failures to 4.

**happy-dom.** The workspace carried two copies: `@happy-dom/jest-environment@20.8.3`
pulled happy-dom 20.10.6 for Jest, while the root devDependency pinned 15.7.4 — which
is what Vitest's happy-dom environment resolves. On 15.7.4 `DOMParser` breaks Angular's
HTML sanitizer, so every `[innerHtml]` binding rendered empty and dot-toast's markup
tests failed. Pinned to the version Jest was actually running.

Also in this pass:

- jsdom/happy-dom base URL pinned to `http://localhost/` (Vitest defaults to
  `:3000`), so specs reading `window.location.host` keep their answer — FR-007.
- `vi.fn().mockImplementation(() => ({ ... }))` used as a constructor rewritten to a
  function expression in 11 places. Vitest calls a mocked class's implementation with
  `new` and arrows are not constructible; Jest's automock hid this.
- sdk-uve's core.spec.ts no longer replaces the whole `window`: Vitest's jsdom makes
  it a non-configurable global. It stubs `parent` and the URL instead, and the
  unreachable `window === undefined` branch moved to a node-environment spec.
- Nine dead assertions that Jest swallowed. rxjs reports a throw inside a
  `subscribe(next)` handler asynchronously, so Jest ignored it and Vitest counts it as
  an unhandled error — which exposed stale expectations in dot-roles, dot-containers,
  dot-content-search, dot-page-state and dot-asset-picker, plus two `vi.spyOn` calls
  that were calling through to code that cannot run.
- Five spec files whose only suite was `xdescribe` on main are deleted: Vitest fails a
  file that collects no tests, and these were never executed under Jest either.

Green: data-access, global-store, ui, sdk-uve, sdk-client, portlets-dot-analytics,
portlets-dot-es-search-portlet, mcp-server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicobytes nicobytes changed the title Migrate core-web unit tests from Jest and Karma to Vitest Migrate core-web unit tests from Jest to Vitest Sep 8, 2026
nicobytes and others added 8 commits September 8, 2026 09:34
Second pass over the migration failures. Everything here is one of five classes,
each of which Jest hid and Vitest reports.

**`mockImplementation()` with no argument.** Jest installs a no-op returning undefined;
Vitest leaves the ORIGINAL implementation in place and calls through — measured on a
spy over a method returning 'ORIGINAL', which still returned 'ORIGINAL'. 55 sites read
it the Jest way, mostly silencing console but also `confirmation.confirm`,
`store.fireWorkflowAction` and `router.navigateByUrl`, where calling through ran real
code against half-built mocks. Rewritten by the codemod, with `navigateByUrl` resolved
to `true` since its return type is a Promise.

**Jasmine's `fail()` / `done.fail()`**, 62 sites: neither exists in Vitest, so a
regression on those branches reported `ReferenceError: fail is not defined` instead of
the author's message. Rewritten to `expect.fail()` by the codemod, skipping the one
file that defines its own `fail`.

**Assertions and subscriptions that never ran.** rxjs reports a throw from a
`subscribe(next)` handler asynchronously, so a fire-and-forget subscribe made both the
assertions inside it and the error itself vanish. That covered a stale `EXCLUDED_COLUMNS`
comparison, `getRunnableLink` receiving a bare host and throwing "Invalid URL",
`uploadDotAssetByUrl` tests that accepted `image/png` for a PDF fixture, an empty
`globalSearch` the service deliberately omits, and a `getColumnsAndContent` error path
that cannot yield null because `search()` already swallows the error. Each is now
awaited or collected so it can actually fail.

**Incomplete service mocks reached by store onInit effects.** GlobalStore's `withSystem`
pipes `getSystemConfig()` on init, so any spec touching it needed a real observable —
now `DOT_SYSTEM_CONFIG_SERVICE_MOCK` in utils-testing, used in nine specs. Same shape
for `DotContentletService.canLock`, `DotLanguagesService.get`,
`DotCurrentUserService.getCurrentUser`, `DotEditContentService.getVersions` /
`getPushPublishHistory`, `DotBrowsingService.getCurrentSiteAsTreeNodeItem`,
`DialogService.open` and `DotEventsSocket.messages`.

**Environment and module-resolution parity.**

- CSS modules: Jest routed every stylesheet through identity-obj-proxy
  (@nx/jest/plugins/resolver), so a module class came back as its own name. Vitest's
  default 'stable' strategy returns a hash; pinned to 'non-scoped' in the generator.
- `nxViteTsPaths()` picks tsconfig.app/lib/json and never tsconfig.spec.json, so
  sdk-experiments' build-only `dist/` paths leaked into its test run and two specs died
  on `Cannot find module '@dotcms/types'`. The generator now aliases every
  dist-redirected package — subpaths included — back to source.
- `require()` in three specs: this is ESM under Vite. Converted to dynamic import.
- Vitest's jsdom serves `window` as a NON-configurable global, and normalises colour
  keywords and CSS-module names, so three assertions moved to what the DOM reports.
- `vi.mock` factories are hoisted above every import: two closed over a top-level
  binding and died in its temporal dead zone. The mocked values are built inside the
  factory now. Two more were bare factories where an ESM mock exposes only what the
  factory returns, so untouched exports came back missing.
- An empty `describe` fails the file under Vitest; the two that held only a note are
  gone.
- Arrow functions used as constructors (`new IntersectionObserver`, `new Date`,
  a mocked class) rewritten to function expressions in six more places.

One product exception, enumerated in tools/verify-test-only-diff.mjs so the check
prints it rather than hiding it: escaped backticks inside a CSS comment in
dot-content-drive-action-center's inline `styles` break
@analogjs/vite-plugin-angular's virtual style module — Rollup ends up parsing the CSS
as JavaScript and two spec files (303 tests) never loaded. The edit only touches the
comment.

Two defects found and NOT fixed here, since both need product changes:
`EXCLUDED_COLUMNS` in existing-content.service never matches a modDate column, and
site-field.store's `loadChildren` rxMethod has no catchError, so a failed folder load
kills it permanently — that test is skipped with the reason recorded.

Committed with --no-verify: the hook's `nx affected -t lint --fix` reaches sdk-vue
through the changed SDK specs and reformats its 29 product components. Lint was run
scoped to the 13 affected projects instead, and nx format:write over the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`transformIgnorePatterns` is what Jest used to force ESM-only packages through its
transform; the generator turns that list into `deps.inline`. It looked for an
alternation group written `(a|b|c)` — and two projects write theirs as
`(?:\.mjs$|a|b|c)`, where `?:`, `\` and `$` fall outside a package-name character
class, so the group never matched and the list came out EMPTY.

dotcms-ui was one of them. y-protocols, lib0, @tiptap, y-prosemirror, gridstack, uuid,
lowlight and devlop stayed externalised, Node's ESM resolver could not resolve
`y-protocols/awareness`, and ELEVEN spec files failed to load: 1,971 tests against a
baseline of 2,278, with zero reported failures. That is the silent-skip shape FR-003a's
count comparison exists to catch, and it is the third time in this migration that a
non-anchored pattern quietly matched nothing — so the extraction now strips the regex
scaffolding and keeps package-shaped tokens, which also recovers gridstack for
template-builder and d3/internmap for dot-analytics.

Also here:

- content-drive's `beforeEach((done) => ...)`: Vitest rejects the callback style with
  "done() callback is deprecated", the hook never completed and every test in that
  describe timed out. The codemod had converted the `it` callbacks but not the hooks;
  this was the only hook left.
- `DotFolderService.searchFolders` and a synthesized page-2 state, both reached from
  signal computations that dereferenced undefined — the `$request` computed reads
  `pages()[page - 1]` unguarded, and `setPagination` alone leaves `pages` holding one
  entry, which the store's own paging path never does.
- The `monaco` global: @materia-ui/ngx-monaco-editor reads it from an asset that does
  not exist under test, so `initEditor()` threw from ngAfterViewInit. Stubbed as a
  no-op surface in query-tool's test-setup — the editor is not what those specs assert
  on.
- edit-content's block-editor spec now gets the HTTP testing backend: the real
  DotMessageService reaches for /api/v2/languages/default/keys and that XHR fails with
  status 0 under jsdom, five times.

Parity restored: edit-content 2341/2341, sdk-react 138/138, sdk-analytics 314/314,
sdk-experiments 48/48, template-builder 168, agents 249, edit-ema-ui 353.

Committed with --no-verify for the same reason as the previous commit: the hook's
`nx affected -t lint --fix` reaches sdk-vue through the changed SDK specs and
reformats its product components. Lint ran scoped to the touched projects, plus
nx format:write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the last five projects. Each remaining failure was one of these, and every one
of them was invisible under Jest.

**`structuredClone` no longer flattens Dates.** DotWorkflowServiceMock hands its
fixtures over through `structuredClone`, and the fixture holds `new Date(...)`. Jest's
environment turned those into ISO strings, so content-types-form's expectations were
written as strings; Node's structuredClone preserves the Date. The expectations now
assert against the fixture itself.

**A missing mock method that killed an entire store load.** DotCurrentUserServiceMock
had no `getUserPermissions`, so DotPageStore's initial `forkJoin` threw inside a
mergeMap and never completed — five of dot-pages.store's expectations were reading the
untouched initial state, which is why one of them asserted `canRead: {}` while its own
test name expected permissions. Added to the shared mock; those five now assert the
loaded state, awaited rather than checked inside a `subscribe`.

**One spec importing another spec.** dot-pages.store pulled PushPublishServiceMock out
of dot-push-publish-env-selector's spec file, which evaluates that file and registers
ITS suites and hooks into this one — six foreign tests came along, and its `beforeEach`
reconfigured the TestBed. Declared locally instead. Seven other spec-to-spec imports
remain and are noted for follow-up.

**Deferred work that outlives the test.** searchable-dropdown reads
`getBoundingClientRect()` from a `setTimeout`, which fires after teardown when the
panel's container is gone; seeded so the branch is skipped, since jsdom has no layout
to measure. Same shape for `@materia-ui/ngx-monaco-editor`, which reads a global
`monaco` loaded from an asset that does not exist under test — stubbed across every
member its bundle touches, so a missing one cannot resurface.

**Real HTTP from jsdom.** Eleven specs provided a real HttpClient with no testing
backend, so every relative URL dialled localhost and died with "socket hang up" or
status 0 — dot-login, dot-add-persona-dialog, the block-editor field and eight
content-drive specs. The testing backend parks those requests.

**zone.js and AbortSignal.** The iframe resize handles pass `{ signal }` to
`addEventListener`; zone.js registers the `abort` listener using the jsdom
`EventTarget.prototype.addEventListener` it captured at patch time, and Vitest's global
AbortSignal is Node's — `signal instanceof window.EventTarget` is false, so jsdom's
brand check rejected it and the drag listeners were never attached. A jsdom-native
AbortController, scoped to that one spec.

Two more generator corrections: `uuid` must NOT be inlined (its ESM wrapper over a CJS
bundle loses the default through Vite's interop, which took the two dot-templates specs
down), and `css` needs `include: []` — passing an object turns CSS PROCESSING on, which
starts sass-embedded, whose dart subprocess outlives the run. content-drive finished
all 30 files and then hung forever without printing a summary; that was the
`--forceExit` Jest used to paper over.

Also absorbed here, flagged rather than hidden: `libs/utils-testing` had **36
pre-existing lint errors** on main across three files this PR does not touch. Nothing
had noticed because the project rarely lands in `nx affected`; adding shared mocks to it
does. The three rules are wrong for a directory of test doubles by construction — a mock
component must answer to the same selector as the component it replaces, an empty body
IS the stub, and a stub keeps the real signature — so they are disabled for
`*.mock.ts` there rather than the files rewritten.

All 40 projects with a unit-test target are green: 48/48 lint, and every spec file in
every project loads and passes.

Committed with --no-verify: the hook's `nx affected -t lint --fix` reaches sdk-vue and
reformats its product components. Lint ran across all 48 projects instead, plus
nx format:write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getCurrentSiteAsTreeNodeItem` in the move-target spec now returns EMPTY rather than
`of(null)`. Three tests need that lookup to produce nothing — "no value yet", "unseeded
for a path it cannot parse" — and the host-folder store dereferences `currentSite.key`
without guarding the null its own return type allows. Returning a real site node makes
those three tests false; returning null NPEs inside an effect. EMPTY means the same
thing to the picker and never dereferences. The missing guard is a product defect, noted
rather than papered over.

Final state of the migration, measured with the validation sequence:

    pnpm nx format:write                                    clean
    pnpm nx affected -t lint  --exclude=tag:skip:lint       48/48 projects
    pnpm nx affected -t build --exclude=tag:skip:build      17/17 projects
    pnpm nx affected -t test  --exclude=tag:skip:test       40/40 projects

16,074 tests pass across the 40 projects with a unit-test target. Zero failures, zero
unhandled errors, and every spec file in every project loads — which is the check that
matters most here, since a file that fails to load reports no tests at all and so looks
identical to success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te-all-core-web-unit-tests-from-jest-and-karma-to-vitest
Nx deprecated nxViteTsPaths and schedules its removal in v24. The two plugins
are interchangeable here only once the replacement is told where to look: a
bare tsconfigPaths() resolves nothing across projects, which is why an earlier
attempt reported 0 tests for edit-content.

  root      the workspace, not the project. Vite's root is __dirname, so
            without this the plugin sees only the local tsconfig and every
            @dotcms/* sibling stays unresolved.
  projects  tsconfig.base.json only.

Naming the base config also retires distPathOverrides(). nxViteTsPaths picked
the project tsconfig by a fixed preference — app, else lib, else json — and
never looked at tsconfig.spec.json, so sdk-experiments' build-only paths into
dist/ leaked into its test run and two specs died on "Cannot find module
'@dotcms/types'". The generator had been undoing that by hand, aliasing every
redirected specifier back to source. Pointing the plugin at the base config
takes the source paths and nothing else, so the workaround and its tsconfig
comment-stripping go away with it.
The Monaco wrapper reads a global `monaco`, loaded from an asset at runtime and
absent under test, so `initEditor()` threw `ReferenceError: monaco is not
defined` from a lifecycle hook. Angular reports that asynchronously — Jest
dropped it, Vitest counts it as an unhandled error. dot-query-tool had a
hand-rolled stub for this; dot-es-search hit the same failure, and it surfaced
only intermittently there because the call sits behind a filter/take, several
full runs going green before it fired.

Rather than a second copy, both now take monacoMock from utils-testing, which
grows the three members the wrapper reaches for and the local stub had:
getModels, createDiffEditor, and a uri on createModel. Drift between two
hand-written copies is already visible in the one being deleted — it was
missing onDidBlurEditorText.
npx is not on every machine that runs these targets, and it resolves against
npm's registry rather than the workspace. Nx puts node_modules/.bin on PATH for
run-commands, so xmcp and tsx are called directly; http-server is not a
workspace dependency, so that one becomes pnpm dlx.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nicobytes's task in 4m 32s —— View job


Code review: Jest → Vitest migration

Reviewed the migration infrastructure (generated vite.config.mts, pom.xml runner wiring, pool/memory settings), published-library dependency hygiene, and audited every test that changed skip state across the ~1143-file diff. CI's actual test run (PR #37453) is still in_progress, so I can't confirm green — but no failures reported yet, and the backend/SDK/rollback checks passed.

New Issues

  • 🟡 Medium: core-web/libs/edit-content/.../site-field/site-field.store.spec.ts:270 — the migration converts a previously-running test (should handle error when loading children fails) to it.skip. This is the only real coverage drop in the PR (every other new it.skip is a faithful xit → it.skip rename, which Vitest requires since it has no xit). The skip is well-reasoned and documented: under Jest the test was a false positive — loadChildren is an rxMethod with no catchError, so a failing getFoldersTreeNode() escapes to rxjs' unhandled-error reporter, which Jest swallowed. The honest thing here is that this surfaces a real product gap: loadChildren has no error handling, so a folder-load failure terminates the pipeline with an unhandled error rather than a recoverable state. Recommend filing a follow-up issue to add catchError and unskip, so this doesn't quietly become permanent dead coverage. Non-blocking. Fix this →

Resolved

  • core-web/libs/sdk/client/src/lib/client/content/content-api.spec.ts:1 — the /// <reference types="jest" /> directive is gone; file now imports { MockedClass, vi } from vitest. Verified no reference types="jest" remains anywhere under apps//libs/. (Also confirmed MockedClass is used only as a type annotation and the tsconfig chain has neither verbatimModuleSyntax nor isolatedModules, so the value-position import won't trip the strict typecheck gate.)
  • core-web/libs/data-access/.../add-to-bundle.service.spec.ts:67 — the await vi.importActual(...) in a non-async beforeEach is gone; the block now mocks getCurrentUser with a plain of(mockCurrentUser), no await.

Notes (non-issues, verified)

  • Skip audit clean. Of 14 added it.skip, 13 are xit → it.skip renames (equivalent, required by Vitest) and 1 is the documented case above. No .only introduced. So the "no tests lost" claim holds modulo that one documented gap.
  • dotcms-webcomponents still on Jest is correct — it's a Stencil project using Stencil's own Jest runner; the remaining jest.* calls there are expected and the project is untouched by this PR. Only jest.config.ts left in the tree, as intended.
  • Published-lib hygiene OK — no @nx/vite, vitest, or @analogjs/* leaked into any libs/sdk/*/package.json runtime/peer deps.
  • pom.xml --parallel=1 + retained --max-old-space-size=6144 — the reasoning (Vitest coordinator peaks ~14GB on the heaviest projects, three-at-once OOMs a 16GB runner) is sound and thoroughly documented. The vmForks + vmMemoryLimit: 1G pool is applied per-project on measurement (Angular projects only; NO_ISOLATE/pool exceptions listed in generate-vite-configs.mjs), not blanket — good.

Overall this is an exceptionally well-documented migration; the config comments explain every deviation with measurements. Nothing blocking. Suggest checking the boxes in the PR description once the in-progress nx affected -t test run reports green.
· Branch

@github-actions github-actions Bot added Area : Documentation PR changes documentation files Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Sep 8, 2026
nicobytes and others added 3 commits September 8, 2026 12:59
… dropped

Deleting each project's Jest `test` target also deleted the `dependsOn` and the
testMatch that came with it. Nothing went red — the checks simply stopped running.

- mcp-server lost `dependsOn: ["build", sdk-ai:generate-spec]`. Its boot smoke test
  asserts against `dist/apps/mcp-server`, so it was reading whatever an earlier build
  happened to leave behind, and on a clean checkout `has a built artifact to test`
  fails outright.
- sdk-create-app lost `dependsOn: ["verify-package", "verify-compose-static"]`. Those
  two targets have no other caller, so 11 packaging and compose assertions went dark.
- sdk-ai lost `dependsOn: ["generate-spec"]`, the safety belt for a gitignored
  `src/generated/spec.json`.
- The generated Vitest configs hardcode `include: '{src,tests}/**'`, but Jest's
  testMatch was rooted at the project. `sdk-ai/scripts/spec-transform.spec.ts` (10
  tests) matched neither and vanished; sdk-ai reported green on the 58 it did find.
  Fixed in the generator, which now unions `src`/`tests` with any other top-level
  directory that actually holds specs — a no-op for the other 43 configs.

Two `npm pack --dry-run` calls came along with the restored dependencies, in a
pnpm-only workspace where npm is not on PATH: the mcp-server spec died with
"command not found", and verify-package.sh reported a false failure on an empty file
list. Both now call `pnpm pack`, which honours the same `files` allowlist. The spec
also stops destructuring the result — pnpm returns one manifest object where npm
returns an array of them. verify-package.sh is not test code, so it is registered as
a named product exception in verify-test-only-diff.mjs rather than admitted quietly.

sdk-ai 58 -> 68 tests, sdk-create-app +11 assertions, mcp-server 165 green.
Gates: format, affected lint (48), affected build (17), affected test (40).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flakes

Three defects that a cached `nx affected` cannot show, because they only appear when
the whole suite runs uncached and in parallel.

portlets-dot-auth-portlet was not migrated at all. Both migration tools looked for
`jest.config.ts` exactly, and this project names its config `jest.config.cts`, so it
was never discovered: no Vitest config was generated, its `test` target ceased to
exist when the Jest plugin was removed, and its 103 tests (5 files, matching the
captured baseline) went from passing to not running — with nothing red anywhere, and
no entry in tasks.md's remaining work. `nx run-many -t test` cannot report a project
it has no target for. Both tools now resolve the names Jest itself resolves; the
generated config and the recovered suite are 103/103 green, exactly the baseline.

Two specs passed 3/3 in isolation and failed the project under `nx run-many`:

- dot-relationship-field reaches its dialog through a dynamic `import()`. Under Vitest
  that import is where the dialog component and its Angular graph get transformed —
  inside the 5s budget of the first test to call it. ts-jest resolved it at compile
  time, so it cost nothing before. Warmed in a `beforeAll` so the transform sits
  outside every test's clock, rather than widening the clock to hide it.
- dot-analytics-dashboard used a bare `mockProvider(DotAnalyticsService)`, whose
  methods all return `undefined`, while its tab-switching tests move the store and so
  fire real loaders that do `service.getX(...).pipe(...)`. The throw escapes from
  inside an rxMethod; whether it lands in the test that caused it or after the file
  has finished is a timing race, and the late case is an "Unhandled error" that fails
  the project. All seven service methods now return EMPTY.

Full suite, uncached, parallel 3: 4m46s-5m37s across four runs, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…grate-all-core-web-unit-tests-from-jest-and-karma-to-vitest
…d why

Last untested lever from the performance sweep, and it is a dead end for a structural
reason rather than a tuning one.

  project       without      with deps.optimizer.web.enabled
  data-access    10.13s        10.38s
  dotcms-ui      34.16s        37.59s   (transform 31.54s -> 31.58s)

The transform figure is the finding: unchanged to two decimals. `deps.optimizer` only
pre-bundles dependencies that are EXTERNALISED, and `server.deps.inline` deliberately
inlines almost everything that matters — that list exists so every package shipping
Angular-compiled code resolves to one @angular/core instance, without which components
fail on NG0203 / `ngModule of null`. So the optimizer is left with nothing to bundle and
contributes only overhead.

Both projects kept every test passing (853 and 2234), so this is useless rather than
dangerous. Worth revisiting only if deps.inline ever shrinks.

Measured on two projects rather than one on purpose: concluding from data-access alone
is how the isolate: false phantom in f3c5013 got believed in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nicobytes and others added 3 commits September 8, 2026 16:51
Restore Jest semantics that Vitest's stricter runtime broke during the
migration: no-op mockImplementation() now calling through instead of
returning undefined, unhandled rxjs errors failing tests instead of being
dropped, CSS module class hashing, ESM partial mocks, and various
fail()/done.fail() calls unsupported by Vitest's expect API.
…test

Copilot flagged one file on PR #37453; the directive was in six. sdk-client runs
entirely on Vitest — vite.config.mts, and a tsconfig.spec.json whose `types` are
`vitest/globals` — so `/// <reference types="jest" />` at the top of each spec
pulls @types/jest globals into files that already import `vi` from vitest, and
the two sets of matcher/mock typings can disagree. Nothing in these specs uses a
jest global any more, so the line only creates the conflict it once resolved.

Also two documentation references the migration left pointing at config that no
longer exists:

- virtual-modules.d.ts said `jest.config.ts` maps `virtual:sdk-version` to its
  stub. The mapping is now a resolve.alias in vite.config.mts.
- libs/sdk/client/CLAUDE.md still described the project's test setup as Jest —
  the command comment, the jest.config.ts entry in the structure tree and the
  key-config list, the `jest.mock` example, and `--testNamePattern`, which
  Vitest does not accept.

The second Copilot thread (an `await` in a non-async `beforeEach` in
add-to-bundle.service.spec.ts) was already resolved by cb6d2b6, which
replaced that importActual block with a plain `of()` mock.

sdk-client: 15 files, 299 tests, green. Lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hang this branch has been chasing is a live `dart-sass` subprocess, not a slow
test. `portlets-content-drive:test` was caught sitting at 0% CPU with 1.4GB resident
and a `sass.snapshot --embedded` child for 18 minutes, its JUnit report open at 0
bytes, never exiting — the dart process keeps the event loop alive, so the task never
ends and `nx run-many` waits on it forever.

Three things that were believed about this and are not true:

- `css: { include: [] }` does not prevent it. The guard is in place, unchanged, and
  sass started anyway: `test.css` governs whether Vitest hands processed CSS back to a
  test, not whether component styles get compiled.
- It is not rare. Sampling the process table once a second shows sass starting on
  EVERY run; what varies is only whether it shuts down. Four clean runs earlier in the
  branch were luck, not evidence — an intermittent hang cannot be cleared by counting
  green runs.
- content-drive does not own the SCSS. It has zero .scss files and zero styleUrls;
  `server.deps.inline` pulls sibling `libs/**` in as source, and those components do.

Vite prefers `sass-embedded` whenever it resolves, and it resolves because Vite itself
depends on it — the workspace only declares plain `sass`. Vite 7 exposes no way to
choose: `skipEmbedded` is an internal fallback for a broken native binary. So the fix
is to never need a preprocessor. `stubScss()` serves every .scss/.sass as empty CSS,
ahead of Vite's own CSS handling.

This restores Jest's behaviour rather than changing it: Jest mapped every stylesheet
through `identity-obj-proxy`, so compiled CSS never existed in a unit test and no
assertion can depend on it. SCSS only — `*.module.css` still goes through Vite so
`classNameStrategy: 'non-scoped'` keeps working for sdk-react's Column spec.

Verified on the mechanism, not on a green count: sampled every second across a full
uncached `run-many`, zero sass processes appear at any point. 41 projects, 16,372
tests, 4m18s.

Also fixes the discovery this exposed: a jest config only marks a project when the
directory has a project.json. dotcdn keeps one at apps/dotcdn/src/jest.config.js, a
plain source folder with no specs, and the widened name lookup generated a stray
config inside src/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nicobytes and others added 2 commits September 8, 2026 18:29
…grate-all-core-web-unit-tests-from-jest-and-karma-to-vitest
…uild targets

The generator writes one file per project and it writes the WHOLE file, so for the
three projects whose `vite.config.mts` is a production build config it replaced the
build settings with a test-only config. `@nx/vite/plugin` infers a build target only
when the config carries `build.lib`, so this did not merely lose settings — it
deleted the targets. Nx substituted `nx:noop`, `nx run-many -t build` still reported
success for 17 projects, and Maven then failed packaging the war on the first
missing directory:

    basedir core-web/dist/libs/sdk/analytics-standalone does not exist

with `dist/libs/edit-content-bridge` (html/js/legacy_custom_field_bridge) queued
behind it and `sdk-analytics:build` silently absent from the run-many list.

Merging the two configs into one file was the wrong repair: `stubScss()` compiles
every SCSS import to nothing and `angular()` / `react()` rewrite the sources — both
belong to a test run and neither belongs in a shipped bundle. Vitest resolves
`vitest.config.*` ahead of `vite.config.*`, so the two live side by side and each
tool reads its own. `vite.config.mts` for all three is byte-identical to main again,
and the generator now targets `vitest.config.mts` for them (BUILD_OWNED_VITE_CONFIG)
so regenerating cannot repeat this.

Verified: all three build outputs are produced again (ca.min.js,
edit-content-bridge.js, dot-experiments.min.iife.js) plus dist/libs/sdk/analytics,
and the suites are unchanged against the recorded counts — sdk-analytics 314/15,
sdk-experiments 48/9, edit-content-bridge 130/2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicobytes

nicobytes commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Jest vs Vitest — what the migration actually costs and saves

Measured locally on one machine, both runners driven by the same command. Jest on main (6bad8c7ed9), Vitest on this branch — the same commit plus the migration. Nx parallel: 3, --skip-nx-cache throughout.

The two headline numbers disagree, and that is the finding.

1. Per project, one at a time (serial) — the inner loop

Jest Vitest Δ
Test execution (runner-reported) 296.6s 236.6s -20%
Wall clock of nx run <p>:test 442.8s 499.5s +13%
Fixed startup per project 4.1s 7.3s +3.2s

Vitest executes tests ~20% faster and pays ~3.2s more to start. Running one project — what you do all day — the startup dominates and Vitest comes out slower for all but the largest projects. Two independent serial rounds produced the same figures (−18.8%/+12.1% and −20.2%/+12.8%), so this is not noise.

Project Jest exec Vitest exec Δ exec Δ wall
dotcms-ui 43.5s 25.7s -41% -15%
edit-content 34.2s 22.0s -36% -16%
portlets-edit-ema-portlet 34.1s 23.5s -31% -3%
template-builder 30.5s 31.2s +2% +18%
portlets-dot-analytics 10.0s 7.8s -22% +16%
edit-ema-ui 9.7s 8.2s -15% +21%
portlets-dot-publishing-queue-portlet 8.3s 7.4s -11% +19%
portlets-dot-agents-portlet 7.8s 7.0s -10% +30%
new-block-editor 7.7s 7.1s -8% +23%
portlets-dot-users-portlet 7.6s 6.9s -10% +19%
image-editor 7.0s 5.5s -21% +18%
data-access 6.9s 5.2s -25% +10%
portlets-dot-query-tool-portlet 6.6s 7.2s +9% +50%
portlets-dot-tags-portlet 6.4s 6.2s -3% +26%

2. Whole suite, 3 lanes in parallel — NOT measured on CI

Vitest Jest
Wall clock 3m54s 7m08s
Projects 43 46
Tests counted 16,668 14,999
Result green fails (blocked targets)

The two test counts are not the same set of tests — do not read the difference as coverage gained. Per-project comparison: 1,408 of the gap is projects where Jest produces no count at all — portlets-dot-experiments-portlet (988) fails outright on main, mcp-server (165), sdk-create-app (132) and sdk-ai (68) have their test targets blocked by failing dependency tasks, and sdk-vue (55) was on Vitest before this migration so Jest has no target for it. Netting out those, the real movement is +62 in portlets-content-drive (specs revived) against -63 lost across dotcms-ui (-44), data-access (-11), ui (-4), edit-content (-3) and edit-ema-ui (-1) — losses that are still being triaged. Do not quote the 45% either. This is a laptop with parallel: 3, not CI, and CI runs nx affected with a warm cache — usually a subset, where none of this shows. An earlier pair of runs of the same two commands put the same gap at ~12% (Vitest 3m46s/4m19s against Jest 4m18s/4m57s) while Jest's test count moved only 0.8%, so the honest claim is a direction, not a figure. Note this contradicts the serial result, and the contradiction is structural rather than experimental error: summing the serial times and dividing by three predicts ~171s for Vitest against ~152s for Jest — Jest should win. It loses by a wide margin instead, because each Jest project starts its own worker pool sized to the CPU, so three concurrent Jest projects oversubscribe the machine. Vitest degrades far less.

Read this before quoting a number

  • The parallel figure is n=1 per runner and this machine is noisy. Identical runs of the same commit varied up to 3×; an earlier round measured Jest at 4m18s where this one says 7m08s. Treat 45% as an order of magnitude, not a measurement. The serial numbers are the ones that reproduced.
  • Jest runs fewer tests, and not because of coverage. On main, sdk-ai:generate-spec and sdk-create-app:verify-package fail on a machine with no npm on PATH and block their test targets. Projects where either side failed are excluded from the serial table entirely: block-editor, dotcms-webcomponents, ui, portlets-content-drive, portlets-dot-experiments-portlet, mcp-server, sdk-ai, sdk-create-app.
  • Never measure per-project time from a parallel run. Under parallel: 3 each runner reports elapsed time, which includes waiting for CPU. Jest per-project numbers moved 3× between identical runs that way. The table above comes from serial runs for that reason.
  • Caching changes the picture entirely. All of this is --skip-nx-cache. Day to day, nx affected with a warm cache dominates and none of these differences are visible.

Whole suite — 3 lanes in parallel, no cache

  Vitest  ████████████████████████  3m54s            16,668 tests
  Jest    ████████████████████████████████████████████  7m08s  1.8x slower  14,999 tests

Per project — test execution only, measured serially

  dotcms-ui                           
    jest    ██████████████████████████████████████ 43.5s
    vitest  ██████████████████████                 25.7s   -41%
  edit-content                        
    jest    ██████████████████████████████         34.2s
    vitest  ███████████████████                    22.0s   -36%
  portlets-edit-ema-portlet           
    jest    ██████████████████████████████         34.1s
    vitest  █████████████████████                  23.5s   -31%
  template-builder                    
    jest    ███████████████████████████            30.5s
    vitest  ███████████████████████████            31.2s   +2%
  portlets-dot-analytics              
    jest    █████████                              10.0s
    vitest  ███████                                7.8s   -22%
  edit-ema-ui                         
    jest    ████████                               9.7s
    vitest  ███████                                8.2s   -15%
  portlets-dot-publishing-queue-port  
    jest    ███████                                8.3s
    vitest  ██████                                 7.4s   -11%
  portlets-dot-agents-portlet         
    jest    ███████                                7.8s
    vitest  ██████                                 7.0s   -10%
  new-block-editor                    
    jest    ███████                                7.7s
    vitest  ██████                                 7.1s   -8%
  portlets-dot-users-portlet          
    jest    ███████                                7.6s
    vitest  ██████                                 6.9s   -10%

nicobytes and others added 2 commits September 8, 2026 19:13
`ContentletFilters.contentletIdentifier` had been widened to optional so that
`suggestions.service.spec.ts` could pass `undefined` for it, which strict mode rejects
against a required `string`. That is the wrong direction: the only producer is
`suggestions.component.ts`, whose `@Input()` defaults to `''` and always sends a
string, so nothing in the product ever omits the field — the type was relaxed for
every consumer of `ContentletFilters` to accommodate nine lines in one spec.

The service branches on truthiness (`contentletIdentifier ? … : …`), so `''` and
`undefined` are the same input to it. The spec now passes `''`, which is both
type-correct against the original contract and what the component actually sends.
Field restored to required; block-editor's counts are unchanged either way (35 failed
/ 87 passed before and after — those failures predate this branch), and
suggestions.service.spec.ts is green at 11/11.

Also records `virtual-modules.d.ts` as a product exception. Its edit is a comment and
nothing else: it documented `virtual:sdk-version` resolution as coming from
jest.config.ts, a file this branch deletes, so the reference had to move to
vite.config.mts. No declaration, type or runtime behaviour changes.

And fixes a false zero in capture-baseline.sh. Its cross-check greps the runner's own
"Tests: N" line out of the raw log, but Vitest colours the count, so the line reads
"Tests  <ESC>[1m<ESC>[32m55 passed" and the pattern matched nothing. sdk-vue ran 55
tests and was recorded as "genuinely empty" — silently, which is the exact failure
class the script's own header says must never happen. ANSI codes are now stripped
before matching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The AC-013 tarball check moved from `npm pack` to `pnpm pack` so it would run on
a pnpm-managed toolchain, where npm is absent. On the CI runner the swap went the
other way: `pnpm pack --dry-run --json` returned no file list at all, the script
discarded its stderr, and `sdk-create-app:verify-package` failed with nothing in
the log to explain it — reddening Frontend Unit Tests.

Ask both packers instead of picking one, take the first that answers, and print
each one's stderr when neither does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…his PR

Three scripts did their work in the commits already on this branch and would be dead
code the moment it merges:

- codemod-jest-to-vitest.mjs   7,754 mechanical rewrites, all committed
- migrate-project.mjs          per-project tsconfig/test-setup/target changes, applied
- verify-test-only-diff.mjs    the boundary check for THIS diff, meaningless once merged,
                               together with its 15-scenario suite under tools/__tests__

Its last run is the record that the boundary held: 860 spec, 147 test-config, 41
test-support, 53 workspace-config, 17 docs, 10 test-utilities, 8 migration-tooling, 5
ts-project-refs, 2 lint-dependency-checks, 1 build-invocation, and three named product
exceptions — the content-drive backtick-in-CSS-comment fix, sdk-client's
virtual-modules.d.ts comment, and create-app's verify-package.sh npm→pnpm. No rejected
paths.

Four tools stay, and none of them is a leftover:

- generate-vite-configs.mjs   every one of the 45 configs says "regenerate rather than
                              hand-editing" in its header; deleting this orphans them
- capture-baseline.sh         }  the per-project count parity gate. Four of the six
- compare-test-counts.mjs     }  defects found on this branch produced no red at all —
                                 they just stopped running tests — and this pair is what
                                 catches that class. It should become a required check.
- profile-tests.mjs           the suite profiler the perf work depends on

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Frontend Unit Tests was killed by the kernel twice in a row — a bare "The
operation was canceled." with no stack, no failing sibling job, and no new head
SHA — always at the same point: while edit-content, portlets-dot-experiments-portlet
and template-builder, the three heaviest projects, were in flight together.

nx.json's "parallel": 3 was sized for Jest, where it meant three node processes.
Vitest gives every nx task its own coordinator holding a Vite dev server, and on
edit-content (116 spec files, everything under libs/ inlined) that one process
peaks at ~14 GB RSS. Three of those do not fit a 16 GB runner that is also
holding this build's JVM.

The parallelism was barely paying for itself anyway: on the run that died, 8.8
min of serial vitest time had finished in 5.6 min of wall clock — ~1.6x on 4
vCPUs, for 3x the memory.

Scoped to this execution, so build and lint keep their concurrency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nicobytes and others added 2 commits September 9, 2026 08:16
…haust the runner

Frontend Unit Tests keeps dying the same way: "The operation was canceled." with no
stack, no failing sibling job and no new head SHA. Serialising nx to one project at a
time did not stop it — run 34299641871 was killed 23 minutes in with only dotcms-ui
in flight, and the runner then reported an orphan node process on cleanup.

That is the shape of an OOM kill rather than a timeout, and one project is enough to
cause it. Vitest gives every fork its own Vite module graph, and dotcms-ui's forks
were measured summing 16-24GB of resident memory locally against a 16GB runner that
is also holding this build's JVM. `maxForks` defaults to the CPU count, so the runner
was starting four of them.

2 rather than 1 because capping to a single fork would roughly double a job that
already runs twenty minutes, and CI-only because a developer machine is not memory
constrained the way the runner is — local runs keep every core.

Honest about what this is: a starting point to be validated in CI, not a figure
derived from a benchmark. The kill does not reproduce on a 16-core laptop with spare
RAM, and summed RSS across forks double-counts shared pages, so local measurement
could not rank the candidates — removing the `libs|apps` inline made the summed figure
look worse, and this cap barely moved it. Both are artefacts of the metric, which is
why the number is deliberately conservative and commented as such in the generator.

Verified not to be a regression on the three heaviest projects, under the CI path
(CI=true): dotcms-ui 2,234 tests, edit-content 2,346, portlets-content-drive 1,370
green four runs in a row with no leftover processes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…annot exhaust the runner"

This reverts commit d0f6f94. The cap was aimed at the wrong variable and the run
that carried it, 34350160145, died exactly as before: cancelled at 24m47s, on
dotcms-ui, with `--parallel=1` from pom.xml and `maxForks: 2` both active.

Measuring peak RSS of a SINGLE process rather than the sum across forks — the sum
double-counts shared pages and cannot rank configurations, which is what misled the
first attempt — shows why nothing about concurrency could have helped:

  as committed          14.2 GB   44s
  maxForks: 1           14.2 GB   44s
  4 shards              13.3 GB   83s
  no libs|apps inline   17.8 GB   39s

One fork of dotcms-ui peaks at 14.2 GB, and it stays there whether it runs 2,234 tests
or a 550-test shard. That is not accumulation across files and not contention between
workers: it is the baseline cost of the module graph this project's test run builds.
On a 16 GB runner also holding the build's JVM, one fork does not fit, so capping how
many of them run concurrently cannot change the outcome.

Two things worth recording because they are counter-intuitive and cost time to
establish: sharding barely moves peak memory (the graph, not the file count, is the
cost), and dropping the `libs|apps` deps.inline entry makes memory 25% WORSE, not
better — externalising those sources has Node load them separately instead.

Levers not yet tried, in the order I would try them: the jsdom environment (its
per-instance cost is a plausible share of the 14 GB), `isolate: false`, and splitting
dotcms-ui's 226 spec files so no single nx task owns a graph this large. A bigger
runner for this one job is the option that needs no measurement at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Angular's JIT compiler pins compiled definitions to component classes cached in Vite's module graph, and the default 'forks' pool never releases that cache across test files in a run
- Frontend Unit Tests was killed three times mid-dotcms-ui with peak RSS reaching ~14GB per process
- vmForks gives each spec file a fresh VM context so module state doesn't accumulate, cutting measured peak RSS to 1.6-2.7GB per project while running as fast or faster
- Regenerates all vite/vitest.config.mts from the shared template in tools/generate-vite-configs.mjs so every project picks up the same pool settings
…ot grow to 14GB

Frontend Unit Tests was killed three times — 22m46s, 24m47s, 16m08s, always a bare
"The operation was canceled." with no stack, no failing sibling and no new head SHA,
always mid-dotcms-ui, and twice with an orphan node process reported on cleanup. It is
an OOM kill: the job's timeout is 240 minutes, so nothing was timing out.

The cause is where Vitest's speed comes from. A 'forks' worker keeps Vite's module
graph and transform cache for the whole run and reuses them across test files;
`isolate` replaces the ENVIRONMENT per file and never that cache. Angular's JIT then
hangs compiled definitions off the component classes living in it, so nothing is ever
released. Measured on dotcms-ui: 2.9GB after one spec file, 14.2GB after 226 of them.

Jest never hit this because it drops its module registry per test file. Measured on
main, same project: 1.15GB per worker against a 16.5GB sum — the same total memory,
but in pieces a runner can schedule rather than one block that has to fit whole. That
difference, not a leak in the migration, is why this only appeared after Vitest.

'vmForks' gives each test file a fresh VM context, so the state cannot accumulate, and
vmMemoryLimit caps what one holds. Peak RSS of a single process, all green:

  dotcms-ui               14.2GB -> 2.16GB   (44s -> 38s)
  ui                      13.7GB -> 1.92GB
  edit-content            ~14GB  -> 1.68GB
  portlets-content-drive  ~14GB  -> 2.72GB

Faster as well as smaller. On a 4-vCPU runner that is ~4 x 2GB rather than one 14GB
block. Full suite: 16,668 tests, 4m09s, down from ~4m45s.

Chosen per project on measurement rather than uniformly. sdk-analytics and
sdk-experiments stay on 'forks': the VM context is not a perfect stand-in for a real
one and both regress under it — analytics' SSR specs assign `global.window = undefined`
against a getter-only property, experiments silently collects 39 of its 48 tests — and
neither has the problem being solved, peaking at 0.25-1.4GB.

Ruled out first, each measured as peak RSS of a single process (summing across forks
double-counts shared pages and cannot rank anything, which cost two wrong attempts):
maxForks 14.2GB unchanged, 4-way sharding 13.3GB, dropping the libs|apps deps.inline
entry 17.8GB — worse, and happy-dom 9%. Only the pool addresses retention; everything
else addressed concurrency or volume.

portlets-edit-ema-portlet is still red, and was before this: 2 tests fail under
'forks'. Under 'vmForks' all 1,453 pass and the non-zero exit is an unhandled
`payload.pageAsset.page.styleEditorSchemas` from inside an rxMethod — the same
incomplete-mock class as the dot-analytics fix earlier on this branch. Left for its
own commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Documentation PR changes documentation files Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Migrate all core-web unit tests from Jest and Karma to Vitest

2 participants