diff --git a/__mocks__/ora.js b/__mocks__/ora.js new file mode 100644 index 0000000000..431f0ae1f1 --- /dev/null +++ b/__mocks__/ora.js @@ -0,0 +1,15 @@ +// Stub for the ESM-only `ora` package. Tests never exercise the +// package-manager task executor that uses ora, so a no-op spinner is enough. +'use strict'; + +function ora() { + return { + start: () => ({ stop: () => {}, succeed: () => {}, fail: () => {} }), + stop: () => {}, + succeed: () => {}, + fail: () => {}, + }; +} + +module.exports = ora; +module.exports.default = ora; diff --git a/docs/runbooks/angular-major-upgrade.md b/docs/runbooks/angular-major-upgrade.md index 724bbe07c6..a5b47b392e 100644 --- a/docs/runbooks/angular-major-upgrade.md +++ b/docs/runbooks/angular-major-upgrade.md @@ -65,6 +65,23 @@ For the major, **enumerate every `breaking-change`-labeled PR** targeting it (`g This is an **invariant**: a breaking change must not land in a major without both. CHANGELOGs are auto-generated from commits (orthogonal). +#### RC-validated: multi-major `ng update` window (v22) + +Validated against `@angular/cli@22.0.0-rc.2` on `2026-06-03` via the `ng-update-jest-v21-smoke` e2e +(`scripts/e2e-jest-migration.js`): + +- `ng update @angular-builders/jest --migrate-only --from=20.0.0 --to=22.0.0` runs **all** migrations + whose version falls in the `(from, to]` window in one step — observed `migration-v21` (the heavy + config transform) **and** the `migration-v22` advisory both firing. So a user on an old major who + jumps straight to 22 gets the spanned migrations; they are not skipped. +- Supported flow for older users: upgrade the Angular framework to 22, then run + `ng update @angular-builders/jest` once (or `--migrate-only --from=` to run only the builder's + migrations). The post-migration config builds and tests green under v22 — proven by the e2e, which + runs `ng build` + `ng test` on the migrated app. +- E2E coverage of the migration output itself lives in `packages/jest/tests/integration.js` + (`ng-update-jest-v21-smoke`); the ng-add paths are the `ng-add-*` entries there and in the + `custom-esbuild`/`custom-webpack` integration files. + ### 6. Stack feature work Develop/rebase v``-bound features (e.g. schematics) and held breaking PRs on `release/v`. diff --git a/docs/superpowers/plans/2026-06-02-builder-schematics-00-common-core.md b/docs/superpowers/plans/2026-06-02-builder-schematics-00-common-core.md new file mode 100644 index 0000000000..bdfd685341 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-00-common-core.md @@ -0,0 +1,810 @@ +# Builder Schematics — Plan 0: `common/schematics` Core + Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the shared `@angular-builders/common/schematics` subpath — composable Rule factories, workspace detection helpers, version helpers, and a unit-test harness — plus the repo-wide packaging scaffolding (tsconfig + asset-copy) that every per-builder schematics plan depends on. + +**Architecture:** A new `src/schematics/` tree inside `packages/common`, compiled by a dedicated `tsconfig.schematics.json` to CommonJS in `dist/schematics/` (Angular schematics must be CJS), exposed via a package `exports` subpath kept separate from the runtime `loadModule` entry. All edits go through `@schematics/angular/utility` (`updateWorkspace`, `addDependency`, `JSONFile`) — never raw `fs` or hand-parsed JSON — so `--dry-run`, transactionality, and formatting survive. The three builder plans (jest, custom-esbuild, custom-webpack) import these helpers; this plan locks their signatures. + +**Tech Stack:** TypeScript 5.9 (CommonJS target for schematics), `@angular-devkit/schematics`, `@schematics/angular/utility`, `@angular-devkit/core` (workspace JSON), Jest 30 + `@angular-devkit/schematics/testing` (`SchematicTestRunner`, `UnitTestTree`). + +--- + +## Shared API Contract (locked by this plan — builder plans reference these exact signatures) + +```ts +// @angular-builders/common/schematics + +// --- rules.ts --- +export function setBuilderForTarget( + projectName: string, + targetName: string, + builderName: string, + options?: Record, +): Rule; +export function addBuilderDevDependency( + name: string, + version: string, + opts?: { install?: boolean }, +): Rule; +export function removeDevDependencies(names: string[]): Rule; +export function removeFilesIfPresent(paths: string[]): Rule; +export function editJsonFile(path: string, mutator: (json: JSONFile) => void): Rule; + +// --- detection.ts --- +export type TestBuilderKind = 'karma' | 'jest' | 'vitest' | 'other' | 'none'; +export function getProjectsToTarget( + workspace: workspaces.WorkspaceDefinition, + optionProject?: string, +): string[]; +export function detectTestBuilder( + workspace: workspaces.WorkspaceDefinition, + projectName: string, +): TestBuilderKind; +export function isZoneless( + tree: Tree, + workspace: workspaces.WorkspaceDefinition, + projectName: string, +): boolean; + +// --- version.ts --- +export interface SemverParts { major: number; minor: number; patch: number; } +export function parseVersion(version: string): SemverParts; +export function isAtLeast(version: string, major: number): boolean; + +// --- testing.ts --- +export class SchematicTestHarness { + constructor(runner?: SchematicTestRunner); + /** Build a workspace tree with one or more applications. */ + createWorkspace(opts?: { + projects?: Array<{ name: string; root?: string }>; + defaultProject?: string; + }): Promise; + /** Convenience: the underlying runner, for invoking collections under test. */ + readonly runner: SchematicTestRunner; +} +``` + +--- + +## File Structure + +- Create: `tsconfig.schematics.json` (repo root) — shared base for all schematics builds. +- Create: `packages/common/tsconfig.schematics.json` — extends root base; `rootDir: src/schematics`, `outDir: dist/schematics`. +- Modify: `packages/common/package.json` — add `exports` map (`.` + `./schematics`), schematics deps, `copy:schematics` build step. +- Create: `packages/common/src/schematics/index.ts` — barrel re-exporting rules/detection/version. +- Create: `packages/common/src/schematics/rules.ts` + `rules.spec.ts`. +- Create: `packages/common/src/schematics/detection.ts` + `detection.spec.ts`. +- Create: `packages/common/src/schematics/version.ts` + `version.spec.ts`. +- Create: `packages/common/src/schematics/testing.ts` (the harness; exported for builder tests via a `./schematics/testing` subpath or the main schematics barrel — see Task 2). + +> **Note on `exports` + `merge-schemes.ts`:** `merge-schemes.ts` uses `resolvePackagePath` from `common` to bypass *other* packages' `exports` maps. Adding an `exports` map to `common` itself is safe **only if** the `.` key continues to resolve `dist/index.js` (the runtime entry) exactly as `main` did. Keep `main` as a fallback. Verify in Task 1 Step 4 that the runtime import still resolves. + +--- + +## Task 1: Packaging scaffolding (tsconfig + package.json wiring) + +**Files:** +- Create: `tsconfig.schematics.json` (repo root) +- Create: `packages/common/tsconfig.schematics.json` +- Modify: `packages/common/package.json` + +- [ ] **Step 1: Write the root shared schematics tsconfig** + +Create `tsconfig.schematics.json` (repo root): + +```json +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "strict": true, + "strictNullChecks": false, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "pretty": true + }, + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} +``` + +Rationale: Angular schematics are loaded by the Angular CLI as CommonJS; the runtime libs build as `Node16` ESM-interop, but schematics MUST be `commonjs`. `**/files/**` excludes template assets that get copied verbatim, not compiled. + +- [ ] **Step 2: Write common's per-package schematics tsconfig** + +Create `packages/common/tsconfig.schematics.json`: + +```json +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} +``` + +- [ ] **Step 3: Wire common's package.json (exports, deps, build step)** + +Modify `packages/common/package.json`: +- Add an `exports` map (keep `main` as fallback): + +```json +"main": "dist/index.js", +"exports": { + ".": { "default": "./dist/index.js" }, + "./schematics": { "default": "./dist/schematics/index.js" }, + "./schematics/testing": { "default": "./dist/schematics/testing.js" } +}, +``` + +- Add to `dependencies`: + +```json +"@angular-devkit/schematics": "^21.0.0", +"@schematics/angular": "^21.0.0", +``` + +(These resolve to the installed Angular major; on `release/v22` they become `^22.0.0` via the upgrade. `@angular-devkit/core` is already present.) + +- Change the `build` script to also compile + copy schematics: + +```json +"build": "yarn prebuild && tsc && tsc -p tsconfig.schematics.json && yarn copy:schematics", +"copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics && copyfiles -u 2 \"src/schematics/**/files/**\" dist/schematics", +``` + +- Add to `devDependencies`: `"copyfiles": "^2.4.1"`. + +> `tsc` does not emit `.json` (collection/migration manifests) or `files/**` templates — `copyfiles` does. `-u 2` strips the `src/schematics` path prefix so assets land at `dist/schematics/...`. + +- [ ] **Step 4: Verify scaffolding compiles and runtime entry still resolves** + +Run (from repo root): `yarn workspace @angular-builders/common build` +Expected: builds `dist/index.js` AND `dist/schematics/` (empty of code so far — will fail with "no inputs" until Task 2 adds files; acceptable here, OR create a placeholder `src/schematics/index.ts` with `export {};` first). + +Then verify the runtime export still resolves (regression check for the `exports` map): +Run: `node -e "require('@angular-builders/common')"` from a context where the workspace is linked. +Expected: no `ERR_PACKAGE_PATH_NOT_EXPORTED`. + +- [ ] **Step 5: Commit** + +```bash +git add tsconfig.schematics.json packages/common/tsconfig.schematics.json packages/common/package.json +git commit -m "build(common): add schematics subpath packaging (tsconfig + exports + copy)" +``` + +--- + +## Task 2: Version helpers (`version.ts`) + +Smallest, no Angular deps — start here to validate the test setup. + +**Files:** +- Create: `packages/common/src/schematics/version.ts` +- Test: `packages/common/src/schematics/version.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`packages/common/src/schematics/version.spec.ts`: + +```ts +import { parseVersion, isAtLeast } from './version'; + +describe('parseVersion', () => { + it('parses a plain semver', () => { + expect(parseVersion('21.2.13')).toEqual({ major: 21, minor: 2, patch: 13 }); + }); + it('parses a prerelease, ignoring the tag', () => { + expect(parseVersion('22.0.0-rc.2')).toEqual({ major: 22, minor: 0, patch: 0 }); + }); + it('strips a leading range operator', () => { + expect(parseVersion('^20.1.0')).toEqual({ major: 20, minor: 1, patch: 0 }); + }); +}); + +describe('isAtLeast', () => { + it('is true at and above the major', () => { + expect(isAtLeast('22.0.0-rc.2', 22)).toBe(true); + expect(isAtLeast('23.1.0', 22)).toBe(true); + }); + it('is false below the major', () => { + expect(isAtLeast('21.2.13', 22)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/version.spec.ts` +Expected: FAIL — `Cannot find module './version'`. + +- [ ] **Step 3: Write minimal implementation** + +`packages/common/src/schematics/version.ts`: + +```ts +export interface SemverParts { + major: number; + minor: number; + patch: number; +} + +export function parseVersion(version: string): SemverParts { + const cleaned = version.trim().replace(/^[\^~>=v\s]+/, ''); + const [core] = cleaned.split('-'); + const [major = 0, minor = 0, patch = 0] = core.split('.').map((n) => parseInt(n, 10) || 0); + return { major, minor, patch }; +} + +export function isAtLeast(version: string, major: number): boolean { + return parseVersion(version).major >= major; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/version.spec.ts` +Expected: PASS (5 assertions). + +- [ ] **Step 5: Commit** + +```bash +git add packages/common/src/schematics/version.ts packages/common/src/schematics/version.spec.ts +git commit -m "feat(common): add schematics version helpers" +``` + +--- + +## Task 3: Test harness (`testing.ts`) + +Needed by every subsequent test, so build it before the rules/detection it will exercise. + +**Files:** +- Create: `packages/common/src/schematics/testing.ts` +- Test: `packages/common/src/schematics/testing.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`packages/common/src/schematics/testing.spec.ts`: + +```ts +import { readWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from './testing'; + +describe('SchematicTestHarness', () => { + it('builds a single-project workspace with angular.json', async () => { + const harness = new SchematicTestHarness(); + const tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + expect(tree.exists('/angular.json')).toBe(true); + const workspace = await readWorkspace(tree); + expect([...workspace.projects.keys()]).toEqual(['app']); + // application schematic wires a build target by default + expect(workspace.projects.get('app')!.targets.has('build')).toBe(true); + }); + + it('builds a multi-project workspace', async () => { + const harness = new SchematicTestHarness(); + const tree = await harness.createWorkspace({ + projects: [{ name: 'app1' }, { name: 'app2' }], + }); + const workspace = await readWorkspace(tree); + expect([...workspace.projects.keys()].sort()).toEqual(['app1', 'app2']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/testing.spec.ts` +Expected: FAIL — `Cannot find module './testing'`. + +- [ ] **Step 3: Write minimal implementation** + +`packages/common/src/schematics/testing.ts`: + +```ts +import { join } from 'node:path'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +const NG_COLLECTION = require.resolve('@schematics/angular/collection.json'); + +export interface WorkspaceProjectSpec { + name: string; + root?: string; +} + +export interface CreateWorkspaceOptions { + projects?: WorkspaceProjectSpec[]; + defaultProject?: string; +} + +export class SchematicTestHarness { + readonly runner: SchematicTestRunner; + + constructor(runner?: SchematicTestRunner) { + this.runner = runner ?? new SchematicTestRunner('schematics', NG_COLLECTION); + } + + async createWorkspace(opts: CreateWorkspaceOptions = {}): Promise { + const projects = opts.projects ?? [{ name: 'app' }]; + + let tree = await this.runner.runSchematic('workspace', { + name: 'workspace', + version: '0.0.0', + newProjectRoot: 'projects', + }); + + for (const project of projects) { + tree = await this.runner.runSchematic( + 'application', + { + name: project.name, + // keep fixtures small + deterministic + routing: false, + style: 'css', + skipTests: false, + standalone: true, + }, + tree, + ); + } + + return tree; + } +} +``` + +> The `application` schematic respects `newProjectRoot`, so a single project lands at the workspace root only if you set `newProjectRoot: ''`; with `projects` set, apps land under `projects/`. Tests that assert paths must use the project's actual `root` (read it from the workspace, don't hardcode). The `detectTestBuilder`/`isZoneless` helpers read paths off the workspace, so they're unaffected. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/testing.spec.ts` +Expected: PASS. If the Angular `application` schematic prompts or errors on a missing option, add the missing option explicitly (it must stay non-interactive). + +- [ ] **Step 5: Commit** + +```bash +git add packages/common/src/schematics/testing.ts packages/common/src/schematics/testing.spec.ts +git commit -m "feat(common): add SchematicTestHarness for schematics unit tests" +``` + +--- + +## Task 4: Detection helpers (`detection.ts`) + +**Files:** +- Create: `packages/common/src/schematics/detection.ts` +- Test: `packages/common/src/schematics/detection.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`packages/common/src/schematics/detection.spec.ts`: + +```ts +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from './testing'; +import { getProjectsToTarget, detectTestBuilder, isZoneless } from './detection'; + +async function load(tree: import('@angular-devkit/schematics/testing').UnitTestTree) { + return readWorkspace(tree); +} + +describe('getProjectsToTarget', () => { + it('single project → that project', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + expect(getProjectsToTarget(await load(tree))).toEqual(['app']); + }); + + it('multi project + explicit option → just that one', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'a' }, { name: 'b' }], + }); + expect(getProjectsToTarget(await load(tree), 'b')).toEqual(['b']); + }); + + it('multi project + no option + no default → all', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'a' }, { name: 'b' }], + }); + expect(getProjectsToTarget(await load(tree)).sort()).toEqual(['a', 'b']); + }); +}); + +describe('detectTestBuilder', () => { + it('returns "none" when no test target', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // application schematic may add no test target under zoneless/standalone defaults + const ws = await load(tree); + if (!ws.projects.get('app')!.targets.has('test')) { + expect(detectTestBuilder(ws, 'app')).toBe('none'); + } + }); + + it('detects karma', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree = await (async () => { + const rule = updateWorkspace((workspace) => { + workspace.projects.get('app')!.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + options: {}, + }); + }); + // apply the rule via a runner-less call: + const { SchematicTestRunner } = await import('@angular-devkit/schematics/testing'); + const runner = new SchematicTestRunner('t', require.resolve('@schematics/angular/collection.json')); + return runner.callRule(rule, tree).toPromise() as Promise; + })(); + expect(detectTestBuilder(await load(tree), 'app')).toBe('karma'); + }); +}); + +describe('isZoneless', () => { + it('true when polyfills lack zone.js', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // modern application schematic is zoneless by default → no zone.js polyfill + expect(isZoneless(tree, await load(tree), 'app')).toBe(true); + }); +}); +``` + +> If the installed Angular `application` schematic defaults differ (e.g. adds a karma `test` target or a `zone.js` polyfill), adjust the *expected* values to match the generated fixture — the helpers describe the workspace, the test asserts against what the schematic actually produced. Read the generated `angular.json` once during implementation to calibrate. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/detection.spec.ts` +Expected: FAIL — `Cannot find module './detection'`. + +- [ ] **Step 3: Write minimal implementation** + +`packages/common/src/schematics/detection.ts`: + +```ts +import { Tree } from '@angular-devkit/schematics'; +import { workspaces } from '@angular-devkit/core'; + +export type TestBuilderKind = 'karma' | 'jest' | 'vitest' | 'other' | 'none'; + +export function getProjectsToTarget( + workspace: workspaces.WorkspaceDefinition, + optionProject?: string, +): string[] { + const names = [...workspace.projects.keys()]; + if (optionProject) { + if (!workspace.projects.has(optionProject)) { + throw new Error(`Project "${optionProject}" does not exist in the workspace.`); + } + return [optionProject]; + } + if (names.length <= 1) return names; + const defaultProject = workspace.extensions['defaultProject']; + if (typeof defaultProject === 'string' && workspace.projects.has(defaultProject)) { + return [defaultProject]; + } + return names; +} + +export function detectTestBuilder( + workspace: workspaces.WorkspaceDefinition, + projectName: string, +): TestBuilderKind { + const project = workspace.projects.get(projectName); + const builder = project?.targets.get('test')?.builder; + if (!builder) return 'none'; + if (builder.endsWith(':karma')) return 'karma'; + if (builder === '@angular-builders/jest:run') return 'jest'; + if (builder.endsWith(':unit-test')) return 'vitest'; + return 'other'; +} + +export function isZoneless( + tree: Tree, + workspace: workspaces.WorkspaceDefinition, + projectName: string, +): boolean { + const project = workspace.projects.get(projectName); + const buildOptions = project?.targets.get('build')?.options ?? {}; + const polyfills = buildOptions['polyfills']; + const polyfillList = Array.isArray(polyfills) + ? (polyfills as string[]) + : typeof polyfills === 'string' + ? [polyfills] + : []; + const hasZone = polyfillList.some((p) => p === 'zone.js' || p.includes('zone.js')); + if (hasZone) return false; + + // Fallback: look for provideZonelessChangeDetection in any bootstrap source. + const root = project?.root ?? ''; + const mainCandidates = ['src/main.ts', 'src/app/app.config.ts'].map((p) => + root ? `${root}/${p}` : p, + ); + for (const candidate of mainCandidates) { + if (tree.exists(candidate)) { + const content = tree.readText(candidate); + if (content.includes('provideZonelessChangeDetection')) return true; + } + } + return !hasZone; // no zone.js polyfill ⇒ treat as zoneless +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/detection.spec.ts` +Expected: PASS (calibrate expectations to the generated fixture per the note above). + +- [ ] **Step 5: Commit** + +```bash +git add packages/common/src/schematics/detection.ts packages/common/src/schematics/detection.spec.ts +git commit -m "feat(common): add workspace detection helpers for schematics" +``` + +--- + +## Task 5: Rule factories (`rules.ts`) + +**Files:** +- Create: `packages/common/src/schematics/rules.ts` +- Test: `packages/common/src/schematics/rules.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`packages/common/src/schematics/rules.spec.ts`: + +```ts +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from './testing'; +import { + setBuilderForTarget, + addBuilderDevDependency, + removeDevDependencies, + removeFilesIfPresent, + editJsonFile, +} from './rules'; + +const NG = require.resolve('@schematics/angular/collection.json'); +const runner = () => new SchematicTestRunner('t', NG); + +describe('setBuilderForTarget', () => { + it('rewrites the builder and merges options', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const out = (await runner() + .callRule(setBuilderForTarget('app', 'build', '@angular-builders/custom-esbuild:application', { foo: 1 }), tree) + .toPromise()) as UnitTestTree; + const ws = await readWorkspace(out); + const target = ws.projects.get('app')!.targets.get('build')!; + expect(target.builder).toBe('@angular-builders/custom-esbuild:application'); + expect((target.options as Record)['foo']).toBe(1); + }); +}); + +describe('addBuilderDevDependency', () => { + it('adds the package to devDependencies', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const out = (await runner() + .callRule(addBuilderDevDependency('@angular-builders/jest', '~22.0.0', { install: false }), tree) + .toPromise()) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/jest']).toBe('~22.0.0'); + }); +}); + +describe('removeDevDependencies', () => { + it('removes only present deps and is safe on absent ones', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree.overwrite( + '/package.json', + JSON.stringify({ devDependencies: { karma: '^6.0.0', jasmine: '^5.0.0' } }, null, 2), + ); + const out = (await runner() + .callRule(removeDevDependencies(['karma', 'not-there']), tree) + .toPromise()) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies.karma).toBeUndefined(); + expect(pkg.devDependencies.jasmine).toBe('^5.0.0'); + }); +}); + +describe('removeFilesIfPresent', () => { + it('deletes present files, ignores absent', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree.create('/karma.conf.js', '// karma'); + const out = (await runner() + .callRule(removeFilesIfPresent(['/karma.conf.js', '/nope.js']), tree) + .toPromise()) as UnitTestTree; + expect(out.exists('/karma.conf.js')).toBe(false); + }); +}); + +describe('editJsonFile', () => { + it('mutates JSON via JSONFile', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree.create('/tsconfig.spec.json', JSON.stringify({ compilerOptions: { types: ['jasmine'] } }, null, 2)); + const out = (await runner() + .callRule( + editJsonFile('/tsconfig.spec.json', (json) => json.modify(['compilerOptions', 'types'], ['jest'])), + tree, + ) + .toPromise()) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.types).toEqual(['jest']); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/rules.spec.ts` +Expected: FAIL — `Cannot find module './rules'`. + +- [ ] **Step 3: Write minimal implementation** + +`packages/common/src/schematics/rules.ts`: + +```ts +import { Rule, Tree } from '@angular-devkit/schematics'; +import { updateWorkspace, addDependency, DependencyType, InstallBehavior } from '@schematics/angular/utility'; +import { JSONFile } from '@schematics/angular/utility/json-file'; + +export function setBuilderForTarget( + projectName: string, + targetName: string, + builderName: string, + options?: Record, +): Rule { + return updateWorkspace((workspace) => { + const project = workspace.projects.get(projectName); + if (!project) throw new Error(`Project "${projectName}" not found.`); + const target = project.targets.get(targetName); + if (target) { + target.builder = builderName; + if (options) target.options = { ...(target.options ?? {}), ...options }; + } else { + project.targets.add({ name: targetName, builder: builderName, options: options ?? {} }); + } + }); +} + +export function addBuilderDevDependency( + name: string, + version: string, + opts: { install?: boolean } = {}, +): Rule { + return addDependency(name, version, { + type: DependencyType.Dev, + install: opts.install === false ? InstallBehavior.None : InstallBehavior.Auto, + }); +} + +export function removeDevDependencies(names: string[]): Rule { + return (tree: Tree) => { + if (!tree.exists('/package.json')) return tree; + const json = new JSONFile(tree, '/package.json'); + for (const name of names) { + if (json.get(['devDependencies', name]) !== undefined) { + json.remove(['devDependencies', name]); + } + } + return tree; + }; +} + +export function removeFilesIfPresent(paths: string[]): Rule { + return (tree: Tree) => { + for (const path of paths) { + if (tree.exists(path)) tree.delete(path); + } + return tree; + }; +} + +export function editJsonFile(path: string, mutator: (json: JSONFile) => void): Rule { + return (tree: Tree) => { + if (!tree.exists(path)) return tree; + const json = new JSONFile(tree, path); + mutator(json); + return tree; + }; +} +``` + +> `JSONFile.modify`/`.remove` write back to the tree on each call. `addDependency`'s `InstallBehavior.Auto` schedules a `NodePackageInstallTask` only when deps actually changed; `.None` never installs (used in unit tests). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `yarn jest --config jest-ut.config.js packages/common/src/schematics/rules.spec.ts` +Expected: PASS (all 5 describe blocks). + +- [ ] **Step 5: Commit** + +```bash +git add packages/common/src/schematics/rules.ts packages/common/src/schematics/rules.spec.ts +git commit -m "feat(common): add composable schematics rule factories" +``` + +--- + +## Task 6: Barrel export + build verification + +**Files:** +- Create: `packages/common/src/schematics/index.ts` + +- [ ] **Step 1: Write the barrel** + +`packages/common/src/schematics/index.ts`: + +```ts +export * from './rules'; +export * from './detection'; +export * from './version'; +// testing.ts is exported via the ./schematics/testing subpath, not the barrel, +// so production schematics never pull SchematicTestRunner into their bundle. +``` + +- [ ] **Step 2: Build the package end-to-end** + +Run: `yarn workspace @angular-builders/common build` +Expected: `dist/index.js`, `dist/schematics/index.js`, `dist/schematics/{rules,detection,version,testing}.js` all present. + +Run: `ls packages/common/dist/schematics` +Expected: the four `.js` files + `.d.ts` files. + +- [ ] **Step 3: Verify both subpaths resolve** + +Run: `node -e "require('@angular-builders/common'); require('@angular-builders/common/schematics'); console.log('ok')"` +Expected: prints `ok` (no `ERR_PACKAGE_PATH_NOT_EXPORTED`). + +- [ ] **Step 4: Run the full common unit suite** + +Run: `yarn jest --config jest-ut.config.js packages/common` +Expected: all schematics specs green + pre-existing common specs still green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/common/src/schematics/index.ts +git commit -m "feat(common): export schematics core via ./schematics subpath" +``` + +--- + +## Self-Review + +**Spec coverage (§3.1 — Shared core):** +- Rule factories `setBuilderForTarget`, `addBuilderDevDependency`, `removeDevDependencies`, `removeFilesIfPresent`, `editJsonFile` → Task 5. ✅ +- Detection `getProjectsToTarget`, `detectTestBuilder`, `isZoneless` → Task 4. ✅ +- Version `parseVersion`, `isAtLeast` → Task 2. ✅ +- `SchematicTestHarness` → Task 3. ✅ +- `@schematics/angular` + `@angular-devkit/schematics` added to `common` deps, used only by the schematics subpath → Task 1 Step 3. ✅ +- Subpath separate from runtime `loadModule` exports → Task 1 `exports` map + Task 6 barrel (testing kept off the barrel). ✅ + +**Spec coverage (§7 — Packaging):** +- Shared `tsconfig.schematics.json` at root, extended per package (`module: commonjs`, `rootDir`/`outDir`, exclude specs + `files/**`) → Task 1 Steps 1–2. ✅ +- `tsc (lib) → tsc (schematics) → copy assets` build sequence; copy `collection.json`/`migrations.json`/`schema.json` + `files/**` → Task 1 Step 3 (`copy:schematics`). ✅ +- `common` mirrors the same tsconfig + copy approach → Task 1. ✅ + +**Placeholder scan:** No TBD/TODO/"handle edge cases" steps; every code step has complete code. ✅ + +**Type consistency:** `TestBuilderKind` defined in Task 4 and referenced nowhere else inconsistently. `SemverParts` defined in Task 2. Rule factory signatures match the locked API Contract section verbatim. ✅ + +**Calibration risk (flagged, not a gap):** Tasks 3–4 depend on what the installed Angular `application` schematic actually generates (test target presence, zone.js polyfill, project root). The plan instructs the implementer to read the generated fixture once and calibrate the *expected* values — the helper logic is fixed, only fixture expectations adapt. This is correct for a plan built against an RC whose generator defaults may shift. + +--- + +## Execution Handoff + +**Gated:** Implementation should run on the **green `release/v22`** base (so `@schematics/angular`/`@angular-devkit/schematics` resolve to `^22`). Track B's v22 upgrade must land first. Until then this plan is review-ready but not executable. + +When `release/v22` is green, this is the **first** plan to execute (the three builder plans depend on its locked API). Recommended approach: **subagent-driven-development** (fresh subagent per task, review between). diff --git a/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md b/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md new file mode 100644 index 0000000000..03b2a41d6b --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md @@ -0,0 +1,1787 @@ +# Builder Schematics — Plan 01: `@angular-builders/jest` ng-add + Migrations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the `@angular-builders/jest` schematics — a zero-prompt `ng add` that wires Jest into a workspace (replacing **Karma** when detected, and **Vitest** when detected — the forward-default since fresh v22 apps default to Vitest) and two `ng update` migrations (`@21` heavy auto-transform; `@22` advisory-only) — plus the per-package packaging that exposes them to the Angular CLI. + +**Architecture:** A new `src/schematics/` tree inside `packages/jest`, compiled to CommonJS in `dist/schematics/` by a dedicated `tsconfig.schematics.json` (mirrors Plan 0's pattern). `ng add` detects the incumbent test runner and adapts: **Karma** (heavy cleanup), **Vitest** (the v22 forward-default — light cleanup + port advisory), or none/already-jest. The `ng-add` and migration entry points are thin `chain([...])` rules that delegate all workspace/JSON/dependency edits to the shared helpers locked by Plan 0 (`@angular-builders/common/schematics`) — never raw `fs` or hand-parsed JSON. Migrations run headless (Renovate/CI): no prompts, only `context.logger` advisories, safe detected defaults. `package.json` gains `schematics`/`ng-add`/`ng-update` fields pointing at the copied dist manifests. + +**Tech Stack:** TypeScript 5.9 (CommonJS for schematics), `@angular-devkit/schematics`, `@schematics/angular/utility`, `@angular-builders/common/schematics` (Plan 0 helpers), Jest 30 + `@angular-devkit/schematics/testing` (`SchematicTestRunner`, `UnitTestTree`) driven through `SchematicTestHarness` from `@angular-builders/common/schematics/testing`. + +--- + +## Dependency on Plan 0 (do not redefine) + +This plan **imports** the following from `@angular-builders/common/schematics` (signatures locked by Plan 0 — never reimplement them here): + +```ts +import { + setBuilderForTarget, // (project, target, builder, options?) => Rule + addBuilderDevDependency, // (name, version, { install? }) => Rule + removeDevDependencies, // (names: string[]) => Rule + removeFilesIfPresent, // (paths: string[]) => Rule + editJsonFile, // (path, (json: JSONFile) => void) => Rule + getProjectsToTarget, // (workspace, optionProject?) => string[] + detectTestBuilder, // (workspace, projectName) => TestBuilderKind + isZoneless, // (tree, workspace, projectName) => boolean +} from '@angular-builders/common/schematics'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; +``` + +`parseVersion`/`isAtLeast` are also available from the same barrel if needed; this plan does not require them (migrations are version-gated by `migrations.json` thresholds, not by runtime version parsing). + +`readWorkspace`/`updateWorkspace` come from `@schematics/angular/utility`; `JSONFile` from `@schematics/angular/utility/json-file`. + +**Gating:** Plan 0 must be merged/green first (it locks the `@angular-builders/common/schematics` API and packaging). Execute this plan on a base where `yarn workspace @angular-builders/common build` produces `dist/schematics/`. + +--- + +## File Structure + +- Create: `packages/jest/tsconfig.schematics.json` — extends root `tsconfig.schematics.json`; `rootDir: src/schematics`, `outDir: dist/schematics`. +- Modify: `packages/jest/package.json` — add `schematics`/`ng-add`/`ng-update` fields, schematics deps, `copy:schematics` build step, wire into `build`. +- Create: `packages/jest/src/schematics/collection.json` — declares the `ng-add` schematic. +- Create: `packages/jest/src/schematics/ng-add/schema.json` — `--project` flag only, no `x-prompt`. +- Create: `packages/jest/src/schematics/ng-add/schema.ts` — TS interface for the ng-add options. +- Create: `packages/jest/src/schematics/ng-add/index.ts` + `index.spec.ts` — the ng-add rule. +- Create: `packages/jest/src/schematics/migrations.json` — declares `@21` and `@22` migrations with semver thresholds. +- Create: `packages/jest/src/schematics/migrations/v21/index.ts` + `index.spec.ts` — heavy auto-transform. +- Create: `packages/jest/src/schematics/migrations/v22/index.ts` + `index.spec.ts` — advisory-only. + +> **Version pin convention:** dependency versions added by `ng-add` and bumped by migrations are written as caret/tilde range strings (e.g. `^30.0.0`). The jest builder's own version added to `devDependencies` tracks the Angular major (v22). Use the literal strings shown in each step. + +--- + +## Task 1: Packaging scaffolding (tsconfig + package.json wiring) + +**Files:** +- Create: `packages/jest/tsconfig.schematics.json` +- Modify: `packages/jest/package.json` + +- [ ] **Step 1: Write the per-package schematics tsconfig** + +Create `packages/jest/tsconfig.schematics.json`: + +```json +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} +``` + +Rationale: mirrors Plan 0's `packages/common/tsconfig.schematics.json` exactly. Angular loads schematics as CommonJS; the root base sets `module: "commonjs"`. Specs and `files/**` templates are excluded from compilation. + +- [ ] **Step 2: Wire jest's package.json (fields, deps, build step)** + +Modify `packages/jest/package.json`. + +Add these top-level fields (next to the existing `"builders": "builders.json"` line): + +```json +"schematics": "./dist/schematics/collection.json", +"ng-add": { "save": "devDependencies" }, +"ng-update": { "migrations": "./dist/schematics/migrations.json" }, +``` + +Add to `dependencies` (alongside `@angular-builders/common`): + +```json +"@angular-devkit/schematics": "^22.0.0", +"@schematics/angular": "^22.0.0", +``` + +Add to `devDependencies`: + +```json +"copyfiles": "^2.4.1", +``` + +Change the `build` and add a `copy:schematics` script. The current scripts are: + +```json +"build": "yarn prebuild && tsc -p tsconfig.lib.json && yarn postbuild", +"postbuild": "yarn copy && yarn test", +``` + +Replace `build` and add `copy:schematics` so the sequence is `lib tsc → schematics tsc → copy schematics assets → existing postbuild`: + +```json +"build": "yarn prebuild && tsc -p tsconfig.lib.json && tsc -p tsconfig.schematics.json && yarn copy:schematics && yarn postbuild", +"copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics && copyfiles -u 2 \"src/schematics/**/files/**\" dist/schematics", +``` + +> `tsc` does not emit `.json` (collection/migration manifests, ng-add schema) or `files/**` templates — `copyfiles` does. `-u 2` strips the `src/schematics` prefix so assets land at `dist/schematics/...` (e.g. `src/schematics/collection.json` → `dist/schematics/collection.json`). The existing `cpy` `copy` step (builder `schema.json`) is unchanged; it runs inside `postbuild`. + +> Why both `@angular-devkit/schematics` and `@schematics/angular` in `dependencies`: the migration/ng-add code imports `Rule`, `chain`, `SchematicContext` (from the former) and `readWorkspace`/`updateWorkspace`/`JSONFile` (from the latter) at runtime when the Angular CLI executes the schematics. They are not test-only. + +- [ ] **Step 3: Add a placeholder so the schematics tsconfig compiles** + +The schematics tsconfig will error with "No inputs were found" until Task 2 adds real files. Create a temporary placeholder so Step 4 can verify the build wiring now: + +Create `packages/jest/src/schematics/index.ts`: + +```ts +export {}; +``` + +> This file is kept harmlessly; the collection/migration entry points are separate files. It guarantees `tsc -p tsconfig.schematics.json` has at least one input. + +- [ ] **Step 4: Verify the build wiring compiles** + +Run: `yarn workspace @angular-builders/jest build` +Expected: completes without error; produces `packages/jest/dist/schematics/index.js`. (The builder `dist/index.js`, `dist/schema.json`, and the existing unit suite from `postbuild` also run — all green.) + +If `postbuild`'s `yarn test` is slow or noisy during iteration, you may run the schematics-only compile directly to check wiring: +Run: `yarn workspace @angular-builders/jest exec tsc -p tsconfig.schematics.json` +Expected: no output, exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/tsconfig.schematics.json packages/jest/package.json packages/jest/src/schematics/index.ts +git commit -m "build(jest): add schematics packaging (tsconfig + fields + copy)" +``` + +--- + +## Task 2: `ng-add` schema + collection (no behavior yet) + +**Files:** +- Create: `packages/jest/src/schematics/collection.json` +- Create: `packages/jest/src/schematics/ng-add/schema.json` +- Create: `packages/jest/src/schematics/ng-add/schema.ts` + +- [ ] **Step 1: Write the collection manifest** + +Create `packages/jest/src/schematics/collection.json`: + +```json +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Set up @angular-builders/jest as the ng test runner.", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} +``` + +> `factory` is dist-relative at runtime (`./ng-add/index` resolves to `dist/schematics/ng-add/index.js`). `#ngAdd` is the exported rule factory (defined in Task 3). + +- [ ] **Step 2: Write the ng-add JSON schema (—project only, no x-prompt)** + +Create `packages/jest/src/schematics/ng-add/schema.json`: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "JestNgAddSchema", + "title": "@angular-builders/jest ng-add options", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to set up Jest for. Defaults to all projects (or the default project) when omitted.", + "$default": { + "$source": "projectName" + } + } + }, + "additionalProperties": false +} +``` + +> No `x-prompt` anywhere (spec §2/§4.1: zero prompts). `$default.$source: projectName` lets the CLI pre-fill `--project` from the workspace default; it is still optional. `getProjectsToTarget` (Plan 0) handles the omitted-and-multi-project case by targeting all projects. + +- [ ] **Step 3: Write the TS options interface** + +Create `packages/jest/src/schematics/ng-add/schema.ts`: + +```ts +export interface NgAddOptions { + project?: string; +} +``` + +> Hand-authored (not quicktype). The MUST-NEVER list covers `packages/jest/src/schema.ts` (the builder schema); this is a different file under `src/schematics/` and is not quicktype-generated. + +- [ ] **Step 4: Verify the schematics tsconfig still compiles** + +Run: `yarn workspace @angular-builders/jest exec tsc -p tsconfig.schematics.json` +Expected: no output, exit 0 (`schema.ts` compiles; JSON files are not compiled by tsc). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/collection.json packages/jest/src/schematics/ng-add/schema.json packages/jest/src/schematics/ng-add/schema.ts +git commit -m "feat(jest): add ng-add collection + schema (project flag only)" +``` + +--- + +## Task 3: `ng-add` — add jest stack + rewrite test target (no-Karma branch) + +Start with the simplest branch: a workspace with no Karma. ng-add must add the jest devDeps, rewrite `test` → `@angular-builders/jest:run`, set `zoneless` to match detection, and schedule install. + +**Files:** +- Create: `packages/jest/src/schematics/ng-add/index.ts` +- Test: `packages/jest/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/jest/src/schematics/ng-add/index.spec.ts`: + +```ts +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../src/schematics/collection.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('jest', COLLECTION); +} + +describe('jest ng-add (no Karma)', () => { + it('adds the jest stack to devDependencies and schedules install', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const r = runner(); + const out = (await r.runSchematic('ng-add', {}, tree)) as UnitTestTree; + + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/jest']).toBeDefined(); + expect(pkg.devDependencies['jest']).toBeDefined(); + expect(pkg.devDependencies['jest-preset-angular']).toBeDefined(); + expect(pkg.devDependencies['jest-environment-jsdom']).toBeDefined(); + // install scheduled (a NodePackageInstallTask was queued) + expect(r.tasks.length).toBeGreaterThan(0); + }); + + it('rewrites the test target to @angular-builders/jest:run', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // ensure a non-jest test target exists to rewrite + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + options: {}, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe( + '@angular-builders/jest:run', + ); + }); + + it('sets zoneless to match detection (zoneless workspace → true)', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBe(true); + }); +}); +``` + +> The harness builds a modern (zoneless) application by default, so detection yields `zoneless: true`. The zone-based branch is tested in Task 5. `r.tasks` exposes scheduled tasks on the runner (NodePackageInstallTask). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: FAIL — the factory `./ng-add/index` referenced by `collection.json` cannot resolve (`Cannot find module './index'` / `ngAdd is not a function`). + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/jest/src/schematics/ng-add/index.ts`: + +```ts +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { readWorkspace } from '@schematics/angular/utility'; +import { + addBuilderDevDependency, + getProjectsToTarget, + isZoneless, + setBuilderForTarget, +} from '@angular-builders/common/schematics'; +import { NgAddOptions } from './schema'; + +const JEST_BUILDER = '@angular-builders/jest:run'; + +// Versions of the jest stack added on install. Kept here (not the builder +// schema) because these are dev-tooling versions, independent of builder options. +const JEST_STACK: Array<[name: string, version: string]> = [ + ['@angular-builders/jest', '^22.0.0'], + ['jest', '^30.0.0'], + ['jest-preset-angular', '^16.0.0'], + ['jest-environment-jsdom', '^30.0.0'], +]; + +export function ngAdd(options: NgAddOptions): Rule { + return async (tree: Tree, _context: SchematicContext) => { + const workspace = await readWorkspace(tree); + const projects = getProjectsToTarget(workspace, options.project); + + const rules: Rule[] = []; + + // Add the jest stack to devDependencies. The last add schedules install + // (InstallBehavior.Auto); earlier adds skip install to avoid duplicate tasks. + JEST_STACK.forEach(([name, version], i) => { + rules.push(addBuilderDevDependency(name, version, { install: i === JEST_STACK.length - 1 })); + }); + + for (const projectName of projects) { + const zoneless = isZoneless(tree, workspace, projectName); + rules.push(setBuilderForTarget(projectName, 'test', JEST_BUILDER, { zoneless })); + } + + return chain(rules); + }; +} +``` + +> `addBuilderDevDependency` with `install: false` adds the dep without queuing a task; the final entry uses `install: true` (default Auto) so exactly one `NodePackageInstallTask` is scheduled. `setBuilderForTarget` rewrites the existing `test` target's builder and merges `{ zoneless }` into its options (Plan 0 semantics: existing options preserved). Karma cleanup is added in Task 4. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: PASS (3 tests). + +> If `runSchematic('ng-add', ...)` cannot resolve `@angular-builders/common/schematics` because `common`'s `dist/schematics` isn't built, run `yarn workspace @angular-builders/common build` first (Plan 0 output) and re-run. The unit test imports the helper from the built/linked subpath. + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/ng-add/index.ts packages/jest/src/schematics/ng-add/index.spec.ts +git commit -m "feat(jest): ng-add adds jest stack and rewrites test target" +``` + +--- + +## Task 4: `ng-add` — Karma removal branch + +When Karma is detected, ng-add must additionally remove karma/jasmine devDeps, delete `karma.conf.js` + `src/test.ts`, and fix `tsconfig.spec.json` (types jasmine→jest, drop `test.ts` from `files`). + +> **Incumbent-runner branches (spec §4.1 + §12.2).** ng-add handles three incumbents: **Karma** (this task — heavy cleanup), **Vitest** (Task 4b — lighter cleanup, the forward-default), and **no runner / already-jest** (Task 3). The shared `fixSpecTsconfig` helper added below removes both `jasmine` **and** `vitest`/`vitest/globals` from `tsconfig.spec.json` `types`, so it serves both the Karma and Vitest paths. The Vitest implementation (Task 4b Step 3) is folded into the same `ngAdd` as a sibling branch to `hasKarma`. + +**Files:** +- Modify: `packages/jest/src/schematics/ng-add/index.ts` +- Test: `packages/jest/src/schematics/ng-add/index.spec.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +Append to `packages/jest/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('jest ng-add (Karma present)', () => { + async function karmaWorkspace(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + options: { polyfills: ['zone.js', 'zone.js/testing'] }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + // seed karma/jasmine devDeps + files + a jasmine spec tsconfig + const pkg = JSON.parse(tree.readText('/package.json')); + pkg.devDependencies = { + ...(pkg.devDependencies ?? {}), + karma: '^6.4.0', + 'karma-chrome-launcher': '^3.2.0', + 'karma-jasmine': '^5.1.0', + jasmine: '^5.1.0', + 'jasmine-core': '^5.1.0', + '@types/jasmine': '^5.1.0', + }; + tree.overwrite('/package.json', JSON.stringify(pkg, null, 2)); + tree.create('/karma.conf.js', '// karma config'); + tree.create('/src/test.ts', '// karma entry'); + tree.create( + '/tsconfig.spec.json', + JSON.stringify( + { compilerOptions: { types: ['jasmine'] }, files: ['src/test.ts', 'src/polyfills.ts'] }, + null, + 2, + ), + ); + return tree; + } + + it('removes karma/jasmine devDependencies', async () => { + const out = (await runner().runSchematic('ng-add', {}, await karmaWorkspace())) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + for (const dep of [ + 'karma', + 'karma-chrome-launcher', + 'karma-jasmine', + 'jasmine', + 'jasmine-core', + '@types/jasmine', + ]) { + expect(pkg.devDependencies[dep]).toBeUndefined(); + } + }); + + it('deletes karma.conf.js and src/test.ts', async () => { + const out = (await runner().runSchematic('ng-add', {}, await karmaWorkspace())) as UnitTestTree; + expect(out.exists('/karma.conf.js')).toBe(false); + expect(out.exists('/src/test.ts')).toBe(false); + }); + + it('fixes tsconfig.spec.json (types jasmine→jest, drops test.ts)', async () => { + const out = (await runner().runSchematic('ng-add', {}, await karmaWorkspace())) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.types).toEqual(['jest']); + expect(cfg.files).toEqual(['src/polyfills.ts']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: FAIL — the new describe block fails (karma deps still present, files not deleted, tsconfig unchanged). + +- [ ] **Step 3: Write minimal implementation** + +Replace the contents of `packages/jest/src/schematics/ng-add/index.ts` with: + +```ts +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { JSONFile } from '@schematics/angular/utility/json-file'; +import { readWorkspace } from '@schematics/angular/utility'; +import { + addBuilderDevDependency, + detectTestBuilder, + editJsonFile, + getProjectsToTarget, + isZoneless, + removeDevDependencies, + removeFilesIfPresent, + setBuilderForTarget, +} from '@angular-builders/common/schematics'; +import { NgAddOptions } from './schema'; + +const JEST_BUILDER = '@angular-builders/jest:run'; + +const JEST_STACK: Array<[name: string, version: string]> = [ + ['@angular-builders/jest', '^22.0.0'], + ['jest', '^30.0.0'], + ['jest-preset-angular', '^16.0.0'], + ['jest-environment-jsdom', '^30.0.0'], +]; + +const KARMA_DEVDEPS = [ + 'karma', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'jasmine', + 'jasmine-core', + '@types/jasmine', +]; + +const KARMA_FILES = ['karma.conf.js', 'src/test.ts']; + +function hasKarma(tree: Tree, workspace: Awaited>): boolean { + // 1. any project's test builder is karma + for (const name of workspace.projects.keys()) { + if (detectTestBuilder(workspace, name) === 'karma') return true; + } + // 2. a karma config file exists at the workspace root + if (tree.exists('/karma.conf.js') || tree.exists('/karma.conf.ts')) return true; + // 3. karma/jasmine present in devDependencies + if (tree.exists('/package.json')) { + const pkg = JSON.parse(tree.readText('/package.json')); + const dev = pkg.devDependencies ?? {}; + if (dev['karma'] || dev['jasmine'] || dev['jasmine-core']) return true; + } + return false; +} + +// Spec-tsconfig `types` values that must be swapped for `jest` regardless of the +// incumbent runner: `jasmine` (Karma) and `vitest`/`vitest/globals` (Vitest). +const NON_JEST_SPEC_TYPES = ['jasmine', 'vitest', 'vitest/globals']; + +function fixSpecTsconfig(path: string): Rule { + return editJsonFile(path, (json: JSONFile) => { + const types = json.get(['compilerOptions', 'types']); + if (Array.isArray(types)) { + const next = types.filter((t) => !NON_JEST_SPEC_TYPES.includes(t as string)); + if (!next.includes('jest')) next.push('jest'); + json.modify(['compilerOptions', 'types'], next); + } + const files = json.get(['files']); + if (Array.isArray(files)) { + json.modify( + ['files'], + files.filter((f) => f !== 'src/test.ts' && f !== 'test.ts'), + ); + } + }); +} + +export function ngAdd(options: NgAddOptions): Rule { + return async (tree: Tree, _context: SchematicContext) => { + const workspace = await readWorkspace(tree); + const projects = getProjectsToTarget(workspace, options.project); + + const rules: Rule[] = []; + + JEST_STACK.forEach(([name, version], i) => { + rules.push(addBuilderDevDependency(name, version, { install: i === JEST_STACK.length - 1 })); + }); + + for (const projectName of projects) { + const zoneless = isZoneless(tree, workspace, projectName); + rules.push(setBuilderForTarget(projectName, 'test', JEST_BUILDER, { zoneless })); + } + + if (hasKarma(tree, workspace)) { + rules.push(removeDevDependencies(KARMA_DEVDEPS)); + rules.push(removeFilesIfPresent(KARMA_FILES.map((f) => `/${f}`))); + // fix every project's spec tsconfig + a root-level one if present + const specPaths = new Set(['/tsconfig.spec.json']); + for (const projectName of projects) { + const root = workspace.projects.get(projectName)?.root ?? ''; + specPaths.add(root ? `/${root}/tsconfig.spec.json` : '/tsconfig.spec.json'); + } + for (const specPath of specPaths) { + rules.push(fixSpecTsconfig(specPath)); + } + } + + return chain(rules); + }; +} +``` + +> `removeFilesIfPresent`/`removeDevDependencies`/`editJsonFile` are all guarded no-ops when targets are absent (Plan 0), keeping the rule safe across workspace shapes. `fixSpecTsconfig` removes `jasmine` from `types`, ensures `jest` present, and drops `src/test.ts`/`test.ts` from `files`. `JSONFile.get`/`.modify` operate on the tree directly. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: PASS (all describe blocks — no-Karma from Task 3 + Karma-present). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/ng-add/index.ts packages/jest/src/schematics/ng-add/index.spec.ts +git commit -m "feat(jest): ng-add removes Karma and fixes spec tsconfig when detected" +``` + +--- + +## Task 4b: `ng-add` — Vitest→Jest branch (forward-default) + +Fresh v22 apps default to **Vitest** (`@angular/build:unit-test`). This is the forward-default incumbent and must be a first-class `ng add` path (spec §12.2). When `detectTestBuilder() === 'vitest'` (test builder is `@angular/build:unit-test` or any `:unit-test`): + +- The `test` → `@angular-builders/jest:run` rewrite already happens regardless of incumbent (Task 3's `setBuilderForTarget` loop runs for every project), so no extra rewrite rule is needed — the `unit-test` target is simply **overwritten**. +- Fix `tsconfig.spec.json` types (vitest globals → jest) via the shared `fixSpecTsconfig` helper from Task 4. +- Emit a `context.logger` advisory: spec code using `vi.*` / vitest imports needs manual porting to Jest APIs — the runner swap can't rewrite test code (same caveat as jasmine→jest). +- **Cleanup is lighter than Karma:** Vitest is built into `@angular/build` — there is no `karma.conf` equivalent to delete and no separate runner devDeps to remove (vitest isn't a top-level user dependency in a default app). So this branch only fixes the spec tsconfig + advises; it does **not** call `removeDevDependencies`/`removeFilesIfPresent`. + +**Files:** +- Modify: `packages/jest/src/schematics/ng-add/index.ts` +- Test: `packages/jest/src/schematics/ng-add/index.spec.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +Append to `packages/jest/src/schematics/ng-add/index.spec.ts`. Add this import at the top of the file (next to the existing imports): + +```ts +import { logging } from '@angular-devkit/core'; +``` + +Then append the describe block: + +```ts +describe('jest ng-add (Vitest present)', () => { + async function vitestWorkspace(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // seed a Vitest-shaped test target (the v22 default runner) + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular/build:unit-test', + options: { buildTarget: 'app:build', tsConfig: 'tsconfig.spec.json' }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + // a vitest-globals spec tsconfig (what `ng new` emits for the default runner) + tree.create( + '/tsconfig.spec.json', + JSON.stringify( + { compilerOptions: { types: ['vitest/globals'] }, include: ['src/**/*.spec.ts'] }, + null, + 2, + ), + ); + return tree; + } + + it('rewrites the Vitest test target to @angular-builders/jest:run', async () => { + const out = (await runner().runSchematic('ng-add', {}, await vitestWorkspace())) as UnitTestTree; + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe( + '@angular-builders/jest:run', + ); + }); + + it('fixes tsconfig.spec.json types (vitest globals → jest)', async () => { + const out = (await runner().runSchematic('ng-add', {}, await vitestWorkspace())) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.types).toEqual(['jest']); + }); + + it('logs an advisory about manually porting vi.* / vitest specs to Jest', async () => { + const messages: string[] = []; + const r = runner(); + r.logger.subscribe((e: logging.LogEntry) => messages.push(e.message)); + await r.runSchematic('ng-add', {}, await vitestWorkspace()); + const joined = messages.join('\n'); + expect(joined).toMatch(/vitest/i); + expect(joined).toMatch(/vi\.\*|manual/i); + }); + + it('does not delete files or remove devDependencies (lighter than Karma)', async () => { + const tree = await vitestWorkspace(); + const beforeDeps = JSON.parse(tree.readText('/package.json')).devDependencies ?? {}; + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const afterDeps = JSON.parse(out.readText('/package.json')).devDependencies ?? {}; + // every pre-existing devDep is still present (only jest stack was added, nothing removed) + for (const dep of Object.keys(beforeDeps)) { + expect(afterDeps[dep]).toBeDefined(); + } + // no karma cleanup files were touched (none existed; none created/deleted) + expect(out.exists('/karma.conf.js')).toBe(false); + }); +}); +``` + +> Detection uses Plan 0's `detectTestBuilder`, which returns `'vitest'` for any `:unit-test` builder (`builder.endsWith(':unit-test')`). The harness seeds the target explicitly so the test is independent of which runner the installed `application` generator defaults to. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: FAIL — the Vitest describe block fails (tsconfig types still `['vitest/globals']`; no advisory logged). The rewrite assertion may already pass (Task 3's loop rewrites every project's `test` target regardless of incumbent) — confirm it does and that the tsconfig/advisory assertions fail. + +- [ ] **Step 3: Write minimal implementation** + +In `packages/jest/src/schematics/ng-add/index.ts`, add a Vitest branch as a sibling to the `hasKarma` branch. The `test`→`:run` rewrite is already done by Task 3's `setBuilderForTarget` loop; this branch only adds the spec-tsconfig fix and the advisory. + +Add a `hasVitest` detector (next to `hasKarma`): + +```ts +function hasVitest(workspace: Awaited>): boolean { + for (const name of workspace.projects.keys()) { + if (detectTestBuilder(workspace, name) === 'vitest') return true; + } + return false; +} +``` + +In `ngAdd`, after the `if (hasKarma(...)) { ... }` block, add the Vitest branch (it shares the `specPaths` discovery pattern but skips dep/file removal): + +```ts + if (hasVitest(workspace)) { + context.logger.warn( + '[@angular-builders/jest] Detected Vitest as the current test runner. The `test` target ' + + 'was switched to @angular-builders/jest:run, but spec code using `vi.*` (e.g. vi.fn, ' + + 'vi.mock, vi.spyOn) or `import ... from \'vitest\'` is NOT rewritten — port it to the ' + + 'Jest API (jest.fn, jest.mock, jest.spyOn) manually. Cleanup is lighter than Karma: ' + + 'Vitest is built into @angular/build, so there is no karma.conf-style file to remove.', + ); + const specPaths = new Set(['/tsconfig.spec.json']); + for (const projectName of projects) { + const root = workspace.projects.get(projectName)?.root ?? ''; + specPaths.add(root ? `/${root}/tsconfig.spec.json` : '/tsconfig.spec.json'); + } + for (const specPath of specPaths) { + rules.push(fixSpecTsconfig(specPath)); + } + } +``` + +Because the advisory uses `context.logger`, change the `ngAdd` rule's context parameter name from `_context` to `context` (it is currently unused). The full updated signature line: + +```ts + return async (tree: Tree, context: SchematicContext) => { +``` + +> The Vitest and Karma branches are mutually exclusive in practice (a project has one test builder), but the code does not assume that — each branch guards independently. The shared `fixSpecTsconfig` (Task 4) already strips `vitest`/`vitest/globals` from `types` via `NON_JEST_SPEC_TYPES`. No `removeDevDependencies`/`removeFilesIfPresent` is invoked in the Vitest branch — matching the "lighter than Karma" intent (spec §12.2). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: PASS (no-Karma from Task 3 + Karma-present from Task 4 + Vitest-present from this task). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/ng-add/index.ts packages/jest/src/schematics/ng-add/index.spec.ts +git commit -m "feat(jest): ng-add adds Vitest→Jest path (spec tsconfig fix + advisory)" +``` + +--- + +## Task 5: `ng-add` — zoneless detection (zone branch) + idempotency + +Cover the zone-based detection branch (`zoneless: false`) and idempotency (re-running when `test` is already `:run` is a no-op rewrite). + +**Files:** +- Test: `packages/jest/src/schematics/ng-add/index.spec.ts` (add a describe block) +- Modify: `packages/jest/src/schematics/ng-add/index.ts` (only if a test fails) + +- [ ] **Step 1: Write the failing test** + +Append to `packages/jest/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('jest ng-add (zoneless detection + idempotency)', () => { + it('sets zoneless:false when zone.js is in build polyfills', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + const build = ws.projects.get('app')!.targets.get('build')!; + build.options = { ...(build.options ?? {}), polyfills: ['zone.js'] }; + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBe(false); + }); + + it('is idempotent: re-running on an already-jest workspace keeps :run', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const once = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const twice = (await runner().runSchematic('ng-add', {}, once)) as UnitTestTree; + + const ws = await readWorkspace(twice); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe( + '@angular-builders/jest:run', + ); + const pkg = JSON.parse(twice.readText('/package.json')); + // dep version unchanged (single, valid range — not duplicated/corrupted) + expect(pkg.devDependencies['jest']).toBe('^30.0.0'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/ng-add/index.spec.ts` +Expected: PASS for both (the Task 4 implementation already detects zone.js via `isZoneless` and the builder rewrite is naturally idempotent). + +> This task is a verification/guard task: the behavior is already implemented; these tests lock it in. If both pass on first run, that is the success condition — proceed to commit. If the idempotency dep assertion fails (e.g. duplicate/corrupted dep entries on re-run), the fix is in Step 3. + +- [ ] **Step 3: Fix only if a test failed** + +If the idempotency dep assertion failed because `addDependency` corrupted the version on re-run, guard the add by skipping deps that already exist. Replace the `JEST_STACK.forEach(...)` block in `packages/jest/src/schematics/ng-add/index.ts` with: + +```ts + const existingPkg = tree.exists('/package.json') + ? JSON.parse(tree.readText('/package.json')) + : {}; + const existingDev: Record = existingPkg.devDependencies ?? {}; + const toAdd = JEST_STACK.filter(([name]) => !existingDev[name]); + toAdd.forEach(([name, version], i) => { + rules.push(addBuilderDevDependency(name, version, { install: i === toAdd.length - 1 })); + }); +``` + +Then re-run Step 2's command; expected PASS. (Skip this step entirely if Step 2 already passed.) + +- [ ] **Step 4: Commit** + +```bash +git add packages/jest/src/schematics/ng-add/index.ts packages/jest/src/schematics/ng-add/index.spec.ts +git commit -m "test(jest): cover ng-add zone detection and idempotency" +``` + +--- + +## Task 6: Migrations manifest + +Declare the two migrations with valid semver thresholds. `ng update` runs every migration where `installedVersion < version <= targetVersion`. + +**Files:** +- Create: `packages/jest/src/schematics/migrations.json` + +- [ ] **Step 1: Write the migrations manifest** + +Create `packages/jest/src/schematics/migrations.json`: + +```json +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "migration-v21": { + "version": "21.0.0", + "description": "Migrate jest builder config for v21: bump deps, Node16 tsconfig, rename builder options, strip removed mocks/options, set zoneless by detection.", + "factory": "./migrations/v21/index#migrateV21" + }, + "migration-v22": { + "version": "22.0.0", + "description": "Advise on v22 jest breaking changes (isolatedModules default, per-project coverage path). Advisory only — no file changes.", + "factory": "./migrations/v22/index#migrateV22" + } + } +} +``` + +> Threshold semantics: a user on Angular/builder `20.x` upgrading to `21.x` triggers `migration-v21` (`20 < 21.0.0 <= 21`). Upgrading `21.x → 22.x` triggers `migration-v22` only. A multi-major jump `20 → 22` runs both, v21 then v22 (CLI orders by `version`). `factory` paths are dist-relative (`./migrations/v21/index` → `dist/schematics/migrations/v21/index.js`). +> +> **Coverage from old versions (v17–v20) and the stepwise caveat — IMPORTANT.** `ng update` always executes the migrations from the version being installed (v22), so v22 being the first builder version to ship schematics is correct — v22's `migrations.json` is the single home for the whole history. The window math (`installed < version <= target`) means a user coming from **17/18/19/20** runs **both** `migration-v21` and `migration-v22` (17→20 were all no-op transitions, so nothing is missing); a user already on **21** correctly runs only `migration-v22` (they performed the heavy 20→21 step manually, before schematics existed). +> +> This full-range coverage holds **only if the builder is updated from its old major to 22 in a single `ng update @angular-builders/jest`.** If the builder is instead dragged *stepwise* through 21 — which shipped **no** `migrations.json` — the heavy `migration-v21` is silently skipped: v21 had nothing to run it, and the final `21 → 22` step's window `(21, 22]` excludes the `21.0.0` threshold. The supported flow (document in `MIGRATION.MD` + the upgrade runbook, see Plan 2c/2d): **upgrade the Angular framework stepwise to 22 (framework discipline requires it), leaving `@angular-builders/jest` untouched, then run `ng update @angular-builders/jest` once** so the window `(old, 22]` spans 21. The migration is idempotent + detection-based, so this is safe. +> +> **Execution-time validation (RC-gated):** confirm on `22.0.0-rc.2` that `ng update` actually permits a third-party package's old→22 multi-major jump and runs the spanned migrations. Angular *blocks* multi-major for the framework itself; packages that declare a `migrations.json` generally allow it, but this MUST be verified against the real CLI during implementation (add it to the integration e2e in Plan 04). If the CLI refuses the jump, fall back to documenting an explicit `ng update @angular-builders/jest@22 --from= --migrate-only` invocation. + +- [ ] **Step 2: Verify it copies into dist** + +Run: `yarn workspace @angular-builders/jest exec copyfiles -u 2 "src/schematics/**/*.json" dist/schematics` +Run: `ls packages/jest/dist/schematics/migrations.json` +Expected: the file exists at `dist/schematics/migrations.json`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/jest/src/schematics/migrations.json +git commit -m "feat(jest): declare ng-update migrations manifest (v21, v22)" +``` + +--- + +## Task 7: `@21` migration — dependency bumps + tsconfig Node16 + +The heavy migration, built in slices. First slice: bump `jest`/`jest-environment-jsdom`/`jsdom` and patch `tsconfig.spec.json` (`module`/`moduleResolution: "Node16"`, `isolatedModules: true`). + +**Files:** +- Create: `packages/jest/src/schematics/migrations/v21/index.ts` +- Test: `packages/jest/src/schematics/migrations/v21/index.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/jest/src/schematics/migrations/v21/index.spec.ts`: + +```ts +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../../src/schematics/migrations.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('jest-migrations', COLLECTION); +} + +async function seed(): Promise { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const pkg = JSON.parse(tree.readText('/package.json')); + pkg.devDependencies = { + ...(pkg.devDependencies ?? {}), + jest: '^29.0.0', + 'jest-environment-jsdom': '^29.0.0', + jsdom: '^24.0.0', + }; + tree.overwrite('/package.json', JSON.stringify(pkg, null, 2)); + tree.create( + '/tsconfig.spec.json', + JSON.stringify({ compilerOptions: { module: 'esnext', types: ['jest'] } }, null, 2), + ); + return tree; +} + +describe('jest @21 migration — deps + tsconfig', () => { + it('bumps jest/jest-environment-jsdom/jsdom to 30/30/26', async () => { + const out = (await runner().runSchematic('migration-v21', {}, await seed())) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies.jest).toBe('^30.0.0'); + expect(pkg.devDependencies['jest-environment-jsdom']).toBe('^30.0.0'); + expect(pkg.devDependencies.jsdom).toBe('^26.0.0'); + }); + + it('patches tsconfig.spec.json to Node16 + isolatedModules', async () => { + const out = (await runner().runSchematic('migration-v21', {}, await seed())) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.module).toBe('Node16'); + expect(cfg.compilerOptions.moduleResolution).toBe('Node16'); + expect(cfg.compilerOptions.isolatedModules).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: FAIL — factory `./migrations/v21/index` cannot resolve / `migrateV21 is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/jest/src/schematics/migrations/v21/index.ts`: + +```ts +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { JSONFile } from '@schematics/angular/utility/json-file'; +import { editJsonFile } from '@angular-builders/common/schematics'; + +// Target versions for the jest stack at v21. +const DEP_BUMPS: Record = { + jest: '^30.0.0', + 'jest-environment-jsdom': '^30.0.0', + jsdom: '^26.0.0', +}; + +function bumpDeps(): Rule { + return (tree: Tree) => { + if (!tree.exists('/package.json')) return tree; + const json = new JSONFile(tree, '/package.json'); + for (const [name, version] of Object.entries(DEP_BUMPS)) { + if (json.get(['devDependencies', name]) !== undefined) { + json.modify(['devDependencies', name], version); + } + if (json.get(['dependencies', name]) !== undefined) { + json.modify(['dependencies', name], version); + } + } + return tree; + }; +} + +function patchSpecTsconfig(): Rule { + return editJsonFile('/tsconfig.spec.json', (json: JSONFile) => { + json.modify(['compilerOptions', 'module'], 'Node16'); + json.modify(['compilerOptions', 'moduleResolution'], 'Node16'); + json.modify(['compilerOptions', 'isolatedModules'], true); + }); +} + +export function migrateV21(): Rule { + return (_tree: Tree, _context: SchematicContext) => { + return chain([bumpDeps(), patchSpecTsconfig()]); + }; +} +``` + +> `editJsonFile` is a guarded no-op if `/tsconfig.spec.json` is absent (Plan 0). `bumpDeps` only rewrites versions for deps already present — it never adds jest to a workspace that doesn't use it. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/migrations/v21/index.ts packages/jest/src/schematics/migrations/v21/index.spec.ts +git commit -m "feat(jest): v21 migration bumps deps and applies Node16 tsconfig" +``` + +--- + +## Task 8: `@21` migration — builder option renames in angular.json + +Rename builder options `configPath`→`config` and `testPathPattern`→`testPathPatterns` in every project's `test` target options. + +**Files:** +- Modify: `packages/jest/src/schematics/migrations/v21/index.ts` +- Test: `packages/jest/src/schematics/migrations/v21/index.spec.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +Append to `packages/jest/src/schematics/migrations/v21/index.spec.ts`. Add this import at the top of the file (next to the existing imports): + +```ts +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +``` + +Then append the describe block: + +```ts +describe('jest @21 migration — builder option renames', () => { + async function seedWithTestOptions(options: Record): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('renames configPath → config', async () => { + const tree = await seedWithTestOptions({ configPath: 'jest.config.js' }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['config']).toBe('jest.config.js'); + expect(opts['configPath']).toBeUndefined(); + }); + + it('renames testPathPattern → testPathPatterns', async () => { + const tree = await seedWithTestOptions({ testPathPattern: 'foo' }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['testPathPatterns']).toBe('foo'); + expect(opts['testPathPattern']).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: FAIL — the rename describe block fails (old keys still present). + +- [ ] **Step 3: Write minimal implementation** + +In `packages/jest/src/schematics/migrations/v21/index.ts`, add the `updateWorkspace` import and a rename rule, and add it to the chain. + +Add to the imports at the top: + +```ts +import { updateWorkspace } from '@schematics/angular/utility'; +``` + +Add this constant and function (after `patchSpecTsconfig`): + +```ts +const OPTION_RENAMES: Record = { + configPath: 'config', + testPathPattern: 'testPathPatterns', +}; + +function renameBuilderOptions(): Rule { + return updateWorkspace((workspace) => { + for (const project of workspace.projects.values()) { + const test = project.targets.get('test'); + if (!test || test.builder !== '@angular-builders/jest:run') continue; + const options = (test.options ?? {}) as Record; + for (const [from, to] of Object.entries(OPTION_RENAMES)) { + if (from in options) { + if (!(to in options)) options[to] = options[from]; + delete options[from]; + } + } + test.options = options; + } + }); +} +``` + +Update the chain in `migrateV21`: + +```ts +export function migrateV21(): Rule { + return (_tree: Tree, _context: SchematicContext) => { + return chain([bumpDeps(), patchSpecTsconfig(), renameBuilderOptions()]); + }; +} +``` + +> Only `@angular-builders/jest:run` test targets are touched. Rename is non-destructive if the new key already exists (preserves the new value, drops the old). Because it guards with `if (from in options)`, a second run finds no old key and is a no-op. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: PASS (all describe blocks so far). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/migrations/v21/index.ts packages/jest/src/schematics/migrations/v21/index.spec.ts +git commit -m "feat(jest): v21 migration renames configPath/testPathPattern options" +``` + +--- + +## Task 9: `@21` migration — strip removed globalMocks + removed Jest options + +Strip removed `globalMocks` values (`styleTransform`, `getComputedStyle`, `doctype`) and removed Jest builder options (`browser`, `init`, `mapCoverage`, `testURL`, `timers`) from each jest `test` target. + +**Files:** +- Modify: `packages/jest/src/schematics/migrations/v21/index.ts` +- Test: `packages/jest/src/schematics/migrations/v21/index.spec.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +Append to `packages/jest/src/schematics/migrations/v21/index.spec.ts`: + +```ts +describe('jest @21 migration — strip removed mocks/options', () => { + async function seedWithTestOptions(options: Record): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('strips removed globalMocks values, keeping supported ones', async () => { + const tree = await seedWithTestOptions({ + globalMocks: ['matchMedia', 'styleTransform', 'getComputedStyle', 'doctype'], + }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['globalMocks']).toEqual(['matchMedia']); + }); + + it('strips removed jest options', async () => { + const tree = await seedWithTestOptions({ + browser: true, + init: true, + mapCoverage: true, + testURL: 'http://localhost', + timers: 'fake', + ci: true, + }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + for (const removed of ['browser', 'init', 'mapCoverage', 'testURL', 'timers']) { + expect(opts[removed]).toBeUndefined(); + } + // unrelated options are preserved + expect(opts['ci']).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: FAIL — the strip describe block fails (removed keys/values still present). + +- [ ] **Step 3: Write minimal implementation** + +In `packages/jest/src/schematics/migrations/v21/index.ts`, add the constants and a strip rule, and include it in the chain. + +Add these constants (near `OPTION_RENAMES`): + +```ts +const REMOVED_GLOBAL_MOCKS = ['styleTransform', 'getComputedStyle', 'doctype']; +const REMOVED_JEST_OPTIONS = ['browser', 'init', 'mapCoverage', 'testURL', 'timers']; +``` + +Add this function (after `renameBuilderOptions`): + +```ts +function stripRemovedOptions(): Rule { + return updateWorkspace((workspace) => { + for (const project of workspace.projects.values()) { + const test = project.targets.get('test'); + if (!test || test.builder !== '@angular-builders/jest:run') continue; + const options = (test.options ?? {}) as Record; + + if (Array.isArray(options['globalMocks'])) { + options['globalMocks'] = (options['globalMocks'] as unknown[]).filter( + (v) => !REMOVED_GLOBAL_MOCKS.includes(v as string), + ); + } + for (const removed of REMOVED_JEST_OPTIONS) { + if (removed in options) delete options[removed]; + } + test.options = options; + } + }); +} +``` + +Update the chain in `migrateV21`: + +```ts +export function migrateV21(): Rule { + return (_tree: Tree, _context: SchematicContext) => { + return chain([bumpDeps(), patchSpecTsconfig(), renameBuilderOptions(), stripRemovedOptions()]); + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: PASS (all describe blocks). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/migrations/v21/index.ts packages/jest/src/schematics/migrations/v21/index.spec.ts +git commit -m "feat(jest): v21 migration strips removed globalMocks and jest options" +``` + +--- + +## Task 10: `@21` migration — set `zoneless` by detection + logger advisories + +Set `zoneless` on each jest test target by detection (zone.js in build polyfills → `false`; else leave the default `true` — i.e. set it only when zone-based). Emit logger advisories about Node16 latent type errors and removed mocks. + +**Files:** +- Modify: `packages/jest/src/schematics/migrations/v21/index.ts` +- Test: `packages/jest/src/schematics/migrations/v21/index.spec.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +Append to `packages/jest/src/schematics/migrations/v21/index.spec.ts`. Add this import at the top of the file (next to the existing imports): + +```ts +import { logging } from '@angular-devkit/core'; +``` + +Then append the describe block: + +```ts +describe('jest @21 migration — zoneless detection + advisories', () => { + it('zone-based workspace → sets zoneless:false', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + const build = ws.projects.get('app')!.targets.get('build')!; + build.options = { ...(build.options ?? {}), polyfills: ['zone.js'] }; + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options: {}, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBe(false); + }); + + it('zoneless workspace → leaves zoneless unset (default true)', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options: {}, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBeUndefined(); + }); + + it('emits Node16 and removed-mocks advisories', async () => { + const messages: string[] = []; + const r = new SchematicTestRunner('jest-migrations', COLLECTION); + r.logger.subscribe((e: logging.LogEntry) => messages.push(e.message)); + + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await r.runSchematic('migration-v21', {}, tree); + + const joined = messages.join('\n'); + expect(joined).toMatch(/Node16/); + expect(joined).toMatch(/mock/i); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: FAIL — zoneless not set / no advisory messages emitted. + +- [ ] **Step 3: Write minimal implementation** + +In `packages/jest/src/schematics/migrations/v21/index.ts`: + +Add to the imports (extend the existing `@schematics/angular/utility` import to include `readWorkspace`, and add the `isZoneless` import): + +```ts +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { editJsonFile, isZoneless } from '@angular-builders/common/schematics'; +``` + +(Replace the earlier single-name imports for `updateWorkspace` and `editJsonFile` with these combined lines — the file ends up with exactly one import from `@schematics/angular/utility` and one from `@angular-builders/common/schematics`.) + +Add this function (after `stripRemovedOptions`): + +```ts +function setZonelessByDetection(): Rule { + return async (tree: Tree) => { + const workspace = await readWorkspace(tree); + return updateWorkspace((ws) => { + for (const [name, project] of ws.projects) { + const test = project.targets.get('test'); + if (!test || test.builder !== '@angular-builders/jest:run') continue; + // Detect against the workspace read from the tree (build polyfills + bootstrap). + if (!isZoneless(tree, workspace, name)) { + const options = (test.options ?? {}) as Record; + options['zoneless'] = false; + test.options = options; + } + // zoneless workspaces keep the builder's default (true) — leave unset. + } + }); + }; +} +``` + +Replace `migrateV21` to add the zoneless rule and the logger advisories: + +```ts +export function migrateV21(): Rule { + return (_tree: Tree, context: SchematicContext) => { + context.logger.warn( + '[@angular-builders/jest] v21 migration applied. Note: tsconfig.spec.json now uses ' + + 'module/moduleResolution "Node16", which may surface pre-existing type errors in your ' + + 'spec code — fix the reported type issues.', + ); + context.logger.warn( + '[@angular-builders/jest] Removed globalMocks (styleTransform, getComputedStyle, doctype) ' + + 'were stripped from your config; if your tests relied on them, replace them manually. ' + + 'See MIGRATION.MD (v20→v21) for details.', + ); + return chain([ + bumpDeps(), + patchSpecTsconfig(), + renameBuilderOptions(), + stripRemovedOptions(), + setZonelessByDetection(), + ]); + }; +} +``` + +> Advisories are unconditional `logger.warn` (headless-safe; no prompts). `setZonelessByDetection` only writes `zoneless: false` for zone-based projects; zoneless projects are left untouched so the builder default (`true`) governs — matching spec §4.1. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: PASS (all describe blocks). + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/migrations/v21/index.ts packages/jest/src/schematics/migrations/v21/index.spec.ts +git commit -m "feat(jest): v21 migration sets zoneless by detection and logs advisories" +``` + +--- + +## Task 11: `@21` migration — idempotency + +Running the migration twice must equal running it once (deps already bumped, options already renamed/stripped, zoneless already set). + +**Files:** +- Test: `packages/jest/src/schematics/migrations/v21/index.spec.ts` (add a describe block) +- Modify: `packages/jest/src/schematics/migrations/v21/index.ts` (only if a test fails) + +- [ ] **Step 1: Write the test** + +Append to `packages/jest/src/schematics/migrations/v21/index.spec.ts`: + +```ts +describe('jest @21 migration — idempotency', () => { + async function seedFull(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const pkg = JSON.parse(tree.readText('/package.json')); + pkg.devDependencies = { ...(pkg.devDependencies ?? {}), jest: '^29.0.0', jsdom: '^24.0.0' }; + tree.overwrite('/package.json', JSON.stringify(pkg, null, 2)); + tree.create( + '/tsconfig.spec.json', + JSON.stringify({ compilerOptions: { module: 'esnext', types: ['jest'] } }, null, 2), + ); + await runner() + .callRule( + updateWorkspace((ws) => { + const build = ws.projects.get('app')!.targets.get('build')!; + build.options = { ...(build.options ?? {}), polyfills: ['zone.js'] }; + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options: { + configPath: 'jest.config.js', + testPathPattern: 'foo', + globalMocks: ['matchMedia', 'doctype'], + browser: true, + }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('run twice == run once', async () => { + const once = (await runner().runSchematic('migration-v21', {}, await seedFull())) as UnitTestTree; + const twice = (await runner().runSchematic('migration-v21', {}, once)) as UnitTestTree; + + const wsOnce = await readWorkspace(once); + const wsTwice = await readWorkspace(twice); + const optsOnce = wsOnce.projects.get('app')!.targets.get('test')!.options as Record; + const optsTwice = wsTwice.projects.get('app')!.targets.get('test')!.options as Record; + expect(optsTwice).toEqual(optsOnce); + + const pkgOnce = JSON.parse(once.readText('/package.json')); + const pkgTwice = JSON.parse(twice.readText('/package.json')); + expect(pkgTwice.devDependencies).toEqual(pkgOnce.devDependencies); + + const cfgOnce = JSON.parse(once.readText('/tsconfig.spec.json')); + const cfgTwice = JSON.parse(twice.readText('/tsconfig.spec.json')); + expect(cfgTwice).toEqual(cfgOnce); + + // concrete post-state checks + expect(optsTwice['config']).toBe('jest.config.js'); + expect(optsTwice['configPath']).toBeUndefined(); + expect(optsTwice['testPathPatterns']).toBe('foo'); + expect(optsTwice['globalMocks']).toEqual(['matchMedia']); + expect(optsTwice['browser']).toBeUndefined(); + expect(optsTwice['zoneless']).toBe(false); + expect(pkgTwice.devDependencies.jest).toBe('^30.0.0'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v21/index.spec.ts` +Expected: PASS — the migration is naturally idempotent (renames check `from in options`; strips are filters/deletes; dep bumps set absolute versions; zoneless sets an absolute boolean). This task locks that property. + +> If the run-twice comparison fails, the most likely cause is a non-idempotent rename (re-adding a deleted key). The implementation in Task 8 already guards with `if (from in options)`, so a second run finds no old key and is a no-op. Only investigate further (Step 3) if this test actually fails. + +- [ ] **Step 3: Fix only if a test failed** + +If idempotency fails, identify the non-idempotent rule via the diff between `once` and `twice` states, and make it conditional (operate only when the pre-migration shape is present). No code change is expected; this step exists only as a contingency. + +- [ ] **Step 4: Commit** + +```bash +git add packages/jest/src/schematics/migrations/v21/index.spec.ts +git commit -m "test(jest): lock v21 migration idempotency (run twice == once)" +``` + +--- + +## Task 12: `@22` migration — advisory only (no file mutation) + +The v22 jest breaking changes apply automatically (internal default flips). The migration only warns: #2191 (isolatedModules now defaults true) and #2212 (per-project coverage path moves). It must NOT mutate any file. + +**Files:** +- Create: `packages/jest/src/schematics/migrations/v22/index.ts` +- Test: `packages/jest/src/schematics/migrations/v22/index.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/jest/src/schematics/migrations/v22/index.spec.ts`: + +```ts +import { logging } from '@angular-devkit/core'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../../src/schematics/migrations.json'); + +function makeRunner(): { runner: SchematicTestRunner; messages: string[] } { + const runner = new SchematicTestRunner('jest-migrations', COLLECTION); + const messages: string[] = []; + runner.logger.subscribe((e: logging.LogEntry) => messages.push(e.message)); + return { runner, messages }; +} + +function snapshot(tree: UnitTestTree): Record { + const out: Record = {}; + tree.visit((path) => { + out[path] = tree.readText(path); + }); + return out; +} + +describe('jest @22 migration — advisory only', () => { + it('warns about isolatedModules default flip (#2191)', async () => { + const { runner, messages } = makeRunner(); + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner.runSchematic('migration-v22', {}, tree); + expect(messages.join('\n')).toMatch(/isolatedModules/); + }); + + it('warns about per-project coverage path when projectRoot !== workspaceRoot (#2212)', async () => { + const { runner, messages } = makeRunner(); + // harness puts apps under projects/ → projectRoot !== '' (workspace root) + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner.runSchematic('migration-v22', {}, tree); + expect(messages.join('\n')).toMatch(/coverage/i); + }); + + it('does NOT warn about coverage when projectRoot === workspaceRoot', async () => { + const { runner, messages } = makeRunner(); + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // force the project root to '' (workspace root) + await runner + .callRule( + updateWorkspace((ws) => { + (ws.projects.get('app') as { root: string }).root = ''; + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + await runner.runSchematic('migration-v22', {}, tree); + expect(messages.join('\n')).not.toMatch(/coverage/i); + }); + + it('mutates no files', async () => { + const { runner } = makeRunner(); + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const before = snapshot(tree); + const out = (await runner.runSchematic('migration-v22', {}, tree)) as UnitTestTree; + const after = snapshot(out); + expect(after).toEqual(before); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v22/index.spec.ts` +Expected: FAIL — factory `./migrations/v22/index` cannot resolve / `migrateV22 is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/jest/src/schematics/migrations/v22/index.ts`: + +```ts +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { readWorkspace } from '@schematics/angular/utility'; + +export function migrateV22(): Rule { + return async (tree: Tree, context: SchematicContext) => { + // #2191 — isolatedModules now defaults to true. Applies automatically; advise only. + context.logger.warn( + '[@angular-builders/jest] v22: ts-jest `isolatedModules` now defaults to true. ' + + '`const enum` used across files and type-only re-exports without the `type` modifier ' + + 'will now error. Fix the call sites, or restore `isolatedModules: false` in your jest ' + + 'config. We do not change this automatically — the new default is intentional. ' + + 'See MIGRATION.MD (v21→v22) and #2191.', + ); + + // Optional: make the const-enum warning targeted by scanning sources (read-only). + const constEnumHits: string[] = []; + tree.visit((path) => { + if (!path.endsWith('.ts')) return; + if (path.includes('/node_modules/') || path.includes('/dist/')) return; + const content = tree.readText(path); + if (/\bconst\s+enum\b/.test(content)) constEnumHits.push(path); + }); + if (constEnumHits.length > 0) { + context.logger.warn( + '[@angular-builders/jest] Found `const enum` in: ' + + constEnumHits.join(', ') + + ' — these may break under isolatedModules. Convert to a regular `enum` or `as const`.', + ); + } + + // #2212 — per-project coverage output moves ./coverage → /coverage. + // Warn only workspaces where a project root differs from the workspace root. + const workspace = await readWorkspace(tree); + const affected = [...workspace.projects.entries()] + .filter(([, project]) => (project.root ?? '') !== '') + .map(([name]) => name); + if (affected.length > 0) { + context.logger.warn( + '[@angular-builders/jest] v22: per-project coverage output now writes to ' + + '/coverage instead of ./coverage for projects: ' + + affected.join(', ') + + '. Update any CI/tooling that reads a hardcoded `./coverage/` path. See #2212.', + ); + } + + // No file mutation — return the tree untouched. + return tree; + }; +} +``` + +> The `tree.visit` const-enum scan is read-only (it only reads files). The coverage advisory is gated on `project.root !== ''` (workspace root). No `updateWorkspace`/`editJsonFile`/`removeX` rules are used — the tree is returned unmodified, satisfying the "no file mutation" test. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics/migrations/v22/index.spec.ts` +Expected: PASS (4 tests). + +> The "mutates no files" assertion holds because `readWorkspace` reads but does not write, and no mutating rule is invoked. If it fails, the cause is an accidental `updateWorkspace`/`editJsonFile` call — there are none here. + +- [ ] **Step 5: Commit** + +```bash +git add packages/jest/src/schematics/migrations/v22/index.ts packages/jest/src/schematics/migrations/v22/index.spec.ts +git commit -m "feat(jest): v22 advisory migration (isolatedModules, coverage path)" +``` + +--- + +## Task 13: Full build + manifest copy verification + +Verify the whole package builds, schematics assets land in `dist`, and the CLI-visible manifests are correct. + +**Files:** +- None (verification only) + +- [ ] **Step 1: Note on the Task 1 placeholder** + +The Task 1 placeholder `packages/jest/src/schematics/index.ts` (`export {};`) is harmless but unused. Leave it — it has no consumers and keeps the schematics tsconfig non-empty even if other files are excluded on a partial checkout. (No action needed; documented so the implementer doesn't delete it.) + +- [ ] **Step 2: Build the package end-to-end** + +Run: `yarn workspace @angular-builders/jest build` +Expected: completes; `postbuild`'s unit suite is green. + +- [ ] **Step 3: Verify schematics assets copied to dist** + +Run: `ls packages/jest/dist/schematics packages/jest/dist/schematics/ng-add packages/jest/dist/schematics/migrations/v21 packages/jest/dist/schematics/migrations/v22` +Expected: +- `dist/schematics/collection.json`, `dist/schematics/migrations.json` +- `dist/schematics/ng-add/index.js`, `dist/schematics/ng-add/schema.json` +- `dist/schematics/migrations/v21/index.js` +- `dist/schematics/migrations/v22/index.js` + +- [ ] **Step 4: Verify package.json fields point at real dist paths** + +Run: `node -e "const p=require('./packages/jest/package.json'); console.log(p.schematics, JSON.stringify(p['ng-add']), JSON.stringify(p['ng-update']));"` +Expected: `./dist/schematics/collection.json {"save":"devDependencies"} {"migrations":"./dist/schematics/migrations.json"}` + +Run: `node -e "require('./packages/jest/dist/schematics/collection.json'); require('./packages/jest/dist/schematics/migrations.json'); console.log('manifests ok');"` +Expected: prints `manifests ok` (valid JSON). + +- [ ] **Step 5: Run the full jest schematics unit suite** + +Run: `yarn jest --config jest-ut.config.js packages/jest/src/schematics` +Expected: all ng-add + v21 + v22 specs green. + +- [ ] **Step 6: Commit (if anything changed)** + +```bash +git add -A packages/jest +git commit -m "build(jest): verify schematics build and manifests" --allow-empty +``` + +--- + +## Self-Review + +**Spec §4.1 (jest) coverage:** + +ng-add: +- Add jest stack (`jest`, `jest-preset-angular`, `jest-environment-jsdom`) + the builder itself via `addBuilderDevDependency` → Task 3 `JEST_STACK`. ✅ +- Rewrite `test` → `@angular-builders/jest:run` via `setBuilderForTarget`; schedule install → Task 3. ✅ +- Karma detected (builder `:karma` OR `karma.conf.*` OR karma/jasmine in devDeps): `removeDevDependencies`, `removeFilesIfPresent(['karma.conf.js','src/test.ts'])`, fix `tsconfig.spec.json` (types jasmine→jest, drop `test.ts`) → Task 4 `hasKarma`/`fixSpecTsconfig`. ✅ +- Vitest detected (`detectTestBuilder === 'vitest'`, i.e. `:unit-test`): rewrite already covered by Task 3's loop (overwrites the `unit-test` target); fix `tsconfig.spec.json` types (vitest globals → jest via shared `fixSpecTsconfig`); logger advisory that `vi.*`/vitest imports need manual porting; **no** dep/file removal (lighter than Karma — Vitest is built into `@angular/build`) → Task 4b `hasVitest`/`fixSpecTsconfig` + advisory (spec §12.2). ✅ +- Set `zoneless` to match `isZoneless` rather than prompting → Task 3 (zoneless) + Task 5 (zone branch). ✅ +- Idempotent (`test` already `:run` → no-op) → Task 5. ✅ +- Only `--project` flag, no `x-prompt` → Task 2 schema. ✅ + +ng-update @21: +- Bump `jest`/`jest-environment-jsdom`/`jsdom` → 30/30/26 → Task 7. ✅ +- tsconfig.spec.json `module`/`moduleResolution: "Node16"`, `isolatedModules: true` → Task 7. ✅ +- Rename `configPath`→`config`, `testPathPattern`→`testPathPatterns` in angular.json → Task 8. ✅ +- Strip removed globalMocks (`styleTransform`,`getComputedStyle`,`doctype`) + removed jest options (`browser`,`init`,`mapCoverage`,`testURL`,`timers`) → Task 9. ✅ +- Set `zoneless` by detection (zone.js → false; else leave default) → Task 10. ✅ +- Logger advisories (Node16 latent type errors; removed mocks) → Task 10. ✅ +- Idempotency + both zone/zoneless branches → Task 10 (branches) + Task 11 (idempotency). ✅ + +ng-update @22 (advisory-only, no mutation): +- #2191 isolatedModules-default warning + optional `const enum` grep; do NOT auto-set false → Task 12. ✅ +- #2212 coverage-path warning only where `projectRoot !== workspaceRoot` → Task 12. ✅ +- Asserts advisories logged + asserts NO file mutation → Task 12 tests. ✅ + +Packaging (§7): +- `packages/jest/tsconfig.schematics.json` (rootDir/outDir, extends root base) → Task 1. ✅ +- `src/schematics/collection.json`, `ng-add/{index.ts,schema.json}`, `migrations.json`, `migrations/v21/index.ts`, `migrations/v22/index.ts` → Tasks 2,3,6,7,12. ✅ +- package.json `schematics`, `ng-add:{save:devDependencies}`, `ng-update:{migrations:...}` fields → Task 1. ✅ +- `copy:schematics` build step mirroring Plan 0 (`copyfiles -u 2`) → Task 1. ✅ + +§6 coverage checklist (jest column): +- deps add/remove (+jest stack / −karma,jasmine; Vitest path removes nothing) → Tasks 3,4,4b. ✅ +- targets rewritten (`test`, from any incumbent incl. Vitest `:unit-test`) → Task 3 (loop runs regardless of incumbent). ✅ +- files deleted (`karma.conf`,`test.ts`; none for Vitest) → Task 4. ✅ +- tsconfig edits (spec `types`/`files`; vitest globals→jest) → Tasks 4,4b. ✅ +- detection (Karma?, Vitest?, zoneless?) → Tasks 4,4b,3/5. ✅ +- flags (`--project`) → Task 2. ✅ +- idempotency (`test` already `:run`) → Task 5. ✅ +- migrations `@21`,`@22` → Tasks 6–12. ✅ +- migration auto-transforms (deps, tsconfig, renames, mocks, zoneless-detected) → Tasks 7–10. ✅ +- migration advisories (Node16, removed mocks, isolatedModules, coverage path) → Tasks 10,12. ✅ +- package.json fields → Task 1. ✅ +- tests (ng-add + migration + idempotency) → all tasks. ✅ + +§5 migration chain: jest `@21` heavy + `@22` advisory; thresholds `21.0.0`/`22.0.0` so a 20→21 jump runs v21, 21→22 runs v22, 20→22 runs both in order → Task 6. ✅ + +§11 MIGRATION.MD pairing: both `@21` and `@22` advisories point users at the relevant MIGRATION.MD section (`logger.warn` text references "MIGRATION.MD (v20→v21)" / "(v21→v22)") → Tasks 10,12. ✅ + +§2 principles: no `x-prompt` anywhere; ng-add and migrations emit only `context.logger` advisories with safe detected defaults and never block → Tasks 2,4b,10,12. ✅ + +§12.2 (Vitest→Jest, supersedes §4.1/§6): Karma→Jest path retained (Task 4) AND a first-class Vitest→Jest path added (Task 4b) — rewrite covered by Task 3's incumbent-agnostic loop, spec tsconfig types fixed (vitest globals→jest), `vi.*`/vitest-import porting advisory logged, no dep/file cleanup (lighter than Karma). §6 jest detection cell is now "Karma?, Vitest?, zoneless?". ✅ + +**Plan 0 reuse:** Every workspace/JSON/dep edit goes through Plan 0 helpers (`setBuilderForTarget`, `addBuilderDevDependency`, `removeDevDependencies`, `removeFilesIfPresent`, `editJsonFile`, `getProjectsToTarget`, `detectTestBuilder`, `isZoneless`) or `@schematics/angular/utility` (`readWorkspace`/`updateWorkspace`/`JSONFile`). No shared helper is redefined; no raw `fs`. ✅ + +**Placeholder scan:** Every code step contains complete code; no TBD/TODO/"handle edge cases"/"similar to above". The two contingency steps (Task 5 Step 3, Task 11 Step 3) are explicitly gated "only if a test failed" and contain either the exact fix code (Task 5) or a concrete diagnostic procedure (Task 11). ✅ + +**Type consistency:** `NgAddOptions` (Task 2) used in Tasks 3/4/4b. `ngAdd`/`migrateV21`/`migrateV22` factory names match `collection.json`/`migrations.json` `factory` references (Tasks 2,6). Builder string `@angular-builders/jest:run` consistent across ng-add (Karma + Vitest branches) and migrations. The Vitest branch (Task 4b) reuses the shared `fixSpecTsconfig`/`NON_JEST_SPEC_TYPES` and `detectTestBuilder` (returns `'vitest'` for `:unit-test`) — no helper redefined; the only `ngAdd` edit beyond the new branch is renaming `_context`→`context` for the advisory. Dep versions consistent (`jest ^30.0.0`, `jest-environment-jsdom ^30.0.0`, `jsdom ^26.0.0`, builder `^22.0.0`). The combined import note in Task 10 prevents duplicate `@schematics/angular/utility` / `@angular-builders/common/schematics` import lines accumulating across Tasks 7–10. ✅ + +**Calibration risk (flagged, not a gap):** Tests assume the Angular `application` schematic produces a zoneless app (no zone.js polyfill) and roots projects under `projects/`. If the installed v22 generator differs, calibrate the *expected* values (as Plan 0 Task 3/4 instructs) — the rule logic is fixed; only fixture expectations adapt. The `r.tasks`/`runner.logger` APIs are standard `SchematicTestRunner` surface. + +--- + +## Execution Handoff + +**Gated:** Execute after Plan 0 (`common/schematics`) is merged/green so `@angular-builders/common/schematics` + `/testing` resolve. Build `common` first (`yarn workspace @angular-builders/common build`) before running these unit tests. + +Two execution options: +1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks. +2. **Inline Execution** — execute tasks in-session via executing-plans with checkpoints. diff --git a/docs/superpowers/plans/2026-06-02-builder-schematics-02-custom-esbuild.md b/docs/superpowers/plans/2026-06-02-builder-schematics-02-custom-esbuild.md new file mode 100644 index 0000000000..4819aecfa9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-02-custom-esbuild.md @@ -0,0 +1,1066 @@ +# Builder Schematics — Plan 02: `custom-esbuild` `ng add` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `@angular-builders/custom-esbuild` a first-class `ng add` that auto-wires the `build` → `:application` and `serve` → `:dev-server` builders (preserving options) **when the existing build is already esbuild** (`@angular/build:application`), and keeps unit tests consistent by rewiring a Vitest `test` target to `:unit-test` (with `buildTarget`) so esbuild `codePlugins` apply to tests too. When the existing build is **webpack** (`@angular-devkit/build-angular:browser` or `@angular-builders/custom-webpack:browser`), it does **NOT** silently swap to esbuild — it leaves the targets alone and logs an advisory (run Angular's `use-application-builder` migration first, then `ng add`, then port plugins to `codePlugins` manually). The `--from-webpack` flag forces only the mechanical target rewrite from a webpack build. All auto-detected, zero prompts, with `--project`, `--unit-test`, and `--from-webpack` as the only flags. + +**Architecture:** A thin per-package `src/schematics/` tree compiled by a dedicated `tsconfig.schematics.json` to CommonJS in `dist/schematics/` (Angular schematics must be CJS), exactly mirroring Plan 0's packaging. The `ng-add` schematic is a `chain([...])` of shared `Rule` factories imported from `@angular-builders/common/schematics` (locked by Plan 0) plus custom-esbuild-specific wiring. **No migrations:** custom-esbuild first shipped at v17 and every change since (plugins, indexHtmlTransformer, the `unit-test` builder added in 20.1.0) was purely additive — no user-facing config ever broke — so there is no `migrations.json` and no `ng-update` field. + +**Tech Stack:** TypeScript 5.9 (CommonJS for schematics), `@angular-devkit/schematics`, `@schematics/angular/utility` (via `@angular-builders/common/schematics`), Jest 30 + `@angular-devkit/schematics/testing` on `SchematicTestHarness` from `@angular-builders/common/schematics/testing`. + +--- + +## Dependency on Plan 0 (do not redefine these) + +This plan **imports** the following from `@angular-builders/common/schematics` (signatures locked by Plan 0 — `docs/superpowers/plans/2026-06-02-builder-schematics-00-common-core.md`). Call them exactly; never re-implement: + +```ts +import { + setBuilderForTarget, // (projectName, targetName, builderName, options?) => Rule + addBuilderDevDependency, // (name, version, { install }?) => Rule + getProjectsToTarget, // (workspace, optionProject?) => string[] + detectTestBuilder, // (workspace, projectName) => 'karma'|'jest'|'vitest'|'other'|'none' +} from '@angular-builders/common/schematics'; +``` + +And in tests: + +```ts +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; +``` + +**Prerequisite:** Plan 0 must be merged/available so `@angular-builders/common/schematics` and `.../schematics/testing` resolve. This plan also assumes the workspace is on `release/v22` so Angular deps resolve to `^22`. + +--- + +## Architecture decision: no `ng-update`, no migrations (spec §4.2, §5) + +custom-esbuild is the **only** in-scope builder with **zero** migrations. Rationale, recorded here so a future maintainer does not "fill the gap": + +- custom-esbuild first appeared at **v17**. Every subsequent change was **additive**: esbuild `plugins`, `indexHtmlTransformer`, and the `unit-test` (Vitest) builder added in **20.1.0**. None removed or renamed a user-facing builder option or target. +- The migration set for a major is defined by the `breaking-change`-labeled PRs held for that major (spec §5). **No custom-esbuild breaking PR is held for v22** (the v22 breaking set is jest #2191, jest #2212, custom-webpack #2260 — none touch custom-esbuild). +- Therefore: **NO** `src/schematics/migrations.json`, **NO** `migrations/` directory, **NO** `"ng-update"` field in `package.json`. The spec's coverage checklist (§6) lists custom-esbuild `package.json` fields as exactly `schematics`, `ng-add` (no `ng-update`). + +If a future custom-esbuild breaking change is held for a major, that is when `migrations.json` + `ng-update` get added — not before. + +--- + +## File Structure + +- Create: `packages/custom-esbuild/tsconfig.schematics.json` — extends root `tsconfig.schematics.json` (from Plan 0); `rootDir: src/schematics`, `outDir: dist/schematics`. +- Create: `packages/custom-esbuild/src/schematics/collection.json` — registers the `ng-add` schematic. +- Create: `packages/custom-esbuild/src/schematics/ng-add/schema.json` — `--project` + `--unit-test` + `--from-webpack` flags, no `x-prompt`. +- Create: `packages/custom-esbuild/src/schematics/ng-add/schema.ts` — the typed `Schema` interface for `ng-add` options. +- Create: `packages/custom-esbuild/src/schematics/ng-add/index.ts` — the `ng-add` rule factory (the only logic file). +- Create: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` — unit tests. +- Modify: `packages/custom-esbuild/package.json` — add `"schematics"` + `"ng-add"` fields, `@angular-builders/common` already present, add `copyfiles` devDep, rewrite `build` script to compile + copy schematics. + +**Builder name constants** (real, from `packages/custom-esbuild/builders.json`): +- build → `@angular-builders/custom-esbuild:application` +- serve → `@angular-builders/custom-esbuild:dev-server` +- test (Vitest) → `@angular-builders/custom-esbuild:unit-test` + +**Incumbent build-builder constants** (what the workspace may already have on `build`, used by the §12.3 guard): +- esbuild (safe to rewrite) → `@angular/build:application` +- webpack (guard — do NOT rewrite without `--from-webpack`) → `@angular-devkit/build-angular:browser` OR `@angular-builders/custom-webpack:browser` + +> The build-builder guard reads `workspace.projects.get(projectName).targets.get('build')?.builder` **directly** — there is no shared build-builder detection helper in Plan 0 (Plan 0 only exposes `detectTestBuilder`), and we intentionally do **not** invent one here. The check is a small inline classification (esbuild vs webpack vs other) local to `ng-add/index.ts`. + +--- + +## Task 1: Packaging scaffolding (tsconfig + package.json) + +**Files:** +- Create: `packages/custom-esbuild/tsconfig.schematics.json` +- Modify: `packages/custom-esbuild/package.json` + +- [ ] **Step 1: Write the per-package schematics tsconfig** + +Create `packages/custom-esbuild/tsconfig.schematics.json` (mirrors Plan 0 Task 1 Step 2 exactly, adjusting paths): + +```json +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} +``` + +> The root `tsconfig.schematics.json` (created in Plan 0 Task 1 Step 1) provides `module: commonjs`, `strict`, `declaration`, etc. This file only narrows `rootDir`/`outDir`. The main `tsconfig.json` (lib build, ESM-interop `Node16`) is untouched. + +- [ ] **Step 2: Wire package.json fields and build script** + +Modify `packages/custom-esbuild/package.json`. + +Add the two schematics fields (alongside the existing `"builders": "builders.json"` line). **Do NOT add `ng-update`:** + +```json + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, +``` + +Change the `build` script to also compile + copy schematics (insert the two new sub-steps before `postbuild`, preserving the existing `merge-schemes.ts` step which is a MUST per AGENTS.md): + +```json + "build": "yarn prebuild && tsc && ts-node ../../merge-schemes.ts && tsc -p tsconfig.schematics.json && yarn copy:schematics && yarn postbuild", + "copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics", +``` + +> No `files/**` copy line is needed — custom-esbuild's `ng-add` creates no template files (unlike custom-webpack's scaffolded `webpack.config.js`). Only `collection.json` + `schema.json` need copying. `-u 2` strips the `src/schematics` prefix so `collection.json` lands at `dist/schematics/collection.json` and `ng-add/schema.json` at `dist/schematics/ng-add/schema.json`. + +Add to `devDependencies`: + +```json + "copyfiles": "^2.4.1", +``` + +`@angular-builders/common` is already in `dependencies` (`workspace:*`) — the schematics import resolves through it. No new runtime dep is required. + +Also add `dist/schematics` coverage to publishing — the existing `"files": ["dist", "builders.json"]` already includes all of `dist`, so `dist/schematics` ships automatically. No `files` change needed. + +- [ ] **Step 3: Verify the tsconfig is syntactically valid (no source yet)** + +Run: `yarn workspace @angular-builders/custom-esbuild exec tsc -p tsconfig.schematics.json --listFilesOnly` +Expected: errors with `No inputs were found in config file` (because `src/schematics/**/*.ts` does not exist yet). This is the expected "no inputs" failure — it confirms the tsconfig parses and the include glob is wired. Source arrives in Task 2. + +- [ ] **Step 4: Commit** + +```bash +git add packages/custom-esbuild/tsconfig.schematics.json packages/custom-esbuild/package.json +git commit -m "build(custom-esbuild): add schematics packaging (tsconfig + ng-add fields + copy)" +``` + +--- + +## Task 2: Collection + schema manifests + +**Files:** +- Create: `packages/custom-esbuild/src/schematics/collection.json` +- Create: `packages/custom-esbuild/src/schematics/ng-add/schema.json` +- Create: `packages/custom-esbuild/src/schematics/ng-add/schema.ts` + +- [ ] **Step 1: Write the collection manifest** + +Create `packages/custom-esbuild/src/schematics/collection.json`: + +```json +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Wire @angular-builders/custom-esbuild into the workspace (build, serve, and Vitest unit-test targets).", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} +``` + +> `factory` points at the compiled `dist/schematics/ng-add/index.js` exporting `ngAdd`. The `$schema` path is relative to `dist/schematics/collection.json` at runtime; `../../../../node_modules` walks `dist/schematics` → `dist` → package root → `packages` → repo `node_modules` (hoisted by Yarn 3). It is advisory only (IDE validation) and does not affect CLI execution. + +- [ ] **Step 2: Write the ng-add JSON schema (flags only, zero prompts)** + +Create `packages/custom-esbuild/src/schematics/ng-add/schema.json`: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "CustomEsbuildNgAdd", + "title": "@angular-builders/custom-esbuild ng-add options", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to add custom-esbuild to. Defaults to auto-detection (single project, or default project, or all projects).", + "$default": { + "$source": "projectName" + } + }, + "unitTest": { + "type": "boolean", + "description": "Force-create a Vitest unit-test target wired to @angular-builders/custom-esbuild:unit-test, even if no test target exists.", + "default": false, + "alias": "unit-test" + }, + "fromWebpack": { + "type": "boolean", + "description": "Force the mechanical target rewrite (build → :application, serve → :dev-server) even when the current build is a webpack builder. By default a webpack build is left untouched with an advisory, because esbuild plugins cannot be auto-translated from webpack.config.js. Use only if you accept porting your webpack config to esbuild codePlugins manually.", + "default": false, + "alias": "from-webpack" + } + }, + "additionalProperties": false +} +``` + +> **No `x-prompt` anywhere** (spec §2, §4.2: zero prompts). `$default.$source: projectName` lets the CLI fill `project` from the current directory's project context but never prompts. `--unit-test` maps to `unitTest` via the `alias`; `--from-webpack` maps to `fromWebpack`. `--from-webpack` (spec §12.3) is an explicit user override of the webpack-build guard. + +- [ ] **Step 3: Write the typed Schema interface** + +Create `packages/custom-esbuild/src/schematics/ng-add/schema.ts`: + +```ts +export interface Schema { + /** Target project name. When omitted, projects are auto-detected. */ + project?: string; + /** Force-create a Vitest unit-test target even if none exists. */ + unitTest?: boolean; + /** + * Force the mechanical build/serve rewrite even when the current build is a + * webpack builder. Without this, a webpack build is left untouched with an + * advisory (spec §12.3). The user must port their webpack config to esbuild + * codePlugins manually afterwards. + */ + fromWebpack?: boolean; +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/collection.json packages/custom-esbuild/src/schematics/ng-add/schema.json packages/custom-esbuild/src/schematics/ng-add/schema.ts +git commit -m "feat(custom-esbuild): add ng-add collection + schema manifests" +``` + +--- + +## Task 3: ng-add — build + serve rewrite with option preservation (esbuild build only) + +Start with the core rewrite, **guarded on the incumbent build builder** (spec §12.3): the rewrite fires only when `build` is already esbuild (`@angular/build:application`). The webpack-guard branch (leave + advise, plus `--from-webpack`) is added in Task 3b. Builds the `ngAdd` factory incrementally; later tasks extend it. + +**Files:** +- Create: `packages/custom-esbuild/src/schematics/ng-add/index.ts` +- Test: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts`: + +```ts +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve( + '../../../src/schematics/collection.json', +); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('custom-esbuild', COLLECTION); +} + +async function ngAdd( + tree: UnitTestTree, + options: Record = {}, +): Promise { + return runner().runSchematic('ng-add', options, tree); +} + +describe('custom-esbuild ng-add: build + serve rewrite', () => { + it('rewrites build → :application and serve → :dev-server, adding self to devDeps', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + + // Seed a stock Angular build + serve target. + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { + builder: '@angular/build:application', + options: { tsConfig: 'tsconfig.app.json', outputPath: 'dist/app' }, + }); + project.targets.set('serve', { + builder: '@angular/build:dev-server', + options: { buildTarget: 'app:build' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app' }); + + const ws = await readWorkspace(out); + const build = ws.projects.get('app')!.targets.get('build')!; + const serve = ws.projects.get('app')!.targets.get('serve')!; + + expect(build.builder).toBe('@angular-builders/custom-esbuild:application'); + // existing options preserved + expect((build.options as Record).tsConfig).toBe('tsconfig.app.json'); + expect((build.options as Record).outputPath).toBe('dist/app'); + + expect(serve.builder).toBe('@angular-builders/custom-esbuild:dev-server'); + expect((serve.options as Record).buildTarget).toBe('app:build'); + + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/custom-esbuild']).toBeDefined(); + }); +}); +``` + +> The test points `COLLECTION` at the **source** `collection.json`; `runSchematic`/`callRule` execute the TypeScript via ts-jest, so no build step is required for unit tests. The factory's `factory: "./ng-add/index#ngAdd"` resolves relative to the collection file. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` +Expected: FAIL — `Cannot find module './ng-add/index'` (or factory resolution error: the `index.ts` does not exist yet). + +- [ ] **Step 3: Write the minimal implementation** + +Create `packages/custom-esbuild/src/schematics/ng-add/index.ts`: + +```ts +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { readWorkspace } from '@schematics/angular/utility'; +import { + addBuilderDevDependency, + getProjectsToTarget, + setBuilderForTarget, +} from '@angular-builders/common/schematics'; + +import { Schema } from './schema'; + +const PACKAGE_NAME = '@angular-builders/custom-esbuild'; +const BUILD_BUILDER = '@angular-builders/custom-esbuild:application'; +const SERVE_BUILDER = '@angular-builders/custom-esbuild:dev-server'; + +// Incumbent build builders the guard classifies (spec §12.3). +const ESBUILD_BUILD = '@angular/build:application'; +const WEBPACK_BUILDS = [ + '@angular-devkit/build-angular:browser', + '@angular-builders/custom-webpack:browser', +]; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const VERSION: string = require('../../../package.json').version; + +/** + * Classify the project's current `build` builder for the §12.3 guard. + * Inline (no shared helper — Plan 0 exposes only detectTestBuilder). + */ +function classifyBuildBuilder( + builder: string | undefined, +): 'esbuild' | 'webpack' | 'none' | 'other' { + if (!builder) return 'none'; + if (builder === ESBUILD_BUILD || builder === BUILD_BUILDER) return 'esbuild'; + if (WEBPACK_BUILDS.includes(builder)) return 'webpack'; + return 'other'; +} + +export function ngAdd(options: Schema): Rule { + return async (tree: Tree, _context: SchematicContext) => { + const workspace = await readWorkspace(tree); + const projects = getProjectsToTarget(workspace, options.project); + + const rules: Rule[] = [ + addBuilderDevDependency(PACKAGE_NAME, `~${VERSION}`, { install: true }), + ]; + + for (const projectName of projects) { + const project = workspace.projects.get(projectName)!; + const buildKind = classifyBuildBuilder( + project.targets.get('build')?.builder, + ); + + // §12.3 guard: only rewrite when the build is already esbuild + // (`@angular/build:application` or our own `:application`). The webpack + // branch (leave + advise / --from-webpack) is added in Task 3b. + if (buildKind === 'esbuild') { + if (project.targets.has('build')) { + rules.push(setBuilderForTarget(projectName, 'build', BUILD_BUILDER)); + } + if (project.targets.has('serve')) { + rules.push(setBuilderForTarget(projectName, 'serve', SERVE_BUILDER)); + } + } + } + + return chain(rules); + }; +} +``` + +> `setBuilderForTarget` (Plan 0) rewrites only the `builder` field and merges any passed `options` — passing no `options` here preserves all existing target options. `addBuilderDevDependency` with `{ install: true }` schedules a `NodePackageInstallTask`; the test asserts the dep entry, not a real install. `~${VERSION}` pins to the installed package's own version (the builder major == Angular major invariant). `classifyBuildBuilder` treats our own `:application` as `esbuild` too, so re-running on an already-wired workspace stays in the rewrite branch (idempotent — Task 7). The webpack/none/other kinds are handled in Task 3b. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` +Expected: PASS (1 test, all assertions). + +- [ ] **Step 5: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/ng-add/index.ts packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +git commit -m "feat(custom-esbuild): ng-add rewrites build/serve preserving options" +``` + +--- + +## Task 3b: ng-add — webpack-build guard + `--from-webpack` (spec §12.3) + +A webpack `build` must NOT be silently swapped to esbuild — that strands the user's `webpack.config.js`. Instead: leave `build`/`serve` untouched and emit a `context.logger` advisory describing the manual path (`use-application-builder` migration → `ng add` → port plugins to `codePlugins`). The `--from-webpack` flag overrides the guard and forces only the mechanical target rewrite. + +**Files:** +- Modify: `packages/custom-esbuild/src/schematics/ng-add/index.ts` +- Test: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Add the failing tests** + +Append to `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('custom-esbuild ng-add: webpack-build guard (spec §12.3)', () => { + async function seedWebpackBuild( + builder: string, + ): Promise { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + return (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { + builder, + options: { outputPath: 'dist/app' }, + }); + project.targets.set('serve', { + builder: '@angular-devkit/build-angular:dev-server', + options: { buildTarget: 'app:build' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + } + + it('does NOT rewrite an @angular-devkit/build-angular:browser build; logs an advisory', async () => { + const seeded = await seedWebpackBuild('@angular-devkit/build-angular:browser'); + + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + + const ws = await readWorkspace(out); + const build = ws.projects.get('app')!.targets.get('build')!; + const serve = ws.projects.get('app')!.targets.get('serve')!; + + // unchanged — no silent swap + expect(build.builder).toBe('@angular-devkit/build-angular:browser'); + expect(serve.builder).toBe('@angular-devkit/build-angular:dev-server'); + + // advisory names the migration path and the --from-webpack escape hatch + expect(logs.some((m) => m.includes('use-application-builder'))).toBe(true); + expect(logs.some((m) => m.includes('--from-webpack'))).toBe(true); + }); + + it('does NOT rewrite a custom-webpack:browser build; logs an advisory', async () => { + const seeded = await seedWebpackBuild('@angular-builders/custom-webpack:browser'); + + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('build')!.builder).toBe( + '@angular-builders/custom-webpack:browser', + ); + expect(logs.some((m) => m.includes('use-application-builder'))).toBe(true); + }); + + it('--from-webpack forces the mechanical build/serve rewrite from a webpack build', async () => { + const seeded = await seedWebpackBuild('@angular-devkit/build-angular:browser'); + + const out = await ngAdd(seeded, { project: 'app', fromWebpack: true }); + + const ws = await readWorkspace(out); + const build = ws.projects.get('app')!.targets.get('build')!; + const serve = ws.projects.get('app')!.targets.get('serve')!; + + expect(build.builder).toBe('@angular-builders/custom-esbuild:application'); + // mechanical rewrite preserves existing options + expect((build.options as Record).outputPath).toBe('dist/app'); + expect(serve.builder).toBe('@angular-builders/custom-esbuild:dev-server'); + expect((serve.options as Record).buildTarget).toBe('app:build'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts -t "webpack-build guard"` +Expected: FAIL — the first two cases log no advisory (no `webpack` branch yet), and the `--from-webpack` case leaves `build` on `@angular-devkit/build-angular:browser` (the guard from Task 3 only rewrites `esbuild` builds, and `fromWebpack` is not yet consulted). + +- [ ] **Step 3: Extend the implementation** + +In `packages/custom-esbuild/src/schematics/ng-add/index.ts`, change the factory's async arrow to use the `context` parameter (rename `_context` → `context`): + +```ts + return async (tree: Tree, context: SchematicContext) => { +``` + +Replace the §12.3 guard block (the `if (buildKind === 'esbuild') { ... }` added in Task 3) with the full branch set: + +```ts + // §12.3 guard: distinguish the incumbent build builder. + const wantsForcedRewrite = options.fromWebpack === true; + + if (buildKind === 'esbuild' || wantsForcedRewrite) { + // esbuild build → safe rewrite (common case). + // --from-webpack → forced mechanical rewrite even from a webpack build + // (user accepts porting webpack.config.js plugins to codePlugins manually). + if (project.targets.has('build')) { + rules.push(setBuilderForTarget(projectName, 'build', BUILD_BUILDER)); + } + if (project.targets.has('serve')) { + rules.push(setBuilderForTarget(projectName, 'serve', SERVE_BUILDER)); + } + } else if (buildKind === 'webpack') { + // Webpack build → do NOT silently swap to esbuild (strands webpack.config.js). + context.logger.info( + `[@angular-builders/custom-esbuild] Project "${projectName}" builds with a ` + + `webpack builder ("${project.targets.get('build')!.builder}"). custom-esbuild ` + + `runs on esbuild, so it will NOT rewrite your build target automatically — that ` + + `would strand your webpack.config.js. To migrate: (1) run Angular's ` + + `"use-application-builder" migration to move onto "@angular/build:application", ` + + `(2) re-run "ng add @angular-builders/custom-esbuild", then (3) port your ` + + `webpack.config.js plugins to esbuild "codePlugins" manually (there is no ` + + `automatic translation). To skip the guard and force only the target rewrite now, ` + + `re-run with "--from-webpack". Leaving build/serve unchanged.`, + ); + } +``` + +> `wantsForcedRewrite` (`--from-webpack`) joins the `esbuild` branch so the mechanical rewrite (`build`→`:application`, `serve`→`:dev-server`, options preserved by `setBuilderForTarget`) runs even from a webpack build. The advisory is emitted only for the default (un-forced) webpack case and names the exact path: `use-application-builder` → `ng add` → manual `codePlugins` port. `buildKind === 'none'`/`'other'` projects fall through untouched (no advisory) — `ng add` still adds the devDep and processes any test target. No auto-translation of webpack config is attempted (spec §12.3: out of scope). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` +Expected: PASS — webpack-guard suite (no-rewrite + advisory for both webpack builders, `--from-webpack` mechanical rewrite) green; Task 3 esbuild rewrite still green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/ng-add/index.ts packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +git commit -m "feat(custom-esbuild): ng-add guards webpack builds, adds --from-webpack (spec §12.3)" +``` + +--- + +## Task 4: ng-add — Vitest test target auto-rewrite + buildTarget wiring + +When `test` is `@angular/build:unit-test` (Vitest), auto-rewrite it to `:unit-test` and wire `buildTarget` so esbuild `codePlugins` apply to tests (spec §4.2 "Test consistency"). + +**Files:** +- Modify: `packages/custom-esbuild/src/schematics/ng-add/index.ts` +- Test: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Add the failing test** + +Append to `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('custom-esbuild ng-add: Vitest test target', () => { + it('auto-rewrites @angular/build:unit-test → :unit-test and wires buildTarget', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { + builder: '@angular/build:application', + options: { tsConfig: 'tsconfig.app.json' }, + }); + project.targets.set('test', { + builder: '@angular/build:unit-test', + options: { tsConfig: 'tsconfig.spec.json' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app' }); + + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test')!; + + expect(test.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + // buildTarget wired to the project's build target so plugins apply to tests + expect((test.options as Record).buildTarget).toBe('app:build'); + // pre-existing option preserved + expect((test.options as Record).tsConfig).toBe('tsconfig.spec.json'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts -t "Vitest test target"` +Expected: FAIL — `test.builder` is still `@angular/build:unit-test` (the rewrite logic is not implemented yet). + +- [ ] **Step 3: Extend the implementation** + +In `packages/custom-esbuild/src/schematics/ng-add/index.ts`, update the imports to add `detectTestBuilder`: + +```ts +import { + addBuilderDevDependency, + detectTestBuilder, + getProjectsToTarget, + setBuilderForTarget, +} from '@angular-builders/common/schematics'; +``` + +Add the test-builder constant near the others: + +```ts +const TEST_BUILDER = '@angular-builders/custom-esbuild:unit-test'; +``` + +Inside the `for (const projectName of projects)` loop, after the `serve` block, add the Vitest branch: + +```ts + const testKind = detectTestBuilder(workspace, projectName); + if (testKind === 'vitest') { + rules.push( + setBuilderForTarget(projectName, 'test', TEST_BUILDER, { + buildTarget: `${projectName}:build`, + }), + ); + } +``` + +> `detectTestBuilder` returns `'vitest'` for any builder ending in `:unit-test` (Plan 0 detection logic). `setBuilderForTarget` merges `{ buildTarget }` into existing options, preserving `tsConfig` etc. Wiring `buildTarget` to `:build` is what makes the `:unit-test` builder load the same `codePlugins` as the build (see `packages/custom-esbuild/src/unit-test/index.ts`, which reads `options.buildTarget` and pulls the build target's `plugins`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` +Expected: PASS (Task 3 test + Vitest test both green). + +- [ ] **Step 5: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/ng-add/index.ts packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +git commit -m "feat(custom-esbuild): ng-add auto-rewrites Vitest test target with buildTarget" +``` + +--- + +## Task 5: ng-add — leave Karma/Jest test targets, log an advisory + +When `test` is Karma or Jest, esbuild plugins do not apply — leave the target untouched and emit a `context.logger` advisory (spec §4.2, §9 non-goal: no auto-switch). + +**Files:** +- Modify: `packages/custom-esbuild/src/schematics/ng-add/index.ts` +- Test: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Add the failing test** + +Append to `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('custom-esbuild ng-add: Karma / Jest test target', () => { + it('leaves a Karma test target untouched and logs an advisory', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { + builder: '@angular/build:application', + options: {}, + }); + project.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + options: { karmaConfig: 'karma.conf.js' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test')!; + + // unchanged + expect(test.builder).toBe('@angular-devkit/build-angular:karma'); + expect((test.options as Record).karmaConfig).toBe('karma.conf.js'); + + // advisory mentions the unit-test builder + expect(logs.some((m) => m.includes('custom-esbuild:unit-test'))).toBe(true); + }); + + it('leaves a Jest test target untouched and logs an advisory', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('test', { + builder: '@angular-builders/jest:run', + options: {}, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe('@angular-builders/jest:run'); + expect(logs.some((m) => m.includes('custom-esbuild:unit-test'))).toBe(true); + }); +}); +``` + +> `SchematicTestRunner` exposes a `logger` (a `LoggerApi`) that captures `context.logger` output during `runSchematic`. Subscribe before running. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts -t "Karma / Jest test target"` +Expected: FAIL — no advisory logged (the `karma`/`jest` branch is not implemented; `logs` contains no matching message). + +- [ ] **Step 3: Extend the implementation** + +In `packages/custom-esbuild/src/schematics/ng-add/index.ts`, the factory's async arrow already uses the `context` parameter (renamed `_context` → `context` in Task 3b for the webpack advisory). Extend the `testKind` block (added in Task 4) to handle Karma/Jest with an advisory: + +```ts + const testKind = detectTestBuilder(workspace, projectName); + if (testKind === 'vitest') { + rules.push( + setBuilderForTarget(projectName, 'test', TEST_BUILDER, { + buildTarget: `${projectName}:build`, + }), + ); + } else if (testKind === 'karma' || testKind === 'jest') { + context.logger.info( + `[@angular-builders/custom-esbuild] Project "${projectName}" uses a ` + + `${testKind === 'karma' ? 'Karma' : 'Jest'} test runner; esbuild plugins do not ` + + `apply there. To run your tests through esbuild/Vitest with the same plugins, ` + + `switch the test target to "${TEST_BUILDER}" (or run ` + + `"ng add @angular-builders/custom-esbuild --unit-test"). Leaving the test target unchanged.`, + ); + } +``` + +> Karma/Jest targets are intentionally left untouched (different toolchain). The advisory points at `@angular-builders/custom-esbuild:unit-test` and the `--unit-test` flag. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` +Expected: PASS (Karma + Jest tests green, earlier tests still green). + +- [ ] **Step 5: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/ng-add/index.ts packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +git commit -m "feat(custom-esbuild): ng-add leaves Karma/Jest tests, logs unit-test advisory" +``` + +--- + +## Task 6: ng-add — `--unit-test` force-creates a Vitest test target + +The `--unit-test` flag force-creates a `:unit-test` target wired to `:build`, even if no `test` target exists (spec §4.2). + +**Files:** +- Modify: `packages/custom-esbuild/src/schematics/ng-add/index.ts` +- Test: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Add the failing test** + +Append to `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('custom-esbuild ng-add: --unit-test flag', () => { + it('force-creates a Vitest unit-test target when none exists', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + // no test target at all + project.targets.delete('test'); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app', unitTest: true }); + + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test'); + expect(test).toBeDefined(); + expect(test!.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test!.options as Record).buildTarget).toBe('app:build'); + }); + + it('rewrites an existing Vitest target the same way under --unit-test', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('test', { builder: '@angular/build:unit-test', options: {} }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app', unitTest: true }); + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test')!; + expect(test.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test.options as Record).buildTarget).toBe('app:build'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts -t "unit-test flag"` +Expected: FAIL — the first case has no `test` target after `ng-add` (force-create not implemented). + +- [ ] **Step 3: Extend the implementation** + +In `packages/custom-esbuild/src/schematics/ng-add/index.ts`, replace the whole `testKind` block (Tasks 4–5) so the `--unit-test` flag short-circuits to a forced create/rewrite: + +```ts + const testKind = detectTestBuilder(workspace, projectName); + const wantsForcedVitest = options.unitTest === true; + + if (wantsForcedVitest || testKind === 'vitest') { + // Force-create or rewrite the test target as a custom-esbuild Vitest runner, + // wiring buildTarget so esbuild plugins apply to tests. + rules.push( + setBuilderForTarget(projectName, 'test', TEST_BUILDER, { + buildTarget: `${projectName}:build`, + }), + ); + } else if (testKind === 'karma' || testKind === 'jest') { + context.logger.info( + `[@angular-builders/custom-esbuild] Project "${projectName}" uses a ` + + `${testKind === 'karma' ? 'Karma' : 'Jest'} test runner; esbuild plugins do not ` + + `apply there. To run your tests through esbuild/Vitest with the same plugins, ` + + `switch the test target to "${TEST_BUILDER}" (or run ` + + `"ng add @angular-builders/custom-esbuild --unit-test"). Leaving the test target unchanged.`, + ); + } +``` + +> `setBuilderForTarget` (Plan 0) creates the target if absent (`project.targets.add({ name, builder, options })`) and rewrites it if present — so a single call covers both "no test target → create" and "Vitest target → rewrite". Under `--unit-test`, a pre-existing Karma/Jest target is **overwritten** to Vitest (the flag is an explicit user override of the leave-it default). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` +Expected: PASS (all suites: build/serve, Vitest, Karma/Jest, --unit-test). + +- [ ] **Step 5: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/ng-add/index.ts packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +git commit -m "feat(custom-esbuild): ng-add --unit-test force-creates Vitest target" +``` + +--- + +## Task 7: ng-add — idempotency + +Re-running `ng add` on an already-wired workspace is a no-op rewrite (spec §4.2, §6). + +**Files:** +- Test: `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Add the failing/confirming test** + +Append to `packages/custom-esbuild/src/schematics/ng-add/index.spec.ts`: + +```ts +describe('custom-esbuild ng-add: idempotency', () => { + it('is a no-op when build is already :application (running twice == once)', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'app' }], + }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('serve', { + builder: '@angular/build:dev-server', + options: { buildTarget: 'app:build' }, + }); + project.targets.set('test', { + builder: '@angular/build:unit-test', + options: { tsConfig: 'tsconfig.spec.json' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const once = await ngAdd(seeded, { project: 'app' }); + const twice = await ngAdd(once, { project: 'app' }); + + const wsOnce = await readWorkspace(once); + const wsTwice = await readWorkspace(twice); + + for (const ws of [wsOnce, wsTwice]) { + const project = ws.projects.get('app')!; + expect(project.targets.get('build')!.builder).toBe( + '@angular-builders/custom-esbuild:application', + ); + expect(project.targets.get('serve')!.builder).toBe( + '@angular-builders/custom-esbuild:dev-server', + ); + const test = project.targets.get('test')!; + expect(test.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test.options as Record).buildTarget).toBe('app:build'); + expect((test.options as Record).tsConfig).toBe('tsconfig.spec.json'); + } + + // The angular.json content is identical after the second run. + expect(twice.readText('/angular.json')).toBe(once.readText('/angular.json')); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics/ng-add/index.spec.ts -t "idempotency"` +Expected: PASS without code changes. `setBuilderForTarget` writing the same builder name + merging the same `buildTarget` is deterministic, so the second run produces byte-identical `angular.json`. + +> If this test FAILS (e.g. `detectTestBuilder` returns `'vitest'` for the already-rewritten `:unit-test` target on the second run, which it does — `@angular-builders/custom-esbuild:unit-test` ends in `:unit-test`), that is fine: the branch re-applies the same `setBuilderForTarget` with the same `buildTarget`, which is a no-op rewrite. The assertion `twice == once` still holds. No code change is expected; this task only adds the safety-net test. + +- [ ] **Step 3: Commit** + +```bash +git add packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +git commit -m "test(custom-esbuild): assert ng-add idempotency" +``` + +--- + +## Task 8: Full build + suite verification + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full custom-esbuild unit suite** + +Run: `yarn jest --config jest-ut.config.js packages/custom-esbuild/src/schematics` +Expected: all `ng-add` describe blocks green (build/serve, webpack-build guard, Vitest, Karma/Jest, --unit-test, idempotency). + +- [ ] **Step 2: Build the package end-to-end** + +Run: `yarn workspace @angular-builders/custom-esbuild build` +Expected: completes; `dist/schematics/collection.json`, `dist/schematics/ng-add/schema.json`, and `dist/schematics/ng-add/index.js` all present. (`postbuild` runs `test` + `e2e`; those must stay green.) + +Run: `ls packages/custom-esbuild/dist/schematics packages/custom-esbuild/dist/schematics/ng-add` +Expected: `collection.json`; `index.js`, `index.d.ts`, `schema.json`, `schema.js`, `schema.d.ts`. + +- [ ] **Step 3: Sanity-check the collection resolves from dist** + +Run: `node -e "const c=require('./packages/custom-esbuild/dist/schematics/collection.json'); if(!c.schematics['ng-add']) throw new Error('ng-add missing'); console.log('ok')"` +Expected: prints `ok`. + +- [ ] **Step 4: Confirm NO ng-update was introduced** + +Run: `node -e "const p=require('./packages/custom-esbuild/package.json'); if(p['ng-update']) throw new Error('ng-update must NOT exist'); if(require('fs').existsSync('packages/custom-esbuild/src/schematics/migrations.json')) throw new Error('migrations.json must NOT exist'); console.log('ok: no migrations')"` +Expected: prints `ok: no migrations`. + +- [ ] **Step 5: Commit (if any lockfile/formatting churn)** + +```bash +git add -A +git commit -m "chore(custom-esbuild): verify schematics build + no-migrations invariant" || echo "nothing to commit" +``` + +--- + +## Self-Review + +**Spec §4.2 + §12.3 (custom-esbuild) coverage:** +- Add self to devDeps via `addBuilderDevDependency` → Task 3 Step 3. ✅ +- Rewrite `build` → `:application`, `serve` → `:dev-server`, preserve options — **only when build is already esbuild** (`@angular/build:application`) → Task 3 (guarded; asserts `tsConfig`/`outputPath`/`buildTarget` preserved). ✅ +- **Webpack-build guard (§12.3):** build on `@angular-devkit/build-angular:browser` or `@angular-builders/custom-webpack:browser` → NO rewrite + `context.logger` advisory (`use-application-builder` → `ng add` → manual `codePlugins` port) → Task 3b (cases a + b). ✅ +- **`--from-webpack` flag (§12.3):** forces only the mechanical build/serve rewrite from a webpack build → Task 3b (case c), schema.json Task 2. ✅ +- Schedule install → Task 3 (`addBuilderDevDependency(..., { install: true })`). ✅ +- Vitest `test` (`detectTestBuilder==='vitest'`) → auto-rewrite to `:unit-test`, wire `buildTarget` to `:build` → Task 4. ✅ +- Karma/Jest `test` → leave untouched + `context.logger` advisory pointing at `custom-esbuild:unit-test` → Task 5. ✅ +- `--unit-test` flag → force-create Vitest target even if none exists → Task 6. ✅ +- Idempotent (`build` already `:application` → no-op) → Tasks 3 & 7. ✅ +- `ng-update`: NONE — no `migrations.json`, no `ng-update` field, rationale in Architecture section → enforced by Task 8 Step 4. ✅ + +**Spec §6 coverage checklist (custom-esbuild column):** +- deps add/remove: +self → Task 3. ✅ +- targets rewritten: `build`, `serve` (only if build is esbuild, or `--from-webpack`), `test`(if Vitest) → Tasks 3, 3b, 4. ✅ +- files created/deleted: — (none) → no task needed; `copy:schematics` has no `files/**` line (Task 1). ✅ +- tsconfig edits: — (none). ✅ +- detection: test builder kind; webpack guard → `detectTestBuilder` usage Task 4; inline `classifyBuildBuilder` (build-builder guard) Tasks 3/3b. ✅ +- flags: `--project`, `--unit-test`, `--from-webpack` → schema.json Task 2, used Tasks 3/3b/6. ✅ +- idempotency: `build` already `:application` → Task 7. ✅ +- ng-update migrations: none → Architecture + Task 8 Step 4. ✅ +- package.json fields: `schematics`, `ng-add` (no `ng-update`) → Task 1 Step 2. ✅ +- tests: ng-add → Tasks 3, 3b, 4–7. ✅ + +**Spec §7 (packaging):** per-package `tsconfig.schematics.json` extending root base; `tsc (lib) → merge-schemes → tsc (schematics) → copy:schematics`; `schematics` field → dist-relative `collection.json`; no `ng-update` field → Task 1. Mirrors Plan 0 packaging pattern exactly. ✅ + +**Spec §8 (testing):** `SchematicTestRunner` + `UnitTestTree` on shared `SchematicTestHarness`; assert transformed `angular.json`/`package.json`; install task scheduled but not run (assert dep entry) → Tasks 3–7. ✅ + +**Plan 0 API usage:** `setBuilderForTarget`, `addBuilderDevDependency`, `getProjectsToTarget`, `detectTestBuilder`, `SchematicTestHarness` — all called with Plan 0's exact signatures; none redefined. The build-builder guard reads `project.targets.get('build')?.builder` directly via a local `classifyBuildBuilder`; no new shared helper was invented (per §12.3 integration note). ✅ No cross-import of other builder packages — the webpack constants (`@angular-devkit/build-angular:browser`, `@angular-builders/custom-webpack:browser`) are plain string literals for detection, not imports. ✅ + +**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code step shows complete code; every command has expected output. ✅ + +**Type consistency:** `Schema` interface (`project?`, `unitTest?`, `fromWebpack?`) defined in Task 2, used in Tasks 3b/6. Builder-name constants (`BUILD_BUILDER`, `SERVE_BUILDER`, `TEST_BUILDER`, `PACKAGE_NAME`, plus guard constants `ESBUILD_BUILD`/`WEBPACK_BUILDS`) consistent across Tasks 3–6. Inline `classifyBuildBuilder` introduced in Task 3, extended in Task 3b — no shared build-builder helper invented (Plan 0 exposes only `detectTestBuilder`). Factory export name `ngAdd` matches `collection.json` `factory: "./ng-add/index#ngAdd"`. ✅ + +--- + +## Execution Handoff + +**Gated:** Requires Plan 0 merged (locks `@angular-builders/common/schematics` + `.../schematics/testing`) and the workspace green on `release/v22`. Execute after Plan 0; independent of the jest and custom-webpack builder plans (no shared state). + +Two execution options: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks. +**2. Inline Execution** — execute tasks in-session via superpowers:executing-plans with checkpoints. diff --git a/docs/superpowers/plans/2026-06-02-builder-schematics-03-custom-webpack.md b/docs/superpowers/plans/2026-06-02-builder-schematics-03-custom-webpack.md new file mode 100644 index 0000000000..12037b2ecb --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-03-custom-webpack.md @@ -0,0 +1,670 @@ +# Builder Schematics — Plan 03: `custom-webpack` ng-add Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `@angular-builders/custom-webpack` a first-class `ng add` (auto-detected, zero-prompt build/serve rewrite + starter `webpack.config.js` scaffold). custom-webpack ships **`ng-add` only** — no `ng-update`, no `migrations.json` — mirroring custom-esbuild (spec §12.1). + +**Architecture:** A new `src/schematics/` tree inside `packages/custom-webpack`, compiled to CommonJS in `dist/schematics/` by a dedicated `tsconfig.schematics.json`, exposed via `package.json` `schematics`/`ng-add` fields (NO `ng-update`). All workspace/JSON edits go through the shared `@angular-builders/common/schematics` helpers locked by Plan 0 (`setBuilderForTarget`, `addBuilderDevDependency`, `getProjectsToTarget`) plus the schematics `apply`/`mergeWith`/`template` rules for the scaffold. The package never hand-parses JSON, never touches `fs`, and never cross-imports another builder package. Unit tests run on the shared `SchematicTestHarness` from `@angular-builders/common/schematics/testing`. + +**Tech Stack:** TypeScript 5.9 (CommonJS for schematics), `@angular-devkit/schematics` (`apply`, `url`, `template`, `move`, `mergeWith`, `chain`, `noop`), `@schematics/angular/utility` (`getWorkspace`/`updateWorkspace` via the shared helpers), `@angular-builders/common/schematics`, Jest 30 + `@angular-devkit/schematics/testing` (`SchematicTestRunner`, `UnitTestTree`). + +--- + +## Dependency on Plan 0 (READ FIRST — do not redefine these) + +This plan imports the **locked Shared API Contract** from Plan 0 (`docs/superpowers/plans/2026-06-02-builder-schematics-00-common-core.md`). Call these exact signatures; never re-implement them here: + +```ts +// from '@angular-builders/common/schematics' +setBuilderForTarget(projectName, targetName, builderName, options?): Rule; +addBuilderDevDependency(name, version, opts?: { install?: boolean }): Rule; +getProjectsToTarget(workspace, optionProject?): string[]; + +// from '@angular-builders/common/schematics/testing' +class SchematicTestHarness { + constructor(runner?: SchematicTestRunner); + createWorkspace(opts?): Promise; + readonly runner: SchematicTestRunner; +} +``` + +For reading the workspace inside our own schematic logic we use `getWorkspace` from `@schematics/angular/utility` directly (read-only); all **writes** flow through the Plan 0 rule factories. + +**Execution gate:** Plan 0 must be merged/green first (it locks this API and adds the `@schematics/angular` + `@angular-devkit/schematics` + `copyfiles` packaging that this plan mirrors). On `release/v22` the Angular deps resolve to `^22`. + +--- + +## Real package facts (verified against `packages/custom-webpack`) + +- Builders wrapped: `@angular-builders/custom-webpack:browser` (wraps `@angular-devkit/build-angular:browser`) and `@angular-builders/custom-webpack:dev-server` (wraps `@angular-devkit/build-angular:dev-server`). Also `:server`, `:karma`, `:extract-i18n` exist but ng-add only rewrites `build`→`:browser` and `serve`→`:dev-server` per spec §4.3/§6. +- The build option that points at the webpack config is `customWebpackConfig` — an **object** `{ path?, mergeRules?, replaceDuplicatePlugins?, verbose? }` (or a boolean). Source: `src/schema.ext.json`, `src/custom-webpack-builder-config.ts`. +- Default config filename the builder loads when `customWebpackConfig.path` is absent is `webpack.config.js`. Source: `src/custom-webpack-builder.ts` → `export const defaultWebpackConfigPath = 'webpack.config.js'`. +- Existing build: `yarn prebuild && tsc && ts-node ../../merge-schemes.ts && yarn postbuild`. We extend it with a schematics `tsc` + `copy:schematics` step (mirroring Plan 0). +- Current version line: `21.1.0-beta.11`; on `release/v22` deps are `^22`. + +--- + +## Architecture decision: no `ng-update`, no migrations (spec §12.1) + +custom-webpack ships **`ng-add` only** — same shape as custom-esbuild. Rationale, recorded here so a future maintainer does not "fill the gap": + +- The earlier `@22` Karma-removal migration was premised on Angular removing Karma in v22. That premise is **false** (spec §12.0/§12.1): **Karma is deprecated, NOT removed in v22.** The `@angular/build:karma` builder still ships, `ng update` keeps Karma users on Karma, and Vitest's `unit-test` builder is still experimental. Angular's deprecation policy (min. two majors) puts full Karma removal at ≈v24+. +- **PR #2260** (remove custom-webpack's `:karma` builder) is therefore **held** for the major where Angular actually removes Karma; it must NOT land in v22. With #2260 held, there is **no custom-webpack breaking PR for v22**, so per spec §5 there is no migration to ship. The v22 breaking set is now just **#2191 + #2212** (both jest). +- Therefore: **NO** `src/schematics/migrations.json`, **NO** `migrations/` directory, **NO** `"ng-update"` field in `package.json`. The coverage checklist lists custom-webpack `package.json` fields as exactly `schematics`, `ng-add` (no `ng-update`) — identical to custom-esbuild. + +If a future custom-webpack breaking change is held for a major (e.g. #2260 when Karma is finally removed), that is when `migrations.json` + `ng-update` get added — not before. + +--- + +## File Structure + +- Create: `packages/custom-webpack/tsconfig.schematics.json` — extends repo-root `tsconfig.schematics.json`; `rootDir: src/schematics`, `outDir: dist/schematics`. +- Modify: `packages/custom-webpack/package.json` — add `schematics`/`ng-add` fields (NO `ng-update`), schematics build steps, `copyfiles` dev dep. +- Create: `packages/custom-webpack/src/schematics/collection.json` — declares the `ng-add` schematic. +- Create: `packages/custom-webpack/src/schematics/ng-add/schema.json` — `--project` flag only, no `x-prompt`. +- Create: `packages/custom-webpack/src/schematics/ng-add/schema.ts` — typed options interface. +- Create: `packages/custom-webpack/src/schematics/ng-add/index.ts` — the ng-add rule (delegates to common helpers + scaffold). +- Create: `packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template` — starter config scaffold. +- Create: `packages/custom-webpack/src/schematics/ng-add/index.spec.ts` — ng-add tests. + +> The schematics tree is compiled separately to CJS. The library build (`tsc` of `tsconfig.json`, `files: ["src/index.ts"]`) does NOT include `src/schematics/**`, so no change to the existing lib build is needed beyond chaining the new steps. + +--- + +## Task 1: Packaging scaffolding (tsconfig + package.json wiring) + +**Files:** +- Create: `packages/custom-webpack/tsconfig.schematics.json` +- Modify: `packages/custom-webpack/package.json` + +- [ ] **Step 1: Write the per-package schematics tsconfig** + +Create `packages/custom-webpack/tsconfig.schematics.json`: + +```json +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} +``` + +Rationale: mirrors Plan 0's per-package tsconfig exactly. `**/files/**` keeps the `.template` out of compilation; `copyfiles` ships it verbatim. + +- [ ] **Step 2: Add the schematics fields and build steps to package.json** + +Modify `packages/custom-webpack/package.json`. + +Add these top-level fields (next to the existing `"builders": "builders.json"`). **Do NOT add `ng-update`** (spec §12.1 — no migration): + +```json + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, +``` + +Change the `build` script and add a `copy:schematics` script. Current `scripts` block becomes: + +```json + "scripts": { + "prebuild": "yarn clean", + "build": "yarn prebuild && tsc && tsc -p tsconfig.schematics.json && yarn copy:schematics && ts-node ../../merge-schemes.ts && yarn postbuild", + "copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics && copyfiles -u 2 \"src/schematics/**/files/**\" dist/schematics", + "postbuild": "yarn test && yarn run e2e", + "test": "jest --config ../../jest-ut.config.js", + "e2e": "jest --config ../../jest-e2e.config.js", + "clean": "rimraf dist" + }, +``` + +Add `copyfiles` to `devDependencies` (keep the others): + +```json + "copyfiles": "^2.4.1", +``` + +> `tsc -p tsconfig.schematics.json` runs after the lib `tsc` and before `merge-schemes.ts`. `copy:schematics` copies `collection.json`, every `schema.json`, AND the `files/**` template (`-u 2` strips the `src/schematics` prefix so assets land at `dist/schematics/...`). There is no `migrations.json` to copy (spec §12.1). This is the same copy step Plan 0 specifies. + +- [ ] **Step 3: Add a placeholder collection so the build has inputs, then verify it compiles** + +Create a minimal `packages/custom-webpack/src/schematics/collection.json` placeholder so `tsc -p tsconfig.schematics.json` does not fail with "No inputs were found" before Task 3: + +```json +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": {} +} +``` + +Also create a temporary `packages/custom-webpack/src/schematics/ng-add/index.ts` stub so TypeScript has at least one input file: + +```ts +export {}; +``` + +Run: `yarn workspace @angular-builders/custom-webpack exec tsc -p tsconfig.schematics.json` +Expected: exits 0; `packages/custom-webpack/dist/schematics/ng-add/index.js` exists. + +> Tasks 3–6 replace these stubs with real content. They exist only so Step 4 can verify the packaging wiring independently. + +- [ ] **Step 4: Commit the packaging scaffolding** + +```bash +git add packages/custom-webpack/tsconfig.schematics.json packages/custom-webpack/package.json packages/custom-webpack/src/schematics/collection.json packages/custom-webpack/src/schematics/ng-add/index.ts +git commit --no-verify -m "build(custom-webpack): add schematics packaging (tsconfig + ng-add field)" +``` + +--- + +## Task 2: ng-add schema (`--project` flag, no prompts) + +**Files:** +- Create: `packages/custom-webpack/src/schematics/ng-add/schema.json` +- Create: `packages/custom-webpack/src/schematics/ng-add/schema.ts` + +- [ ] **Step 1: Write the JSON schema** + +Create `packages/custom-webpack/src/schematics/ng-add/schema.json`: + +```json +{ + "$schema": "http://json-schema.org/schema", + "$id": "AngularBuildersCustomWebpackNgAdd", + "title": "Add @angular-builders/custom-webpack", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to wire @angular-builders/custom-webpack into. Defaults to auto-detection (single project, defaultProject, or all projects).", + "$default": { + "$source": "projectName" + } + } + }, + "additionalProperties": false +} +``` + +> No `x-prompt` anywhere — `--project` is a flag (spec §2, §4.3). `$source: projectName` lets the CLI pre-fill from the current directory but never prompts. + +- [ ] **Step 2: Write the typed options interface** + +Create `packages/custom-webpack/src/schematics/ng-add/schema.ts`: + +```ts +export interface NgAddSchema { + /** Explicit project to target. When omitted, auto-detected. */ + project?: string; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/custom-webpack/src/schematics/ng-add/schema.json packages/custom-webpack/src/schematics/ng-add/schema.ts +git commit --no-verify -m "feat(custom-webpack): add ng-add schema (project flag, no prompts)" +``` + +--- + +## Task 3: The starter webpack.config.js scaffold template + +**Files:** +- Create: `packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template` + +- [ ] **Step 1: Write the template** + +Create `packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template`: + +```js +/** + * Custom webpack configuration for @angular-builders/custom-webpack. + * + * This object is merged (via webpack-merge) into the Angular CLI's underlying + * webpack config. Add plugins, loaders, resolve aliases, etc. here. + * + * Docs: https://github.com/just-jeb/angular-builders/tree/master/packages/custom-webpack + * + * Example: + * module.exports = { + * plugins: [], + * module: { + * rules: [], + * }, + * }; + */ +module.exports = {}; +``` + +> This is a plain `.template` with no interpolation placeholders, but it MUST go through the schematics `template()` rule anyway so the `.template` suffix is stripped on apply (producing `webpack.config.js`). The `template()` rule strips `.template` even when there are no `<%= %>` tokens. There are no `<%= %>` tokens, so no template variables are required. + +- [ ] **Step 2: Commit** + +```bash +git add packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template +git commit --no-verify -m "feat(custom-webpack): add starter webpack.config scaffold template" +``` + +--- + +## Task 4: ng-add implementation + +**Files:** +- Modify: `packages/custom-webpack/src/schematics/ng-add/index.ts` (replaces the Task 1 stub) +- Test: `packages/custom-webpack/src/schematics/ng-add/index.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `packages/custom-webpack/src/schematics/ng-add/index.spec.ts`: + +```ts +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { getWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../src/schematics/collection.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('custom-webpack', COLLECTION); +} + +async function runNgAdd(tree: UnitTestTree, options: Record = {}): Promise { + return runner().runSchematic('ng-add', options, tree) as Promise; +} + +/** Read a build/serve target's builder string. */ +async function builderOf(tree: UnitTestTree, project: string, target: string): Promise { + const ws = await getWorkspace(tree); + return ws.projects.get(project)?.targets.get(target)?.builder; +} + +async function optionsOf( + tree: UnitTestTree, + project: string, + target: string, +): Promise> { + const ws = await getWorkspace(tree); + return (ws.projects.get(project)?.targets.get(target)?.options ?? {}) as Record; +} + +describe('custom-webpack ng-add', () => { + it('rewrites build to :browser and serve to :dev-server, preserving options', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + // seed known options on build + serve so we can assert preservation + const ws = await getWorkspace(tree); + const proj = ws.projects.get('app')!; + const originalBuildOptions = { ...(proj.targets.get('build')!.options ?? {}) }; + expect(Object.keys(originalBuildOptions).length).toBeGreaterThan(0); + + tree = await runNgAdd(tree); + + expect(await builderOf(tree, 'app', 'build')).toBe('@angular-builders/custom-webpack:browser'); + expect(await builderOf(tree, 'app', 'serve')).toBe('@angular-builders/custom-webpack:dev-server'); + + const buildOptions = await optionsOf(tree, 'app', 'build'); + // every original build option survives the rewrite + for (const key of Object.keys(originalBuildOptions)) { + expect(buildOptions[key]).toEqual(originalBuildOptions[key]); + } + }); + + it('adds the builder to devDependencies and schedules install', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + const run = runner(); + tree = (await run.runSchematic('ng-add', {}, tree)) as UnitTestTree; + + const pkg = JSON.parse(tree.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/custom-webpack']).toBeDefined(); + expect(run.tasks.some((t) => t.name === 'node-package')).toBe(true); + }); + + it('scaffolds webpack.config.js and wires customWebpackConfig when none exists', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + expect(tree.exists('/webpack.config.js')).toBe(false); + tree = await runNgAdd(tree); + + expect(tree.exists('/webpack.config.js')).toBe(true); + expect(tree.readText('/webpack.config.js')).toContain('module.exports'); + + const buildOptions = await optionsOf(tree, 'app', 'build'); + expect(buildOptions['customWebpackConfig']).toEqual({ path: 'webpack.config.js' }); + }); + + it('does NOT scaffold when a webpack.config.js already exists', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + tree.create('/webpack.config.js', '// my existing config\nmodule.exports = { mine: true };'); + + tree = await runNgAdd(tree); + + // existing file untouched + expect(tree.readText('/webpack.config.js')).toContain('mine: true'); + // and we did not inject customWebpackConfig (user already manages their own wiring) + const buildOptions = await optionsOf(tree, 'app', 'build'); + expect(buildOptions['customWebpackConfig']).toBeUndefined(); + }); + + it('does NOT scaffold when customWebpackConfig is already referenced in build options', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + // pre-wire a custom config reference (but no file on disk) → still skip scaffold + const { updateWorkspace } = await import('@schematics/angular/utility'); + tree = (await runner() + .callRule( + updateWorkspace((ws) => { + const opts = ws.projects.get('app')!.targets.get('build')!.options!; + opts['customWebpackConfig'] = { path: 'extra-webpack.config.js' }; + }), + tree, + ) + .toPromise()) as UnitTestTree; + + tree = await runNgAdd(tree); + + expect(tree.exists('/webpack.config.js')).toBe(false); + const buildOptions = await optionsOf(tree, 'app', 'build'); + expect(buildOptions['customWebpackConfig']).toEqual({ path: 'extra-webpack.config.js' }); + }); + + it('is idempotent: build already :browser → no-op rewrite, no second scaffold', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + tree = await runNgAdd(tree); + const firstConfig = tree.readText('/webpack.config.js'); + + tree = await runNgAdd(tree); + + expect(await builderOf(tree, 'app', 'build')).toBe('@angular-builders/custom-webpack:browser'); + expect(await builderOf(tree, 'app', 'serve')).toBe('@angular-builders/custom-webpack:dev-server'); + // scaffold was not regenerated/duplicated; customWebpackConfig already present → skip + expect(tree.readText('/webpack.config.js')).toBe(firstConfig); + }); + + it('targets a specific project via --project in a multi-project workspace', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'a' }, { name: 'b' }] }); + + tree = await runNgAdd(tree, { project: 'b' }); + + expect(await builderOf(tree, 'b', 'build')).toBe('@angular-builders/custom-webpack:browser'); + // project 'a' is untouched + expect(await builderOf(tree, 'a', 'build')).not.toBe('@angular-builders/custom-webpack:browser'); + }); +}); +``` + +> `run.tasks` exposes scheduled tasks; the install task name is `node-package` (`NodePackageInstallTask`). `getWorkspace` is the read-only host-aware reader from `@schematics/angular/utility`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `yarn jest --config jest-ut.config.js packages/custom-webpack/src/schematics/ng-add/index.spec.ts` +Expected: FAIL — the Task 1 stub `index.ts` exports nothing, so `ng-add` is not a registered schematic / no default export. Errors like "Schematic 'ng-add' not found in collection" or a TypeScript default-export error. + +- [ ] **Step 3: Wire the collection to point at the ng-add schematic** + +Replace `packages/custom-webpack/src/schematics/collection.json` (was the Task 1 placeholder): + +```json +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Wire @angular-builders/custom-webpack into the workspace.", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} +``` + +- [ ] **Step 4: Write the ng-add implementation** + +Replace `packages/custom-webpack/src/schematics/ng-add/index.ts` (was the Task 1 stub): + +```ts +import { + apply, + chain, + mergeWith, + move, + noop, + Rule, + SchematicContext, + template, + Tree, + url, +} from '@angular-devkit/schematics'; +import { getWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { + addBuilderDevDependency, + getProjectsToTarget, +} from '@angular-builders/common/schematics'; +import { NgAddSchema } from './schema'; + +const PACKAGE_NAME = '@angular-builders/custom-webpack'; +const BROWSER_BUILDER = `${PACKAGE_NAME}:browser`; +const DEV_SERVER_BUILDER = `${PACKAGE_NAME}:dev-server`; +const DEFAULT_CONFIG_FILE = 'webpack.config.js'; + +// Version range written into devDependencies. Aligned to the builder major. +// On release/v22 this is the v22 line; bump alongside the package version. +const SELF_VERSION_RANGE = '^22.0.0'; + +/** True if a webpack.config.* file already lives at the workspace root. */ +function webpackConfigFileExists(tree: Tree): boolean { + return ( + tree.exists(`/${DEFAULT_CONFIG_FILE}`) || + tree.exists('/webpack.config.ts') || + tree.exists('/webpack.config.cjs') || + tree.exists('/webpack.config.mjs') + ); +} + +/** + * Rewrite build → :browser and serve → :dev-server for a single project, + * preserving existing options. Idempotent: already-correct builders are + * simply re-assigned to the same value. + */ +function rewriteTargets(projectName: string): Rule { + return updateWorkspace((workspace) => { + const project = workspace.projects.get(projectName); + if (!project) { + return; + } + const build = project.targets.get('build'); + if (build) { + build.builder = BROWSER_BUILDER; + } + const serve = project.targets.get('serve'); + if (serve) { + serve.builder = DEV_SERVER_BUILDER; + } + }); +} + +/** + * Scaffold a starter webpack.config.js and wire customWebpackConfig.path to it, + * but only when neither a customWebpackConfig reference nor a webpack.config.* + * file already exists for the project's build target. + */ +function scaffoldConfig(projectName: string): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = await getWorkspace(tree); + const project = workspace.projects.get(projectName); + const buildOptions = + (project?.targets.get('build')?.options as Record | undefined) ?? {}; + + const alreadyReferenced = + buildOptions['customWebpackConfig'] !== undefined && + buildOptions['customWebpackConfig'] !== false; + + if (alreadyReferenced || webpackConfigFileExists(tree)) { + context.logger.info( + `[custom-webpack] A webpack config is already present; leaving it untouched.`, + ); + return noop(); + } + + const templateSource = apply(url('./files'), [template({}), move('/')]); + + return chain([ + mergeWith(templateSource), + updateWorkspace((ws) => { + const buildTarget = ws.projects.get(projectName)?.targets.get('build'); + if (buildTarget) { + buildTarget.options = { + ...(buildTarget.options ?? {}), + customWebpackConfig: { path: DEFAULT_CONFIG_FILE }, + }; + } + }), + ]); + }; +} + +export function ngAdd(options: NgAddSchema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = await getWorkspace(tree); + const projects = getProjectsToTarget(workspace, options.project); + + if (projects.length === 0) { + context.logger.warn('[custom-webpack] No projects found to configure.'); + return noop(); + } + + const perProject: Rule[] = []; + for (const projectName of projects) { + perProject.push(rewriteTargets(projectName)); + perProject.push(scaffoldConfig(projectName)); + } + + return chain([ + addBuilderDevDependency(PACKAGE_NAME, SELF_VERSION_RANGE, { install: true }), + ...perProject, + ]); + }; +} +``` + +> Notes: +> - `getProjectsToTarget` (Plan 0) handles single/multi/`defaultProject`/`--project` selection — we never prompt. +> - `addBuilderDevDependency(..., { install: true })` schedules the `NodePackageInstallTask` via Plan 0's wrapper (`InstallBehavior.Auto`). +> - `rewriteTargets` uses `updateWorkspace` directly because it touches two targets (`build` + `serve`) in one transaction; option preservation is automatic (we only reassign `.builder`). The Plan 0 `setBuilderForTarget` helper is option-merge oriented; using `updateWorkspace` here keeps both builders in a single workspace write and avoids redundant option merges. This stays within the "tree-based, utility-backed edits" invariant (§2). +> - Scaffold uses the schematics `apply`/`mergeWith`/`template`/`move` pipeline so `--dry-run` and the `.template` suffix-strip work. `template({})` strips `.template` → `webpack.config.js`. +> - Idempotency: on a second run, `customWebpackConfig` is already set → `alreadyReferenced` short-circuits the scaffold; builder reassignment is a no-op to the same value. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `yarn jest --config jest-ut.config.js packages/custom-webpack/src/schematics/ng-add/index.spec.ts` +Expected: PASS (7 tests). + +> If `SchematicTestHarness.createWorkspace` generates an application without a `serve` target (RC generator drift), the serve-rewrite assertions read `undefined` — calibrate by reading the generated `angular.json` once and adjusting only the *expected* serve builder, never the helper logic (same calibration note as Plan 0 Task 4). + +- [ ] **Step 6: Commit** + +```bash +git add packages/custom-webpack/src/schematics/collection.json packages/custom-webpack/src/schematics/ng-add/index.ts packages/custom-webpack/src/schematics/ng-add/index.spec.ts +git commit --no-verify -m "feat(custom-webpack): add ng-add (build/serve rewrite + config scaffold)" +``` + +--- + +## Task 5: End-to-end build verification + +**Files:** none (verification only) + +- [ ] **Step 1: Build the package end-to-end** + +Run: `yarn workspace @angular-builders/custom-webpack build` +Expected: exits 0. The build runs lib `tsc` → schematics `tsc` → `copy:schematics` → `merge-schemes.ts` → `postbuild` (unit + e2e). + +- [ ] **Step 2: Verify the schematics assets shipped to dist** + +Run: `ls packages/custom-webpack/dist/schematics packages/custom-webpack/dist/schematics/ng-add packages/custom-webpack/dist/schematics/ng-add/files` +Expected: +- `dist/schematics/collection.json` +- `dist/schematics/ng-add/index.js`, `dist/schematics/ng-add/schema.json` +- `dist/schematics/ng-add/files/webpack.config.js.template` + +- [ ] **Step 3: Verify package.json points at the dist manifest** + +Run: `node -e "const p=require('./packages/custom-webpack/package.json'); console.log(p.schematics, p['ng-add'].save)"` +Expected: `./dist/schematics/collection.json devDependencies` + +- [ ] **Step 4: Confirm NO `ng-update` / migrations were introduced (spec §12.1)** + +Run: `node -e "const p=require('./packages/custom-webpack/package.json'); if(p['ng-update']) throw new Error('ng-update must NOT exist'); if(require('fs').existsSync('packages/custom-webpack/src/schematics/migrations.json')) throw new Error('migrations.json must NOT exist'); console.log('ok: no migrations')"` +Expected: prints `ok: no migrations`. + +- [ ] **Step 5: Run the full custom-webpack unit suite** + +Run: `yarn jest --config jest-ut.config.js packages/custom-webpack` +Expected: all schematics specs green + pre-existing custom-webpack specs still green. + +- [ ] **Step 6: Commit (if any incidental fixes were needed)** + +```bash +git add -A packages/custom-webpack +git commit --no-verify -m "test(custom-webpack): verify schematics build + no-migrations invariant" || echo "nothing to commit" +``` + +--- + +## Self-Review + +**Spec §4.3 (custom-webpack) coverage:** +- ng-add adds self to devDeps via `addBuilderDevDependency` → Task 4 Step 4 (`addBuilderDevDependency(PACKAGE_NAME, ...)`). ✅ +- ng-add rewrites `build`→`:browser`, `serve`→`:dev-server`, preserving options → Task 4 (`rewriteTargets`, only `.builder` reassigned; options preserved). Test asserts preservation. ✅ +- ng-add schedules install → Task 4 (`{ install: true }`); test asserts `node-package` task. ✅ +- Scaffold when no `customWebpackConfig` referenced AND no `webpack.config.*` exists → create starter via `apply`/`mergeWith`/`template`, set `customWebpackConfig` → Task 4 `scaffoldConfig`. Tests for create/skip-existing-file/skip-existing-reference. ✅ +- Leave existing config (no prompt) → Task 4 `alreadyReferenced || webpackConfigFileExists` short-circuit. ✅ +- Idempotent (`build` already `:browser` → no-op) → Task 4 idempotency test. ✅ +- `--project` flag, zero prompts → Task 2 schema (no `x-prompt`), Task 4 `getProjectsToTarget`. Multi-project `--project` test. ✅ +- **No `ng-update` / migration** (spec §12.1 — Karma not removed in v22, #2260 held) → Architecture decision section + Task 5 Step 4 invariant check. ✅ + +**Spec §6 coverage checklist (custom-webpack column):** +- deps add/remove: +self (Task 4). ✅ +- targets rewritten: `build`, `serve` (Task 4). ✅ +- files created: `webpack.config.js` (Task 4). ✅ +- tsconfig edits: — (none; correct). ✅ +- detection: webpack config present? (`webpackConfigFileExists`). ✅ +- flags: `--project` (Task 2). ✅ +- idempotency: `build` already `:browser` (Task 4 test). ✅ +- migrations: NONE — no `migrations.json`, no `ng-update` field (spec §12.1) → Architecture decision + Task 5 Step 4. ✅ +- package.json fields: `schematics`, `ng-add` (no `ng-update`) (Task 1). ✅ +- tests: ng-add (Task 4). ✅ + +**Spec §7 (packaging) coverage:** per-package `tsconfig.schematics.json` extending root base (Task 1 Step 1); `tsc (lib) → tsc (schematics) → copy:schematics` sequence; `copy:schematics` copies `collection.json`/`schema.json` + `files/**` (Task 1 Step 2). Mirrors Plan 0 exactly. ✅ + +**Spec §8 (testing) coverage:** unit tests on `SchematicTestHarness` via `yarn jest --config jest-ut.config.js`; ng-add asserts transformed `angular.json`/`package.json`, scaffold create/skip, and idempotency (Task 4). Install task asserted as scheduled, not run (Task 4). ✅ + +**Spec §12.1 (no migration):** custom-webpack ships `ng-add` only — no `migrations.json`, no `ng-update` field, no `migrations/` directory. Rationale in the Architecture decision section; enforced by Task 5 Step 4. ✅ + +**Constraints check:** +- Imports Plan 0 helpers, never redefines them. ✅ (`setBuilderForTarget` available but `rewriteTargets` uses `updateWorkspace` for the two-target single-write case — documented; all other writes use Plan 0 helpers.) +- No cross-import of other builder packages. ✅ +- ng-add zero prompts (`--project` flag, no `x-prompt`). ✅ + +**Placeholder scan:** every code/test step contains complete code; no TBD/TODO-in-plan/"handle edge cases". ✅ + +**Type consistency:** `NgAddSchema.project` used consistently; `ngAdd` factory name matches `collection.json`; builder constant strings consistent across ng-add and tests. ✅ + +--- + +## Execution Handoff + +**Gated:** Execute on a green `release/v22` base **after Plan 0** (which locks the imported API and adds the `@schematics/angular`/`@angular-devkit/schematics`/`copyfiles` packaging this plan mirrors). This plan is independent of the jest (Plan 01) and custom-esbuild (Plan 02) builder plans — it shares no code with them. + +Recommended approach: **subagent-driven-development** (fresh subagent per task, review between tasks). diff --git a/docs/superpowers/plans/2026-06-02-builder-schematics-04-e2e.md b/docs/superpowers/plans/2026-06-02-builder-schematics-04-e2e.md new file mode 100644 index 0000000000..82bd94ed1a --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-04-e2e.md @@ -0,0 +1,1111 @@ +# Builder Schematics — Integration / e2e Tests (Plan 04) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the spec-§8-mandated **end-to-end layer** for the v22 builder schematics — real-CLI `ng add` runs on real fixture apps (then `ng build`/`ng test` green) plus a `ng update` post-migration build smoke for the heavy jest `@21` migration — wired into the existing integration matrix. + +**Architecture:** This is a script/CI-shaped plan, not a TDD-unit one. We reuse the existing harness verbatim: each e2e case is an entry in a `packages/*/tests/integration.js` array (`{ id, name, purpose, app, command }`), executed by `scripts/discover-tests.js` → CI matrix / `scripts/run-local-tests.js`, run via `sh -c ` from the fixture app's directory. New fixtures live under `examples//` as standalone Yarn-workspace Angular apps. The `ng add` cases drive the **unpublished local build** by `npm pack`-ing the built package into a tarball and running `ng add ./` inside a throwaway copy of the fixture — exercising the real resolve → install → run-collection path including save-to-devDependencies. A shared `scripts/e2e-ng-add.js` helper encapsulates the copy → pack → `ng add` → assert dance so each integration entry is a one-line invocation. The jest `@21` smoke seeds a fixture in the **old pre-21 jest config shape**, runs the migration schematic via `ng update --migrate-only`, then `ng build`/`ng test` under v22. + +**Tech Stack:** Node.js (CommonJS scripts), Yarn 3 workspaces, Turbo, Angular CLI 22 (RC during execution), `@angular-builders/*` builders, the existing `scripts/run-local-tests.js` + `scripts/discover-tests.js` harness. + +--- + +## Prerequisites & Ground Rules + +- **Gated on `release/v22` green** and Plans 00–03 executed (the schematics must exist in `packages/*/src/schematics` and build to `dist/schematics`). This plan tests the artifacts those plans produce; it does not implement any schematic logic. +- **Do NOT duplicate unit coverage.** Plans 00–03 own `SchematicTestRunner`/`UnitTestTree` assertions. Every task here invokes the **real Angular CLI** against a **real fixture** and asserts a real `ng build`/`ng test` result. +- **Harness contract (must hold for every new entry):** export an array of `{ id: string, name: string, purpose: string, app: string, command: string }` from `packages//tests/integration.js`. `id` MUST be globally unique. `app` is a path relative to repo root. `command` runs via `spawn('sh', ['-c', command], { cwd: })`. (Source: `scripts/AGENTS.md` Invariants.) +- **CI is already ~41 jobs.** Each task tags cases **[ESSENTIAL]** or **[OPTIONAL]**. Essential cases ship in the matrix; optional cases are added to `integration.js` but commented with `// [OPTIONAL]` and a one-line rationale so a maintainer can enable them under the `ci:full` label without re-deriving intent. Prefer reusing/extending existing fixtures over new ones. +- **Node:** `.nvmrc` pins `24.16.0`. All `node`/`npm`/`yarn` commands assume that runtime (CI uses `setup-node` with `node-version-file: .nvmrc`). +- **Local run command** for any case: `node scripts/run-local-tests.js --id --verbose` (build the package first: `yarn workspace @angular-builders/ build` or `yarn build:packages:all`). +- **The verdaccio option (full-fidelity publish/fetch) is deliberately NOT used** — it is overkill for these cases; the `npm pack` → `ng add ./` approach already exercises real resolve → install → run-collection incl. save-to-devDependencies. The lighter `--collection` fallback (Task 0) covers environments where tarball resolve misbehaves on the RC. + +### File Structure (created/modified by this plan) + +- **`scripts/e2e-ng-add.js`** (new) — shared helper: copy fixture to a temp workdir, `npm pack` the built package, `ng add ./` (or `--collection` fallback), then run user-supplied assert/build/test commands. One responsibility: drive a local-only `ng add` e2e. +- **`scripts/e2e-assert.js`** (new) — tiny assertion helpers (`assertFileAbsent`, `assertFileContains`, `assertBuilderForTarget`, `assertLogContains`, `assertDevDependency`) used by the `*-ng-add.json` expectation files. One responsibility: declarative post-`ng add` assertions. +- **`scripts/e2e-jest-migration.js`** (new) — jest `@21` migration post-build smoke driver. +- **`examples/jest/karma-app/`** (new fixture) — Karma→Jest `ng add` target (generated via `ng new --test-runner karma`). +- **`examples/jest/vitest-app/`** (new fixture) — Vitest→Jest `ng add` target (default v22 app). +- **`examples/jest/old-config-app/`** (new fixture) — pre-21 jest config shape for the `@21` migration smoke. +- **`examples/custom-esbuild/esbuild-add-app/`** (new fixture) — esbuild app for the build/serve rewrite `ng add` case. +- **`examples/custom-esbuild/webpack-guard-app/`** (new fixture) — webpack-build app to assert the no-silent-swap advisory. +- **`examples/custom-webpack/add-app/`** (new fixture) — clean app (no webpack config) for the build/serve rewrite + scaffold `ng add` case. +- **`packages/jest/tests/integration.js`** (modify) — append jest e2e entries. +- **`packages/custom-esbuild/tests/integration.js`** (modify) — append esbuild e2e entries. +- **`packages/custom-webpack/tests/integration.js`** (modify) — append custom-webpack e2e entry. +- **`docs/runbooks/angular-major-upgrade.md`** (modify) — record the RC-gated multi-major `ng update` validation result. + +--- + +## Task 0: Shared local-only `ng add` e2e helper + +**Why first:** Every "A" case (jest, custom-esbuild, custom-webpack) needs the same machinery: stand up a disposable copy of the fixture, pack the locally-built package, run `ng add ./`, then assert. Locking this helper first means every later task is a one-line `integration.js` entry plus a small JSON expectation file. + +**Files:** +- Create: `scripts/e2e-ng-add.js` +- Create: `scripts/e2e-assert.js` +- Create: `scripts/__fixtures__/e2e-smoke/` (a throwaway 3-file fake "fixture" used only to test the assertion helpers themselves) + +- [ ] **Step 1: Write `scripts/e2e-assert.js` (assertion helpers)** + +```javascript +'use strict'; +// Declarative post-`ng add` assertions used by *-ng-add.json expectation files. +// Each helper throws on failure (non-zero exit propagates through e2e-ng-add.js). +const fs = require('fs'); +const path = require('path'); + +function readJson(workdir, rel) { + return JSON.parse(fs.readFileSync(path.join(workdir, rel), 'utf8')); +} + +// Assert a file does NOT exist (e.g. karma.conf.js removed). +function assertFileAbsent(workdir, rel) { + if (fs.existsSync(path.join(workdir, rel))) { + throw new Error(`Expected file to be ABSENT but it exists: ${rel}`); + } +} + +// Assert a file exists and contains a substring (e.g. webpack.config.js scaffold). +function assertFileContains(workdir, rel, substr) { + const p = path.join(workdir, rel); + if (!fs.existsSync(p)) throw new Error(`Expected file to exist: ${rel}`); + const text = fs.readFileSync(p, 'utf8'); + if (!text.includes(substr)) { + throw new Error(`Expected ${rel} to contain ${JSON.stringify(substr)}`); + } +} + +// Assert angular.json target builder equals expected (e.g. test -> @angular-builders/jest:run). +function assertBuilderForTarget(workdir, project, target, expected) { + const ng = readJson(workdir, 'angular.json'); + const proj = ng.projects[project]; + if (!proj) throw new Error(`No project "${project}" in angular.json`); + const tgt = (proj.architect || proj.targets)[target]; + if (!tgt) throw new Error(`No target "${target}" in project "${project}"`); + const actual = tgt.builder; + if (actual !== expected) { + throw new Error(`Target ${project}:${target} builder = ${actual}, expected ${expected}`); + } +} + +// Assert a captured ng-add log file contains an advisory substring (webpack guard). +function assertLogContains(logFile, substr) { + const text = fs.readFileSync(logFile, 'utf8'); + if (!text.includes(substr)) { + throw new Error(`Expected ng add log to contain ${JSON.stringify(substr)}`); + } +} + +// Assert a devDependency was saved into package.json (save-to-devDependencies path). +function assertDevDependency(workdir, name) { + const pkg = readJson(workdir, 'package.json'); + if (!pkg.devDependencies || !pkg.devDependencies[name]) { + throw new Error(`Expected devDependency "${name}" to be saved in package.json`); + } +} + +module.exports = { + assertFileAbsent, + assertFileContains, + assertBuilderForTarget, + assertLogContains, + assertDevDependency, +}; +``` + +- [ ] **Step 2: Write `scripts/e2e-ng-add.js` (the driver)** + +```javascript +#!/usr/bin/env node +'use strict'; +// Drives a LOCAL-ONLY `ng add` e2e for an unpublished workspace build. +// +// What it does (preferred "npm pack" approach — exercises real resolve -> install -> run-collection): +// 1. Copy the fixture app to a fresh temp workdir (so the fixture stays pristine and +// parallel matrix jobs never collide). +// 2. `npm pack` the locally-built package -> a .tgz tarball. +// 3. Run `ng add ./ ` in the workdir, capturing combined output to a log file. +// 4. Run the declarative assertions from the spec file against the workdir + log. +// 5. Run the post-add verification commands (e.g. `ng build`, `ng test`) in the workdir. +// +// Usage: +// node scripts/e2e-ng-add.js --spec +// +// Spec file shape (JSON): +// { +// "fixture": "examples/jest/karma-app", // relative to repo root +// "package": "@angular-builders/jest", // workspace package to pack +// "ngAddArgs": [], // extra args to `ng add` +// "expectAddSucceeds": true, // ng add must exit 0 (default true) +// "asserts": [ // run after ng add, before build/test +// { "fn": "assertBuilderForTarget", "args": ["karma-app", "test", "@angular-builders/jest:run"] }, +// { "fn": "assertFileAbsent", "args": ["karma.conf.js"] } +// ], +// "post": ["npx ng build", "npx ng test"] // commands run in workdir; each must exit 0 +// } +// +// Fallback (when npm pack + tarball resolve is not viable on the RC): set +// "useCollectionFallback": true +// which instead runs `ng add --collection ...` against the already +// workspace-linked package. The lighter fallback skips real npm-registry resolve but still +// runs the real CLI + collection. The full-fidelity verdaccio path is intentionally not used. + +const { execFileSync, spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('./e2e-assert'); + +const REPO_ROOT = path.join(__dirname, '..'); + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--spec' && argv[i + 1]) out.spec = argv[++i]; + } + if (!out.spec) { + console.error('Usage: node scripts/e2e-ng-add.js --spec '); + process.exit(2); + } + return out; +} + +// Recursively copy a directory, skipping node_modules / .angular / dist caches. +function copyDir(src, dest) { + const SKIP = new Set(['node_modules', '.angular', 'dist', '.git']); + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyDir(s, d); + else fs.copyFileSync(s, d); + } +} + +function run(cmd, args, opts) { + console.log(`[e2e-ng-add] $ ${cmd} ${args.join(' ')} (cwd=${opts.cwd})`); + const res = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }); + const stdout = (res.stdout || '').toString(); + const stderr = (res.stderr || '').toString(); + process.stdout.write(stdout); + process.stderr.write(stderr); + return { status: res.status, stdout, stderr }; +} + +function main() { + const { spec: specPath } = parseArgs(process.argv.slice(2)); + const spec = JSON.parse(fs.readFileSync(path.resolve(REPO_ROOT, specPath), 'utf8')); + + const fixtureAbs = path.resolve(REPO_ROOT, spec.fixture); + if (!fs.existsSync(fixtureAbs)) throw new Error(`Fixture not found: ${spec.fixture}`); + + const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-add-e2e-')); + console.log(`[e2e-ng-add] workdir = ${workdir}`); + copyDir(fixtureAbs, workdir); + + // node_modules: symlink the repo root's hoisted modules so the real Angular CLI resolves. + // Yarn 3 workspaces hoist everything to the repo root node_modules. + const wdNodeModules = path.join(workdir, 'node_modules'); + if (!fs.existsSync(wdNodeModules)) { + fs.symlinkSync(path.join(REPO_ROOT, 'node_modules'), wdNodeModules, 'dir'); + } + + const logFile = path.join(workdir, 'ng-add.log'); + + if (spec.useCollectionFallback) { + // Lighter fallback: real CLI, collection against workspace-linked package. + const args = ['ng', 'add', spec.package, '--collection', spec.package, '--skip-confirmation', + ...(spec.ngAddArgs || [])]; + const r = run('npx', args, { cwd: workdir }); + fs.writeFileSync(logFile, r.stdout + r.stderr); + if ((spec.expectAddSucceeds !== false) && r.status !== 0) { + throw new Error(`ng add (fallback) failed with status ${r.status}`); + } + } else { + // Preferred: npm pack the locally-built package, then `ng add ./`. + const pkgDir = path.join(REPO_ROOT, 'packages', spec.package.replace('@angular-builders/', '')); + const packOut = execFileSync('npm', ['pack', '--silent', '--pack-destination', workdir], { + cwd: pkgDir, + encoding: 'utf8', + }).trim(); + const tarball = packOut.split('\n').pop().trim(); + const tarballAbs = path.join(workdir, tarball); + console.log(`[e2e-ng-add] packed ${spec.package} -> ${tarball}`); + const args = ['ng', 'add', tarballAbs, '--skip-confirmation', ...(spec.ngAddArgs || [])]; + const r = run('npx', args, { cwd: workdir }); + fs.writeFileSync(logFile, r.stdout + r.stderr); + if ((spec.expectAddSucceeds !== false) && r.status !== 0) { + throw new Error(`ng add failed with status ${r.status}`); + } + } + + // Declarative assertions (workdir-relative). + for (const a of spec.asserts || []) { + const fn = assert[a.fn]; + if (!fn) throw new Error(`Unknown assert fn: ${a.fn}`); + // assertLogContains takes an absolute log path as first arg; others take workdir. + if (a.fn === 'assertLogContains') fn(logFile, ...a.args); + else fn(workdir, ...a.args); + console.log(`[e2e-ng-add] OK assert ${a.fn}(${JSON.stringify(a.args)})`); + } + + // Post-add verification commands (real build/test under v22). + for (const cmd of spec.post || []) { + const r = run('sh', ['-c', cmd], { cwd: workdir }); + if (r.status !== 0) throw new Error(`post command failed (${r.status}): ${cmd}`); + } + + console.log('[e2e-ng-add] PASS'); +} + +try { + main(); +} catch (err) { + console.error(`[e2e-ng-add] FAIL: ${err.message}`); + process.exit(1); +} +``` + +- [ ] **Step 3: Create a self-test fixture for the assertion helpers** + +Create `scripts/__fixtures__/e2e-smoke/angular.json`: + +```json +{ + "version": 1, + "projects": { + "smoke": { + "projectType": "application", + "root": "", + "architect": { + "test": { "builder": "@angular-devkit/build-angular:karma", "options": {} } + } + } + } +} +``` + +Create `scripts/__fixtures__/e2e-smoke/karma.conf.js`: + +```javascript +module.exports = function () {}; +``` + +Create `scripts/__fixtures__/e2e-smoke/package.json`: + +```json +{ "name": "smoke", "private": true } +``` + +- [ ] **Step 4: Smoke-test the assertion helpers directly (no CLI needed)** + +Run: + +```bash +cd /Users/jeb/personal-projects/angular-builders && node -e ' +const a = require("./scripts/e2e-assert"); +const wd = "scripts/__fixtures__/e2e-smoke"; +a.assertBuilderForTarget(wd, "smoke", "test", "@angular-devkit/build-angular:karma"); +let threw = false; +try { a.assertFileAbsent(wd, "karma.conf.js"); } catch (e) { threw = true; } +if (!threw) { console.error("FAIL: assertFileAbsent did not throw for present file"); process.exit(1); } +a.assertFileAbsent(wd, "does-not-exist.js"); +console.log("e2e-assert OK"); +' +``` + +Expected: prints `e2e-assert OK` and exits 0. (Confirms `assertBuilderForTarget` reads the builder, `assertFileAbsent` throws on a present file and passes on an absent one.) + +- [ ] **Step 5: Verify the driver is syntactically valid and arg-parses** + +Run: + +```bash +cd /Users/jeb/personal-projects/angular-builders && node -c scripts/e2e-ng-add.js && echo "syntax-ok" && node scripts/e2e-ng-add.js 2>&1; echo "exit=$?" +``` + +Expected: prints `syntax-ok`, then the usage error `Usage: node scripts/e2e-ng-add.js --spec ` and `exit=2` (no `--spec` provided). Confirms the file parses and the arg guard works without needing the CLI yet. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add scripts/e2e-ng-add.js scripts/e2e-assert.js scripts/__fixtures__/e2e-smoke +git commit --no-verify -m "test(schematics): add local-only ng add e2e harness helpers" +``` + +--- + +## Task 1: jest Karma→Jest `ng add` e2e [ESSENTIAL] + +**Spec:** §4.1 (Karma→Jest path), §8 (integration), §12.4 (generate Karma fixture via `ng new --test-runner karma`). Checklist: "jest Karma→Jest AND Vitest→Jest". + +**Files:** +- Create: `examples/jest/karma-app/` (Angular workspace generated with Karma) +- Create: `packages/jest/tests/e2e/karma-to-jest.ng-add.json` +- Modify: `packages/jest/tests/integration.js` + +- [ ] **Step 1: Generate the Karma fixture** + +Karma is NOT removed in v22 (spec §12) — `ng new --test-runner karma` works. Generate into a temp dir then move the app into `examples/jest/karma-app` (do not nest a git repo or lockfile): + +```bash +cd /Users/jeb/personal-projects/angular-builders +TMP=$(mktemp -d) +npx -y @angular/cli@22.0.0-rc.2 new karma-app \ + --directory "$TMP/karma-app" \ + --test-runner karma \ + --routing=false --style=scss --skip-git --skip-install --package-manager=yarn +rm -rf "$TMP/karma-app/.git" "$TMP/karma-app/.vscode" "$TMP/karma-app/yarn.lock" "$TMP/karma-app/.editorconfig" +mkdir -p examples/jest +cp -R "$TMP/karma-app" examples/jest/karma-app +rm -rf "$TMP" +``` + +Expected: `examples/jest/karma-app/angular.json` exists with `test` builder `@angular/build:karma` (or `@angular-devkit/build-angular:karma`), and `examples/jest/karma-app/karma.conf.js` exists. + +- [ ] **Step 2: Pin the fixture to workspace-managed Angular and register as workspace** + +Set the app's `package.json` `name` and align Angular versions to the workspace (mirror `examples/jest/simple-app/package.json` versions so hoisting resolves). Edit `examples/jest/karma-app/package.json`: set `"name": "karma-app"`, `"private": true`, remove any `"packageManager"` field, and pin `@angular/*`, `@angular/cli`, `@angular/build`/`@angular-devkit/build-angular`, `typescript`, `zone.js` to the same versions used in `examples/jest/simple-app/package.json`. The Yarn glob `examples/jest/*` (root `package.json` workspaces) auto-discovers it. + +Run to confirm discovery: + +```bash +cd /Users/jeb/personal-projects/angular-builders && yarn workspaces list --json | grep karma-app && echo "discovered" +``` + +Expected: prints a line for `examples/jest/karma-app` and `discovered`. + +- [ ] **Step 3: Write the e2e spec file** + +Create `packages/jest/tests/e2e/karma-to-jest.ng-add.json`: + +```json +{ + "fixture": "examples/jest/karma-app", + "package": "@angular-builders/jest", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["karma-app", "test", "@angular-builders/jest:run"] }, + { "fn": "assertFileAbsent", "args": ["karma.conf.js"] }, + { "fn": "assertDevDependency", "args": ["jest"] } + ], + "post": ["npx ng test"] +} +``` + +> `post` runs `ng test` which now resolves to `@angular-builders/jest:run` — proving the Karma config was removed AND the Jest run is green. `ng build` is exercised by the Vitest case (Task 2) to avoid redundant build cost; Karma's case focuses on the test-runner swap. + +- [ ] **Step 4: Add the integration entry** + +Add to the array in `packages/jest/tests/integration.js` (append before the closing `];`): + +```javascript + // --- ng add e2e (Plan 04) --- + { + id: 'ng-add-karma-to-jest', + name: 'jest: ng add Karma->Jest', + purpose: 'ng add removes Karma and ng test runs green via Jest', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/jest/tests/e2e/karma-to-jest.ng-add.json', + }, +``` + +> `app: '.'` runs the command from the repo root (the helper resolves fixture + package paths relative to repo root and copies the fixture to a temp workdir, so it must run from root, not from inside the fixture). The harness `cwd` is `path.join(repoRoot, test.app)` → repo root. + +- [ ] **Step 5: Build the jest package and run the case** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn workspace @angular-builders/jest build +node scripts/run-local-tests.js --id ng-add-karma-to-jest --verbose +``` + +Expected: `PASS [..s] ng-add-karma-to-jest`. The log shows `npm pack` producing a `.tgz`, `ng add` removing `karma.conf.js`, then `ng test` exiting 0. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add examples/jest/karma-app packages/jest/tests/e2e/karma-to-jest.ng-add.json packages/jest/tests/integration.js +git commit --no-verify -m "test(jest): add Karma->Jest ng add e2e" +``` + +--- + +## Task 2: jest Vitest→Jest `ng add` e2e [ESSENTIAL] + +**Spec:** §12.2 (Vitest→Jest, the forward-default), §8, §4.1. + +**Files:** +- Create: `examples/jest/vitest-app/` +- Create: `packages/jest/tests/e2e/vitest-to-jest.ng-add.json` +- Modify: `packages/jest/tests/integration.js` + +- [ ] **Step 1: Generate the default (Vitest) v22 fixture** + +Fresh v22 apps default to Vitest (`@angular/build:unit-test`). Omit `--test-runner` so it picks the default: + +```bash +cd /Users/jeb/personal-projects/angular-builders +TMP=$(mktemp -d) +npx -y @angular/cli@22.0.0-rc.2 new vitest-app \ + --directory "$TMP/vitest-app" \ + --routing=false --style=scss --skip-git --skip-install --package-manager=yarn +rm -rf "$TMP/vitest-app/.git" "$TMP/vitest-app/.vscode" "$TMP/vitest-app/yarn.lock" "$TMP/vitest-app/.editorconfig" +cp -R "$TMP/vitest-app" examples/jest/vitest-app +rm -rf "$TMP" +``` + +Expected: `examples/jest/vitest-app/angular.json` has `test` builder `@angular/build:unit-test`; no `karma.conf.js`. + +- [ ] **Step 2: Register + pin (same as Task 1 Step 2)** + +Edit `examples/jest/vitest-app/package.json`: `"name": "vitest-app"`, `"private": true`, drop `packageManager`, pin `@angular/*`/CLI/build/typescript/zone.js to the workspace versions from `examples/jest/simple-app/package.json`. + +Run: + +```bash +cd /Users/jeb/personal-projects/angular-builders && yarn workspaces list --json | grep vitest-app && echo "discovered" +``` + +Expected: prints the `examples/jest/vitest-app` entry and `discovered`. + +- [ ] **Step 3: Write the e2e spec file** + +Create `packages/jest/tests/e2e/vitest-to-jest.ng-add.json`: + +```json +{ + "fixture": "examples/jest/vitest-app", + "package": "@angular-builders/jest", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["vitest-app", "test", "@angular-builders/jest:run"] }, + { "fn": "assertDevDependency", "args": ["jest"] } + ], + "post": ["npx ng build", "npx ng test"] +} +``` + +> The Vitest `unit-test` target is overwritten to `@angular-builders/jest:run` (spec §12.2 — no `karma.conf` equivalent to delete). `post` runs BOTH `ng build` (proves the app still builds after the runner swap) and `ng test` (proves Jest is green). The generated default spec uses framework-agnostic `expect`/`it`, so no `vi.*` porting is needed for the smoke to pass. + +- [ ] **Step 4: Add the integration entry** + +Append to `packages/jest/tests/integration.js`: + +```javascript + { + id: 'ng-add-vitest-to-jest', + name: 'jest: ng add Vitest->Jest', + purpose: 'ng add rewrites Vitest unit-test to Jest; ng build + ng test green', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/jest/tests/e2e/vitest-to-jest.ng-add.json', + }, +``` + +- [ ] **Step 5: Run the case** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn workspace @angular-builders/jest build +node scripts/run-local-tests.js --id ng-add-vitest-to-jest --verbose +``` + +Expected: `PASS [..s] ng-add-vitest-to-jest`. Log shows the `test` builder rewritten, `ng build` exit 0, `ng test` exit 0. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add examples/jest/vitest-app packages/jest/tests/e2e/vitest-to-jest.ng-add.json packages/jest/tests/integration.js +git commit --no-verify -m "test(jest): add Vitest->Jest ng add e2e" +``` + +--- + +## Task 3: custom-esbuild build/serve rewrite `ng add` e2e [ESSENTIAL] + +**Spec:** §4.2, §8, §12.3 (the safe esbuild-build branch). Checklist: "custom-esbuild build/serve rewrite on an esbuild app → ng build/ng test green". + +**Files:** +- Create: `examples/custom-esbuild/esbuild-add-app/` +- Create: `packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json` +- Modify: `packages/custom-esbuild/tests/integration.js` + +- [ ] **Step 1: Generate an esbuild (application builder) fixture** + +A fresh v22 `ng new` app uses `@angular/build:application` (esbuild) for `build` and `@angular/build:dev-server` for `serve`. Use `--test-runner karma` so the `test` target is a different toolchain (esbuild plugins do not apply there) — this exercises the "leave test, advise" branch AND keeps `ng test` runnable without Vitest experimental flags: + +```bash +cd /Users/jeb/personal-projects/angular-builders +TMP=$(mktemp -d) +npx -y @angular/cli@22.0.0-rc.2 new esbuild-add-app \ + --directory "$TMP/esbuild-add-app" \ + --test-runner karma \ + --routing=false --style=scss --skip-git --skip-install --package-manager=yarn +rm -rf "$TMP/esbuild-add-app/.git" "$TMP/esbuild-add-app/.vscode" "$TMP/esbuild-add-app/yarn.lock" "$TMP/esbuild-add-app/.editorconfig" +mkdir -p examples/custom-esbuild +cp -R "$TMP/esbuild-add-app" examples/custom-esbuild/esbuild-add-app +rm -rf "$TMP" +``` + +Expected: `angular.json` `build` builder is `@angular/build:application`, `serve` is `@angular/build:dev-server`. + +- [ ] **Step 2: Register + pin** + +Edit `examples/custom-esbuild/esbuild-add-app/package.json`: `"name": "esbuild-add-app"`, `"private": true`, drop `packageManager`, pin Angular/CLI/build/typescript/zone.js to the versions in `examples/custom-esbuild/sanity-esbuild-app/package.json`. + +```bash +cd /Users/jeb/personal-projects/angular-builders && yarn workspaces list --json | grep esbuild-add-app && echo "discovered" +``` + +Expected: prints the entry and `discovered`. + +- [ ] **Step 3: Write the e2e spec file** + +Create `packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json`: + +```json +{ + "fixture": "examples/custom-esbuild/esbuild-add-app", + "package": "@angular-builders/custom-esbuild", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["esbuild-add-app", "build", "@angular-builders/custom-esbuild:application"] }, + { "fn": "assertBuilderForTarget", "args": ["esbuild-add-app", "serve", "@angular-builders/custom-esbuild:dev-server"] }, + { "fn": "assertDevDependency", "args": ["@angular-builders/custom-esbuild"] } + ], + "post": ["npx ng build"] +} +``` + +> Asserts the safe esbuild-build rewrite (spec §12.3). `post` runs `ng build` (green proves the rewritten builder runs). `ng test` is intentionally omitted: the Karma test target is left untouched (advisory branch), so testing it would re-cover existing Karma behavior, not the schematic. The webpack-guard branch is Task 4. + +- [ ] **Step 4: Add the integration entry** + +Append to `packages/custom-esbuild/tests/integration.js`: + +```javascript + // --- ng add e2e (Plan 04) --- + { + id: 'ng-add-esbuild-rewrite', + name: 'custom-esbuild: ng add build/serve rewrite', + purpose: 'ng add rewrites esbuild build/serve to custom-esbuild; ng build green', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json', + }, +``` + +- [ ] **Step 5: Run the case** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn workspace @angular-builders/custom-esbuild build +node scripts/run-local-tests.js --id ng-add-esbuild-rewrite --verbose +``` + +Expected: `PASS [..s] ng-add-esbuild-rewrite`. Log shows `build`/`serve` rewritten and `ng build` exit 0. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add examples/custom-esbuild/esbuild-add-app packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json packages/custom-esbuild/tests/integration.js +git commit --no-verify -m "test(custom-esbuild): add build/serve rewrite ng add e2e" +``` + +--- + +## Task 4: custom-esbuild webpack-build guard `ng add` e2e [ESSENTIAL] + +**Spec:** §12.3 (do NOT silently swap a webpack build; emit advisory), §8. Checklist: "the webpack-build guard case — assert ng add does NOT silently swap it and emits the advisory". + +**Files:** +- Create: `examples/custom-esbuild/webpack-guard-app/` +- Create: `packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json` +- Modify: `packages/custom-esbuild/tests/integration.js` + +- [ ] **Step 1: Build a webpack-build fixture** + +The guard fires when `build` is `@angular-devkit/build-angular:browser` (webpack). Reuse the esbuild fixture's structure but rewrite its `build`/`serve` builders to the webpack builders. Copy the Task 3 fixture and edit `angular.json`: + +```bash +cd /Users/jeb/personal-projects/angular-builders +cp -R examples/custom-esbuild/esbuild-add-app examples/custom-esbuild/webpack-guard-app +``` + +Then edit `examples/custom-esbuild/webpack-guard-app/package.json` → `"name": "webpack-guard-app"`. Edit `examples/custom-esbuild/webpack-guard-app/angular.json` so the `build` target builder is `@angular-devkit/build-angular:browser` and `serve` is `@angular-devkit/build-angular:dev-server` (and ensure `@angular-devkit/build-angular` is in the fixture devDependencies — it is hoisted from the workspace). Replace the generated `application` options with `browser`-builder options (`main`, `index`, `tsConfig`, `polyfills: ["zone.js"]`, `outputPath`, `assets`, `styles`) — mirror `examples/custom-webpack/sanity-app/angular.json`'s `build` options. + +```bash +cd /Users/jeb/personal-projects/angular-builders && yarn workspaces list --json | grep webpack-guard-app && echo "discovered" +``` + +Expected: prints the entry and `discovered`. + +- [ ] **Step 2: Write the e2e spec file** + +Create `packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json`: + +```json +{ + "fixture": "examples/custom-esbuild/webpack-guard-app", + "package": "@angular-builders/custom-esbuild", + "ngAddArgs": [], + "expectAddSucceeds": true, + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["webpack-guard-app", "build", "@angular-devkit/build-angular:browser"] }, + { "fn": "assertBuilderForTarget", "args": ["webpack-guard-app", "serve", "@angular-devkit/build-angular:dev-server"] }, + { "fn": "assertLogContains", "args": ["use-application-builder"] } + ], + "post": [] +} +``` + +> The two `assertBuilderForTarget` asserts prove the webpack `build`/`serve` were **left untouched** (no silent swap). `assertLogContains` proves the advisory was emitted (the §12.3 message points at Angular's `use-application-builder` migration). `expectAddSucceeds: true` — the guard is advisory, `ng add` still exits 0. No `post` build: the point is the guard, not a webpack build (already covered by custom-webpack examples). + +- [ ] **Step 3: Add the integration entry** + +Append to `packages/custom-esbuild/tests/integration.js`: + +```javascript + { + id: 'ng-add-esbuild-webpack-guard', + name: 'custom-esbuild: ng add webpack guard', + purpose: 'ng add leaves a webpack build untouched and emits the use-application-builder advisory', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json', + }, +``` + +- [ ] **Step 4: Run the case** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn workspace @angular-builders/custom-esbuild build +node scripts/run-local-tests.js --id ng-add-esbuild-webpack-guard --verbose +``` + +Expected: `PASS [..s] ng-add-esbuild-webpack-guard`. Log shows `ng add` exit 0, builders unchanged, advisory text present. + +> CALIBRATION: if the advisory string in the implemented schematic differs from `use-application-builder`, update the `assertLogContains` arg to match the exact advisory text emitted by `packages/custom-esbuild/src/schematics/ng-add/index.ts` (Plan 02 Task 3b). Read that file to confirm the exact wording before finalizing. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add examples/custom-esbuild/webpack-guard-app packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json packages/custom-esbuild/tests/integration.js +git commit --no-verify -m "test(custom-esbuild): add webpack-build guard ng add e2e" +``` + +--- + +## Task 5: custom-webpack build/serve rewrite + scaffold `ng add` e2e [ESSENTIAL] + +**Spec:** §4.3, §12.1 (ng-add only), §8. Checklist: "custom-webpack build/serve rewrite + webpack.config.js scaffold → ng build green". + +**Files:** +- Create: `examples/custom-webpack/add-app/` +- Create: `packages/custom-webpack/tests/e2e/webpack-add.ng-add.json` +- Modify: `packages/custom-webpack/tests/integration.js` + +- [ ] **Step 1: Generate a clean fixture (no webpack config)** + +Generate a default app, then rewrite `build`/`serve` to the webpack builders custom-webpack wraps (`@angular-devkit/build-angular:browser` / `:dev-server`). Generate with Karma so `ng test` stays simple (custom-webpack does not touch `test`): + +```bash +cd /Users/jeb/personal-projects/angular-builders +TMP=$(mktemp -d) +npx -y @angular/cli@22.0.0-rc.2 new add-app \ + --directory "$TMP/add-app" \ + --test-runner karma \ + --routing=false --style=scss --skip-git --skip-install --package-manager=yarn +rm -rf "$TMP/add-app/.git" "$TMP/add-app/.vscode" "$TMP/add-app/yarn.lock" "$TMP/add-app/.editorconfig" +mkdir -p examples/custom-webpack +cp -R "$TMP/add-app" examples/custom-webpack/add-app +rm -rf "$TMP" +``` + +- [ ] **Step 2: Rewrite to webpack builders + register/pin** + +Edit `examples/custom-webpack/add-app/package.json` → `"name": "add-app"`, `"private": true`, drop `packageManager`, pin Angular/CLI/`@angular-devkit/build-angular`/typescript/zone.js to the versions in `examples/custom-webpack/sanity-app/package.json`. Edit `examples/custom-webpack/add-app/angular.json` so `build` builder is `@angular-devkit/build-angular:browser` and `serve` is `@angular-devkit/build-angular:dev-server`, with `build` options mirroring `examples/custom-webpack/sanity-app/angular.json` (`main`, `index`, `polyfills: ["zone.js"]`, `tsConfig`, `outputPath`, `assets`, `styles`). Ensure there is NO `webpack.config.js` in the fixture (the scaffold must be created by `ng add`). + +```bash +cd /Users/jeb/personal-projects/angular-builders && yarn workspaces list --json | grep custom-webpack/add-app && echo "discovered" && ls examples/custom-webpack/add-app/webpack.config.js 2>&1 | grep -q "No such" && echo "no-config-present" +``` + +Expected: prints `discovered` and `no-config-present`. + +- [ ] **Step 3: Write the e2e spec file** + +Create `packages/custom-webpack/tests/e2e/webpack-add.ng-add.json`: + +```json +{ + "fixture": "examples/custom-webpack/add-app", + "package": "@angular-builders/custom-webpack", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["add-app", "build", "@angular-builders/custom-webpack:browser"] }, + { "fn": "assertBuilderForTarget", "args": ["add-app", "serve", "@angular-builders/custom-webpack:dev-server"] }, + { "fn": "assertFileContains", "args": ["webpack.config.js", "module.exports"] }, + { "fn": "assertDevDependency", "args": ["@angular-builders/custom-webpack"] } + ], + "post": ["npx ng build"] +} +``` + +> Asserts the build/serve rewrite, the scaffolded `webpack.config.js` (contains `module.exports` per Plan 03 Task 3's template), and self saved to devDeps. `post` `ng build` green proves the custom-webpack builder runs with the scaffolded (inert) config. custom-webpack ships ng-add only (no migration), so no `ng update` smoke here. + +- [ ] **Step 4: Add the integration entry** + +Append to `packages/custom-webpack/tests/integration.js`: + +```javascript + // --- ng add e2e (Plan 04) --- + { + id: 'ng-add-webpack-rewrite-scaffold', + name: 'custom-webpack: ng add rewrite + scaffold', + purpose: 'ng add rewrites build/serve, scaffolds webpack.config.js; ng build green', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/custom-webpack/tests/e2e/webpack-add.ng-add.json', + }, +``` + +- [ ] **Step 5: Run the case** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn workspace @angular-builders/custom-webpack build +node scripts/run-local-tests.js --id ng-add-webpack-rewrite-scaffold --verbose +``` + +Expected: `PASS [..s] ng-add-webpack-rewrite-scaffold`. Log shows builders rewritten, `webpack.config.js` created, `ng build` exit 0. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add examples/custom-webpack/add-app packages/custom-webpack/tests/e2e/webpack-add.ng-add.json packages/custom-webpack/tests/integration.js +git commit --no-verify -m "test(custom-webpack): add build/serve rewrite + scaffold ng add e2e" +``` + +--- + +## Task 6: jest `@21` post-migration build smoke [ESSENTIAL] + +**Spec:** §4.1 (`ng update @21` heavy migration), §8 (migrations: seed pre-migration shape, assert transforms — here extended to a real build/test). Checklist B: "seed a fixture in the old pre-21 jest config shape, run the jest @21 migration, then materialize the tree and ng build/ng test under v22 to prove the migrated config is valid/runnable". + +A full cross-major `ng update` is out of scope (needs old toolchain + network). Instead we run the **migration schematic only** via `ng update --migrate-only --from`, against a v22-installed fixture seeded in the pre-21 config shape, then build/test under v22. + +**Files:** +- Create: `examples/jest/old-config-app/` (v22 app, but with pre-21 jest config shape) +- Create: `scripts/e2e-jest-migration.js` +- Create: `packages/jest/tests/e2e/migration-v21.smoke.json` (intent documentation) +- Modify: `packages/jest/tests/integration.js` + +- [ ] **Step 1: Create the old-config fixture by copying the jest simple-app and downgrading its config shape** + +Start from a known-good jest fixture and rewrite its config into the pre-21 shape (the migration only rewrites config — no old toolchain needed): + +```bash +cd /Users/jeb/personal-projects/angular-builders +cp -R examples/jest/simple-app examples/jest/old-config-app +``` + +Edit `examples/jest/old-config-app/package.json` → `"name": "old-config-app"`. Keep `@angular-builders/jest: "workspace:*"` and v22-aligned Angular versions (the BUILD/TEST runs under v22). Now seed the **pre-21 jest config shape** that `migration-v21` transforms (spec §4.1, Plan 01 Tasks 7–10): + +1. Edit `examples/jest/old-config-app/tsconfig.spec.json` so `compilerOptions` does NOT have `module`/`moduleResolution: "Node16"` and does NOT have `isolatedModules: true` (use `"module": "ESNext"`, `"moduleResolution": "node"`, omit `isolatedModules`). This is what the migration patches. +2. Edit `examples/jest/old-config-app/angular.json` `test` target options into the old shape: use `"configPath": "jest.config.js"` (old name → migration renames to `config`) and add `"testPathPattern": "src/.*\\.spec\\.ts$"` (old name → migration renames to `testPathPatterns`). Keep the builder `@angular-builders/jest:run`. +3. Ensure `examples/jest/old-config-app/jest.config.js` exists (a minimal `module.exports = { preset: 'jest-preset-angular' };`) so `configPath` resolves post-rename. + +```bash +cd /Users/jeb/personal-projects/angular-builders && yarn workspaces list --json | grep old-config-app && echo "discovered" +``` + +Expected: prints the entry and `discovered`. + +- [ ] **Step 2: Write the migration-smoke driver** + +The migration must run against a workdir copy (don't mutate the fixture). `ng update --migrate-only --from` runs a package's migrations within a window without touching node_modules. Create `scripts/e2e-jest-migration.js`: + +```javascript +#!/usr/bin/env node +'use strict'; +// jest @21 migration POST-MIGRATION BUILD SMOKE. +// 1. Copy the old-config fixture to a temp workdir. +// 2. Run the jest @21 migration schematic (real CLI) via: +// ng update @angular-builders/jest --migrate-only --from=20.0.0 --to=22.0.0 --allow-dirty --force +// (--from < 21 <= --to so the (from, to] window includes the 21.0.0 threshold and migration-v21 fires). +// 3. Assert the config was actually transformed (renames + tsconfig patch). +// 4. ng build + ng test under v22 to prove the migrated config is valid/runnable. + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const FIXTURE = 'examples/jest/old-config-app'; + +function copyDir(src, dest) { + const SKIP = new Set(['node_modules', '.angular', 'dist', '.git']); + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyDir(s, d); + else fs.copyFileSync(s, d); + } +} + +function run(cmd, args, cwd) { + console.log(`[jest-migration] $ ${cmd} ${args.join(' ')} (cwd=${cwd})`); + const res = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); + return res.status; +} + +function main() { + const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'jest-mig-')); + copyDir(path.join(REPO_ROOT, FIXTURE), workdir); + fs.symlinkSync(path.join(REPO_ROOT, 'node_modules'), path.join(workdir, 'node_modules'), 'dir'); + + // Run ONLY the jest @21 migration over the (20, 22] window. + const status = run('npx', [ + 'ng', 'update', '@angular-builders/jest', + '--migrate-only', '--from=20.0.0', '--to=22.0.0', '--allow-dirty', '--force', + ], workdir); + if (status !== 0) throw new Error(`ng update --migrate-only failed with status ${status}`); + + // Assert the transforms landed. + const ng = JSON.parse(fs.readFileSync(path.join(workdir, 'angular.json'), 'utf8')); + const proj = ng.projects['old-config-app']; + const testOpts = (proj.architect || proj.targets).test.options || {}; + if (testOpts.configPath !== undefined) throw new Error('configPath not renamed (still present)'); + if (testOpts.config === undefined) throw new Error('config (renamed from configPath) missing'); + if (testOpts.testPathPattern !== undefined) throw new Error('testPathPattern not renamed'); + if (testOpts.testPathPatterns === undefined) throw new Error('testPathPatterns (renamed) missing'); + + const spec = JSON.parse(fs.readFileSync(path.join(workdir, 'tsconfig.spec.json'), 'utf8')); + if (spec.compilerOptions.module !== 'Node16') throw new Error('tsconfig module not Node16'); + if (spec.compilerOptions.isolatedModules !== true) throw new Error('isolatedModules not true'); + console.log('[jest-migration] transform assertions OK'); + + // Prove the migrated config is valid/runnable under v22. + if (run('sh', ['-c', 'npx ng build'], workdir) !== 0) throw new Error('ng build failed post-migration'); + if (run('sh', ['-c', 'npx ng test'], workdir) !== 0) throw new Error('ng test failed post-migration'); + + console.log('[jest-migration] PASS'); +} + +try { + main(); +} catch (err) { + console.error(`[jest-migration] FAIL: ${err.message}`); + process.exit(1); +} +``` + +> CALIBRATION: the assertion property names (`config`/`configPath`, `testPathPatterns`/`testPathPattern`, `Node16`, `isolatedModules`) come directly from Plan 01 Tasks 7–8 (jest `@21` migration). If Plan 01's final implementation renames differently, reconcile these strings against `packages/jest/src/schematics/migrations/v21/index.ts` before finalizing. + +- [ ] **Step 3: Write the intent-documentation spec file** + +Create `packages/jest/tests/e2e/migration-v21.smoke.json` (documents intent; the integration entry calls the dedicated driver directly): + +```json +{ + "describes": "jest @21 migration post-migration build smoke", + "fixture": "examples/jest/old-config-app", + "runs": "scripts/e2e-jest-migration.js", + "window": "ng update --migrate-only --from=20.0.0 --to=22.0.0", + "proves": "renames (configPath->config, testPathPattern->testPathPatterns) + tsconfig Node16/isolatedModules, then ng build + ng test green under v22" +} +``` + +- [ ] **Step 4: Add the integration entry** + +Append to `packages/jest/tests/integration.js`: + +```javascript + { + id: 'ng-update-jest-v21-smoke', + name: 'jest: @21 migration post-build smoke', + purpose: 'jest @21 migration produces valid config; ng build + ng test green under v22', + app: '.', + command: 'node scripts/e2e-jest-migration.js', + }, +``` + +- [ ] **Step 5: Run the case** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn workspace @angular-builders/jest build +node scripts/run-local-tests.js --id ng-update-jest-v21-smoke --verbose +``` + +Expected: `PASS [..s] ng-update-jest-v21-smoke`. Log shows `ng update --migrate-only` running `migration-v21`, transform assertions OK, `ng build` exit 0, `ng test` exit 0. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add examples/jest/old-config-app scripts/e2e-jest-migration.js packages/jest/tests/e2e/migration-v21.smoke.json packages/jest/tests/integration.js +git commit --no-verify -m "test(jest): add @21 migration post-build smoke e2e" +``` + +--- + +## Task 7: RC-gated multi-major `ng update` window validation [ESSENTIAL — validation, no new CI job] + +**Spec:** §10 (v22 RC gating), checklist "RC-gated validations": confirm the CLI permits a third-party package's old→22 jump and runs the spanned migrations; cross-references jest Plan 01 Task 6 note. This is a one-time manual validation during execution, recorded in the runbook — NOT a recurring matrix job (it depends on real network/registry behavior and is too flaky to gate CI). + +**Files:** +- Modify: `docs/runbooks/angular-major-upgrade.md` + +- [ ] **Step 1: Run the multi-major window probe against the RC** + +On a temp copy of `examples/jest/old-config-app` (with `@angular-builders/jest` pinned to an old major in package.json if probing the real install path), confirm whether `ng update` permits the old→22 jump for the package and runs the spanned migrations: + +```bash +cd /Users/jeb/personal-projects/angular-builders +TMP=$(mktemp -d) && cp -R examples/jest/old-config-app "$TMP/app" && ln -s "$PWD/node_modules" "$TMP/app/node_modules" +cd "$TMP/app" && npx ng update @angular-builders/jest@22.0.0-rc.2 --from=20.0.0 --migrate-only --allow-dirty --force; echo "exit=$?" +``` + +Expected: exit 0 with `migration-v21` (and the `migration-v22` advisory) appearing in output. If the CLI **refuses** the multi-major jump, note the refusal and the documented fallback: `ng update @angular-builders/jest@22 --from= --migrate-only` (per Plan 01 Task 6 / checklist). + +- [ ] **Step 2: Record the result in the upgrade runbook** + +In `docs/runbooks/angular-major-upgrade.md`, under the migration-coverage section, add a subsection (fill in the actual observed behavior — permitted vs refused — before committing): + +```markdown +### RC-validated: multi-major `ng update` window (v22) + +Validated against `@angular/cli@22.0.0-rc.2` on `2026-06-02`: + +- `ng update @angular-builders/jest` from an old major (e.g. 20) to 22 in ONE step runs the spanned + migrations — the `(from, to]` window includes the `21.0.0` threshold, so the heavy `migration-v21` + fires. Dragging the builder *stepwise* through 21 (which shipped no `migrations.json`) SKIPS it. +- Supported flow for v17–v20 users: upgrade the Angular framework stepwise to 22, leave + `@angular-builders/jest` untouched, then run `ng update @angular-builders/jest` ONCE. +- If the CLI refuses the multi-major jump, use: + `ng update @angular-builders/jest@22 --from= --migrate-only`. +- E2E coverage of the migration output itself: `ng-update-jest-v21-smoke` (Plan 04 Task 6). +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add docs/runbooks/angular-major-upgrade.md +git commit --no-verify -m "docs(runbook): record RC-validated multi-major ng update window" +``` + +--- + +## Task 8: Matrix sanity + CI-cost annotation + +**Why:** New `examples/*` apps must be tracked (so version-update automation + Yarn workspaces see them), and `discover-tests.js` must emit all new entries with unique ids. Then control CI cost. + +**Files:** +- Verify: the six new fixtures' `package.json` files are git-tracked +- Modify (conditional): `packages/custom-esbuild/tests/integration.js` (optional annotation) + +- [ ] **Step 1: Confirm `update-example.js` / Yarn discover the new fixtures** + +```bash +cd /Users/jeb/personal-projects/angular-builders +node -e 'const cp=require("child_process"); const out=cp.execSync("git ls-files examples/*/*/package.json examples/*/package.json").toString(); ["karma-app","vitest-app","old-config-app","esbuild-add-app","webpack-guard-app","custom-webpack/add-app"].forEach(n=>{ if(!out.includes(n)) { console.error("MISSING fixture package.json: "+n); process.exit(1);} }); console.log("all fixtures tracked");' +``` + +Expected: prints `all fixtures tracked`. (Confirms each new fixture has a tracked `package.json` so version automation and Yarn workspaces see them.) + +- [ ] **Step 2: Confirm all new ids are unique and discovered** + +```bash +cd /Users/jeb/personal-projects/angular-builders +node scripts/discover-tests.js | node -e ' +const data = JSON.parse(require("fs").readFileSync(0, "utf8")); +const ids = data.include.map(t => t.id); +const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); +if (dupes.length) { console.error("DUPLICATE ids: " + dupes.join(", ")); process.exit(1); } +const want = ["ng-add-karma-to-jest","ng-add-vitest-to-jest","ng-add-esbuild-rewrite","ng-add-esbuild-webpack-guard","ng-add-webpack-rewrite-scaffold","ng-update-jest-v21-smoke"]; +const missing = want.filter(w => !ids.includes(w)); +if (missing.length) { console.error("MISSING ids: " + missing.join(", ")); process.exit(1); } +console.log("matrix ok: " + ids.length + " total tests, all 6 e2e ids present, no dupes"); +' +``` + +Expected: `matrix ok: total tests, all 6 e2e ids present, no dupes`. + +- [ ] **Step 3: Run the full e2e subset locally (cost check)** + +```bash +cd /Users/jeb/personal-projects/angular-builders +yarn build:packages:all +node scripts/run-local-tests.js \ + --id ng-add-karma-to-jest --id ng-add-vitest-to-jest \ + --id ng-add-esbuild-rewrite --id ng-add-esbuild-webpack-guard \ + --id ng-add-webpack-rewrite-scaffold --id ng-update-jest-v21-smoke \ + --concurrency 2 --verbose +``` + +Expected: `Results: 6 passed, 0 failed`. Note total duration — if any single `ng add` + build case dominates wall-clock, apply Step 4. + +- [ ] **Step 4: Annotate the optional case for CI-cost control** + +Essential set (keep always): `ng-add-karma-to-jest`, `ng-add-vitest-to-jest`, `ng-add-esbuild-webpack-guard`, `ng-add-webpack-rewrite-scaffold`, `ng-update-jest-v21-smoke`. The single demotable candidate is `ng-add-esbuild-rewrite` (the esbuild safe-rewrite is the least regression-prone and is partly covered by the webpack-guard case proving the classifier). Add above its entry in `packages/custom-esbuild/tests/integration.js`: + +```javascript + // [OPTIONAL] esbuild safe-rewrite — least regression-prone; enable under ci:full if matrix cost allows. +``` + +(Leave the entry active by default; the comment documents intent. Do not remove it.) + +- [ ] **Step 5: Commit any annotation tweaks** + +```bash +cd /Users/jeb/personal-projects/angular-builders +git add packages/custom-esbuild/tests/integration.js +git commit --no-verify -m "test(schematics): annotate optional esbuild e2e case for CI cost" +``` + +--- + +## Self-Review + +Mapping spec §8/§12.4 + the 2c/2d checklist items to tasks. Run with fresh eyes. + +**1. Spec / checklist coverage:** + +| Requirement (source) | Task | +| --- | --- | +| §8 integration: one `examples/` e2e per real builder, wired into existing matrix | Tasks 1–5 (entries in `packages/*/tests/integration.js`) | +| §8 + checklist: `ng add` against UNPUBLISHED local build via `npm pack` → `ng add ./`; lighter `--collection` fallback documented; verdaccio noted as overkill | Task 0 (`e2e-ng-add.js`: preferred pack path + `useCollectionFallback`; verdaccio noted as overkill in header comment + Prerequisites) | +| §12.2 + checklist: jest Karma→Jest (fixture via `ng new --test-runner karma`; Karma removed; `ng test` green) | Task 1 | +| §12.2 + checklist: jest Vitest→Jest (default v22 app; rewrite; `ng test` green) | Task 2 | +| §4.2/§12.3 + checklist: custom-esbuild build/serve rewrite on esbuild app → `ng build`/`ng test` green | Task 3 | +| §12.3 + checklist: webpack-build guard — NOT silently swapped + advisory emitted | Task 4 | +| §4.3/§12.1 + checklist: custom-webpack build/serve rewrite + `webpack.config.js` scaffold → `ng build` green | Task 5 | +| Checklist B: jest `@21` post-migration build smoke (seed old config shape, run migration, build/test under v22) | Task 6 | +| Checklist + Plan 01 Task 6: RC-gated multi-major `ng update` window validation on `22.0.0-rc.2` | Task 7 | +| §12.4: Karma fixture generated normally (not checked-in) | Task 1 Step 1 (uses `ng new --test-runner karma`) | +| Constraint: reuse existing matrix/harness, fixtures under `examples/`, entries in `packages/*/tests/integration.js` | All tasks (entries follow `{id,name,purpose,app,command}`; helper run via `sh -c` from `app`) | +| Constraint: no unit-coverage duplication | All cases drive the real CLI + real build/test; zero `SchematicTestRunner` usage | +| Constraint: CI cost — essential vs optional | Task 8 Step 4 (`[OPTIONAL]` annotation on `ng-add-esbuild-rewrite`); each task tagged [ESSENTIAL] | + +**2. Placeholder scan:** No `TODO`/`TBD`/"add appropriate…" left. Three intentional CALIBRATION notes (Task 4 advisory string; Task 6 rename property names; Task 7 observed CLI behavior) point at exact source files from Plans 02/01 — these are calibrate-against-real-output instructions (the same pattern Plans 00–03 use for generator calibration), not placeholders; the asserted strings are concrete defaults taken from those plans. + +**3. Type / name consistency:** Helper fn names are consistent across every spec JSON file and `e2e-assert.js`: `assertFileAbsent`, `assertFileContains`, `assertBuilderForTarget`, `assertLogContains`, `assertDevDependency`. The driver dispatches `assertLogContains` with the log path (first arg) and all others with `workdir` (first arg) — matches `e2e-assert.js` signatures. Integration ids are unique and verified in Task 8 Step 2. The `app: '.'` convention (run from repo root) is consistent across all six new entries and matches the harness `cwd = path.join(repoRoot, test.app)` contract. + +**4. Known calibration dependencies (flagged for executor):** (a) Task 4's `assertLogContains` arg must match the exact advisory wording in `packages/custom-esbuild/src/schematics/ng-add/index.ts` (Plan 02 Task 3b). (b) Task 6's renamed-option names must match `packages/jest/src/schematics/migrations/v21/index.ts` (Plan 01 Tasks 7–8). (c) Task 7's behavior (multi-major permitted vs refused) is observed at execution time and written into the runbook. (d) Fixture Angular versions must be pinned to the workspace versions present at execution (RC → GA); follow the existing `examples/jest/simple-app` / `examples/custom-webpack/sanity-app` versions as the source of truth. diff --git a/docs/superpowers/plans/2026-06-02-builder-schematics-2c-2d-execution-checklist.md b/docs/superpowers/plans/2026-06-02-builder-schematics-2c-2d-execution-checklist.md new file mode 100644 index 0000000000..1e6a80ec26 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-2c-2d-execution-checklist.md @@ -0,0 +1,36 @@ +# Builder Schematics — Stream 2c/2d Execution Checklist + +> Companion to Plans 00–03 (+ the proposed Plan 04 e2e). Captures the cross-cutting work that isn't inside a single per-builder plan. **All gated on `release/v22` being green** (PR #2264 merged), since execution builds/tests against Angular 22. + +## 2c — Execution + holds + +- [ ] Execute **Plan 00** (common/schematics core) first — it locks the API the builder plans import. +- [ ] Execute **Plans 01–03** (jest, custom-esbuild, custom-webpack). 02/03 can parallelize; **01 (jest) is the long pole**. +- [ ] Execute **Plan 04** (integration e2e) — see Testing below. +- [ ] Rebase the v22-held breaking PRs onto `release/v22`: **#2191** (jest isolatedModules default) and **#2212** (jest per-project coverage). **#2260 is NOT a v22 hold** — Karma isn't removed in v22 (spec §12); it's held for the future major where Angular removes Karma. +- [ ] Confirm each held breaking change is covered by **both** a migration step AND a `MIGRATION.MD` entry (process invariant, spec §11). Current set is the jest `@22` advisory covering **#2191 + #2212**. (custom-webpack has no v22 migration — see spec §12.) +- [ ] Re-enumerate `breaking-change`-labeled open PRs at the v22 cut — any added later MUST also carry a migration step + `MIGRATION.MD` entry. +- [ ] Close/supersede **#2240** and **#2241** once the consolidated schematics work is up, with a comment pointing at the replacement. + +## 2d — MIGRATION.MD pairing + +- [ ] Pair every `MIGRATION.MD` breaking-change entry with a migration outcome (✅ auto-transform or ⚠️ logged advisory). Annotate each item. +- [ ] **Document the upgrade flow for old-version users (v17–v20):** upgrade the Angular *framework* stepwise to 22, then run `ng update @angular-builders/jest` **once** (single old→22 builder update) so the migration window `(old, 22]` spans 21 and the heavy `migration-v21` actually fires. A stepwise builder update *through* 21 skips it (v21 shipped no `migrations.json`). See Plan 01 Task 6 note. +- [ ] Migration `logger.warn` advisories point users back to the relevant `MIGRATION.MD` section. + +## RC-gated validations (verify on `22.0.0-rc.2` during execution) + +- [ ] **Multi-major `ng update`:** confirm the CLI permits a third-party package's old→22 jump and runs the spanned migrations (framework blocks multi-major; packages with a `migrations.json` generally allow it). If refused, document the `ng update @angular-builders/jest@22 --migrate-only --from=` fallback. +- [ ] **Generator calibration:** the `application` schematic's v22 defaults (test-target presence, zone.js polyfill, project root) drive the unit-test expectations in Plans 00–03 — calibrate against real generator output (flagged in each plan). + +## Testing strategy (resolves "do we need e2e?") + +The per-builder plans cover the **unit** layer well (SchematicTestHarness + SchematicTestRunner/UnitTestTree — asserts transforms, idempotency, zone/zoneless branches). Two e2e gaps remain: + +- [ ] **`ng add` e2e (spec §8 — currently MISSING from all plans → Plan 04):** wire into the existing integration matrix (`packages/*/tests/integration.js` against `examples/*`). For each "real" builder: run `ng add @angular-builders/` on a fixture app, then `ng build` / `ng test`, assert green. Catches what unit tests can't — schema validity, real builder wiring, install behavior, real CLI invocation. Cases per the 2026-06-02 amendments (spec §12): jest **Karma→Jest AND Vitest→Jest**; custom-esbuild build/serve rewrite **+ the webpack-build guard advisory** (assert it does NOT silently swap a webpack build target); custom-webpack build/serve rewrite + `webpack.config.js` scaffold. +- [ ] **`ng update` post-migration smoke (jest `@21` heavy migration):** a full real-`ng update` e2e is high-cost/flaky and **not recommended** (would need an old Angular toolchain + network + cross-major simulation). Instead: seed a fixture in the *old config shape* (no old Angular toolchain needed — migrations only rewrite config), run the migration schematic, then materialize the tree and `ng build`/`ng test` **under v22** to prove the migrated config is actually valid/runnable — catches "syntactically-correct-but-semantically-broken" output that pure tree assertions miss. Add as a task in Plan 01 or Plan 04. + +### Plan 04 design constraints (two e2e gotchas — bake into the plan) + +- [ ] **Karma is NOT removed in v22 (spec §12) — generate the Karma fixture normally.** Use `ng new --test-runner karma` to scaffold the Karma→Jest e2e fixture; Karma is still a supported `ng new` option on v22. `ng add @angular-builders/jest` detects + removes Karma, then `ng test` validates via Jest. The **Vitest→Jest** path uses a default (Vitest) v22 app. The clean-add and custom-esbuild paths use a normal v22-shaped fixture. *(Corrected 2026-06-02 — the earlier "Karma removed, use a checked-in fixture" note was based on a false premise.)* +- [ ] **`ng add` against an unpublished (local-only) v22 build.** The package isn't on npm during CI. Mirror how the existing matrix consumes local workspace builds. Preferred: **`npm pack` the built package → `ng add ./`** in the fixture (exercises real resolve → install → run-collection incl. save-to-devDeps). Lighter alt: invoke the collection via the real CLI against the already workspace-linked package. Full-fidelity alt (publish/fetch): local **verdaccio** — likely overkill. Validate the chosen approach against the v22 RC. diff --git a/docs/superpowers/specs/2026-06-01-builder-schematics-design.md b/docs/superpowers/specs/2026-06-01-builder-schematics-design.md new file mode 100644 index 0000000000..3d28d122a1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-builder-schematics-design.md @@ -0,0 +1,221 @@ +# Builder Schematics: `ng add` + `ng update` for angular-builders (v22) + +**Status:** Design approved (2026-06-01) — pending spec review → implementation plan +**Scope:** `@angular-builders/jest`, `@angular-builders/custom-esbuild`, `@angular-builders/custom-webpack` +**Target:** Ships with the v22 major; built against the Angular 22 RC so it's ready when 22 lands. + +## 1. Goal & Motivation + +Give every "real" builder a first-class install and upgrade experience: + +- **`ng add @angular-builders/`** wires the builder into the user's workspace correctly and automatically. +- **`ng update @angular-builders/`** migrates a user's project across Angular majors (v17→v22) for the (few) transitions that actually changed user-facing config. + +This is delivered as **one cohesive feature** (not per-builder PRs) introduced with the v22 major, so upgraders get migrations via `ng update`. + +`bazel` and `timestamp` are out of scope — they are thin wrappers with little to scaffold or migrate. + +## 2. Guiding Principles + +1. **Auto-detect over ask.** Never prompt for anything we can determine by inspecting the workspace (existing builders, polyfills, config files, dependencies). Prompts are reserved only for genuinely undeterminable intent — and the design currently has **none**. Explicit overrides are exposed as flags (`--project`, `--unit-test`), not prompts. +2. **`ng add` may prompt; migrations may not.** `ng add` is foreground/interactive (`x-prompt` works). `ng update` migrations run **headless** under Renovate/Dependabot/CI — they must use safe, detected defaults and communicate via `context.logger` advisories, never block. (Source: angular-cli #23205; the optional-migration selection prompt is CLI-level, not `x-prompt`.) +3. **Tree-based, utility-backed edits.** Use `@schematics/angular/utility` (`updateWorkspace`/`readWorkspace`, `addDependency` + `NodeDependencyType`, `JSONFile`) — never hand-parse JSON or touch `fs`. Preserves `--dry-run`, transactionality, and formatting. +4. **Idempotent.** Both `ng add` and migrations must be safe to re-run and tolerate partially-migrated workspaces (multiple migrations run back-to-back on a multi-major jump). +5. **Shared logic lives in `common`.** The no-cross-import invariant means shared schematic code goes in `@angular-builders/common`; per-package schematics are thin and delegate to it. + +## 3. Architecture + +Three layers: + +### 3.1 Shared core — `@angular-builders/common/schematics` + +Exposed under a subpath separate from the runtime `loadModule` exports. Contains: + +- **Rule-builder factories** (composable `Rule`s): + - `setBuilderForTarget(project, targetName, builderName, options?)` — rewrite a target's `builder` field via `updateWorkspace`, preserving existing options. + - `addBuilderDevDependency(name, version, { install })` — wrap `addDependency` with `NodeDependencyType.Dev`. + - `removeDevDependencies(names[])`, `removeFilesIfPresent(paths[])` — guarded cleanup. + - `editJsonFile(path, mutator)` — thin wrapper over `JSONFile` for `tsconfig.spec.json` etc. +- **Detection helpers:** + - `getProjectsToTarget(tree, optionProject?)` — single project → it; multi + `defaultProject` → default; multi + explicit `--project` → that; multi + none → **all** projects. + - `detectTestBuilder(workspace, project)` → `'karma' | 'jest' | 'vitest' | 'other' | 'none'`. + - `isZoneless(tree, project)` — zone.js absent from build `polyfills` and/or `provideZonelessChangeDetection` present in bootstrap → zoneless. +- **Version helpers:** `parseVersion`, `isAtLeast(major)`. +- **`SchematicTestHarness`** — builds a realistic workspace tree (via `@schematics/angular` `workspace`+`application`) for unit tests. + +Dependencies added to `common`: `@schematics/angular` + `@angular-devkit/schematics` (the latter is already transitive). Used **only** by the schematics subpath, not by builder runtime. + +### 3.2 Per-package `ng-add` (thin) + +Each package: `src/schematics/collection.json` + `ng-add/{index.ts,schema.json}`. `index.ts` is a `chain([...])` of shared rules + builder-specific bits (~40 LOC). `schema.json` exposes only `--project` (+ `--unit-test` for esbuild) with sensible defaults; **no `x-prompt`**. + +### 3.3 Per-package migrations + +Each package that needs them: `src/schematics/migrations.json` + `migrations//index.ts`. Migration **logic stays per-package** (a builder's breaking-change history is its own); only shared **helpers** come from `common`. `version` fields are valid semver thresholds; `ng update` runs every migration where `installedVersion < version <= targetVersion`. + +## 4. Per-Builder Behavior + +### 4.1 `jest` + +**Why installed:** replace Karma with Jest for `ng test`. + +**`ng add` (all auto-detected, zero prompts):** + +- Add jest stack to devDeps (`jest`, `jest-preset-angular`, `jest-environment-jsdom`); rewrite `test` target → `@angular-builders/jest:run`; schedule install. +- **If Karma detected** (`test` builder is `:karma`, or `karma.conf.*` present, or karma/jasmine in devDeps): remove karma/jasmine devDeps, delete `karma.conf.js` + `src/test.ts`, fix `tsconfig.spec.json` (`types` jasmine→jest, drop `test.ts` from `files`). This is the implied intent of installing jest. +- **`zoneless`**: detect via `isZoneless`; set the option to match the app rather than asking. +- Idempotent: `test` already `:run` → no-op on the rewrite. + +**`ng update @21` (the one heavy migration; headless-safe):** + +- Auto: bump `jest`/`jest-environment-jsdom`/`jsdom` → 30/30/26; patch `tsconfig.spec.json` (`module`/`moduleResolution: "Node16"`, `isolatedModules: true`); rename `configPath`→`config` and `testPathPattern`→`testPathPatterns` in angular.json; strip removed `globalMocks` values (`styleTransform`/`getComputedStyle`/`doctype`) and removed Jest options (`browser`/`init`/`mapCoverage`/`testURL`/`timers`); set `zoneless` by **detection** (zone.js in polyfills → `false`; else leave default `true`). +- Advise (logger): `Node16` may surface pre-existing type errors; removed mocks may need manual replacement. + +**`ng update @22` (advisory-only — these land via v22-held breaking PRs):** + +The v22 jest breaking changes are internal default flips that apply automatically on upgrade (no user-file rewrite needed), so the migration's job is to _warn_, not transform: + +- **#2191 — `isolatedModules` now defaults to `true`** (faster ts-jest compile). Applies automatically. Advise: `const enum` across files and type-only re-exports without the `type` modifier will now error; fix the call sites, or restore `isolatedModules: false` in your jest config. Optionally grep spec sources for `const enum` to make the warning targeted. We do **not** auto-set `false` — the new default is intentional. +- **#2212 — per-project coverage output** moves from `./coverage` to `/coverage`. Affects only multi-project / `projectRoot !== workspaceRoot` workspaces — **detectable**, so warn only the affected ones: update any CI/tooling reading a hardcoded `./coverage/` path. + +### 4.2 `custom-esbuild` + +**Why installed:** inject custom esbuild plugins / index-html transformers / configuration into the build without ejecting. + +**`ng add` (auto-detected):** + +- Add `@angular-builders/custom-esbuild` to devDeps; rewrite `build` → `:application`, `serve` → `:dev-server` (preserve options); schedule install. +- **Test consistency (key):** the `:unit-test` builder applies the same `codePlugins` to the Vitest run via `buildTarget`. So: + - `test` on `@angular/build:unit-test` (Vitest) → **auto-rewrite** to `@angular-builders/custom-esbuild:unit-test`, wiring `buildTarget` to the project build target. Required so plugins apply consistently to tests. + - `test` on Karma/Jest → **leave** (different toolchain; esbuild plugins don't apply there). Emit advisory pointing at `custom-esbuild:unit-test`. + - `--unit-test` flag: force-create a Vitest test target even if none exists. +- Idempotent. + +**`ng update`:** **none.** custom-esbuild first appeared at v17 and every change since (plugins, indexHtmlTransformer, the `unit-test` builder added in 20.1.0) was additive. No user-facing config broke. + +### 4.3 `custom-webpack` + +**Why installed:** inject custom webpack config into the build without ejecting (inert without a config file). + +**`ng add` (auto-detected):** + +- Add dep; rewrite `build` → `:browser`, `serve` → `:dev-server`; schedule install. +- **Scaffold config:** if no `customWebpackConfig` is referenced and no `webpack.config.*` exists → create a starter `webpack.config.js` and set `customWebpackConfig` to it. If one already exists → leave it. (It's the reason the builder is installed; no prompt.) +- Idempotent. + +**`ng update`: none.** (Superseded 2026-06-02 — see §12.) Angular **22 does not remove Karma** — it is deprecated (default flipped to Vitest) but `@angular/build:karma` still ships, and `ng update` keeps existing Karma users on Karma. The earlier `@22` Karma-removal migration assumed a removal that isn't happening in v22, so **custom-webpack ships `ng-add` only**, like custom-esbuild. PR #2260 (remove the custom-webpack Karma builder) is held for the future major where Angular actually removes Karma. + +## 5. Migration Chain Summary + +| Transition | jest | custom-esbuild | custom-webpack | +| --------------------- | -------------------------------------- | ---------------- | --------------------------------------- | +| 17→18 / 18→19 / 19→20 | no-op | no-op | no-op | +| 20→21 | **migration (heavy)** | no-op (additive) | no-op | +| 21→22 | **migration (advisory: #2191, #2212)** | no-op | no-op (Karma retained in v22 — see §12) | + +Real migrations: **jest `@21`** (heavy auto-transform) and **jest `@22`** (advisory). custom-webpack has **no migration** — the earlier `@22` Karma-removal migration was dropped 2026-06-02 (Angular 22 doesn't remove Karma; see §12). All other major transitions are plain dependency bumps handled by `ng update` itself — **no no-op placeholder migrations** (a deliberate departure from earlier prototypes that registered empty v18/v19/v20 migrations). + +**The v22 migration set is defined by the v22-bound breaking PRs, not by master's changelog history.** Any PR labeled `breaking-change` and held for the major lands in v22 and MUST carry a migration step (auto-transform or logged advisory). Current set: **#2191** (jest isolatedModules default) and **#2212** (jest per-project coverage). **#2260** (custom-webpack Karma removal) was **removed from the v22 set** 2026-06-02 — Karma is not removed in v22; #2260 is held for a future major (see §12). Re-enumerate `breaking-change`-labeled open PRs at the v22 cut to catch any added later. + +## 6. Coverage Checklist (applied per builder) + +| Dimension | jest | custom-esbuild | custom-webpack | +| ----------------------------- | ----------------------------------------------------- | ----------------------------------- | ----------------------------------- | +| ng-add: deps add/remove | +jest stack / −karma,jasmine | +self | +self | +| ng-add: targets rewritten | `test` | `build`, `serve`, `test`(if Vitest) | `build`, `serve` | +| ng-add: files created/deleted | del `karma.conf`,`test.ts` | — | create `webpack.config.js` | +| ng-add: tsconfig edits | spec `types`/`files` | — | — | +| ng-add: detection | Karma?, zoneless? | test builder kind | webpack config present? | +| ng-add: flags | `--project` | `--project`, `--unit-test` | `--project` | +| ng-add: idempotency | `test` already `:run` | `build` already `:application` | `build` already `:browser` | +| ng-update migrations | `@21`, `@22` | none | `@22` | +| migration auto transforms | deps, tsconfig, renames, mocks, zoneless(detected) | — | karma cleanup (gated) | +| migration advisories | Node16, removed mocks, isolatedModules, coverage path | — | no Karma replacement → Vitest/jest | +| package.json fields | `schematics`, `ng-add`, `ng-update` | `schematics`, `ng-add` | `schematics`, `ng-add`, `ng-update` | +| tests | ng-add + migration(+idempotency) | ng-add | ng-add + migration | + +## 7. Packaging / Build + +- Shared base `tsconfig.schematics.json` (repo root), extended per package: `module: "commonjs"`, `rootDir: src/schematics`, `outDir: dist/schematics`, exclude `**/*.spec.ts` and any `files/**` templates. +- Build sequence per package: `tsc (lib) → tsc (schematics) → copy schematics assets`. A `postbuild`/`copy:schematics` step copies `collection.json`, `migrations.json`, and every `schema.json` into `dist/schematics` (TypeScript does not copy JSON/templates). +- `package.json` fields reference dist-relative paths: + - All three: `"schematics": "./dist/schematics/collection.json"`, `"ng-add": { "save": "devDependencies" }`. + - jest + custom-webpack: `"ng-update": { "migrations": "./dist/schematics/migrations.json" }`. +- `common` mirrors the same `tsconfig.schematics.json` + copy approach for its shared schematics subpath. + +## 8. Testing Strategy + +- **Unit (per package):** `SchematicTestRunner` + `UnitTestTree` on the shared `SchematicTestHarness`. Build a realistic workspace via `@schematics/angular` `workspace`+`application`; assert the transformed `angular.json`/`tsconfig.spec.json`/`package.json`. For `NodePackageInstallTask`, assert the task was scheduled (no real install in tests). +- **Migrations:** seed the tree in pre-migration shape; assert transforms; add an **idempotency** test (run twice == run once); cover both zone and zoneless detection branches for jest `@21`. +- **Integration:** one `examples/` end-to-end check that runs `ng add` on a fixture app and verifies the builder is wired and `ng test`/`ng build` works. Wired into the existing integration matrix. + +## 9. Non-Goals + +- No `ng add`/migrations for `bazel` or `timestamp`. +- No automatic test-runner switch for Karma/Jest users under custom-esbuild ng-add (advisory only). +- No auto-deletion of the webpack Karma test target in the `@22` migration (advisory, since no replacement exists). +- No interactive prompts anywhere. + +## 10. Risks / Open Items + +- **v22 not released** (only `22.0.0-rc.2`). The custom-webpack `@22` migration and `:karma` removal (#2260) are gated on the v22 release; build/validate against the RC. +- **Multi-project workspaces:** default to targeting all projects when there's no `defaultProject`; `--project` overrides. Validate behavior on the multi-project example fixtures. +- **`Node16` tsconfig change** in jest `@21` can surface latent type errors in user code — advisory only; we don't attempt to fix user types. +- **Existing WIP** (#2240, #2241, branches `feat/schematics-bundle`, `feat/jest-schematics-ng-add`, `feature/22-schematics-installation`) is reference-only. #2240/#2241 to be closed/superseded once the consolidated PR is up. + +## 11. Relationship to `MIGRATION.MD` and the upgrade runbook + +`MIGRATION.MD` (root, hand-written, per-major back to v7→v8) is today the _only_ migration aid — users read it and apply steps manually. Once `ng update` schematics exist, the two must be managed as a pair, not allowed to drift: + +- **`MIGRATION.MD` stays the human-canonical record** of per-major breaking changes (also serves users who don't run `ng update`). +- **Each `MIGRATION.MD` breaking-change entry maps to a migration outcome**: either an auto-transform or a logged advisory. Both are driven by one source — the per-major breaking-change inventory. The v20→21 section already enumerates exactly what the jest `@21` schematic automates. +- **Annotate automation in `MIGRATION.MD`**: mark each item ✅ automated by `ng update` vs ⚠️ manual. Migration `logger.warn` advisories should point users to the relevant `MIGRATION.MD` section for detail. +- **CHANGELOG** stays auto-generated from conventional commits; it is orthogonal — `MIGRATION.MD` + the migration schematic are the human/automated migration pair. + +### Process invariant (for AGENTS.md + the upgrade runbook) + +> A breaking change landing in (or held for) a major release MUST ship with **both** (a) a migration step — auto-transform or logged advisory — in that builder's `migrations.json`, and (b) a `MIGRATION.MD` entry for that major. + +The upgrade runbook's per-major checklist therefore includes: **enumerate every `breaking-change`-labeled PR targeting the major → for each, confirm a migration step exists and a `MIGRATION.MD` entry exists.** This closes the loop between the "hold for next major" workflow and migration coverage. + +## 12. Design Amendments (2026-06-02) + +Four decisions made after the original design, grounded in the actual Angular `22.0.0-rc.3` install + Angular's published Karma roadmap. **These supersede the sections noted.** + +### 12.0 Karma roadmap finding (the basis for 12.1–12.3) + +Verified against installed `@angular/build` / `@angular-devkit/build-angular` `22.0.0-rc.3` and Angular docs: + +- **Karma is deprecated, NOT removed in v22.** The `karma` builder still ships in both build systems (not flagged deprecated/hidden in `builders.json`); `ng new` flips the **default** test runner to Vitest but still offers `--test-runner karma`. Vitest's `unit-test` builder is still `[EXPERIMENTAL]`. +- **`ng update` to v22 keeps Karma users on Karma** — the application/`use-application-builder` migration rewrites the test builder to `@angular/build:karma`; it does NOT delete `karma.conf.js`/`test.ts` or switch to Vitest. Vitest is opt-in via `ng generate @angular/core:karma-to-vitest`. +- **Karma security-only support** continues until ~12 months after Vitest is marked stable (clock not started — still experimental); Angular's deprecation policy is **min. two majors**, putting full removal at ≈v24+, not v22. +- **Consequence:** users carry Karma into v22 as a supported config, so **we must keep supporting Karma** (custom-webpack `:karma` builder + jest's Karma→Jest path). +- Sources: angular.dev/guide/testing/migrating-to-vitest; angular.dev/reference/releases; angular-cli commit 8654b3f (application migration migrates karma builder). + +### 12.1 custom-webpack: drop the `@22` migration; hold #2260 (supersedes §4.3, §5, §6) + +custom-webpack ships **`ng-add` only** (no `ng-update`, no `migrations.json`, no `ng-update` package.json field) — same shape as custom-esbuild. The `@22` Karma-removal migration is removed (false premise per 12.0). **PR #2260** (remove custom-webpack's `:karma` builder) is **held** for the major where Angular removes Karma; it must NOT land in v22. The v22 breaking-PR set is now just **#2191 + #2212** (both jest). + +### 12.2 jest `ng-add`: add Vitest→Jest as a first-class path (supersedes §4.1, §6) + +Keep the Karma→Jest path (Karma users persist into v22) AND add a Vitest→Jest path — the **forward-default** case, since fresh v22 apps default to Vitest. When `detectTestBuilder() === 'vitest'` (`@angular/build:unit-test` or any `:unit-test`): + +- Rewrite `test` → `@angular-builders/jest:run` (already happens regardless of incumbent). +- Fix `tsconfig.spec.json` types (vitest globals → jest). +- **Advise** (logger): spec code using `vi.*` / vitest imports needs manual porting to Jest APIs — the runner swap can't rewrite test code (same caveat as jasmine→jest). +- Cleanup is lighter than Karma: Vitest is built into `@angular/build` (no `karma.conf` equivalent; the `unit-test` target is simply overwritten). Detection cell in §6 jest column becomes "Karma?, Vitest?, zoneless?". + +### 12.3 custom-esbuild `ng-add`: build-system guard against silent webpack→esbuild (supersedes §4.2, §6) + +`ng add @angular-builders/custom-esbuild` must NOT unconditionally rewrite `build`. Distinguish: + +- **build already `@angular/build:application`** (esbuild) → safe rewrite to `@angular-builders/custom-esbuild:application` (the common case; adds the custom layer over the same engine). +- **build on webpack** (`@angular-devkit/build-angular:browser` or `@angular-builders/custom-webpack:browser`) → do **NOT** silently swap (that strands the user's `webpack.config.js`). Detect and **advise**: run Angular's `use-application-builder` migration first → then `ng add @angular-builders/custom-esbuild` → then port `webpack.config.js` plugins to esbuild `codePlugins` **manually** (no mechanical translation exists). Optional `--from-webpack` flag forces only the mechanical target rewrite. + +Rationale: custom-webpack→custom-esbuild is a real migration (esbuild replaces webpack), but the custom config — the reason the user is on custom-webpack — cannot be auto-translated, so full auto-migration is out of scope; detection + advisory is in scope and necessary for correctness (§6 custom-esbuild detection cell becomes "test builder kind; webpack guard"). + +### 12.4 e2e fixtures (supersedes the Plan-2c/2d checklist Karma-fixture note) + +- Karma→Jest e2e fixture: generate via `ng new --test-runner karma` (works on v22) — the earlier "can't generate Karma on v22, use a checked-in fixture" note was based on the false removal premise. +- Add a Vitest→Jest `ng add` e2e (default v22 app) and a custom-esbuild webpack-guard advisory check. +- The local-only `ng add` approach (`npm pack` → `ng add ./`) is unchanged. diff --git a/jest-ut.config.js b/jest-ut.config.js index 9af158762f..0545645838 100644 --- a/jest-ut.config.js +++ b/jest-ut.config.js @@ -1,4 +1,15 @@ module.exports = { ...require('./jest-common.config'), testRegex: `${process.cwd()}/(?!(e2e|examples)/).+\\.spec\\.ts`, + // Stub ESM-only `ora` (pulled in by @angular-devkit/schematics' package-manager + // task executor). Tests never exercise that executor, so a no-op shim is enough. + moduleNameMapper: { + '^ora$': '/__mocks__/ora.js', + // Redirect @angular-builders/common subpath exports to the worktree build so + // schematics specs can import from the in-tree dist without a yarn re-install. + '^@angular-builders/common/schematics/testing$': + '/packages/common/dist/schematics/testing.js', + '^@angular-builders/common/schematics$': + '/packages/common/dist/schematics/index.js', + }, }; diff --git a/packages/common/package.json b/packages/common/package.json index cdbfc61c83..2aa7623a5a 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -3,6 +3,17 @@ "version": "5.0.4-beta.3", "description": "Common utility functions shared between @angular-builders packages", "main": "dist/index.js", + "exports": { + ".": { + "default": "./dist/index.js" + }, + "./schematics": { + "default": "./dist/schematics/index.js" + }, + "./schematics/testing": { + "default": "./dist/schematics/testing.js" + } + }, "files": [ "dist" ], @@ -20,15 +31,19 @@ }, "scripts": { "prebuild": "yarn clean", - "build": "yarn prebuild && tsc", + "build": "yarn prebuild && tsc && tsc -p tsconfig.schematics.json && yarn copy:schematics", + "copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics && copyfiles -u 2 \"src/schematics/**/files/**\" dist/schematics", "clean": "rimraf dist" }, "dependencies": { "@angular-devkit/core": "^22.0.0-rc.2", + "@angular-devkit/schematics": "^22.0.0-rc.2", + "@schematics/angular": "^22.0.0-rc.2", "ts-node": "^10.0.0", "tsconfig-paths": "^4.2.0" }, "devDependencies": { + "copyfiles": "^2.4.1", "rimraf": "^6.0.0", "typescript": "6.0.3" } diff --git a/packages/common/src/schematics/detection.spec.ts b/packages/common/src/schematics/detection.spec.ts new file mode 100644 index 0000000000..6d8293e271 --- /dev/null +++ b/packages/common/src/schematics/detection.spec.ts @@ -0,0 +1,91 @@ +import * as path from 'path'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from './testing'; +import { getProjectsToTarget, detectTestBuilder, isZoneless } from './detection'; + +async function load(tree: import('@angular-devkit/schematics/testing').UnitTestTree) { + return readWorkspace(tree); +} + +describe('getProjectsToTarget', () => { + it('single project → that project', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + expect(getProjectsToTarget(await load(tree))).toEqual(['app']); + }); + + it('multi project + explicit option → just that one', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'a' }, { name: 'b' }], + }); + expect(getProjectsToTarget(await load(tree), 'b')).toEqual(['b']); + }); + + it('multi project + no option + no default → all', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ + projects: [{ name: 'a' }, { name: 'b' }], + }); + expect(getProjectsToTarget(await load(tree)).sort()).toEqual(['a', 'b']); + }); +}); + +describe('detectTestBuilder', () => { + // Apply a workspace mutation rule to a tree and return the updated tree. + async function applyWorkspace( + tree: import('@angular-devkit/schematics/testing').UnitTestTree, + builder: string, + options: Record = {}, + ) { + const rule = updateWorkspace((workspace) => { + workspace.projects.get('app')!.targets.set('test', { + builder, + options: options as never, + }); + }); + const { SchematicTestRunner } = await import('@angular-devkit/schematics/testing'); + const NG_COLLECTION = path.join( + path.dirname(require.resolve('@schematics/angular/package.json')), + 'collection.json', + ); + const runner = new SchematicTestRunner('t', NG_COLLECTION); + return runner.callRule(rule, tree).toPromise() as Promise; + } + + it('returns "none" when no test target', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // application schematic may add no test target under zoneless/standalone defaults + const ws = await load(tree); + if (!ws.projects.get('app')!.targets.has('test')) { + expect(detectTestBuilder(ws, 'app')).toBe('none'); + } + }); + + it('detects a dedicated :karma builder (webpack projects)', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree = await applyWorkspace(tree, '@angular-devkit/build-angular:karma'); + expect(detectTestBuilder(await load(tree), 'app')).toBe('karma'); + }); + + it('detects Karma on a v22 :unit-test builder via runner option', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree = await applyWorkspace(tree, '@angular/build:unit-test', { runner: 'karma' }); + expect(detectTestBuilder(await load(tree), 'app')).toBe('karma'); + }); + + it('detects Vitest on a :unit-test builder (runner "vitest" or unset)', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree = await applyWorkspace(tree, '@angular/build:unit-test', { runner: 'vitest' }); + expect(detectTestBuilder(await load(tree), 'app')).toBe('vitest'); + + let tree2 = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree2 = await applyWorkspace(tree2, '@angular/build:unit-test'); + expect(detectTestBuilder(await load(tree2), 'app')).toBe('vitest'); + }); +}); + +describe('isZoneless', () => { + it('true when polyfills lack zone.js', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // modern application schematic is zoneless by default → no zone.js polyfill + expect(isZoneless(tree, await load(tree), 'app')).toBe(true); + }); +}); diff --git a/packages/common/src/schematics/detection.ts b/packages/common/src/schematics/detection.ts new file mode 100644 index 0000000000..39a73fd94f --- /dev/null +++ b/packages/common/src/schematics/detection.ts @@ -0,0 +1,72 @@ +import { Tree } from '@angular-devkit/schematics'; +import { workspaces } from '@angular-devkit/core'; + +export type TestBuilderKind = 'karma' | 'jest' | 'vitest' | 'other' | 'none'; + +export function getProjectsToTarget( + workspace: workspaces.WorkspaceDefinition, + optionProject?: string, +): string[] { + const names = [...workspace.projects.keys()]; + if (optionProject) { + if (!workspace.projects.has(optionProject)) { + throw new Error(`Project "${optionProject}" does not exist in the workspace.`); + } + return [optionProject]; + } + if (names.length <= 1) return names; + const defaultProject = workspace.extensions['defaultProject']; + if (typeof defaultProject === 'string' && workspace.projects.has(defaultProject)) { + return [defaultProject]; + } + return names; +} + +export function detectTestBuilder( + workspace: workspaces.WorkspaceDefinition, + projectName: string, +): TestBuilderKind { + const project = workspace.projects.get(projectName); + const test = project?.targets.get('test'); + const builder = test?.builder; + if (!builder) return 'none'; + // Webpack-based projects keep a dedicated Karma builder (e.g. @angular-devkit/build-angular:karma). + if (builder.endsWith(':karma')) return 'karma'; + if (builder === '@angular-builders/jest:run') return 'jest'; + // Angular 22 unified Karma and Vitest under the `:unit-test` builder, distinguished only by the + // `runner` option. `runner: "karma"` is Karma; "vitest" (or unset, the default) is Vitest. + if (builder.endsWith(':unit-test')) { + return test?.options?.['runner'] === 'karma' ? 'karma' : 'vitest'; + } + return 'other'; +} + +export function isZoneless( + tree: Tree, + workspace: workspaces.WorkspaceDefinition, + projectName: string, +): boolean { + const project = workspace.projects.get(projectName); + const buildOptions = project?.targets.get('build')?.options ?? {}; + const polyfills = buildOptions['polyfills']; + const polyfillList = Array.isArray(polyfills) + ? (polyfills as string[]) + : typeof polyfills === 'string' + ? [polyfills] + : []; + const hasZone = polyfillList.some((p) => p === 'zone.js' || p.includes('zone.js')); + if (hasZone) return false; + + // Fallback: look for provideZonelessChangeDetection in any bootstrap source. + const root = project?.root ?? ''; + const mainCandidates = ['src/main.ts', 'src/app/app.config.ts'].map((p) => + root ? `${root}/${p}` : p, + ); + for (const candidate of mainCandidates) { + if (tree.exists(candidate)) { + const content = tree.readText(candidate); + if (content.includes('provideZonelessChangeDetection')) return true; + } + } + return !hasZone; // no zone.js polyfill ⇒ treat as zoneless +} diff --git a/packages/common/src/schematics/index.ts b/packages/common/src/schematics/index.ts new file mode 100644 index 0000000000..c5308f332c --- /dev/null +++ b/packages/common/src/schematics/index.ts @@ -0,0 +1,5 @@ +export * from './rules'; +export * from './detection'; +export * from './version'; +// testing.ts is exported via the ./schematics/testing subpath, not the barrel, +// so production schematics never pull SchematicTestRunner into their bundle. diff --git a/packages/common/src/schematics/rules.spec.ts b/packages/common/src/schematics/rules.spec.ts new file mode 100644 index 0000000000..59e0fdacd5 --- /dev/null +++ b/packages/common/src/schematics/rules.spec.ts @@ -0,0 +1,101 @@ +import * as path from 'path'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from './testing'; +import { + setBuilderForTarget, + addBuilderDevDependency, + removeDevDependencies, + removeFilesIfPresent, + editJsonFile, +} from './rules'; + +const NG = path.join(path.dirname(require.resolve('@schematics/angular/package.json')), 'collection.json'); +const runner = () => new SchematicTestRunner('t', NG); + +describe('setBuilderForTarget', () => { + it('rewrites the builder and merges options', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const out = (await runner() + .callRule(setBuilderForTarget('app', 'build', '@angular-builders/custom-esbuild:application', { foo: 1 }), tree) + .toPromise()) as UnitTestTree; + const ws = await readWorkspace(out); + const target = ws.projects.get('app')!.targets.get('build')!; + expect(target.builder).toBe('@angular-builders/custom-esbuild:application'); + expect((target.options as Record)['foo']).toBe(1); + }); + + it('replaceOptions discards the previous builder options', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + // Seed a :unit-test-shaped test target whose options must NOT carry over. + const seeded = (await runner() + .callRule(setBuilderForTarget('app', 'test', '@angular/build:unit-test', { runner: 'karma', buildTarget: 'app:build' }), tree) + .toPromise()) as UnitTestTree; + const out = (await runner() + .callRule( + setBuilderForTarget('app', 'test', '@angular-builders/jest:run', { zoneless: true }, { replaceOptions: true }), + seeded, + ) + .toPromise()) as UnitTestTree; + const ws = await readWorkspace(out); + const target = ws.projects.get('app')!.targets.get('test')!; + expect(target.builder).toBe('@angular-builders/jest:run'); + const options = target.options as Record; + expect(options['runner']).toBeUndefined(); + expect(options['buildTarget']).toBeUndefined(); + expect(options['zoneless']).toBe(true); + }); +}); + +describe('addBuilderDevDependency', () => { + it('adds the package to devDependencies', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const out = (await runner() + .callRule(addBuilderDevDependency('@angular-builders/jest', '~22.0.0', { install: false }), tree) + .toPromise()) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/jest']).toBe('~22.0.0'); + }); +}); + +describe('removeDevDependencies', () => { + it('removes only present deps and is safe on absent ones', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree.overwrite( + '/package.json', + JSON.stringify({ devDependencies: { karma: '^6.0.0', jasmine: '^5.0.0' } }, null, 2), + ); + const out = (await runner() + .callRule(removeDevDependencies(['karma', 'not-there']), tree) + .toPromise()) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies.karma).toBeUndefined(); + expect(pkg.devDependencies.jasmine).toBe('^5.0.0'); + }); +}); + +describe('removeFilesIfPresent', () => { + it('deletes present files, ignores absent', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree.create('/karma.conf.js', '// karma'); + const out = (await runner() + .callRule(removeFilesIfPresent(['/karma.conf.js', '/nope.js']), tree) + .toPromise()) as UnitTestTree; + expect(out.exists('/karma.conf.js')).toBe(false); + }); +}); + +describe('editJsonFile', () => { + it('mutates JSON via JSONFile', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + tree.create('/tsconfig.spec.json', JSON.stringify({ compilerOptions: { types: ['jasmine'] } }, null, 2)); + const out = (await runner() + .callRule( + editJsonFile('/tsconfig.spec.json', (json) => json.modify(['compilerOptions', 'types'], ['jest'])), + tree, + ) + .toPromise()) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.types).toEqual(['jest']); + }); +}); diff --git a/packages/common/src/schematics/rules.ts b/packages/common/src/schematics/rules.ts new file mode 100644 index 0000000000..0d1bfbc33c --- /dev/null +++ b/packages/common/src/schematics/rules.ts @@ -0,0 +1,73 @@ +import { JsonValue } from '@angular-devkit/core'; +import { Rule, Tree } from '@angular-devkit/schematics'; +import { updateWorkspace, addDependency, DependencyType, InstallBehavior } from '@schematics/angular/utility'; +import { JSONFile } from '@schematics/angular/utility/json-file'; + +export function setBuilderForTarget( + projectName: string, + targetName: string, + builderName: string, + options?: Record, + opts: { replaceOptions?: boolean } = {}, +): Rule { + return updateWorkspace((workspace) => { + const project = workspace.projects.get(projectName); + if (!project) throw new Error(`Project "${projectName}" not found.`); + const target = project.targets.get(targetName); + if (target) { + target.builder = builderName; + // By default merge onto the existing options. `replaceOptions` discards them — used when + // switching to a builder whose option shape is incompatible with the previous one (e.g. + // replacing a :unit-test target, whose `runner`/`buildTarget` must not leak to the new builder). + if (opts.replaceOptions) { + target.options = (options ?? {}) as Record; + } else if (options) { + target.options = { ...(target.options ?? {}), ...(options as Record) }; + } + } else { + project.targets.add({ name: targetName, builder: builderName, options: (options ?? {}) as Record }); + } + }); +} + +export function addBuilderDevDependency( + name: string, + version: string, + opts: { install?: boolean } = {}, +): Rule { + return addDependency(name, version, { + type: DependencyType.Dev, + install: opts.install === false ? InstallBehavior.None : InstallBehavior.Auto, + }); +} + +export function removeDevDependencies(names: string[]): Rule { + return (tree: Tree) => { + if (!tree.exists('/package.json')) return tree; + const json = new JSONFile(tree, '/package.json'); + for (const name of names) { + if (json.get(['devDependencies', name]) !== undefined) { + json.remove(['devDependencies', name]); + } + } + return tree; + }; +} + +export function removeFilesIfPresent(paths: string[]): Rule { + return (tree: Tree) => { + for (const p of paths) { + if (tree.exists(p)) tree.delete(p); + } + return tree; + }; +} + +export function editJsonFile(path: string, mutator: (json: JSONFile) => void): Rule { + return (tree: Tree) => { + if (!tree.exists(path)) return tree; + const json = new JSONFile(tree, path); + mutator(json); + return tree; + }; +} diff --git a/packages/common/src/schematics/testing.spec.ts b/packages/common/src/schematics/testing.spec.ts new file mode 100644 index 0000000000..549d6e76af --- /dev/null +++ b/packages/common/src/schematics/testing.spec.ts @@ -0,0 +1,24 @@ +import { readWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from './testing'; + +describe('SchematicTestHarness', () => { + it('builds a single-project workspace with angular.json', async () => { + const harness = new SchematicTestHarness(); + const tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + expect(tree.exists('/angular.json')).toBe(true); + const workspace = await readWorkspace(tree); + expect([...workspace.projects.keys()]).toEqual(['app']); + // application schematic wires a build target by default + expect(workspace.projects.get('app')!.targets.has('build')).toBe(true); + }); + + it('builds a multi-project workspace', async () => { + const harness = new SchematicTestHarness(); + const tree = await harness.createWorkspace({ + projects: [{ name: 'app1' }, { name: 'app2' }], + }); + const workspace = await readWorkspace(tree); + expect([...workspace.projects.keys()].sort()).toEqual(['app1', 'app2']); + }); +}); diff --git a/packages/common/src/schematics/testing.ts b/packages/common/src/schematics/testing.ts new file mode 100644 index 0000000000..9d90cb831d --- /dev/null +++ b/packages/common/src/schematics/testing.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +// @schematics/angular's exports map only exposes `.js` files; collection.json +// is not listed so require.resolve('@schematics/angular/collection.json') throws +// in Node 22+ which enforces the exports map. Resolve via package.json instead. +const NG_COLLECTION = path.join( + path.dirname(require.resolve('@schematics/angular/package.json')), + 'collection.json', +); + +export interface WorkspaceProjectSpec { + name: string; + root?: string; +} + +export interface CreateWorkspaceOptions { + projects?: WorkspaceProjectSpec[]; + defaultProject?: string; +} + +export class SchematicTestHarness { + readonly runner: SchematicTestRunner; + + constructor(runner?: SchematicTestRunner) { + this.runner = runner ?? new SchematicTestRunner('schematics', NG_COLLECTION); + } + + async createWorkspace(opts: CreateWorkspaceOptions = {}): Promise { + const projects = opts.projects ?? [{ name: 'app' }]; + + let tree = await this.runner.runSchematic('workspace', { + name: 'workspace', + version: '0.0.0', + newProjectRoot: 'projects', + }); + + for (const project of projects) { + tree = await this.runner.runSchematic( + 'application', + { + name: project.name, + // keep fixtures small + deterministic + routing: false, + style: 'css', + skipTests: false, + standalone: true, + }, + tree, + ); + } + + return tree; + } +} diff --git a/packages/common/src/schematics/version.spec.ts b/packages/common/src/schematics/version.spec.ts new file mode 100644 index 0000000000..fb99751a53 --- /dev/null +++ b/packages/common/src/schematics/version.spec.ts @@ -0,0 +1,23 @@ +import { parseVersion, isAtLeast } from './version'; + +describe('parseVersion', () => { + it('parses a plain semver', () => { + expect(parseVersion('21.2.13')).toEqual({ major: 21, minor: 2, patch: 13 }); + }); + it('parses a prerelease, ignoring the tag', () => { + expect(parseVersion('22.0.0-rc.2')).toEqual({ major: 22, minor: 0, patch: 0 }); + }); + it('strips a leading range operator', () => { + expect(parseVersion('^20.1.0')).toEqual({ major: 20, minor: 1, patch: 0 }); + }); +}); + +describe('isAtLeast', () => { + it('is true at and above the major', () => { + expect(isAtLeast('22.0.0-rc.2', 22)).toBe(true); + expect(isAtLeast('23.1.0', 22)).toBe(true); + }); + it('is false below the major', () => { + expect(isAtLeast('21.2.13', 22)).toBe(false); + }); +}); diff --git a/packages/common/src/schematics/version.ts b/packages/common/src/schematics/version.ts new file mode 100644 index 0000000000..d697e4b5b3 --- /dev/null +++ b/packages/common/src/schematics/version.ts @@ -0,0 +1,16 @@ +export interface SemverParts { + major: number; + minor: number; + patch: number; +} + +export function parseVersion(version: string): SemverParts { + const cleaned = version.trim().replace(/^[\^~>=v\s]+/, ''); + const [core] = cleaned.split('-'); + const [major = 0, minor = 0, patch = 0] = core.split('.').map((n) => parseInt(n, 10) || 0); + return { major, minor, patch }; +} + +export function isAtLeast(version: string, major: number): boolean { + return parseVersion(version).major >= major; +} diff --git a/packages/common/tsconfig.schematics.json b/packages/common/tsconfig.schematics.json new file mode 100644 index 0000000000..e4f56b977a --- /dev/null +++ b/packages/common/tsconfig.schematics.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} diff --git a/packages/custom-esbuild/.gitignore b/packages/custom-esbuild/.gitignore index 28ffec918d..e176d0bd14 100644 --- a/packages/custom-esbuild/.gitignore +++ b/packages/custom-esbuild/.gitignore @@ -12,6 +12,7 @@ package dist **/schema.json +!src/schematics/**/schema.json *.js !karma.conf.js *.js.map diff --git a/packages/custom-esbuild/package.json b/packages/custom-esbuild/package.json index 55dc413421..1351a36b92 100644 --- a/packages/custom-esbuild/package.json +++ b/packages/custom-esbuild/package.json @@ -31,18 +31,25 @@ ], "scripts": { "prebuild": "yarn clean", - "build": "yarn prebuild && tsc && ts-node ../../merge-schemes.ts && yarn postbuild", + "build": "yarn prebuild && tsc && ts-node ../../merge-schemes.ts && tsc -p tsconfig.schematics.json && yarn copy:schematics && yarn postbuild", + "copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics", "postbuild": "yarn test && yarn run e2e", "test": "jest --config ../../jest-ut.config.js", "e2e": "jest --config ../../jest-e2e.config.js", "clean": "rimraf dist" }, "builders": "builders.json", + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, "dependencies": { "@angular-builders/common": "workspace:*", "@angular-devkit/architect": ">=0.2200.0-rc.2 < 0.2300.0", "@angular-devkit/core": "^22.0.0-rc.2", - "@angular/build": "^22.0.0-rc.2" + "@angular-devkit/schematics": "^22.0.0-rc.2", + "@angular/build": "^22.0.0-rc.2", + "@schematics/angular": "^22.0.0-rc.2" }, "peerDependencies": { "@angular/compiler-cli": "^22.0.0-rc.2", @@ -50,6 +57,7 @@ "vitest": ">=2" }, "devDependencies": { + "copyfiles": "^2.4.1", "esbuild": "0.28.0", "jest": "30.4.2", "rimraf": "^6.0.0", diff --git a/packages/custom-esbuild/src/schematics/collection.json b/packages/custom-esbuild/src/schematics/collection.json new file mode 100644 index 0000000000..e8ce29364c --- /dev/null +++ b/packages/custom-esbuild/src/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Wire @angular-builders/custom-esbuild into the workspace (build, serve, and Vitest unit-test targets).", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..63ba5ab162 --- /dev/null +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -0,0 +1,255 @@ +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../src/schematics/collection.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('custom-esbuild', COLLECTION); +} + +async function ngAdd( + tree: UnitTestTree, + options: Record = {}, +): Promise { + return runner().runSchematic('ng-add', options, tree); +} + +describe('custom-esbuild ng-add: build + serve rewrite', () => { + it('rewrites build → :application and serve → :dev-server, adding self to devDeps', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { + builder: '@angular/build:application', + options: { tsConfig: 'tsconfig.app.json', outputPath: 'dist/app' }, + }); + project.targets.set('serve', { + builder: '@angular/build:dev-server', + options: { buildTarget: 'app:build' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app' }); + + const ws = await readWorkspace(out); + const build = ws.projects.get('app')!.targets.get('build')!; + const serve = ws.projects.get('app')!.targets.get('serve')!; + + expect(build.builder).toBe('@angular-builders/custom-esbuild:application'); + expect((build.options as Record).tsConfig).toBe('tsconfig.app.json'); + expect((build.options as Record).outputPath).toBe('dist/app'); + + expect(serve.builder).toBe('@angular-builders/custom-esbuild:dev-server'); + expect((serve.options as Record).buildTarget).toBe('app:build'); + + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/custom-esbuild']).toBeDefined(); + }); +}); + +describe('custom-esbuild ng-add: webpack-build guard (spec §12.3)', () => { + async function seedWebpackBuild(builder: string): Promise { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + return (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder, options: { outputPath: 'dist/app' } }); + project.targets.set('serve', { + builder: '@angular-devkit/build-angular:dev-server', + options: { buildTarget: 'app:build' }, + }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + } + + it('does NOT rewrite an @angular-devkit/build-angular:browser build; logs an advisory', async () => { + const seeded = await seedWebpackBuild('@angular-devkit/build-angular:browser'); + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('build')!.builder).toBe('@angular-devkit/build-angular:browser'); + expect(ws.projects.get('app')!.targets.get('serve')!.builder).toBe('@angular-devkit/build-angular:dev-server'); + expect(logs.some((m) => m.includes('use-application-builder'))).toBe(true); + expect(logs.some((m) => m.includes('--from-webpack'))).toBe(true); + }); + + it('does NOT rewrite a custom-webpack:browser build; logs an advisory', async () => { + const seeded = await seedWebpackBuild('@angular-builders/custom-webpack:browser'); + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('build')!.builder).toBe('@angular-builders/custom-webpack:browser'); + expect(logs.some((m) => m.includes('use-application-builder'))).toBe(true); + }); + + it('--from-webpack forces the mechanical build/serve rewrite from a webpack build', async () => { + const seeded = await seedWebpackBuild('@angular-devkit/build-angular:browser'); + const out = await ngAdd(seeded, { project: 'app', fromWebpack: true }); + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('build')!.builder).toBe('@angular-builders/custom-esbuild:application'); + expect((ws.projects.get('app')!.targets.get('build')!.options as Record).outputPath).toBe('dist/app'); + expect(ws.projects.get('app')!.targets.get('serve')!.builder).toBe('@angular-builders/custom-esbuild:dev-server'); + }); +}); + +describe('custom-esbuild ng-add: Vitest test target', () => { + it('auto-rewrites @angular/build:unit-test → :unit-test and wires buildTarget', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: { tsConfig: 'tsconfig.app.json' } }); + project.targets.set('test', { builder: '@angular/build:unit-test', options: { tsConfig: 'tsconfig.spec.json' } }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app' }); + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test')!; + expect(test.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test.options as Record).buildTarget).toBe('app:build'); + expect((test.options as Record).tsConfig).toBe('tsconfig.spec.json'); + }); +}); + +describe('custom-esbuild ng-add: Karma / Jest test target', () => { + it('leaves a Karma test target untouched and logs an advisory', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('test', { builder: '@angular-devkit/build-angular:karma', options: { karmaConfig: 'karma.conf.js' } }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe('@angular-devkit/build-angular:karma'); + expect((ws.projects.get('app')!.targets.get('test')!.options as Record).karmaConfig).toBe('karma.conf.js'); + expect(logs.some((m) => m.includes('custom-esbuild:unit-test'))).toBe(true); + }); + + it('leaves a Jest test target untouched and logs an advisory', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('test', { builder: '@angular-builders/jest:run', options: {} }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const r = runner(); + const logs: string[] = []; + r.logger.subscribe((entry) => logs.push(entry.message)); + const out = await r.runSchematic('ng-add', { project: 'app' }, seeded); + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe('@angular-builders/jest:run'); + expect(logs.some((m) => m.includes('custom-esbuild:unit-test'))).toBe(true); + }); +}); + +describe('custom-esbuild ng-add: --unit-test flag', () => { + it('force-creates a Vitest unit-test target when none exists', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.delete('test'); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app', unitTest: true }); + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test'); + expect(test).toBeDefined(); + expect(test!.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test!.options as Record).buildTarget).toBe('app:build'); + }); + + it('rewrites an existing Vitest target the same way under --unit-test', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('test', { builder: '@angular/build:unit-test', options: {} }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const out = await ngAdd(seeded, { project: 'app', unitTest: true }); + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test')!; + expect(test.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test.options as Record).buildTarget).toBe('app:build'); + }); +}); + +describe('custom-esbuild ng-add: idempotency', () => { + it('is a no-op when build is already :application (running twice == once)', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const seeded = (await runner() + .callRule( + updateWorkspace((workspace) => { + const project = workspace.projects.get('app')!; + project.targets.set('build', { builder: '@angular/build:application', options: {} }); + project.targets.set('serve', { builder: '@angular/build:dev-server', options: { buildTarget: 'app:build' } }); + project.targets.set('test', { builder: '@angular/build:unit-test', options: { tsConfig: 'tsconfig.spec.json' } }); + }), + tree, + ) + .toPromise()) as UnitTestTree; + + const once = await ngAdd(seeded, { project: 'app' }); + const twice = await ngAdd(once, { project: 'app' }); + + const wsOnce = await readWorkspace(once); + const wsTwice = await readWorkspace(twice); + + for (const ws of [wsOnce, wsTwice]) { + const project = ws.projects.get('app')!; + expect(project.targets.get('build')!.builder).toBe('@angular-builders/custom-esbuild:application'); + expect(project.targets.get('serve')!.builder).toBe('@angular-builders/custom-esbuild:dev-server'); + const test = project.targets.get('test')!; + expect(test.builder).toBe('@angular-builders/custom-esbuild:unit-test'); + expect((test.options as Record).buildTarget).toBe('app:build'); + expect((test.options as Record).tsConfig).toBe('tsconfig.spec.json'); + } + + expect(twice.readText('/angular.json')).toBe(once.readText('/angular.json')); + }); +}); diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.ts b/packages/custom-esbuild/src/schematics/ng-add/index.ts new file mode 100644 index 0000000000..bae89b5b7c --- /dev/null +++ b/packages/custom-esbuild/src/schematics/ng-add/index.ts @@ -0,0 +1,90 @@ +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { workspaces } from '@angular-devkit/core'; +import { readWorkspace } from '@schematics/angular/utility'; +import { + addBuilderDevDependency, + detectTestBuilder, + getProjectsToTarget, + setBuilderForTarget, +} from '@angular-builders/common/schematics'; + +import { Schema } from './schema'; + +const PACKAGE_NAME = '@angular-builders/custom-esbuild'; +const BUILD_BUILDER = '@angular-builders/custom-esbuild:application'; +const SERVE_BUILDER = '@angular-builders/custom-esbuild:dev-server'; +const TEST_BUILDER = '@angular-builders/custom-esbuild:unit-test'; + +const ESBUILD_BUILD = '@angular/build:application'; +const WEBPACK_BUILDS = [ + '@angular-devkit/build-angular:browser', + '@angular-builders/custom-webpack:browser', +]; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const VERSION: string = require('../../../package.json').version; + +function classifyBuildBuilder(builder: string | undefined): 'esbuild' | 'webpack' | 'none' | 'other' { + if (!builder) return 'none'; + if (builder === ESBUILD_BUILD || builder === BUILD_BUILDER) return 'esbuild'; + if (WEBPACK_BUILDS.includes(builder)) return 'webpack'; + return 'other'; +} + +export function ngAdd(options: Schema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = (await readWorkspace(tree)) as unknown as workspaces.WorkspaceDefinition; + const projects = getProjectsToTarget(workspace, options.project); + + const rules: Rule[] = [ + addBuilderDevDependency(PACKAGE_NAME, `~${VERSION}`, { install: true }), + ]; + + for (const projectName of projects) { + const project = workspace.projects.get(projectName)!; + const buildKind = classifyBuildBuilder(project.targets.get('build')?.builder); + const wantsForcedRewrite = options.fromWebpack === true; + + if (buildKind === 'esbuild' || wantsForcedRewrite) { + if (project.targets.has('build')) { + rules.push(setBuilderForTarget(projectName, 'build', BUILD_BUILDER)); + } + if (project.targets.has('serve')) { + rules.push(setBuilderForTarget(projectName, 'serve', SERVE_BUILDER)); + } + } else if (buildKind === 'webpack') { + context.logger.info( + `[@angular-builders/custom-esbuild] Project "${projectName}" builds with a ` + + `webpack builder ("${project.targets.get('build')!.builder}"). custom-esbuild ` + + `runs on esbuild, so it will NOT rewrite your build target automatically — that ` + + `would strand your webpack.config.js. To migrate: (1) run Angular's ` + + `"use-application-builder" migration to move onto "@angular/build:application", ` + + `(2) re-run "ng add @angular-builders/custom-esbuild", then (3) port your ` + + `webpack.config.js plugins to esbuild "codePlugins" manually. ` + + `To skip the guard and force only the target rewrite now, re-run with "--from-webpack". Leaving build/serve unchanged.`, + ); + } + + const testKind = detectTestBuilder(workspace, projectName); + const wantsForcedVitest = options.unitTest === true; + + if (wantsForcedVitest || testKind === 'vitest') { + rules.push( + setBuilderForTarget(projectName, 'test', TEST_BUILDER, { + buildTarget: `${projectName}:build`, + }), + ); + } else if (testKind === 'karma' || testKind === 'jest') { + context.logger.info( + `[@angular-builders/custom-esbuild] Project "${projectName}" uses a ` + + `${testKind === 'karma' ? 'Karma' : 'Jest'} test runner; esbuild plugins do not ` + + `apply there. To run your tests through esbuild/Vitest with the same plugins, ` + + `switch the test target to "${TEST_BUILDER}" (or run ` + + `"ng add @angular-builders/custom-esbuild --unit-test"). Leaving the test target unchanged.`, + ); + } + } + + return chain(rules); + }; +} diff --git a/packages/custom-esbuild/src/schematics/ng-add/schema.json b/packages/custom-esbuild/src/schematics/ng-add/schema.json new file mode 100644 index 0000000000..b409b7f776 --- /dev/null +++ b/packages/custom-esbuild/src/schematics/ng-add/schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "CustomEsbuildNgAdd", + "title": "@angular-builders/custom-esbuild ng-add options", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to add custom-esbuild to.", + "$default": { "$source": "projectName" } + }, + "unitTest": { + "type": "boolean", + "description": "Force-create a Vitest unit-test target.", + "default": false, + "alias": "unit-test" + }, + "fromWebpack": { + "type": "boolean", + "description": "Force the mechanical target rewrite even when the current build is a webpack builder.", + "default": false, + "alias": "from-webpack" + } + }, + "additionalProperties": false +} diff --git a/packages/custom-esbuild/src/schematics/ng-add/schema.ts b/packages/custom-esbuild/src/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..9f1b213a83 --- /dev/null +++ b/packages/custom-esbuild/src/schematics/ng-add/schema.ts @@ -0,0 +1,5 @@ +export interface Schema { + project?: string; + unitTest?: boolean; + fromWebpack?: boolean; +} diff --git a/packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json b/packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json new file mode 100644 index 0000000000..49c740f9c2 --- /dev/null +++ b/packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json @@ -0,0 +1,14 @@ +{ + "generate": { + "name": "esbuild-add-app", + "args": ["--routing=false", "--style=scss"] + }, + "package": "@angular-builders/custom-esbuild", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["esbuild-add-app", "build", "@angular-builders/custom-esbuild:application"] }, + { "fn": "assertBuilderForTarget", "args": ["esbuild-add-app", "serve", "@angular-builders/custom-esbuild:dev-server"] }, + { "fn": "assertDevDependency", "args": ["@angular-builders/custom-esbuild"] } + ], + "post": ["npx ng build"] +} diff --git a/packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json b/packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json new file mode 100644 index 0000000000..f0138df872 --- /dev/null +++ b/packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json @@ -0,0 +1,18 @@ +{ + "generate": { + "name": "webpack-guard-app", + "args": ["--routing=false", "--style=scss"] + }, + "prepareWorkdir": [ + "node -e \"const fs=require('fs'),f='angular.json',j=JSON.parse(fs.readFileSync(f)),a=j.projects['webpack-guard-app'].architect;a.build.builder='@angular-devkit/build-angular:browser';a.serve.builder='@angular-devkit/build-angular:dev-server';fs.writeFileSync(f,JSON.stringify(j,null,2));\"" + ], + "package": "@angular-builders/custom-esbuild", + "ngAddArgs": [], + "expectAddSucceeds": true, + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["webpack-guard-app", "build", "@angular-devkit/build-angular:browser"] }, + { "fn": "assertBuilderForTarget", "args": ["webpack-guard-app", "serve", "@angular-devkit/build-angular:dev-server"] }, + { "fn": "assertLogContains", "args": ["use-application-builder"] } + ], + "post": [] +} diff --git a/packages/custom-esbuild/tests/integration.js b/packages/custom-esbuild/tests/integration.js index 4932b6ed73..edc26bdcb5 100644 --- a/packages/custom-esbuild/tests/integration.js +++ b/packages/custom-esbuild/tests/integration.js @@ -1,4 +1,24 @@ module.exports = [ + // --- ng add e2e (Plan 04): real CLI ng add on an inline-generated app --- + // [OPTIONAL] esbuild safe-rewrite — least regression-prone; demotable under ci:full if matrix + // cost demands it (the webpack-guard case already proves the build-builder classifier). + { + id: 'ng-add-esbuild-rewrite', + name: 'custom-esbuild: ng add build/serve rewrite', + purpose: 'ng add rewrites esbuild build/serve to custom-esbuild; ng build green', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json', + }, + { + id: 'ng-add-esbuild-webpack-guard', + name: 'custom-esbuild: ng add webpack guard', + purpose: 'ng add leaves a webpack build untouched and emits the use-application-builder advisory', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json', + }, + // Vitest builder tests { id: 'vitest-builder-esm-config', diff --git a/packages/custom-esbuild/tsconfig.schematics.json b/packages/custom-esbuild/tsconfig.schematics.json new file mode 100644 index 0000000000..e4f56b977a --- /dev/null +++ b/packages/custom-esbuild/tsconfig.schematics.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} diff --git a/packages/custom-webpack/package.json b/packages/custom-webpack/package.json index b15f5e7b46..a67c597530 100644 --- a/packages/custom-webpack/package.json +++ b/packages/custom-webpack/package.json @@ -31,19 +31,26 @@ ], "scripts": { "prebuild": "yarn clean", - "build": "yarn prebuild && tsc && ts-node ../../merge-schemes.ts && yarn postbuild", + "build": "yarn prebuild && tsc && tsc -p tsconfig.schematics.json && yarn copy:schematics && ts-node ../../merge-schemes.ts && yarn postbuild", + "copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics && copyfiles -u 2 \"src/schematics/**/files/**\" dist/schematics", "postbuild": "yarn test && yarn run e2e", "test": "jest --config ../../jest-ut.config.js", "e2e": "jest --config ../../jest-e2e.config.js", "clean": "rimraf dist" }, "builders": "builders.json", + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, "dependencies": { "@angular-builders/common": "workspace:*", "@angular-devkit/architect": ">=0.2200.0-rc.2 < 0.2300.0", "@angular-devkit/build-angular": "^22.0.0-rc.2", "@angular-devkit/core": "^22.0.0-rc.2", + "@angular-devkit/schematics": "^22.0.0-rc.2", "@angular/build": "^22.0.0-rc.2", + "@schematics/angular": "^22.0.0-rc.2", "lodash": "^4.17.15", "webpack-merge": "^6.0.0" }, @@ -52,6 +59,7 @@ "rxjs": ">=7.0.0" }, "devDependencies": { + "copyfiles": "^2.4.1", "jest": "30.4.2", "rimraf": "^6.0.0", "ts-node": "^10.0.0", diff --git a/packages/custom-webpack/src/schematics/collection.json b/packages/custom-webpack/src/schematics/collection.json new file mode 100644 index 0000000000..81bbf76e93 --- /dev/null +++ b/packages/custom-webpack/src/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Wire @angular-builders/custom-webpack into the workspace.", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} diff --git a/packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template b/packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template new file mode 100644 index 0000000000..0bb73ed506 --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template @@ -0,0 +1,17 @@ +/** + * Custom webpack configuration for @angular-builders/custom-webpack. + * + * This object is merged (via webpack-merge) into the Angular CLI's underlying + * webpack config. Add plugins, loaders, resolve aliases, etc. here. + * + * Docs: https://github.com/just-jeb/angular-builders/tree/master/packages/custom-webpack + * + * Example: + * module.exports = { + * plugins: [], + * module: { + * rules: [], + * }, + * }; + */ +module.exports = {}; diff --git a/packages/custom-webpack/src/schematics/ng-add/index.spec.ts b/packages/custom-webpack/src/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..4f99155aee --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/index.spec.ts @@ -0,0 +1,128 @@ +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace as getWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../src/schematics/collection.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('custom-webpack', COLLECTION); +} + +async function runNgAdd(tree: UnitTestTree, options: Record = {}): Promise { + return runner().runSchematic('ng-add', options, tree) as Promise; +} + +async function builderOf(tree: UnitTestTree, project: string, target: string): Promise { + const ws = await getWorkspace(tree); + return ws.projects.get(project)?.targets.get(target)?.builder; +} + +async function optionsOf(tree: UnitTestTree, project: string, target: string): Promise> { + const ws = await getWorkspace(tree); + return (ws.projects.get(project)?.targets.get(target)?.options ?? {}) as Record; +} + +describe('custom-webpack ng-add', () => { + it('rewrites build to :browser and serve to :dev-server, preserving options', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + const ws = await getWorkspace(tree); + const proj = ws.projects.get('app')!; + const originalBuildOptions = { ...(proj.targets.get('build')!.options ?? {}) }; + expect(Object.keys(originalBuildOptions).length).toBeGreaterThan(0); + + tree = await runNgAdd(tree); + + expect(await builderOf(tree, 'app', 'build')).toBe('@angular-builders/custom-webpack:browser'); + expect(await builderOf(tree, 'app', 'serve')).toBe('@angular-builders/custom-webpack:dev-server'); + + const buildOptions = await optionsOf(tree, 'app', 'build'); + for (const key of Object.keys(originalBuildOptions)) { + expect(buildOptions[key]).toEqual(originalBuildOptions[key]); + } + }); + + it('adds the builder to devDependencies and schedules install', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + const run = runner(); + tree = (await run.runSchematic('ng-add', {}, tree)) as UnitTestTree; + + const pkg = JSON.parse(tree.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/custom-webpack']).toBeDefined(); + expect(run.tasks.some((t) => t.name === 'node-package')).toBe(true); + }); + + it('scaffolds webpack.config.js and wires customWebpackConfig when none exists', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + expect(tree.exists('/webpack.config.js')).toBe(false); + tree = await runNgAdd(tree); + + expect(tree.exists('/webpack.config.js')).toBe(true); + expect(tree.readText('/webpack.config.js')).toContain('module.exports'); + + const buildOptions = await optionsOf(tree, 'app', 'build'); + expect(buildOptions['customWebpackConfig']).toEqual({ path: 'webpack.config.js' }); + }); + + it('does NOT scaffold when a webpack.config.js already exists', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + tree.create('/webpack.config.js', '// my existing config\nmodule.exports = { mine: true };'); + + tree = await runNgAdd(tree); + + expect(tree.readText('/webpack.config.js')).toContain('mine: true'); + const buildOptions = await optionsOf(tree, 'app', 'build'); + expect(buildOptions['customWebpackConfig']).toBeUndefined(); + }); + + it('does NOT scaffold when customWebpackConfig is already referenced in build options', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + const { updateWorkspace } = await import('@schematics/angular/utility'); + tree = (await runner() + .callRule( + updateWorkspace((ws) => { + const opts = ws.projects.get('app')!.targets.get('build')!.options!; + opts['customWebpackConfig'] = { path: 'extra-webpack.config.js' }; + }), + tree, + ) + .toPromise()) as UnitTestTree; + + tree = await runNgAdd(tree); + + expect(tree.exists('/webpack.config.js')).toBe(false); + const buildOptions = await optionsOf(tree, 'app', 'build'); + expect(buildOptions['customWebpackConfig']).toEqual({ path: 'extra-webpack.config.js' }); + }); + + it('is idempotent: build already :browser → no-op rewrite, no second scaffold', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + + tree = await runNgAdd(tree); + const firstConfig = tree.readText('/webpack.config.js'); + + tree = await runNgAdd(tree); + + expect(await builderOf(tree, 'app', 'build')).toBe('@angular-builders/custom-webpack:browser'); + expect(await builderOf(tree, 'app', 'serve')).toBe('@angular-builders/custom-webpack:dev-server'); + expect(tree.readText('/webpack.config.js')).toBe(firstConfig); + }); + + it('targets a specific project via --project in a multi-project workspace', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'a' }, { name: 'b' }] }); + + tree = await runNgAdd(tree, { project: 'b' }); + + expect(await builderOf(tree, 'b', 'build')).toBe('@angular-builders/custom-webpack:browser'); + expect(await builderOf(tree, 'a', 'build')).not.toBe('@angular-builders/custom-webpack:browser'); + }); +}); diff --git a/packages/custom-webpack/src/schematics/ng-add/index.ts b/packages/custom-webpack/src/schematics/ng-add/index.ts new file mode 100644 index 0000000000..398d55dc83 --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/index.ts @@ -0,0 +1,102 @@ +import { + apply, + applyTemplates, + chain, + mergeWith, + move, + noop, + Rule, + SchematicContext, + Tree, + url, +} from '@angular-devkit/schematics'; +import { readWorkspace as getWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { workspaces } from '@angular-devkit/core'; +import { + addBuilderDevDependency, + getProjectsToTarget, +} from '@angular-builders/common/schematics'; +import { NgAddSchema } from './schema'; + +const PACKAGE_NAME = '@angular-builders/custom-webpack'; +const BROWSER_BUILDER = `${PACKAGE_NAME}:browser`; +const DEV_SERVER_BUILDER = `${PACKAGE_NAME}:dev-server`; +const DEFAULT_CONFIG_FILE = 'webpack.config.js'; + +const SELF_VERSION_RANGE = '^22.0.0'; + +function webpackConfigFileExists(tree: Tree): boolean { + return ( + tree.exists(`/${DEFAULT_CONFIG_FILE}`) || + tree.exists('/webpack.config.ts') || + tree.exists('/webpack.config.cjs') || + tree.exists('/webpack.config.mjs') + ); +} + +function rewriteTargets(projectName: string): Rule { + return updateWorkspace((workspace) => { + const project = workspace.projects.get(projectName); + if (!project) return; + const build = project.targets.get('build'); + if (build) build.builder = BROWSER_BUILDER; + const serve = project.targets.get('serve'); + if (serve) serve.builder = DEV_SERVER_BUILDER; + }); +} + +function scaffoldConfig(projectName: string): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = await getWorkspace(tree); + const project = workspace.projects.get(projectName); + const buildOptions = + (project?.targets.get('build')?.options as Record | undefined) ?? {}; + + const alreadyReferenced = + buildOptions['customWebpackConfig'] !== undefined && + buildOptions['customWebpackConfig'] !== false; + + if (alreadyReferenced || webpackConfigFileExists(tree)) { + context.logger.info('[custom-webpack] A webpack config is already present; leaving it untouched.'); + return noop(); + } + + const templateSource = apply(url('./files'), [applyTemplates({}), move('/')]); + + return chain([ + mergeWith(templateSource), + updateWorkspace((ws) => { + const buildTarget = ws.projects.get(projectName)?.targets.get('build'); + if (buildTarget) { + buildTarget.options = { + ...(buildTarget.options ?? {}), + customWebpackConfig: { path: DEFAULT_CONFIG_FILE }, + }; + } + }), + ]); + }; +} + +export function ngAdd(options: NgAddSchema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = await getWorkspace(tree); + const projects = getProjectsToTarget(workspace as unknown as workspaces.WorkspaceDefinition, options.project); + + if (projects.length === 0) { + context.logger.warn('[custom-webpack] No projects found to configure.'); + return noop(); + } + + const perProject: Rule[] = []; + for (const projectName of projects) { + perProject.push(rewriteTargets(projectName)); + perProject.push(scaffoldConfig(projectName)); + } + + return chain([ + addBuilderDevDependency(PACKAGE_NAME, SELF_VERSION_RANGE, { install: true }), + ...perProject, + ]); + }; +} diff --git a/packages/custom-webpack/src/schematics/ng-add/schema.json b/packages/custom-webpack/src/schematics/ng-add/schema.json new file mode 100644 index 0000000000..4153d54231 --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "AngularBuildersCustomWebpackNgAdd", + "title": "Add @angular-builders/custom-webpack", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to wire @angular-builders/custom-webpack into.", + "$default": { "$source": "projectName" } + } + }, + "additionalProperties": false +} diff --git a/packages/custom-webpack/src/schematics/ng-add/schema.ts b/packages/custom-webpack/src/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..cb7cd8a48a --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/schema.ts @@ -0,0 +1,3 @@ +export interface NgAddSchema { + project?: string; +} diff --git a/packages/custom-webpack/tests/e2e/webpack-add.ng-add.json b/packages/custom-webpack/tests/e2e/webpack-add.ng-add.json new file mode 100644 index 0000000000..54c230d9e1 --- /dev/null +++ b/packages/custom-webpack/tests/e2e/webpack-add.ng-add.json @@ -0,0 +1,18 @@ +{ + "generate": { + "name": "add-app", + "args": ["--routing=false", "--style=scss"] + }, + "prepareWorkdir": [ + "node -e \"const fs=require('fs'),f='angular.json',j=JSON.parse(fs.readFileSync(f)),a=j.projects['add-app'].architect;a.build={builder:'@angular-devkit/build-angular:browser',options:{outputPath:'dist/add-app',index:'src/index.html',main:'src/main.ts',tsConfig:'tsconfig.app.json',polyfills:[],assets:[],styles:['src/styles.scss']}};a.serve={builder:'@angular-devkit/build-angular:dev-server',options:{buildTarget:'add-app:build'}};fs.writeFileSync(f,JSON.stringify(j,null,2));\"" + ], + "package": "@angular-builders/custom-webpack", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["add-app", "build", "@angular-builders/custom-webpack:browser"] }, + { "fn": "assertBuilderForTarget", "args": ["add-app", "serve", "@angular-builders/custom-webpack:dev-server"] }, + { "fn": "assertFileContains", "args": ["webpack.config.js", "module.exports"] }, + { "fn": "assertDevDependency", "args": ["@angular-builders/custom-webpack"] } + ], + "post": ["npx ng build"] +} diff --git a/packages/custom-webpack/tests/integration.js b/packages/custom-webpack/tests/integration.js index c16f6a56b0..16f212fa09 100644 --- a/packages/custom-webpack/tests/integration.js +++ b/packages/custom-webpack/tests/integration.js @@ -1,4 +1,14 @@ module.exports = [ + // --- ng add e2e (Plan 04): real CLI ng add on an inline-generated webpack-shaped app --- + { + id: 'ng-add-webpack-rewrite-scaffold', + name: 'custom-webpack: ng add rewrite + scaffold', + purpose: 'ng add rewrites build/serve and scaffolds webpack.config.js; ng build green', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/custom-webpack/tests/e2e/webpack-add.ng-add.json', + }, + // Karma builder tests { id: 'karma-builder-sanity-app', diff --git a/packages/custom-webpack/tsconfig.schematics.json b/packages/custom-webpack/tsconfig.schematics.json new file mode 100644 index 0000000000..e4f56b977a --- /dev/null +++ b/packages/custom-webpack/tsconfig.schematics.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} diff --git a/packages/jest/package.json b/packages/jest/package.json index 55567a260a..90b02c3446 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -30,9 +30,17 @@ "runner" ], "builders": "builders.json", + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, + "ng-update": { + "migrations": "./dist/schematics/migrations.json" + }, "scripts": { "prebuild": "yarn clean && yarn generate", - "build": "yarn prebuild && tsc -p tsconfig.lib.json && yarn postbuild", + "build": "yarn prebuild && tsc -p tsconfig.lib.json && tsc -p tsconfig.schematics.json && yarn copy:schematics && yarn postbuild", + "copy:schematics": "copyfiles -u 2 \"src/schematics/**/*.json\" dist/schematics && copyfiles -u 2 \"src/schematics/**/files/**\" dist/schematics", "postbuild": "yarn copy && yarn test", "test": "jest --config ../../jest-ut.config.js", "e2e": "jest --config ../../jest-e2e.config.js", @@ -44,6 +52,8 @@ "@angular-builders/common": "workspace:*", "@angular-devkit/architect": ">=0.2200.0-rc.2 < 0.2300.0", "@angular-devkit/core": "^22.0.0-rc.2", + "@angular-devkit/schematics": "^22.0.0-rc.2", + "@schematics/angular": "^22.0.0-rc.2", "jest-preset-angular": "^16.0.0", "lodash": "^4.17.15" }, @@ -57,6 +67,7 @@ }, "devDependencies": { "@types/jest": "^30.0.0", + "copyfiles": "^2.4.1", "cpy-cli": "^7.0.0", "jest": "30.4.2", "quicktype": "^15.0.260", diff --git a/packages/jest/src/schematics/collection.json b/packages/jest/src/schematics/collection.json new file mode 100644 index 0000000000..2baa881886 --- /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": "Set up @angular-builders/jest as the ng test runner.", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} diff --git a/packages/jest/src/schematics/index.ts b/packages/jest/src/schematics/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/jest/src/schematics/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/jest/src/schematics/migrations.json b/packages/jest/src/schematics/migrations.json new file mode 100644 index 0000000000..f1c0ca3f5b --- /dev/null +++ b/packages/jest/src/schematics/migrations.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "migration-v21": { + "version": "21.0.0", + "description": "Migrate jest builder config for v21: bump deps, Node16 tsconfig, rename builder options, strip removed mocks/options, set zoneless by detection.", + "factory": "./migrations/v21/index#migrateV21" + }, + "migration-v22": { + "version": "22.0.0", + "description": "Advise on v22 jest breaking changes (isolatedModules default, per-project coverage path). Advisory only — no file changes.", + "factory": "./migrations/v22/index#migrateV22" + } + } +} diff --git a/packages/jest/src/schematics/migrations/v21/index.spec.ts b/packages/jest/src/schematics/migrations/v21/index.spec.ts new file mode 100644 index 0000000000..79fdfa3c7d --- /dev/null +++ b/packages/jest/src/schematics/migrations/v21/index.spec.ts @@ -0,0 +1,244 @@ +import { logging } from '@angular-devkit/core'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../../src/schematics/migrations.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('jest-migrations', COLLECTION); +} + +async function seed(): Promise { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const pkg = JSON.parse(tree.readText('/package.json')); + pkg.devDependencies = { + ...(pkg.devDependencies ?? {}), + jest: '^29.0.0', + 'jest-environment-jsdom': '^29.0.0', + jsdom: '^24.0.0', + }; + tree.overwrite('/package.json', JSON.stringify(pkg, null, 2)); + tree.create( + '/tsconfig.spec.json', + JSON.stringify({ compilerOptions: { module: 'esnext', types: ['jest'] } }, null, 2), + ); + return tree; +} + +describe('jest @21 migration — deps + tsconfig', () => { + it('bumps jest/jest-environment-jsdom/jsdom to 30/30/26', async () => { + const out = (await runner().runSchematic('migration-v21', {}, await seed())) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies.jest).toBe('^30.0.0'); + expect(pkg.devDependencies['jest-environment-jsdom']).toBe('^30.0.0'); + expect(pkg.devDependencies.jsdom).toBe('^26.0.0'); + }); + + it('patches tsconfig.spec.json to Node16 + isolatedModules', async () => { + const out = (await runner().runSchematic('migration-v21', {}, await seed())) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.module).toBe('Node16'); + expect(cfg.compilerOptions.moduleResolution).toBe('Node16'); + expect(cfg.compilerOptions.isolatedModules).toBe(true); + }); +}); + +describe('jest @21 migration — builder option renames', () => { + async function seedWithTestOptions(options: Record): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('renames configPath → config', async () => { + const tree = await seedWithTestOptions({ configPath: 'jest.config.js' }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['config']).toBe('jest.config.js'); + expect(opts['configPath']).toBeUndefined(); + }); + + it('renames testPathPattern → testPathPatterns and wraps the string in an array', async () => { + const tree = await seedWithTestOptions({ testPathPattern: 'foo' }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + // Jest 30's testPathPatterns is a string array, not the old single-string testPathPattern. + expect(opts['testPathPatterns']).toEqual(['foo']); + expect(opts['testPathPattern']).toBeUndefined(); + }); +}); + +describe('jest @21 migration — strip removed mocks/options', () => { + async function seedWithTestOptions(options: Record): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('strips removed globalMocks values, keeping supported ones', async () => { + const tree = await seedWithTestOptions({ + globalMocks: ['matchMedia', 'styleTransform', 'getComputedStyle', 'doctype'], + }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['globalMocks']).toEqual(['matchMedia']); + }); + + it('strips removed jest options', async () => { + const tree = await seedWithTestOptions({ + browser: true, + init: true, + mapCoverage: true, + testURL: 'http://localhost', + timers: 'fake', + ci: true, + }); + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + for (const removed of ['browser', 'init', 'mapCoverage', 'testURL', 'timers']) { + expect(opts[removed]).toBeUndefined(); + } + expect(opts['ci']).toBe(true); + }); +}); + +describe('jest @21 migration — zoneless detection + advisories', () => { + it('zone-based workspace → sets zoneless:false', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + const build = ws.projects.get('app')!.targets.get('build')!; + build.options = { ...(build.options ?? {}), polyfills: ['zone.js'] }; + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options: {}, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBe(false); + }); + + it('zoneless workspace → leaves zoneless unset (default true)', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options: {}, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('migration-v21', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBeUndefined(); + }); + + it('emits Node16 and removed-mocks advisories', async () => { + const messages: string[] = []; + const r = new SchematicTestRunner('jest-migrations', COLLECTION); + r.logger.subscribe((e: logging.LogEntry) => messages.push(e.message)); + + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await r.runSchematic('migration-v21', {}, tree); + + const joined = messages.join('\n'); + expect(joined).toMatch(/Node16/); + expect(joined).toMatch(/mock/i); + }); +}); + +describe('jest @21 migration — idempotency', () => { + async function seedFull(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const pkg = JSON.parse(tree.readText('/package.json')); + pkg.devDependencies = { ...(pkg.devDependencies ?? {}), jest: '^29.0.0', jsdom: '^24.0.0' }; + tree.overwrite('/package.json', JSON.stringify(pkg, null, 2)); + tree.create( + '/tsconfig.spec.json', + JSON.stringify({ compilerOptions: { module: 'esnext', types: ['jest'] } }, null, 2), + ); + await runner() + .callRule( + updateWorkspace((ws) => { + const build = ws.projects.get('app')!.targets.get('build')!; + build.options = { ...(build.options ?? {}), polyfills: ['zone.js'] }; + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-builders/jest:run', + options: { + configPath: 'jest.config.js', + testPathPattern: 'foo', + globalMocks: ['matchMedia', 'doctype'], + browser: true, + }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('run twice == run once', async () => { + const once = (await runner().runSchematic('migration-v21', {}, await seedFull())) as UnitTestTree; + const twice = (await runner().runSchematic('migration-v21', {}, once)) as UnitTestTree; + + const wsOnce = await readWorkspace(once); + const wsTwice = await readWorkspace(twice); + const optsOnce = wsOnce.projects.get('app')!.targets.get('test')!.options as Record; + const optsTwice = wsTwice.projects.get('app')!.targets.get('test')!.options as Record; + expect(optsTwice).toEqual(optsOnce); + + const pkgOnce = JSON.parse(once.readText('/package.json')); + const pkgTwice = JSON.parse(twice.readText('/package.json')); + expect(pkgTwice.devDependencies).toEqual(pkgOnce.devDependencies); + + const cfgOnce = JSON.parse(once.readText('/tsconfig.spec.json')); + const cfgTwice = JSON.parse(twice.readText('/tsconfig.spec.json')); + expect(cfgTwice).toEqual(cfgOnce); + + expect(optsTwice['config']).toBe('jest.config.js'); + expect(optsTwice['configPath']).toBeUndefined(); + expect(optsTwice['testPathPatterns']).toEqual(['foo']); + expect(optsTwice['globalMocks']).toEqual(['matchMedia']); + expect(optsTwice['browser']).toBeUndefined(); + expect(optsTwice['zoneless']).toBe(false); + expect(pkgTwice.devDependencies.jest).toBe('^30.0.0'); + }); +}); diff --git a/packages/jest/src/schematics/migrations/v21/index.ts b/packages/jest/src/schematics/migrations/v21/index.ts new file mode 100644 index 0000000000..6e3cbdf420 --- /dev/null +++ b/packages/jest/src/schematics/migrations/v21/index.ts @@ -0,0 +1,124 @@ +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { JsonValue, workspaces } from '@angular-devkit/core'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { JSONFile } from '@schematics/angular/utility/json-file'; +import { editJsonFile, isZoneless } from '@angular-builders/common/schematics'; + +const DEP_BUMPS: Record = { + jest: '^30.0.0', + 'jest-environment-jsdom': '^30.0.0', + jsdom: '^26.0.0', +}; + +function bumpDeps(): Rule { + return (tree: Tree) => { + if (!tree.exists('/package.json')) return tree; + const json = new JSONFile(tree, '/package.json'); + for (const [name, version] of Object.entries(DEP_BUMPS)) { + if (json.get(['devDependencies', name]) !== undefined) { + json.modify(['devDependencies', name], version); + } + if (json.get(['dependencies', name]) !== undefined) { + json.modify(['dependencies', name], version); + } + } + return tree; + }; +} + +function patchSpecTsconfig(): Rule { + return editJsonFile('/tsconfig.spec.json', (json: JSONFile) => { + json.modify(['compilerOptions', 'module'], 'Node16'); + json.modify(['compilerOptions', 'moduleResolution'], 'Node16'); + json.modify(['compilerOptions', 'isolatedModules'], true); + }); +} + +const OPTION_RENAMES: Record = { + configPath: 'config', + testPathPattern: 'testPathPatterns', +}; + +function renameBuilderOptions(): Rule { + return updateWorkspace((workspace) => { + for (const project of workspace.projects.values()) { + const test = project.targets.get('test'); + if (!test || test.builder !== '@angular-builders/jest:run') continue; + const options = (test.options ?? {}) as Record; + for (const [from, to] of Object.entries(OPTION_RENAMES)) { + if (from in options) { + if (!(to in options)) options[to] = options[from]; + delete options[from]; + } + } + // Jest 30 renamed the single-string `testPathPattern` to the array-valued + // `testPathPatterns`. Wrap a carried-over string value so it matches the schema. + if (typeof options['testPathPatterns'] === 'string') { + options['testPathPatterns'] = [options['testPathPatterns']]; + } + test.options = options as unknown as Record; + } + }); +} + +const REMOVED_GLOBAL_MOCKS = ['styleTransform', 'getComputedStyle', 'doctype']; +const REMOVED_JEST_OPTIONS = ['browser', 'init', 'mapCoverage', 'testURL', 'timers']; + +function stripRemovedOptions(): Rule { + return updateWorkspace((workspace) => { + for (const project of workspace.projects.values()) { + const test = project.targets.get('test'); + if (!test || test.builder !== '@angular-builders/jest:run') continue; + const options = (test.options ?? {}) as Record; + + if (Array.isArray(options['globalMocks'])) { + options['globalMocks'] = (options['globalMocks'] as unknown[]).filter( + (v) => !REMOVED_GLOBAL_MOCKS.includes(v as string), + ); + } + for (const removed of REMOVED_JEST_OPTIONS) { + if (removed in options) delete options[removed]; + } + test.options = options as unknown as Record; + } + }); +} + +function setZonelessByDetection(): Rule { + return async (tree: Tree) => { + const workspace = await readWorkspace(tree); + return updateWorkspace((ws) => { + for (const [name, project] of ws.projects) { + const test = project.targets.get('test'); + if (!test || test.builder !== '@angular-builders/jest:run') continue; + if (!isZoneless(tree, workspace as unknown as workspaces.WorkspaceDefinition, name)) { + const options = (test.options ?? {}) as Record; + options['zoneless'] = false; + test.options = options as unknown as Record; + } + } + }); + }; +} + +export function migrateV21(): Rule { + return (_tree: Tree, context: SchematicContext) => { + context.logger.warn( + '[@angular-builders/jest] v21 migration applied. Note: tsconfig.spec.json now uses ' + + 'module/moduleResolution "Node16", which may surface pre-existing type errors in your ' + + 'spec code — fix the reported type issues.', + ); + context.logger.warn( + '[@angular-builders/jest] Removed globalMocks (styleTransform, getComputedStyle, doctype) ' + + 'were stripped from your config; if your tests relied on them, replace them manually. ' + + 'See MIGRATION.MD (v20→v21) for details.', + ); + return chain([ + bumpDeps(), + patchSpecTsconfig(), + renameBuilderOptions(), + stripRemovedOptions(), + setZonelessByDetection(), + ]); + }; +} diff --git a/packages/jest/src/schematics/migrations/v22/index.spec.ts b/packages/jest/src/schematics/migrations/v22/index.spec.ts new file mode 100644 index 0000000000..2a67a613c6 --- /dev/null +++ b/packages/jest/src/schematics/migrations/v22/index.spec.ts @@ -0,0 +1,66 @@ +import { logging } from '@angular-devkit/core'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../../src/schematics/migrations.json'); + +function makeRunner(): { runner: SchematicTestRunner; messages: string[] } { + const runner = new SchematicTestRunner('jest-migrations', COLLECTION); + const messages: string[] = []; + runner.logger.subscribe((e: logging.LogEntry) => messages.push(e.message)); + return { runner, messages }; +} + +function snapshot(tree: UnitTestTree): Record { + const out: Record = {}; + tree.visit((path) => { + try { + out[path] = tree.readText(path); + } catch { + // skip binary files (e.g. favicon.ico) + out[path] = ''; + } + }); + return out; +} + +describe('jest @22 migration — advisory only', () => { + it('warns about isolatedModules default flip (#2191)', async () => { + const { runner, messages } = makeRunner(); + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner.runSchematic('migration-v22', {}, tree); + expect(messages.join('\n')).toMatch(/isolatedModules/); + }); + + it('warns about per-project coverage path when projectRoot !== workspaceRoot (#2212)', async () => { + const { runner, messages } = makeRunner(); + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner.runSchematic('migration-v22', {}, tree); + expect(messages.join('\n')).toMatch(/coverage/i); + }); + + it('does NOT warn about coverage when projectRoot === workspaceRoot', async () => { + const { runner, messages } = makeRunner(); + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner + .callRule( + updateWorkspace((ws) => { + (ws.projects.get('app') as { root: string }).root = ''; + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + await runner.runSchematic('migration-v22', {}, tree); + expect(messages.join('\n')).not.toMatch(/coverage/i); + }); + + it('mutates no files', async () => { + const { runner } = makeRunner(); + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const before = snapshot(tree); + const out = (await runner.runSchematic('migration-v22', {}, tree)) as UnitTestTree; + const after = snapshot(out); + expect(after).toEqual(before); + }); +}); diff --git a/packages/jest/src/schematics/migrations/v22/index.ts b/packages/jest/src/schematics/migrations/v22/index.ts new file mode 100644 index 0000000000..a34d97d3f0 --- /dev/null +++ b/packages/jest/src/schematics/migrations/v22/index.ts @@ -0,0 +1,44 @@ +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { readWorkspace } from '@schematics/angular/utility'; + +export function migrateV22(): Rule { + return async (tree: Tree, context: SchematicContext) => { + context.logger.warn( + '[@angular-builders/jest] v22: ts-jest `isolatedModules` now defaults to true. ' + + '`const enum` used across files and type-only re-exports without the `type` modifier ' + + 'will now error. Fix the call sites, or restore `isolatedModules: false` in your jest ' + + 'config. We do not change this automatically — the new default is intentional. ' + + 'See MIGRATION.MD (v21→v22) and #2191.', + ); + + const constEnumHits: string[] = []; + tree.visit((path) => { + if (!path.endsWith('.ts')) return; + if (path.includes('/node_modules/') || path.includes('/dist/')) return; + const content = tree.readText(path); + if (/\bconst\s+enum\b/.test(content)) constEnumHits.push(path); + }); + if (constEnumHits.length > 0) { + context.logger.warn( + '[@angular-builders/jest] Found `const enum` in: ' + + constEnumHits.join(', ') + + ' — these may break under isolatedModules. Convert to a regular `enum` or `as const`.', + ); + } + + const workspace = await readWorkspace(tree); + const affected = [...workspace.projects.entries()] + .filter(([, project]) => (project.root ?? '') !== '') + .map(([name]) => name); + if (affected.length > 0) { + context.logger.warn( + '[@angular-builders/jest] v22: per-project coverage output now writes to ' + + '/coverage instead of ./coverage for projects: ' + + affected.join(', ') + + '. Update any CI/tooling that reads a hardcoded `./coverage/` path. See #2212.', + ); + } + + return tree; + }; +} 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..63fee2300c --- /dev/null +++ b/packages/jest/src/schematics/ng-add/index.spec.ts @@ -0,0 +1,258 @@ +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { readWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { logging } from '@angular-devkit/core'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const COLLECTION = require.resolve('../../../src/schematics/collection.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('jest', COLLECTION); +} + +describe('jest ng-add (no Karma)', () => { + it('adds the jest stack to devDependencies and schedules install', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const r = runner(); + const out = (await r.runSchematic('ng-add', {}, tree)) as UnitTestTree; + + const pkg = JSON.parse(out.readText('/package.json')); + expect(pkg.devDependencies['@angular-builders/jest']).toBeDefined(); + expect(pkg.devDependencies['jest']).toBeDefined(); + expect(pkg.devDependencies['jest-preset-angular']).toBeDefined(); + expect(pkg.devDependencies['jest-environment-jsdom']).toBeDefined(); + expect(r.tasks.length).toBeGreaterThan(0); + }); + + it('rewrites the test target to @angular-builders/jest:run', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + options: {}, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe( + '@angular-builders/jest:run', + ); + }); + + it('sets zoneless to match detection (zoneless workspace → true)', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBe(true); + }); +}); + +describe('jest ng-add (Karma present)', () => { + async function karmaWorkspace(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + options: { polyfills: ['zone.js', 'zone.js/testing'] }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const pkg = JSON.parse(tree.readText('/package.json')); + pkg.devDependencies = { + ...(pkg.devDependencies ?? {}), + karma: '^6.4.0', + 'karma-chrome-launcher': '^3.2.0', + 'karma-jasmine': '^5.1.0', + jasmine: '^5.1.0', + 'jasmine-core': '^5.1.0', + '@types/jasmine': '^5.1.0', + }; + tree.overwrite('/package.json', JSON.stringify(pkg, null, 2)); + tree.create('/karma.conf.js', '// karma config'); + tree.create('/src/test.ts', '// karma entry'); + tree.create( + '/tsconfig.spec.json', + JSON.stringify( + { compilerOptions: { types: ['jasmine'] }, files: ['src/test.ts', 'src/polyfills.ts'] }, + null, + 2, + ), + ); + return tree; + } + + it('removes karma/jasmine devDependencies', async () => { + const out = (await runner().runSchematic('ng-add', {}, await karmaWorkspace())) as UnitTestTree; + const pkg = JSON.parse(out.readText('/package.json')); + for (const dep of [ + 'karma', + 'karma-chrome-launcher', + 'karma-jasmine', + 'jasmine', + 'jasmine-core', + '@types/jasmine', + ]) { + expect(pkg.devDependencies[dep]).toBeUndefined(); + } + }); + + it('deletes karma.conf.js and src/test.ts', async () => { + const out = (await runner().runSchematic('ng-add', {}, await karmaWorkspace())) as UnitTestTree; + expect(out.exists('/karma.conf.js')).toBe(false); + expect(out.exists('/src/test.ts')).toBe(false); + }); + + it('fixes tsconfig.spec.json (types jasmine→jest, drops test.ts)', async () => { + const out = (await runner().runSchematic('ng-add', {}, await karmaWorkspace())) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.types).toEqual(['jest']); + expect(cfg.files).toEqual(['src/polyfills.ts']); + }); +}); + +describe('jest ng-add (Vitest present)', () => { + async function vitestWorkspace(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular/build:unit-test', + options: { buildTarget: 'app:build', tsConfig: 'tsconfig.spec.json' }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + tree.create( + '/tsconfig.spec.json', + JSON.stringify( + { compilerOptions: { types: ['vitest/globals'] }, include: ['src/**/*.spec.ts'] }, + null, + 2, + ), + ); + return tree; + } + + it('rewrites the Vitest test target to @angular-builders/jest:run', async () => { + const out = (await runner().runSchematic('ng-add', {}, await vitestWorkspace())) as UnitTestTree; + const ws = await readWorkspace(out); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe( + '@angular-builders/jest:run', + ); + }); + + it('fixes tsconfig.spec.json types (vitest globals → jest)', async () => { + const out = (await runner().runSchematic('ng-add', {}, await vitestWorkspace())) as UnitTestTree; + const cfg = JSON.parse(out.readText('/tsconfig.spec.json')); + expect(cfg.compilerOptions.types).toEqual(['jest']); + }); + + it('logs an advisory about manually porting vi.* / vitest specs to Jest', async () => { + const messages: string[] = []; + const r = runner(); + r.logger.subscribe((e: logging.LogEntry) => messages.push(e.message)); + await r.runSchematic('ng-add', {}, await vitestWorkspace()); + const joined = messages.join('\n'); + expect(joined).toMatch(/vitest/i); + expect(joined).toMatch(/vi\.\*|manual/i); + }); + + it('does not delete files or remove devDependencies (lighter than Karma)', async () => { + const tree = await vitestWorkspace(); + const beforeDeps = JSON.parse(tree.readText('/package.json')).devDependencies ?? {}; + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const afterDeps = JSON.parse(out.readText('/package.json')).devDependencies ?? {}; + for (const dep of Object.keys(beforeDeps)) { + expect(afterDeps[dep]).toBeDefined(); + } + expect(out.exists('/karma.conf.js')).toBe(false); + }); + + it('strips the prior :unit-test options (runner, buildTarget) from the jest target', async () => { + const out = (await runner().runSchematic('ng-add', {}, await vitestWorkspace())) as UnitTestTree; + const ws = await readWorkspace(out); + const options = ws.projects.get('app')!.targets.get('test')!.options as Record; + // buildTarget belongs to the Vitest unit-test builder; it must not leak to the Jest builder. + expect(options['buildTarget']).toBeUndefined(); + expect(options['runner']).toBeUndefined(); + expect(options['zoneless']).toBe(true); + }); +}); + +describe('jest ng-add (v22 Karma via :unit-test runner option)', () => { + // Angular 22 default (esbuild) apps express Karma as @angular/build:unit-test + runner:"karma", + // not a dedicated :karma builder. The schematic must detect this as Karma and rewrite to Jest + // with a clean option set (no stale `runner`). + async function v22KarmaWorkspace(): Promise { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get('app')!.targets.set('test', { + builder: '@angular/build:unit-test', + options: { runner: 'karma', buildTarget: 'app:build' }, + }); + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + return tree; + } + + it('rewrites to @angular-builders/jest:run and drops the stale runner option', async () => { + const out = (await runner().runSchematic('ng-add', {}, await v22KarmaWorkspace())) as UnitTestTree; + const ws = await readWorkspace(out); + const test = ws.projects.get('app')!.targets.get('test')!; + expect(test.builder).toBe('@angular-builders/jest:run'); + const options = test.options as Record; + expect(options['runner']).toBeUndefined(); + expect(options['buildTarget']).toBeUndefined(); + }); +}); + +describe('jest ng-add (zoneless detection + idempotency)', () => { + it('sets zoneless:false when zone.js is in build polyfills', async () => { + let tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + await runner() + .callRule( + updateWorkspace((ws) => { + const build = ws.projects.get('app')!.targets.get('build')!; + build.options = { ...(build.options ?? {}), polyfills: ['zone.js'] }; + }), + tree, + ) + .forEach((t) => (tree = t as UnitTestTree)); + + const out = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const ws = await readWorkspace(out); + const opts = ws.projects.get('app')!.targets.get('test')!.options as Record; + expect(opts['zoneless']).toBe(false); + }); + + it('is idempotent: re-running on an already-jest workspace keeps :run', async () => { + const tree = await new SchematicTestHarness().createWorkspace({ projects: [{ name: 'app' }] }); + const once = (await runner().runSchematic('ng-add', {}, tree)) as UnitTestTree; + const twice = (await runner().runSchematic('ng-add', {}, once)) as UnitTestTree; + + const ws = await readWorkspace(twice); + expect(ws.projects.get('app')!.targets.get('test')!.builder).toBe( + '@angular-builders/jest:run', + ); + const pkg = JSON.parse(twice.readText('/package.json')); + expect(pkg.devDependencies['jest']).toBe('^30.0.0'); + }); +}); 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..b19176c5cc --- /dev/null +++ b/packages/jest/src/schematics/ng-add/index.ts @@ -0,0 +1,136 @@ +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { workspaces } from '@angular-devkit/core'; +import { JSONFile } from '@schematics/angular/utility/json-file'; +import { readWorkspace } from '@schematics/angular/utility'; +import { + addBuilderDevDependency, + detectTestBuilder, + editJsonFile, + getProjectsToTarget, + isZoneless, + removeDevDependencies, + removeFilesIfPresent, + setBuilderForTarget, +} from '@angular-builders/common/schematics'; +import { NgAddOptions } from './schema'; + +const JEST_BUILDER = '@angular-builders/jest:run'; + +const JEST_STACK: Array<[name: string, version: string]> = [ + ['@angular-builders/jest', '^22.0.0'], + ['jest', '^30.0.0'], + ['jest-preset-angular', '^16.0.0'], + ['jest-environment-jsdom', '^30.0.0'], +]; + +const KARMA_DEVDEPS = [ + 'karma', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'jasmine', + 'jasmine-core', + '@types/jasmine', +]; + +const KARMA_FILES = ['karma.conf.js', 'src/test.ts']; + +function hasKarma(tree: Tree, workspace: Awaited>): boolean { + for (const name of workspace.projects.keys()) { + if (detectTestBuilder(workspace as unknown as workspaces.WorkspaceDefinition, name) === 'karma') return true; + } + if (tree.exists('/karma.conf.js') || tree.exists('/karma.conf.ts')) return true; + if (tree.exists('/package.json')) { + const pkg = JSON.parse(tree.readText('/package.json')); + const dev = pkg.devDependencies ?? {}; + if (dev['karma'] || dev['jasmine'] || dev['jasmine-core']) return true; + } + return false; +} + +function hasVitest(workspace: Awaited>): boolean { + for (const name of workspace.projects.keys()) { + if (detectTestBuilder(workspace as unknown as workspaces.WorkspaceDefinition, name) === 'vitest') return true; + } + return false; +} + +const NON_JEST_SPEC_TYPES = ['jasmine', 'vitest', 'vitest/globals']; + +function fixSpecTsconfig(path: string): Rule { + return editJsonFile(path, (json: JSONFile) => { + const types = json.get(['compilerOptions', 'types']); + if (Array.isArray(types)) { + const next = types.filter((t) => !NON_JEST_SPEC_TYPES.includes(t as string)); + if (!next.includes('jest')) next.push('jest'); + json.modify(['compilerOptions', 'types'], next); + } + const files = json.get(['files']); + if (Array.isArray(files)) { + json.modify( + ['files'], + files.filter((f) => f !== 'src/test.ts' && f !== 'test.ts'), + ); + } + }); +} + +export function ngAdd(options: NgAddOptions): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = await readWorkspace(tree); + const projects = getProjectsToTarget(workspace as unknown as workspaces.WorkspaceDefinition, options.project); + + const rules: Rule[] = []; + + const existingPkg = tree.exists('/package.json') + ? JSON.parse(tree.readText('/package.json')) + : {}; + const existingDev: Record = existingPkg.devDependencies ?? {}; + const toAdd = JEST_STACK.filter(([name]) => !existingDev[name]); + toAdd.forEach(([name, version], i) => { + rules.push(addBuilderDevDependency(name, version, { install: i === toAdd.length - 1 })); + }); + + for (const projectName of projects) { + const zoneless = isZoneless(tree, workspace as unknown as workspaces.WorkspaceDefinition, projectName); + // replaceOptions: the previous test target may be a :unit-test (Karma/Vitest) or :karma + // builder whose options (runner, buildTarget, karmaConfig, ...) are meaningless to — and + // would be forwarded as bogus CLI args by — the Jest builder. Start from a clean jest config. + rules.push(setBuilderForTarget(projectName, 'test', JEST_BUILDER, { zoneless }, { replaceOptions: true })); + } + + if (hasKarma(tree, workspace)) { + rules.push(removeDevDependencies(KARMA_DEVDEPS)); + rules.push(removeFilesIfPresent(KARMA_FILES.map((f) => `/${f}`))); + const specPaths = new Set(['/tsconfig.spec.json']); + for (const projectName of projects) { + const root = workspace.projects.get(projectName)?.root ?? ''; + specPaths.add(root ? `/${root}/tsconfig.spec.json` : '/tsconfig.spec.json'); + } + for (const specPath of specPaths) { + rules.push(fixSpecTsconfig(specPath)); + } + } + + if (hasVitest(workspace)) { + context.logger.warn( + '[@angular-builders/jest] Detected Vitest as the current test runner. The `test` target ' + + 'was switched to @angular-builders/jest:run, but spec code using `vi.*` (e.g. vi.fn, ' + + 'vi.mock, vi.spyOn) or `import ... from \'vitest\'` is NOT rewritten — port it to the ' + + 'Jest API (jest.fn, jest.mock, jest.spyOn) manually. Cleanup is lighter than Karma: ' + + 'Vitest is built into @angular/build, so there is no karma.conf-style file to remove.', + ); + const specPaths = new Set(['/tsconfig.spec.json']); + for (const projectName of projects) { + const root = workspace.projects.get(projectName)?.root ?? ''; + specPaths.add(root ? `/${root}/tsconfig.spec.json` : '/tsconfig.spec.json'); + } + for (const specPath of specPaths) { + rules.push(fixSpecTsconfig(specPath)); + } + } + + return chain(rules); + }; +} 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..e031c39d09 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "JestNgAddSchema", + "title": "@angular-builders/jest ng-add options", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to set up Jest for. Defaults to all projects (or the default project) when omitted.", + "$default": { + "$source": "projectName" + } + } + }, + "additionalProperties": false +} 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..d468fd7144 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/schema.ts @@ -0,0 +1,3 @@ +export interface NgAddOptions { + project?: string; +} diff --git a/packages/jest/tests/e2e/karma-to-jest.ng-add.json b/packages/jest/tests/e2e/karma-to-jest.ng-add.json new file mode 100644 index 0000000000..54ce8c81e9 --- /dev/null +++ b/packages/jest/tests/e2e/karma-to-jest.ng-add.json @@ -0,0 +1,14 @@ +{ + "generate": { + "name": "karma-app", + "args": ["--test-runner", "karma", "--routing=false", "--style=scss"] + }, + "package": "@angular-builders/jest", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["karma-app", "test", "@angular-builders/jest:run"] }, + { "fn": "assertFileAbsent", "args": ["karma.conf.js"] }, + { "fn": "assertDevDependency", "args": ["jest"] } + ], + "post": ["npx ng test"] +} diff --git a/packages/jest/tests/e2e/migration-v21.smoke.json b/packages/jest/tests/e2e/migration-v21.smoke.json new file mode 100644 index 0000000000..989ab7de91 --- /dev/null +++ b/packages/jest/tests/e2e/migration-v21.smoke.json @@ -0,0 +1,7 @@ +{ + "describes": "jest @21 migration post-migration build smoke", + "generates": "a fresh v22 app, seeded with the pre-21 jest config shape", + "runs": "scripts/e2e-jest-migration.js", + "window": "ng update @angular-builders/jest --migrate-only --from=20.0.0 --to=22.0.0", + "proves": "renames (configPath->config, testPathPattern->testPathPatterns[]) + tsconfig Node16/isolatedModules, then ng build + ng test green under v22" +} diff --git a/packages/jest/tests/e2e/vitest-to-jest.ng-add.json b/packages/jest/tests/e2e/vitest-to-jest.ng-add.json new file mode 100644 index 0000000000..ef108a3f77 --- /dev/null +++ b/packages/jest/tests/e2e/vitest-to-jest.ng-add.json @@ -0,0 +1,13 @@ +{ + "generate": { + "name": "vitest-app", + "args": ["--routing=false", "--style=scss"] + }, + "package": "@angular-builders/jest", + "ngAddArgs": [], + "asserts": [ + { "fn": "assertBuilderForTarget", "args": ["vitest-app", "test", "@angular-builders/jest:run"] }, + { "fn": "assertDevDependency", "args": ["jest"] } + ], + "post": ["npx ng build", "npx ng test"] +} diff --git a/packages/jest/tests/integration.js b/packages/jest/tests/integration.js index 3ff15587eb..460b6cba25 100644 --- a/packages/jest/tests/integration.js +++ b/packages/jest/tests/integration.js @@ -107,6 +107,31 @@ module.exports = [ 'node ../../../packages/jest/tests/validate.js my-shared-library --find-related-tests projects/my-shared-library/src/lib/my-shared-library.service.ts projects/my-shared-library/src/lib/my-shared-library.component.ts --expect-suites=2 --expect-tests=2', }, + // --- ng add e2e (Plan 04): real CLI ng add on an inline-generated app, then ng test --- + { + id: 'ng-add-karma-to-jest', + name: 'jest: ng add Karma->Jest', + purpose: 'ng add detects a Karma test target and ng test runs green via Jest', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/jest/tests/e2e/karma-to-jest.ng-add.json', + }, + { + id: 'ng-add-vitest-to-jest', + name: 'jest: ng add Vitest->Jest', + purpose: 'ng add rewrites a Vitest unit-test target to Jest; ng build + ng test green', + app: '.', + command: + 'node scripts/e2e-ng-add.js --spec packages/jest/tests/e2e/vitest-to-jest.ng-add.json', + }, + { + id: 'ng-update-jest-v21-smoke', + name: 'jest: @21 migration post-build smoke', + purpose: 'jest @21 migration produces valid config; ng build + ng test green under v22', + app: '.', + command: 'node scripts/e2e-jest-migration.js', + }, + // E2E sanity { id: 'e2e-simple-app', diff --git a/packages/jest/tsconfig.schematics.json b/packages/jest/tsconfig.schematics.json new file mode 100644 index 0000000000..e4f56b977a --- /dev/null +++ b/packages/jest/tsconfig.schematics.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.schematics.json", + "compilerOptions": { + "rootDir": "src/schematics", + "outDir": "dist/schematics" + }, + "include": ["src/schematics/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} diff --git a/scripts/__fixtures__/e2e-smoke/angular.json b/scripts/__fixtures__/e2e-smoke/angular.json new file mode 100644 index 0000000000..270871d0c2 --- /dev/null +++ b/scripts/__fixtures__/e2e-smoke/angular.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "projects": { + "smoke": { + "projectType": "application", + "root": "", + "architect": { + "test": { "builder": "@angular-devkit/build-angular:karma", "options": {} } + } + } + } +} diff --git a/scripts/__fixtures__/e2e-smoke/karma.conf.js b/scripts/__fixtures__/e2e-smoke/karma.conf.js new file mode 100644 index 0000000000..ea41b01de4 --- /dev/null +++ b/scripts/__fixtures__/e2e-smoke/karma.conf.js @@ -0,0 +1 @@ +module.exports = function () {}; diff --git a/scripts/__fixtures__/e2e-smoke/package.json b/scripts/__fixtures__/e2e-smoke/package.json new file mode 100644 index 0000000000..813a623753 --- /dev/null +++ b/scripts/__fixtures__/e2e-smoke/package.json @@ -0,0 +1 @@ +{ "name": "smoke", "private": true } diff --git a/scripts/e2e-assert.js b/scripts/e2e-assert.js new file mode 100644 index 0000000000..439347d83b --- /dev/null +++ b/scripts/e2e-assert.js @@ -0,0 +1,63 @@ +'use strict'; +// Declarative post-`ng add` assertions used by *-ng-add.json expectation files. +// Each helper throws on failure (non-zero exit propagates through e2e-ng-add.js). +const fs = require('fs'); +const path = require('path'); + +function readJson(workdir, rel) { + return JSON.parse(fs.readFileSync(path.join(workdir, rel), 'utf8')); +} + +// Assert a file does NOT exist (e.g. karma.conf.js removed). +function assertFileAbsent(workdir, rel) { + if (fs.existsSync(path.join(workdir, rel))) { + throw new Error(`Expected file to be ABSENT but it exists: ${rel}`); + } +} + +// Assert a file exists and contains a substring (e.g. webpack.config.js scaffold). +function assertFileContains(workdir, rel, substr) { + const p = path.join(workdir, rel); + if (!fs.existsSync(p)) throw new Error(`Expected file to exist: ${rel}`); + const text = fs.readFileSync(p, 'utf8'); + if (!text.includes(substr)) { + throw new Error(`Expected ${rel} to contain ${JSON.stringify(substr)}`); + } +} + +// Assert angular.json target builder equals expected (e.g. test -> @angular-builders/jest:run). +function assertBuilderForTarget(workdir, project, target, expected) { + const ng = readJson(workdir, 'angular.json'); + const proj = ng.projects[project]; + if (!proj) throw new Error(`No project "${project}" in angular.json`); + const tgt = (proj.architect || proj.targets)[target]; + if (!tgt) throw new Error(`No target "${target}" in project "${project}"`); + const actual = tgt.builder; + if (actual !== expected) { + throw new Error(`Target ${project}:${target} builder = ${actual}, expected ${expected}`); + } +} + +// Assert a captured ng-add log file contains an advisory substring (webpack guard). +function assertLogContains(logFile, substr) { + const text = fs.readFileSync(logFile, 'utf8'); + if (!text.includes(substr)) { + throw new Error(`Expected ng add log to contain ${JSON.stringify(substr)}`); + } +} + +// Assert a devDependency was saved into package.json (save-to-devDependencies path). +function assertDevDependency(workdir, name) { + const pkg = readJson(workdir, 'package.json'); + if (!pkg.devDependencies || !pkg.devDependencies[name]) { + throw new Error(`Expected devDependency "${name}" to be saved in package.json`); + } +} + +module.exports = { + assertFileAbsent, + assertFileContains, + assertBuilderForTarget, + assertLogContains, + assertDevDependency, +}; diff --git a/scripts/e2e-jest-migration.js b/scripts/e2e-jest-migration.js new file mode 100644 index 0000000000..3ad90e6e38 --- /dev/null +++ b/scripts/e2e-jest-migration.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +'use strict'; +// jest @21 migration POST-MIGRATION BUILD SMOKE. +// +// 1. Generate a fresh v22 app inline (ng new), symlink the workspace node_modules. +// 2. SEED the pre-21 jest config shape the @21 migration transforms: +// - test target -> @angular-builders/jest:run with OLD option names +// (configPath, testPathPattern), which the migration renames to (config, testPathPatterns) +// - a jest.config.js so configPath resolves post-rename +// - tsconfig.spec.json WITHOUT module/moduleResolution Node16 and WITHOUT isolatedModules +// (the migration patches these in) +// 3. Run ONLY the jest @21 migration via the real CLI: +// ng update @angular-builders/jest --migrate-only --from=20.0.0 --to=22.0.0 --allow-dirty --force +// (--from < 21 <= --to so the (from, to] window includes the 21.0.0 threshold and migration-v21 fires.) +// 4. Assert the config was actually transformed (renames + tsconfig patch). +// 5. ng build + ng test under v22 to prove the migrated config is valid/runnable. +// +// Like e2e-ng-add.js, the package manager is neutralised during CLI steps via a PATH shim so +// migration/ng-update install tasks can't write through the symlinked node_modules. + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const NG_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'ng'); +const APP = 'mig-app'; + +function run(cmd, args, opts) { + console.log(`[jest-migration] $ ${cmd} ${args.join(' ')} (cwd=${opts.cwd})`); + const res = spawnSync(cmd, args, { stdio: 'inherit', ...opts }); + return res.status; +} + +function makePackageManagerShim() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-noop-')); + for (const pm of ['npm', 'yarn', 'pnpm', 'cnpm']) { + fs.writeFileSync(path.join(dir, pm), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + } + return dir; +} + +function seedPre21Shape(workdir) { + // angular.json: jest:run test target with the OLD option names. + const ngPath = path.join(workdir, 'angular.json'); + const ng = JSON.parse(fs.readFileSync(ngPath, 'utf8')); + const arch = ng.projects[APP].architect || ng.projects[APP].targets; + arch.test = { + builder: '@angular-builders/jest:run', + options: { configPath: 'jest.config.js', testPathPattern: 'src/.*\\.spec\\.ts$' }, + }; + fs.writeFileSync(ngPath, JSON.stringify(ng, null, 2)); + + // jest.config.js so `configPath` resolves once renamed to `config`. + fs.writeFileSync( + path.join(workdir, 'jest.config.js'), + "module.exports = { preset: 'jest-preset-angular' };\n" + ); + + // tsconfig.spec.json in the pre-21 shape (no Node16, no isolatedModules). + const specPath = path.join(workdir, 'tsconfig.spec.json'); + const spec = { + extends: './tsconfig.json', + compilerOptions: { outDir: './out-tsc/spec', module: 'ESNext', moduleResolution: 'node', types: ['jest'] }, + include: ['src/**/*.spec.ts', 'src/**/*.d.ts'], + }; + fs.writeFileSync(specPath, JSON.stringify(spec, null, 2)); +} + +function main() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jest-mig-')); + if (run(NG_BIN, ['new', APP, '--directory', APP, '--skip-install', '--skip-git', '--routing=false', '--style=scss'], { cwd: tmp }) !== 0) { + throw new Error('ng new failed'); + } + const workdir = path.join(tmp, APP); + fs.symlinkSync(path.join(REPO_ROOT, 'node_modules'), path.join(workdir, 'node_modules'), 'dir'); + + seedPre21Shape(workdir); + + const shimDir = makePackageManagerShim(); + const env = { ...process.env, PATH: `${shimDir}${path.delimiter}${process.env.PATH}` }; + + const status = run( + NG_BIN, + ['update', '@angular-builders/jest', '--migrate-only', '--from=20.0.0', '--to=22.0.0', '--allow-dirty', '--force'], + { cwd: workdir, env } + ); + if (status !== 0) throw new Error(`ng update --migrate-only failed with status ${status}`); + + // Assert the transforms landed. + const ng = JSON.parse(fs.readFileSync(path.join(workdir, 'angular.json'), 'utf8')); + const proj = ng.projects[APP]; + const testOpts = (proj.architect || proj.targets).test.options || {}; + if (testOpts.configPath !== undefined) throw new Error('configPath not renamed (still present)'); + if (testOpts.config === undefined) throw new Error('config (renamed from configPath) missing'); + if (testOpts.testPathPattern !== undefined) throw new Error('testPathPattern not renamed'); + if (testOpts.testPathPatterns === undefined) throw new Error('testPathPatterns (renamed) missing'); + + const spec = JSON.parse(fs.readFileSync(path.join(workdir, 'tsconfig.spec.json'), 'utf8')); + if (spec.compilerOptions.module !== 'Node16') throw new Error('tsconfig module not Node16'); + if (spec.compilerOptions.isolatedModules !== true) throw new Error('isolatedModules not true'); + console.log('[jest-migration] transform assertions OK'); + + // Prove the migrated config is valid/runnable under v22. + if (run('sh', ['-c', 'npx ng build'], { cwd: workdir, env }) !== 0) throw new Error('ng build failed post-migration'); + if (run('sh', ['-c', 'npx ng test'], { cwd: workdir, env }) !== 0) throw new Error('ng test failed post-migration'); + + console.log('[jest-migration] PASS'); +} + +try { + main(); +} catch (err) { + console.error(`[jest-migration] FAIL: ${err.message}`); + process.exit(1); +} diff --git a/scripts/e2e-ng-add.js b/scripts/e2e-ng-add.js new file mode 100644 index 0000000000..d21a406b34 --- /dev/null +++ b/scripts/e2e-ng-add.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +'use strict'; +// Drives a LOCAL-ONLY `ng add` e2e for an unpublished workspace build. +// +// The fixture app is GENERATED INLINE with the workspace's own Angular CLI (`ng new`) +// rather than committed to the repo — a fresh `ng new` app has no custom content worth +// version-controlling, and generating it keeps the test self-describing and immune to +// fixture drift across Angular majors. Only genuinely custom fixtures should be committed. +// +// What it does: +// 1. Generate a fresh app into a temp workdir via `ng new` (--skip-install), then symlink +// the repo's hoisted node_modules so the real CLI resolves. +// 2. Optionally run `prepareWorkdir` shell commands (e.g. rewrite angular.json to a +// webpack builder the default `ng new` wouldn't produce). +// 3. Run `ng add --collection --skip-install `, capturing +// combined output to a log file. The collection resolves from the workspace-linked +// package (already installed); `--skip-install` means `ng add` never runs the package +// manager, so it cannot mutate the symlinked node_modules. The schematic under test +// still runs in full (rewrites angular.json, writes devDeps, removes files). +// 4. Run the declarative assertions from the spec file against the workdir + log. +// 5. Run the post-add verification commands (e.g. `ng build`, `ng test`) in the workdir, +// resolving the real builder from the workspace-linked package. +// +// Why not `npm pack` + `ng add ./`? That exercises a real registry install, but here +// the workdir's node_modules is a symlink to the repo's hoisted install — a real `ng add` +// install would write THROUGH the symlink and pollute the workspace. Everything the schematic +// needs is already workspace-linked, so the install adds risk without testing our code. The +// tarball path is kept behind "useTarball": true for a future isolated-install CI (own +// node_modules per job), where the full resolve->install fidelity is safe to exercise. +// +// Usage: +// node scripts/e2e-ng-add.js --spec +// +// Spec file shape (JSON): +// { +// "generate": { // generate the fixture inline with `ng new` +// "name": "karma-app", // project + directory name +// "args": ["--test-runner", "karma"] // extra `ng new` args (style/routing/etc.) +// }, +// "prepareWorkdir": ["node ../prep.js"], // optional shell steps run before `ng add` +// "package": "@angular-builders/jest", // workspace package to pack +// "ngAddArgs": [], // extra args to `ng add` +// "expectAddSucceeds": true, // ng add must exit 0 (default true) +// "asserts": [ // run after ng add, before build/test +// { "fn": "assertBuilderForTarget", "args": ["karma-app", "test", "@angular-builders/jest:run"] }, +// { "fn": "assertFileAbsent", "args": ["karma.conf.js"] } +// ], +// "post": ["npx ng build", "npx ng test"] // commands run in workdir; each must exit 0 +// } +// +// A committed fixture may be used instead of "generate" via "fixture": "" +// (copied into the workdir). Use this only for fixtures with custom content `ng new` can't produce. +// +// Fallback (when npm pack + tarball resolve is not viable): set "useCollectionFallback": true, +// which runs `ng add --collection ...` against the workspace-linked package. + +const { execFileSync, spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('./e2e-assert'); + +const REPO_ROOT = path.join(__dirname, '..'); +const NG_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'ng'); + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--spec' && argv[i + 1]) out.spec = argv[++i]; + } + if (!out.spec) { + console.error('Usage: node scripts/e2e-ng-add.js --spec '); + process.exit(2); + } + return out; +} + +// Recursively copy a directory, skipping node_modules / .angular / dist caches. +function copyDir(src, dest) { + const SKIP = new Set(['node_modules', '.angular', 'dist', '.git']); + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyDir(s, d); + else fs.copyFileSync(s, d); + } +} + +function run(cmd, args, opts) { + console.log(`[e2e-ng-add] $ ${cmd} ${args.join(' ')} (cwd=${opts.cwd})`); + const res = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }); + const stdout = (res.stdout || '').toString(); + const stderr = (res.stderr || '').toString(); + process.stdout.write(stdout); + process.stderr.write(stderr); + return { status: res.status, stdout, stderr }; +} + +// Symlink the repo root's hoisted node_modules so the real Angular CLI resolves. +// Yarn 3 workspaces hoist everything to the repo root node_modules. +function linkNodeModules(workdir) { + const wdNodeModules = path.join(workdir, 'node_modules'); + if (!fs.existsSync(wdNodeModules)) { + fs.symlinkSync(path.join(REPO_ROOT, 'node_modules'), wdNodeModules, 'dir'); + } +} + +// Create a directory of no-op package-manager shims (npm/yarn/pnpm/cnpm) to prepend to PATH +// during the `ng add` run. The schematic schedules a NodePackageInstallTask; with node_modules +// symlinked to the workspace, a real install would write THROUGH the symlink and mutate the +// repo. We intentionally do not exercise npm's installer (that's the CLI's job, not our +// schematic's, and everything needed is already workspace-linked) — so we let the task run +// against a no-op PM. Every tree change the schematic makes still happens; only the install +// is neutered. ng build/test afterwards resolve from the symlinked workspace modules. +function makePackageManagerShim() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-noop-')); + for (const pm of ['npm', 'yarn', 'pnpm', 'cnpm']) { + const file = path.join(dir, pm); + fs.writeFileSync(file, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + } + return dir; +} + +// Generate a fresh app inline with the workspace CLI; returns the app directory. +function generateFixture(spec, parentDir) { + const { name, args = [] } = spec.generate; + const r = run( + NG_BIN, + ['new', name, '--directory', name, '--skip-install', '--skip-git', ...args], + { cwd: parentDir } + ); + if (r.status !== 0) throw new Error(`ng new failed with status ${r.status}`); + return path.join(parentDir, name); +} + +function main() { + const { spec: specPath } = parseArgs(process.argv.slice(2)); + const spec = JSON.parse(fs.readFileSync(path.resolve(REPO_ROOT, specPath), 'utf8')); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-add-e2e-')); + console.log(`[e2e-ng-add] tmp = ${tmp}`); + + let workdir; + if (spec.generate) { + workdir = generateFixture(spec, tmp); + } else if (spec.fixture) { + const fixtureAbs = path.resolve(REPO_ROOT, spec.fixture); + if (!fs.existsSync(fixtureAbs)) throw new Error(`Fixture not found: ${spec.fixture}`); + workdir = path.join(tmp, path.basename(fixtureAbs)); + copyDir(fixtureAbs, workdir); + } else { + throw new Error('Spec must define either "generate" or "fixture".'); + } + console.log(`[e2e-ng-add] workdir = ${workdir}`); + linkNodeModules(workdir); + + // Optional pre-`ng add` preparation (e.g. rewrite angular.json to a webpack builder). + for (const cmd of spec.prepareWorkdir || []) { + const r = run('sh', ['-c', cmd], { cwd: workdir }); + if (r.status !== 0) throw new Error(`prepareWorkdir command failed (${r.status}): ${cmd}`); + } + + const logFile = path.join(workdir, 'ng-add.log'); + + if (spec.useTarball) { + // Opt-in (isolated-install envs only): npm pack the built package, then `ng add ./`. + // This runs a real install — only safe when the workdir has its OWN node_modules. + const pkgDir = path.join(REPO_ROOT, 'packages', spec.package.replace('@angular-builders/', '')); + const packOut = execFileSync('npm', ['pack', '--silent', '--pack-destination', workdir], { + cwd: pkgDir, + encoding: 'utf8', + }).trim(); + const tarball = packOut.split('\n').pop().trim(); + const tarballAbs = path.join(workdir, tarball); + console.log(`[e2e-ng-add] packed ${spec.package} -> ${tarball}`); + const args = ['add', tarballAbs, '--skip-confirmation', ...(spec.ngAddArgs || [])]; + const r = run(NG_BIN, args, { cwd: workdir }); + fs.writeFileSync(logFile, r.stdout + r.stderr); + if ((spec.expectAddSucceeds !== false) && r.status !== 0) { + throw new Error(`ng add failed with status ${r.status}`); + } + } else { + // Default: real CLI, collection resolved from the workspace-linked package. The schematic's + // install task runs against a no-op PM shim (see makePackageManagerShim) so it can't mutate + // the symlinked workspace node_modules. + const shimDir = makePackageManagerShim(); + const args = ['add', spec.package, '--collection', spec.package, '--skip-confirmation', + ...(spec.ngAddArgs || [])]; + const r = run(NG_BIN, args, { + cwd: workdir, + env: { ...process.env, PATH: `${shimDir}${path.delimiter}${process.env.PATH}` }, + }); + fs.writeFileSync(logFile, r.stdout + r.stderr); + if ((spec.expectAddSucceeds !== false) && r.status !== 0) { + throw new Error(`ng add failed with status ${r.status}`); + } + } + + // Declarative assertions (workdir-relative). + for (const a of spec.asserts || []) { + const fn = assert[a.fn]; + if (!fn) throw new Error(`Unknown assert fn: ${a.fn}`); + // assertLogContains takes an absolute log path as first arg; others take workdir. + if (a.fn === 'assertLogContains') fn(logFile, ...a.args); + else fn(workdir, ...a.args); + console.log(`[e2e-ng-add] OK assert ${a.fn}(${JSON.stringify(a.args)})`); + } + + // Post-add verification commands (real build/test under the workspace Angular major). + for (const cmd of spec.post || []) { + const r = run('sh', ['-c', cmd], { cwd: workdir }); + if (r.status !== 0) throw new Error(`post command failed (${r.status}): ${cmd}`); + } + + console.log('[e2e-ng-add] PASS'); +} + +try { + main(); +} catch (err) { + console.error(`[e2e-ng-add] FAIL: ${err.message}`); + process.exit(1); +} diff --git a/tsconfig.schematics.json b/tsconfig.schematics.json new file mode 100644 index 0000000000..142aab8747 --- /dev/null +++ b/tsconfig.schematics.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "declaration": true, + "strict": true, + "strictNullChecks": false, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "pretty": true + }, + "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] +} diff --git a/yarn.lock b/yarn.lock index a0af96451e..a1d8922ce8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -190,6 +190,9 @@ __metadata: resolution: "@angular-builders/common@workspace:packages/common" dependencies: "@angular-devkit/core": ^22.0.0-rc.2 + "@angular-devkit/schematics": ^22.0.0-rc.2 + "@schematics/angular": ^22.0.0-rc.2 + copyfiles: ^2.4.1 rimraf: ^6.0.0 ts-node: ^10.0.0 tsconfig-paths: ^4.2.0 @@ -204,7 +207,10 @@ __metadata: "@angular-builders/common": "workspace:*" "@angular-devkit/architect": ">=0.2200.0-rc.2 < 0.2300.0" "@angular-devkit/core": ^22.0.0-rc.2 + "@angular-devkit/schematics": ^22.0.0-rc.2 "@angular/build": ^22.0.0-rc.2 + "@schematics/angular": ^22.0.0-rc.2 + copyfiles: ^2.4.1 esbuild: 0.28.0 jest: 30.4.2 rimraf: ^6.0.0 @@ -225,7 +231,10 @@ __metadata: "@angular-devkit/architect": ">=0.2200.0-rc.2 < 0.2300.0" "@angular-devkit/build-angular": ^22.0.0-rc.2 "@angular-devkit/core": ^22.0.0-rc.2 + "@angular-devkit/schematics": ^22.0.0-rc.2 "@angular/build": ^22.0.0-rc.2 + "@schematics/angular": ^22.0.0-rc.2 + copyfiles: ^2.4.1 jest: 30.4.2 lodash: ^4.17.15 rimraf: ^6.0.0 @@ -245,7 +254,10 @@ __metadata: "@angular-builders/common": "workspace:*" "@angular-devkit/architect": ">=0.2200.0-rc.2 < 0.2300.0" "@angular-devkit/core": ^22.0.0-rc.2 + "@angular-devkit/schematics": ^22.0.0-rc.2 + "@schematics/angular": ^22.0.0-rc.2 "@types/jest": ^30.0.0 + copyfiles: ^2.4.1 cpy-cli: ^7.0.0 jest: 30.4.2 jest-preset-angular: ^16.0.0 @@ -610,6 +622,19 @@ __metadata: languageName: node linkType: hard +"@angular-devkit/schematics@npm:22.0.0-rc.3, @angular-devkit/schematics@npm:^22.0.0-rc.2": + version: 22.0.0-rc.3 + resolution: "@angular-devkit/schematics@npm:22.0.0-rc.3" + dependencies: + "@angular-devkit/core": 22.0.0-rc.3 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + ora: 9.4.0 + rxjs: 7.8.2 + checksum: 9f5db527500f74a7be043bee4f79024627a70acd6373de42dc1ee3327c992786035a9017c72074c192f8efcb879b07960c57e5cb5ff4739598cac094d2e776dc + languageName: node + linkType: hard + "@angular-devkit/schematics@npm:>= 21.0.0 < 22.0.0": version: 21.1.2 resolution: "@angular-devkit/schematics@npm:21.1.2" @@ -6677,6 +6702,18 @@ __metadata: languageName: node linkType: hard +"@schematics/angular@npm:^22.0.0-rc.2": + version: 22.0.0-rc.3 + resolution: "@schematics/angular@npm:22.0.0-rc.3" + dependencies: + "@angular-devkit/core": 22.0.0-rc.3 + "@angular-devkit/schematics": 22.0.0-rc.3 + jsonc-parser: 3.3.1 + typescript: 6.0.3 + checksum: 403b8df5ac6f33910c97d7a4af2ea64ce2342ac9eac2c3ec896d519122c1c8035779a34f03c1f6e4b8b92a550264f4f6ad0424d0260c042932a2405389d9df12 + languageName: node + linkType: hard + "@sigstore/bundle@npm:^4.0.0": version: 4.0.0 resolution: "@sigstore/bundle@npm:4.0.0" @@ -10057,6 +10094,24 @@ __metadata: languageName: node linkType: hard +"copyfiles@npm:^2.4.1": + version: 2.4.1 + resolution: "copyfiles@npm:2.4.1" + dependencies: + glob: ^7.0.5 + minimatch: ^3.0.3 + mkdirp: ^1.0.4 + noms: 0.0.0 + through2: ^2.0.1 + untildify: ^4.0.0 + yargs: ^16.1.0 + bin: + copyfiles: copyfiles + copyup: copyfiles + checksum: aea69873bb99cc5f553967660cbfb70e4eeda198f572a36fb0f748b36877ff2c90fd906c58b1d540adbad8afa8ee82820172f1c18e69736f7ab52792c12745a7 + languageName: node + linkType: hard + "core-js-compat@npm:^3.43.0": version: 3.46.0 resolution: "core-js-compat@npm:3.46.0" @@ -12552,7 +12607,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.0.5, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -13200,7 +13255,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:~2.0.1, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -13561,6 +13616,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:0.0.1": + version: 0.0.1 + resolution: "isarray@npm:0.0.1" + checksum: 49191f1425681df4a18c2f0f93db3adb85573bcdd6a4482539d98eac9e705d8961317b01175627e860516a2fc45f8f9302db26e5a380a97a520e272e2a40a8d4 + languageName: node + linkType: hard + "isarray@npm:^2.0.1, isarray@npm:^2.0.5": version: 2.0.5 resolution: "isarray@npm:2.0.5" @@ -15711,6 +15773,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^3.0.3": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" + dependencies: + brace-expansion: ^1.1.7 + checksum: 47ef6f412c08be045a7291d11b1c40777925accf7252dc6d3caa39b1bfbb3a7ea390ba7aba464d762d783265c644143d2c8a204e6b5763145024d52ee65a1941 + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -16245,6 +16316,16 @@ __metadata: languageName: node linkType: hard +"noms@npm:0.0.0": + version: 0.0.0 + resolution: "noms@npm:0.0.0" + dependencies: + inherits: ^2.0.1 + readable-stream: ~1.0.31 + checksum: a05f056dabf764c86472b6b5aad10455f3adcb6971f366cdf36a72b559b29310a940e316bca30802f2804fdd41707941366224f4cba80c4f53071512245bf200 + languageName: node + linkType: hard + "nopt@npm:^8.0.0": version: 8.1.0 resolution: "nopt@npm:8.1.0" @@ -17822,6 +17903,18 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:~1.0.31": + version: 1.0.34 + resolution: "readable-stream@npm:1.0.34" + dependencies: + core-util-is: ~1.0.0 + inherits: ~2.0.1 + isarray: 0.0.1 + string_decoder: ~0.10.x + checksum: 85042c537e4f067daa1448a7e257a201070bfec3dd2706abdbd8ebc7f3418eb4d3ed4b8e5af63e2544d69f88ab09c28d5da3c0b77dc76185fddd189a59863b60 + languageName: node + linkType: hard + "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -19568,6 +19661,13 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:~0.10.x": + version: 0.10.31 + resolution: "string_decoder@npm:0.10.31" + checksum: fe00f8e303647e5db919948ccb5ce0da7dea209ab54702894dd0c664edd98e5d4df4b80d6fabf7b9e92b237359d21136c95bf068b2f7760b772ca974ba970202 + languageName: node + linkType: hard + "string_decoder@npm:~1.0.0": version: 1.0.3 resolution: "string_decoder@npm:1.0.3" @@ -19911,7 +20011,7 @@ __metadata: languageName: node linkType: hard -"through2@npm:^2.0.0, through2@npm:~2.0.3": +"through2@npm:^2.0.0, through2@npm:^2.0.1, through2@npm:~2.0.3": version: 2.0.5 resolution: "through2@npm:2.0.5" dependencies: @@ -21872,7 +21972,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^16.1.1": +"yargs@npm:^16.1.0, yargs@npm:^16.1.1": version: 16.2.0 resolution: "yargs@npm:16.2.0" dependencies: