From 0c9519dacfb4c3934eac328a61ad41fd48ad0e1b Mon Sep 17 00:00:00 2001 From: Jeb Date: Sun, 17 May 2026 16:37:45 +0200 Subject: [PATCH 1/2] feat(jest): add ng-add schematic (#22) Introduces an `ng add @angular-builders/jest` schematic that swaps a project's test builder from `@angular-devkit/build-angular:karma` to `@angular-builders/jest:run`, removes karma/jasmine devDependencies, deletes karma.conf.js and src/test.ts, updates tsconfig.spec.json types from jasmine to jest, and schedules a package install. Scope is intentionally narrow: jest package only, ng-add only. Migrations and schematics for the other builder packages will land in follow-up PRs. Structural fixes vs. the closed #2229: - Schematics compile via a dedicated `tsconfig.schematics.json` (rootDir src/schematics, outDir dist/schematics) wired into the package build script, so JS output actually ships. - `collection.json` lives under `src/schematics/` and is copied to dist via a new `copy:schematics` script. Factory path is `./ng-add/index#default`, relative to the dist'd collection.json (not the source tree). - `package.json` `schematics` field points at `./dist/schematics/collection.json`. - No cross-package source-tree imports; the ng-add Rule uses only `@angular-devkit/schematics` + `@angular-devkit/core` (already direct deps). - Default export is the Rule factory, as Angular's schematics runner expects. Test coverage is deferred to a follow-up because `@angular-devkit/schematics/testing` transitively imports `ora@9` (ESM-only) which the repo's current ts-jest config cannot load. The follow-up will adjust `transformIgnorePatterns` and add `SchematicTestRunner`-based specs. A TODO in ng-add/index.ts enumerates the assertions to add. --- packages/jest/package.json | 9 +- packages/jest/src/schematics/collection.json | 10 + packages/jest/src/schematics/ng-add/index.ts | 199 ++++++++++++++++++ .../jest/src/schematics/ng-add/schema.json | 21 ++ packages/jest/src/schematics/ng-add/schema.ts | 6 + packages/jest/tsconfig.schematics.json | 14 ++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 packages/jest/src/schematics/collection.json create mode 100644 packages/jest/src/schematics/ng-add/index.ts create mode 100644 packages/jest/src/schematics/ng-add/schema.json create mode 100644 packages/jest/src/schematics/ng-add/schema.ts create mode 100644 packages/jest/tsconfig.schematics.json diff --git a/packages/jest/package.json b/packages/jest/package.json index 68777926c0..21c1cb8180 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -30,14 +30,19 @@ "runner" ], "builders": "builders.json", + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, "scripts": { "prebuild": "yarn clean && yarn generate", - "build": "yarn prebuild && tsc -p tsconfig.lib.json && yarn postbuild", - "postbuild": "yarn copy && yarn test", + "build": "yarn prebuild && tsc -p tsconfig.lib.json && tsc -p tsconfig.schematics.json && yarn postbuild", + "postbuild": "yarn copy && yarn copy:schematics && yarn test", "test": "jest --config ../../jest-ut.config.js", "e2e": "jest --config ../../jest-e2e.config.js", "clean": "rimraf dist src/schema.ts", "copy": "cpy --flat src/schema.json dist", + "copy:schematics": "cpy \"src/schematics/**/*.json\" dist/schematics", "generate": "quicktype -s schema src/schema.json -o src/schema.ts" }, "dependencies": { diff --git a/packages/jest/src/schematics/collection.json b/packages/jest/src/schematics/collection.json new file mode 100644 index 0000000000..e03c7eeed1 --- /dev/null +++ b/packages/jest/src/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Configure @angular-builders/jest as the test runner for an Angular workspace", + "factory": "./ng-add/index#default", + "schema": "./ng-add/schema.json" + } + } +} diff --git a/packages/jest/src/schematics/ng-add/index.ts b/packages/jest/src/schematics/ng-add/index.ts new file mode 100644 index 0000000000..a875743a63 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/index.ts @@ -0,0 +1,199 @@ +import { JsonObject, JsonValue } from '@angular-devkit/core'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + chain, +} from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; + +import { NgAddOptions } from './schema'; + +const BUILDER_NAME = '@angular-builders/jest:run'; +const KARMA_BUILDER_NAME = '@angular-devkit/build-angular:karma'; +const PACKAGE_NAME = '@angular-builders/jest'; + +const KARMA_DEPS = [ + 'karma', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'jasmine-core', + '@types/jasmine', +]; + +const FILES_TO_REMOVE = ['karma.conf.js', 'src/test.ts']; + +interface JsonRecord { + [key: string]: JsonValue; +} + +function readJson(tree: Tree, path: string): JsonRecord { + const buffer = tree.read(path); + if (!buffer) { + throw new SchematicsException(`Could not read ${path}`); + } + try { + return JSON.parse(buffer.toString('utf-8')) as JsonRecord; + } catch (err) { + throw new SchematicsException(`Could not parse ${path}: ${(err as Error).message}`); + } +} + +function writeJson(tree: Tree, path: string, value: JsonRecord): void { + tree.overwrite(path, JSON.stringify(value, null, 2) + '\n'); +} + +function getOwnVersion(): string { + // Resolves at runtime to dist/schematics/ng-add/index.js, so package.json is + // three levels up (dist/schematics/ng-add -> dist/schematics -> dist -> pkg root). + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkg = require('../../../package.json') as { version: string }; + return pkg.version; +} + +function updateAngularJson(options: NgAddOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + const angularJsonPath = tree.exists('angular.json') + ? 'angular.json' + : tree.exists('.angular.json') + ? '.angular.json' + : null; + + if (!angularJsonPath) { + throw new SchematicsException('Could not find angular.json in workspace root.'); + } + + const workspace = readJson(tree, angularJsonPath); + const projects = (workspace.projects ?? {}) as JsonRecord; + const projectNames = Object.keys(projects); + + if (projectNames.length === 0) { + throw new SchematicsException('No projects found in angular.json.'); + } + + let projectName = options.project; + if (!projectName) { + projectName = + typeof workspace.defaultProject === 'string' ? workspace.defaultProject : projectNames[0]; + } + + const project = projects[projectName] as JsonRecord | undefined; + if (!project) { + throw new SchematicsException(`Project "${projectName}" not found in angular.json.`); + } + + const architect = (project.architect ?? project.targets) as JsonRecord | undefined; + if (!architect) { + throw new SchematicsException( + `Project "${projectName}" has no architect/targets configuration.` + ); + } + + const existingTest = architect.test as JsonObject | undefined; + architect.test = { + builder: BUILDER_NAME, + options: + existingTest && existingTest.builder === KARMA_BUILDER_NAME + ? {} + : (existingTest?.options ?? {}), + }; + + writeJson(tree, angularJsonPath, workspace); + context.logger.info(`Updated angular.json: ${projectName}.architect.test -> ${BUILDER_NAME}`); + return tree; + }; +} + +function updatePackageJson(): Rule { + return (tree: Tree, context: SchematicContext) => { + const pkg = readJson(tree, 'package.json'); + const devDeps = ((pkg.devDependencies ?? {}) as JsonRecord) ?? {}; + + let removed = 0; + for (const dep of KARMA_DEPS) { + if (dep in devDeps) { + delete devDeps[dep]; + removed++; + } + } + if (removed > 0) { + context.logger.info(`Removed ${removed} karma/jasmine dev dependencies.`); + } + + devDeps[PACKAGE_NAME] = `^${getOwnVersion()}`; + pkg.devDependencies = devDeps; + + writeJson(tree, 'package.json', pkg); + context.logger.info(`Added ${PACKAGE_NAME} to devDependencies.`); + return tree; + }; +} + +function removeKarmaFiles(): Rule { + return (tree: Tree, context: SchematicContext) => { + for (const path of FILES_TO_REMOVE) { + if (tree.exists(path)) { + tree.delete(path); + context.logger.info(`Deleted ${path}`); + } + } + return tree; + }; +} + +function updateTsConfigSpec(): Rule { + return (tree: Tree, context: SchematicContext) => { + const candidates = ['tsconfig.spec.json', 'src/tsconfig.spec.json']; + for (const path of candidates) { + if (!tree.exists(path)) continue; + const buffer = tree.read(path); + if (!buffer) continue; + + let raw = buffer.toString('utf-8'); + // Best-effort textual swap so JSONC comments survive. + const before = raw; + raw = raw.replace(/"jasmine"/g, '"jest"'); + // Drop a `"files": [...]` block referencing test.ts. + raw = raw.replace(/,?\s*"files"\s*:\s*\[\s*"src\/test\.ts"\s*\]\s*,?/g, ''); + if (raw !== before) { + tree.overwrite(path, raw); + context.logger.info(`Updated ${path}: jasmine -> jest types`); + } + } + return tree; + }; +} + +function scheduleInstall(options: NgAddOptions): Rule { + return (_tree: Tree, context: SchematicContext) => { + if (options.skipInstall) { + return; + } + context.addTask(new NodePackageInstallTask()); + context.logger.info('Scheduled package install task.'); + }; +} + +// TODO: test scaffold - add a Jest spec using `SchematicTestRunner` to verify: +// 1. angular.json `architect.test.builder` is rewritten to `@angular-builders/jest:run` +// 2. karma/jasmine devDependencies are removed and @angular-builders/jest is added +// 3. karma.conf.js and src/test.ts are deleted when present +// 4. tsconfig.spec.json `types` swap to `['jest']` and `files` entry removed +// 5. NodePackageInstallTask is scheduled (and skipped when skipInstall=true) +// Blocker for v1: `@angular-devkit/schematics/testing` transitively imports `ora@9` +// (ESM-only). The repo's ts-jest config rejects it with "Cannot use import statement +// outside a module". Follow-up PR: extend `transformIgnorePatterns` and/or +// `moduleNameMapper` to enable schematic specs in this jest config. + +export default function ngAdd(options: NgAddOptions = {}): Rule { + return chain([ + updateAngularJson(options), + updatePackageJson(), + removeKarmaFiles(), + updateTsConfigSpec(), + scheduleInstall(options), + ]); +} diff --git a/packages/jest/src/schematics/ng-add/schema.json b/packages/jest/src/schematics/ng-add/schema.json new file mode 100644 index 0000000000..3659ccdba4 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "AngularBuildersJestNgAdd", + "title": "@angular-builders/jest ng-add schema", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the project to configure. Defaults to the workspace default project.", + "$default": { + "$source": "projectName" + } + }, + "skipInstall": { + "type": "boolean", + "description": "Skip running the package install task.", + "default": false + } + }, + "required": [] +} diff --git a/packages/jest/src/schematics/ng-add/schema.ts b/packages/jest/src/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..fa87af43f2 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/schema.ts @@ -0,0 +1,6 @@ +export interface NgAddOptions { + /** Name of the Angular workspace project to configure. */ + project?: string; + /** Skip running the package install task after schematic updates. */ + skipInstall?: boolean; +} diff --git a/packages/jest/tsconfig.schematics.json b/packages/jest/tsconfig.schematics.json new file mode 100644 index 0000000000..47193e716a --- /dev/null +++ b/packages/jest/tsconfig.schematics.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/schematics", + "rootDir": "./src/schematics", + "module": "CommonJS", + "moduleResolution": "Node", + "declaration": true, + "noImplicitAny": false, + "types": ["node"] + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["src/schematics/**/*.spec.ts"] +} From 4609d040bbaa76a432adda4d18d7a71f20bd7838 Mon Sep 17 00:00:00 2001 From: Jeb Date: Mon, 18 May 2026 11:22:21 +0200 Subject: [PATCH 2/2] test(jest): add SchematicTestRunner spec for ng-add schematic Add index.spec.ts covering all 5 assertions from the deferred TODO: builder rewrite, karma dep removal, file deletion, tsconfig update, and NodePackageInstallTask scheduling (with skipInstall guard). Fix the ora ESM blocker by adding a CJS no-op moduleNameMapper entry in jest-common.config.js and a jest-ora-mock.cjs stub at repo root. --- jest-common.config.js | 6 + jest-ora-mock.cjs | 15 ++ .../jest/src/schematics/ng-add/index.spec.ts | 150 ++++++++++++++++++ packages/jest/src/schematics/ng-add/index.ts | 11 -- 4 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 jest-ora-mock.cjs create mode 100644 packages/jest/src/schematics/ng-add/index.spec.ts diff --git a/jest-common.config.js b/jest-common.config.js index 5016e1791d..c67dc71ad0 100644 --- a/jest-common.config.js +++ b/jest-common.config.js @@ -9,4 +9,10 @@ module.exports = { }, moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], testEnvironment: './jest-custom-environment', + // `@angular-devkit/schematics/testing` transitively imports `ora` (ESM-only). + // Map it to a CJS no-op stub so schematic unit tests can run under ts-jest without + // enabling full ESM mode for the entire test suite. + moduleNameMapper: { + '^ora$': '/jest-ora-mock.cjs', + }, }; diff --git a/jest-ora-mock.cjs b/jest-ora-mock.cjs new file mode 100644 index 0000000000..5ddfa971a0 --- /dev/null +++ b/jest-ora-mock.cjs @@ -0,0 +1,15 @@ +// CJS stub for ESM-only `ora` package. +// `@angular-devkit/schematics/tasks/package-manager/executor` requires ora at import +// time but only uses it inside the executor body. Schematic unit tests never invoke +// the install task, so this stub keeps ts-jest happy without side-effects. +'use strict'; + +function createSpinner() { + const noop = () => spinner; + const spinner = { start: noop, stop: noop, succeed: noop, fail: noop, warn: noop, info: noop }; + return spinner; +} + +module.exports = createSpinner; +module.exports.default = createSpinner; +module.exports.oraPromise = async (_action, _opts) => _action; diff --git a/packages/jest/src/schematics/ng-add/index.spec.ts b/packages/jest/src/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..ab52717ba1 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/index.spec.ts @@ -0,0 +1,150 @@ +import { HostTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; + +// Points at the compiled schematics collection (already built in dist/). +// Using dist avoids ts-jest factory-resolution issues with the SchematicTestRunner. +const collectionPath = path.join(__dirname, '../../../dist/schematics/collection.json'); + +const DEFAULT_ANGULAR_JSON = { + version: 1, + projects: { + 'my-app': { + projectType: 'application', + architect: { + test: { + builder: '@angular-devkit/build-angular:karma', + options: { + karmaConfig: 'karma.conf.js', + }, + }, + }, + }, + }, +}; + +const DEFAULT_PACKAGE_JSON = { + name: 'my-app', + version: '1.0.0', + devDependencies: { + karma: '^6.0.0', + 'karma-chrome-launcher': '^3.0.0', + 'karma-coverage': '^2.0.0', + 'karma-jasmine': '^5.0.0', + 'karma-jasmine-html-reporter': '^2.0.0', + 'jasmine-core': '^4.0.0', + '@types/jasmine': '^4.0.0', + }, +}; + +const DEFAULT_TSCONFIG_SPEC = `{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "files": [ + "src/test.ts" + ], + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} +`; + +describe('@angular-builders/jest ng-add schematic', () => { + let runner: SchematicTestRunner; + + beforeEach(() => { + runner = new SchematicTestRunner('ng-add', collectionPath); + }); + + function buildBaseTree(): UnitTestTree { + const tree = new UnitTestTree(new HostTree()); + tree.create('angular.json', JSON.stringify(DEFAULT_ANGULAR_JSON, null, 2)); + tree.create('package.json', JSON.stringify(DEFAULT_PACKAGE_JSON, null, 2)); + return tree; + } + + // 1. angular.json architect.test.builder is rewritten to @angular-builders/jest:run + it('rewrites angular.json architect.test.builder to @angular-builders/jest:run', async () => { + const tree = buildBaseTree(); + const result = await runner.runSchematic('ng-add', { skipInstall: true }, tree); + + const angularJson = JSON.parse(result.read('angular.json')!.toString('utf-8')); + expect(angularJson.projects['my-app'].architect.test.builder).toBe( + '@angular-builders/jest:run' + ); + }); + + // 2. karma/jasmine devDependencies are removed and @angular-builders/jest is added + it('removes karma/jasmine devDependencies and adds @angular-builders/jest', async () => { + const tree = buildBaseTree(); + const result = await runner.runSchematic('ng-add', { skipInstall: true }, tree); + + const pkgJson = JSON.parse(result.read('package.json')!.toString('utf-8')); + const devDeps = pkgJson.devDependencies as Record; + + // Karma/jasmine packages must be gone + for (const dep of [ + 'karma', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'jasmine-core', + '@types/jasmine', + ]) { + expect(devDeps).not.toHaveProperty(dep); + } + + // @angular-builders/jest must be present with a semver range + expect(devDeps['@angular-builders/jest']).toBeDefined(); + expect(devDeps['@angular-builders/jest']).toMatch(/^\^/); + }); + + // 3. karma.conf.js and src/test.ts are deleted when present + it('deletes karma.conf.js and src/test.ts when present', async () => { + const tree = buildBaseTree(); + tree.create('karma.conf.js', '// karma config'); + tree.create('src/test.ts', '// karma test entry'); + + const result = await runner.runSchematic('ng-add', { skipInstall: true }, tree); + + expect(result.exists('karma.conf.js')).toBe(false); + expect(result.exists('src/test.ts')).toBe(false); + }); + + // 4. tsconfig.spec.json types swap to ['jest'] and files entry removed + it('updates tsconfig.spec.json: replaces jasmine type with jest and removes files entry', async () => { + const tree = buildBaseTree(); + tree.create('tsconfig.spec.json', DEFAULT_TSCONFIG_SPEC); + + const result = await runner.runSchematic('ng-add', { skipInstall: true }, tree); + + const raw = result.read('tsconfig.spec.json')!.toString('utf-8'); + expect(raw).toContain('"jest"'); + expect(raw).not.toContain('"jasmine"'); + expect(raw).not.toContain('"src/test.ts"'); + }); + + // 5a. NodePackageInstallTask is scheduled by default (skipInstall=false) + it('schedules NodePackageInstallTask by default', async () => { + const tree = buildBaseTree(); + await runner.runSchematic('ng-add', {}, tree); + + expect(runner.tasks.length).toBeGreaterThan(0); + expect(runner.tasks.some(t => t.name === 'node-package')).toBe(true); + }); + + // 5b. NodePackageInstallTask is NOT scheduled when skipInstall=true + it('does not schedule NodePackageInstallTask when skipInstall=true', async () => { + const tree = buildBaseTree(); + await runner.runSchematic('ng-add', { skipInstall: true }, tree); + + expect(runner.tasks.every(t => t.name !== 'node-package')).toBe(true); + }); +}); diff --git a/packages/jest/src/schematics/ng-add/index.ts b/packages/jest/src/schematics/ng-add/index.ts index a875743a63..f79d406d66 100644 --- a/packages/jest/src/schematics/ng-add/index.ts +++ b/packages/jest/src/schematics/ng-add/index.ts @@ -177,17 +177,6 @@ function scheduleInstall(options: NgAddOptions): Rule { }; } -// TODO: test scaffold - add a Jest spec using `SchematicTestRunner` to verify: -// 1. angular.json `architect.test.builder` is rewritten to `@angular-builders/jest:run` -// 2. karma/jasmine devDependencies are removed and @angular-builders/jest is added -// 3. karma.conf.js and src/test.ts are deleted when present -// 4. tsconfig.spec.json `types` swap to `['jest']` and `files` entry removed -// 5. NodePackageInstallTask is scheduled (and skipped when skipInstall=true) -// Blocker for v1: `@angular-devkit/schematics/testing` transitively imports `ora@9` -// (ESM-only). The repo's ts-jest config rejects it with "Cannot use import statement -// outside a module". Follow-up PR: extend `transformIgnorePatterns` and/or -// `moduleNameMapper` to enable schematic specs in this jest config. - export default function ngAdd(options: NgAddOptions = {}): Rule { return chain([ updateAngularJson(options),