oxc-angular-testing
Fast Angular transforms for unit tests, implemented in Rust on oxc and exposed to Node via napi.
A drop-in-spirit reimplementation of the source transforms
jest-preset-angular applies
to Angular code under test — component resource inlining, Angular decorator
downleveling, and the JIT signal-initializer-API decorators — plus optional
istanbul-compatible coverage instrumentation folded into the same AST pass
(one parse, one codegen) via
oxc-coverage-instrument.
Packages
| Package | What it is |
|---|---|
@oxc-angular-testing/transform |
napi bindings to the Rust transform. Per-platform binaries ship as @oxc-angular-testing/binding-* optional deps. |
@oxc-angular-testing/jest |
Jest transformer wiring up the transform. |
@oxc-angular-testing/vitest |
Vitest/Vite plugin wiring up the transform. |
No Rust crates are published — only the npm packages.
Pipeline
parse → semantic → Angular passes → oxc TS/decorator lowering → [coverage] → codegen
(one oxc_allocator::Allocator, one parse, one codegen)
The coverage pass runs oxc_coverage_instrument's visitor on the same
arena-allocated AST the Angular passes mutate, via a small vendored patch
(vendor/, see vendor/VENDORED.md) that exposes a post-parse
instrument_program entry. All oxc crates — ours and the vendored coverage
crate — are pinned to oxc 0.126 so the AST types are shared.
Usage
Vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import oxcAngular from '@oxc-angular-testing/vitest';
export default defineConfig({
plugins: [oxcAngular()],
});
Coverage auto-enables when vitest runs with the istanbul provider
(test.coverage.provider: 'istanbul'); pass oxcAngular({ coverage: true | false })
to force it.
Component styles (keepStyles)
By default component styles are stripped (styles, styleUrl, styleUrls
removed) — matching jest-preset-angular, and right for node/jsdom unit tests
where layout doesn't exist anyway. Under vitest browser mode, real-layout
tests need styles applied, so the plugin keeps them there: when keepStyles is
not set, it is decided per Vite environment — styles are kept for the client
environment (browser mode) and stripped for ssr (node/jsdom projects). Pass
oxcAngular({ keepStyles: true | false }) to force one behavior everywhere.
When styles are kept, the transform does not compile any CSS itself —
that's vite's job (an explicit non-goal: no sass in the transform).
styleUrl/styleUrls entries are rewritten to hoisted default imports with
the inline query the plugin passes (e.g. import __oxc_ng_style_0__ from './a.scss?inline'), which vite's CSS pipeline (with your sass/less/postcss
config) compiles to a CSS string, and the decorator property is replaced with
styles: [__oxc_ng_style_0__] — which Angular JIT accepts as-is. Inline
styles are preserved and merged ahead of the URL-derived entries, matching
Angular's own resolution order.
Jest (ESM)
// jest.config.mjs — CommonJS (classic jest)
import { createCjsPreset } from '@oxc-angular-testing/jest/presets';
export default { ...createCjsPreset({ tsconfig: './tsconfig.spec.json' }) };
// jest.config.mjs — native ESM (run with NODE_OPTIONS=--experimental-vm-modules)
import { createEsmPreset } from '@oxc-angular-testing/jest/presets';
export default { ...createEsmPreset({ tsconfig: './tsconfig.spec.json' }) };
The presets set transform, transformIgnorePatterns and moduleFileExtensions
for you. You can also wire the transformer manually ('^.+\\.(ts|js)).
ESM-only dependencies
Like jest-preset-angular's esbuild fast path, the jest plugin downlevels ESM
dependencies (.mjs / node_modules, e.g. @angular/core) to the runner's
module format with the Angular passes skipped — using our own oxc ESM→CJS
transform, not esbuild. The CJS preset sets
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'] so .mjs files in
node_modules (including @angular/*, which ships .mjs) reach the transformer
instead of being ignored.
How
.mjsworks under classic CJS jest: jest only routes modules to its ESM loader when run with--experimental-vm-modules. The CJS preset runs without that flag, so jest transforms every matched file — including.mjs(e.g.@angular/core) — to CommonJS andrequire()s it. This works on all supported Node versions (no Node ≥ 24.9 needed); it's how jest-preset-angular handles Angular too. Run the CJS and ESM presets as separate jest invocations, since the vm-modules flag is process-global (jest --selectProjectsor two configs). The ESM preset does use the flag and loads.mjsnatively.
Direct
import { transform } from '@oxc-angular-testing/transform';
const { code, map, coverageMap, errors } = transform(source, 'foo.component.ts', {
module: 'commonjs', // 'commonjs' | 'esm' — drives templateUrl require/import + ESM→CJS
coverage: false,
});
@oxc-angular-testing/transform depends on @oxc-project/runtime for the
decorator/class helpers the lowering emits.
Status
| Transform | Status |
|---|---|
templateUrl → template (require/import per module) |
resources.rs |
styleUrls / styleUrl / styles / moduleId stripping |
resources.rs |
keepStyles: style URLs → ?inline imports, merged styles: [...] (vitest browser mode) |
resources.rs |
Constructor/decorator downleveling (ctorParameters/propDecorators) |
jit_transform.rs |
Signal initializer-API decorators (input()/output()/model()/queries) |
jit_transform.rs |
TS → JS + legacy decorator lowering, ES target downleveling |
via oxc_transformer |
ESM → CommonJS (matches tsc module:commonjs + esModuleInterop) |
esm_to_cjs.rs |
Dynamic import() → require (matches tsc) |
esm_to_cjs.rs |
jest.mock() hoisting (babel-plugin-jest-hoist) |
jest_hoist.rs (jest plugin) |
JSX/TSX for mixed Angular + React (automatic/classic, from tsconfig jsx) |
via oxc_transformer |
| istanbul coverage in the same AST pass | vendored instrument_program |
Options derived from tsconfig (target / module / decorators / useDefineForClassFields / jsx) |
transform/src/tsconfig.ts |
| ESM-only dependency downleveling for jest (esbuild-fast-path equivalent) | jest plugin + presets.ts |
| jest (ESM and CommonJS) + vitest plugins, real component integration tests |
Every row is covered by tests: cargo test --workspace (resources, JIT, ESM→CJS,
lowering, coverage) plus the jest/vitest integration suites.
Notes
- CommonJS output matches TypeScript's
module: "commonjs"+esModuleInteropemit (__importDefault/__importStar/__exportStarinterop,(0, m_1.x)()call wrapping,exports.x = …,__esModule). Re-exports use assignment rather thanObject.definePropertygetters (runnable-equivalent for static re-exports). targetmaps to oxc'sEnvOptions::from_target; only syntax newer than the target is downleveled.lower: falseis a test-only switch to inspect the pre-lowering TypeScript AST.useDefineForClassFields: false(the default, Angular's setting) emits class fields as plain assignments (oxcset_public_class_fields+remove_class_fields_without_initializer).keepStyles(defaultfalse) keeps component styles instead of stripping them, for tests that exercise real layout (vitest browser mode). Style URLs become default imports (ESM) orrequire(...)calls (CommonJS) — the bundler's CSS pipeline owns compilation (no sass in the transform, by design).keepStylesQuery(default unset — URLs emitted verbatim) names a query parameter to append to each rewritten URL: the vitest plugin hard-codes'inline', so vite yields the CSS text, which Angular JIT accepts instyles: [...](URLs that already carry a query get&inline). The hoisted identifiers (__oxc_ng_style_N__) are deterministic and dodge user bindings. Note the CommonJS form is emitted for completeness: plain jest has no CSS pipeline to resolverequire('./a.scss?inline'), so the jest plugin does not expose the option —keepStylesis a vitest (vite) feature.- Content stringification — files matching
stringifyContentPathRegex(default\.(html|svg)$) are returned as a string module (their raw content) rather than compiled, so componenttemplateUrlHTML and inline SVG imports work. The jest presets route.html/.svgthrough this; the vitest plugin'sloadhook does the same. Mirrors jest-preset-angular's option of the same name. - Coverage baseline — instrumentation runs on the source (before any
transform), so the coverage map is target-independent and matches
istanbul-lib-instrumentbyte-for-byte on the same source (enforced by the differential test intransform/test/coverage-differential.test.mts). This is a deliberate, more-truthful baseline.jest-preset-angular(ts-jest) instead instruments the compiled CommonJS output, so its%reads slightly higher on the same code: it counts two always-covered structural nodes that don't exist in the source — the synthesized field-init constructor (a function) and the CJS export plumbing (exports.X = …, statements). Both are always hit, and since the base fraction is < 100%, including them raises the percentage. Same covered/uncovered lines, different denominator. Migrating from jest-preset-angular: re-baseline yourcoverageThresholds against this transform's output — run once with--coverageand set the thresholds from the reported numbers (they reflect the real, executable surface), e.g. derive them fromcoverage/coverage-summary.json.
Development
pnpm install
cargo test --workspace # Rust transforms + coverage
pnpm build # native binding (napi) + TypeScript packages (tsc → dist/)
pnpm typecheck # tsc --noEmit across all packages
pnpm test # binding + jest (esm+cjs) + vitest
The npm package sources are TypeScript (packages/*/src, crates/ng-transform-napi/src)
compiled to dist/ by tsc; the napi binding's index.js/index.d.ts are
generated by napi build. Run pnpm build before pnpm test/pnpm typecheck
(jest loads the built transformer; vitest runs against source via its
vitest.config.ts). Test fixtures keep their deliberate module formats
(.mjs/.cjs/.js) and are not converted.
Toolchain: pnpm 11, Node ≥ 20.19 / 22.12, Rust stable (oxc pinned to 0.126).
Releasing
The package.json version fields are placeholders (0.0.0) — the release
workflow (.github/workflows/release-npm.yml) is the source of truth for the
published version. Trigger it either by pushing a v* tag (e.g. v1.2.3) or via
workflow_dispatch with an explicit version input. The workflow stamps that
version across all three packages with scripts/set-version.mjs; napi prepublish then propagates it to the @oxc-angular-testing/binding-* platform
packages + the main package's optionalDependencies, and pnpm publish rewrites
jest/vitest's workspace:* dependency on @oxc-angular-testing/transform to that
exact version — so all packages publish in lockstep. Publishing is idempotent: a
package is skipped only if that exact version is already on npm, so a re-run after
a partial failure completes the rest.
ESM-only dependencies
Like jest-preset-angular's esbuild fast path, the jest plugin downlevels ESM dependencies (__INLINE_CODE_31__ / __INLINE_CODE_32__, e.g. __INLINE_CODE_33__) to the runner's module format with the Angular passes skipped — using our own oxc ESM→CJS transform, not esbuild. The CJS preset sets __INLINE_CODE_34__ so __INLINE_CODE_35__ files in __INLINE_CODE_36__ (including __INLINE_CODE_37__, which ships __INLINE_CODE_38__) reach the transformer instead of being ignored.
How __INLINE_CODE_39__ works under classic CJS jest: jest only routes modules to its ESM loader when run with __INLINE_CODE_40__. The CJS preset runs without that flag, so jest transforms every matched file — including __INLINE_CODE_41__ (e.g. __INLINE_CODE_42__) — to CommonJS and __INLINE_CODE_43__s it. This works on all supported Node versions (no Node ≥ 24.9 needed); it's how jest-preset-angular handles Angular too. Run the CJS and ESM presets as separate jest invocations, since the vm-modules flag is process-global (__INLINE_CODE_44__ or two configs). The ESM preset does use the flag and loads __INLINE_CODE_45__ natively.
Direct
import { transform } from '@oxc-angular-testing/transform';
const { code, map, coverageMap, errors } = transform(source, 'foo.component.ts', {
module: 'commonjs', // 'commonjs' | 'esm' — drives templateUrl require/import + ESM→CJS
coverage: false,
});
__INLINE_CODE_46__ depends on __INLINE_CODE_47__ for the decorator/class helpers the lowering emits.
Status
| Transform | Status |
|---|---|
| __INLINE_CODE_48__ → __INLINE_CODE_49__ (__INLINE_CODE_50__/__INLINE_CODE_51__ per __INLINE_CODE_52__) | __INLINE_CODE_53__ |
| __INLINE_CODE_54__ / __INLINE_CODE_55__ / __INLINE_CODE_56__ / __INLINE_CODE_57__ stripping | __INLINE_CODE_58__ |
| __INLINE_CODE_59__: style URLs → __INLINE_CODE_60__ imports, merged __INLINE_CODE_61__ (vitest browser mode) | __INLINE_CODE_62__ |
| Constructor/decorator downleveling (__INLINE_CODE_63__/__INLINE_CODE_64__) | __INLINE_CODE_65__ |
| Signal initializer-API decorators (__INLINE_CODE_66__/__INLINE_CODE_67__/__INLINE_CODE_68__/queries) | __INLINE_CODE_69__ |
| TS → JS + legacy decorator lowering, ES __INLINE_CODE_70__ downleveling | via __INLINE_CODE_71__ |
| ESM → CommonJS (matches __INLINE_CODE_72__ __INLINE_CODE_73__ + __INLINE_CODE_74__) | __INLINE_CODE_75__ |
| Dynamic __INLINE_CODE_76__ → __INLINE_CODE_77__ (matches __INLINE_CODE_78__) | __INLINE_CODE_79__ |
| __INLINE_CODE_80__ hoisting (babel-plugin-jest-hoist) | __INLINE_CODE_81__ (jest plugin) |
| JSX/TSX for mixed Angular + React (automatic/classic, from tsconfig __INLINE_CODE_82__) | via __INLINE_CODE_83__ |
| istanbul coverage in the same AST pass | vendored __INLINE_CODE_84__ |
| Options derived from tsconfig (target / module / decorators / __INLINE_CODE_85__ / __INLINE_CODE_86__) | __INLINE_CODE_87__ |
| ESM-only dependency downleveling for jest (esbuild-fast-path equivalent) | jest plugin + __INLINE_CODE_88__ |
| jest (ESM and CommonJS) + vitest plugins, real component integration tests |
Every row is covered by tests: __INLINE_CODE_89__ (resources, JIT, ESM→CJS, lowering, coverage) plus the jest/vitest integration suites.
Notes
- CommonJS output matches TypeScript's __INLINE_CODE_90__ + __INLINE_CODE_91__ emit (__INLINE_CODE_92__/__INLINE_CODE_93__/__INLINE_CODE_94__ interop, __INLINE_CODE_95__ call wrapping, __INLINE_CODE_96__, __INLINE_CODE_97__). Re-exports use assignment rather than __INLINE_CODE_98__ getters (runnable-equivalent for static re-exports).
- __INLINE_CODE_99__ maps to oxc's __INLINE_CODE_100__; only syntax newer than the target is downleveled. __INLINE_CODE_101__ is a test-only switch to inspect the pre-lowering TypeScript AST.
- __INLINE_CODE_102__ (the default, Angular's setting) emits class fields as plain assignments (oxc __INLINE_CODE_103__ + __INLINE_CODE_104__).
- __INLINE_CODE_105__ (default __INLINE_CODE_106__) keeps component styles instead of stripping them, for tests that exercise real layout (vitest browser mode). Style URLs become default imports (ESM) or __INLINE_CODE_107__ calls (CommonJS) — the bundler's CSS pipeline owns compilation (no sass in the transform, by design). __INLINE_CODE_108__ (default unset — URLs emitted verbatim) names a query parameter to append to each rewritten URL: the vitest plugin hard-codes __INLINE_CODE_109__, so vite yields the CSS text, which Angular JIT accepts in __INLINE_CODE_110__ (URLs that already carry a query get __INLINE_CODE_111__). The hoisted identifiers (__INLINE_CODE_112__) are deterministic and dodge user bindings. Note the CommonJS form is emitted for completeness: plain jest has no CSS pipeline to resolve __INLINE_CODE_113__, so the jest plugin does not expose the option — __INLINE_CODE_114__ is a vitest (vite) feature.
- Content stringification — files matching __INLINE_CODE_115__ (default __INLINE_CODE_116__) are returned as a string module (their raw content) rather than compiled, so component __INLINE_CODE_117__ HTML and inline SVG imports work. The jest presets route __INLINE_CODE_118__/__INLINE_CODE_119__ through this; the vitest plugin's __INLINE_CODE_120__ hook does the same. Mirrors jest-preset-angular's option of the same name.
- Coverage baseline — instrumentation runs on the source (before any transform), so the coverage map is target-independent and matches __INLINE_CODE_121__ byte-for-byte on the same source (enforced by the differential test in __INLINE_CODE_122__). This is a deliberate, more-truthful baseline. __INLINE_CODE_123__ (ts-jest) instead instruments the compiled CommonJS output, so its __INLINE_CODE_124__ reads slightly higher on the same code: it counts two always-covered structural nodes that don't exist in the source — the synthesized field-init constructor (a function) and the CJS export plumbing (__INLINE_CODE_125__, statements). Both are always hit, and since the base fraction is < 100%, including them raises the percentage. Same covered/uncovered lines, different denominator. Migrating from jest-preset-angular: re-baseline your __INLINE_CODE_126__s against this transform's output — run once with __INLINE_CODE_127__ and set the thresholds from the reported numbers (they reflect the real, executable surface), e.g. derive them from __INLINE_CODE_128__.
Development
pnpm install
cargo test --workspace # Rust transforms + coverage
pnpm build # native binding (napi) + TypeScript packages (tsc → dist/)
pnpm typecheck # tsc --noEmit across all packages
pnpm test # binding + jest (esm+cjs) + vitest
The npm package sources are TypeScript (__INLINE_CODE_129__, __INLINE_CODE_130__) compiled to __INLINE_CODE_131__ by __INLINE_CODE_132__; the napi binding's __INLINE_CODE_133__/__INLINE_CODE_134__ are generated by __INLINE_CODE_135__. Run __INLINE_CODE_136__ before __INLINE_CODE_137__/__INLINE_CODE_138__ (jest loads the built transformer; vitest runs against source via its __INLINE_CODE_139__). Test fixtures keep their deliberate module formats (__INLINE_CODE_140__/__INLINE_CODE_141__/__INLINE_CODE_142__) and are not converted.
Toolchain: pnpm 11, Node ≥ 20.19 / 22.12, Rust stable (oxc pinned to 0.126).
Releasing
The package.json __INLINE_CODE_143__ fields are placeholders (__INLINE_CODE_144__) — the release workflow (__INLINE_CODE_145__) is the source of truth for the published version. Trigger it either by pushing a __INLINE_CODE_146__ tag (e.g. __INLINE_CODE_147__) or via workflow_dispatch with an explicit __INLINE_CODE_148__ input. The workflow stamps that version across all three packages with __INLINE_CODE_149__; __INLINE_CODE_150__ then propagates it to the __INLINE_CODE_151__ platform packages + the main package's __INLINE_CODE_152__, and __INLINE_CODE_153__ rewrites jest/vitest's __INLINE_CODE_154__ dependency on __INLINE_CODE_155__ to that exact version — so all packages publish in lockstep. Publishing is idempotent: a package is skipped only if that exact version is already on npm, so a re-run after a partial failure completes the rest.