Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions jest-common.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$': '<rootDir>/jest-ora-mock.cjs',
},
};
15 changes: 15 additions & 0 deletions jest-ora-mock.cjs
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 7 additions & 2 deletions packages/jest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions packages/jest/src/schematics/collection.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
150 changes: 150 additions & 0 deletions packages/jest/src/schematics/ng-add/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

// 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);
});
});
188 changes: 188 additions & 0 deletions packages/jest/src/schematics/ng-add/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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.');
};
}

export default function ngAdd(options: NgAddOptions = {}): Rule {
return chain([
updateAngularJson(options),
updatePackageJson(),
removeKarmaFiles(),
updateTsConfigSpec(),
scheduleInstall(options),
]);
}
Loading
Loading