From fb63b49266377e8f45c97ef403758326fb38cd18 Mon Sep 17 00:00:00 2001 From: Jeb Date: Mon, 1 Jun 2026 12:54:35 +0200 Subject: [PATCH 01/48] docs(schematics): add v22 builder ng-add + ng-update design spec --- .../2026-06-01-builder-schematics-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-builder-schematics-design.md 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 000000000..d564c58d3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-builder-schematics-design.md @@ -0,0 +1,159 @@ +# 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. + +### 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 @22` (v22-gated; ships with #2260; mostly advisory):** + +- The `:karma` builder is removed in v22 with **no drop-in replacement**. Do **not** auto-delete the `test` target (would leave the project with no `ng test`). +- Advise + leave a TODO: migrate the test target to `@angular-builders/custom-esbuild:unit-test` (Vitest) or `@angular-builders/jest` (replacement tracked in #1928). +- Dead `karma.conf.*` / karma-jasmine-puppeteer devDeps: clean only once a replacement test target exists; otherwise advisory. + +## 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 | no-op (pending) | no-op | **migration (Karma removal, advisory)** | + +Only two real migrations exist: **jest `@21`** and **custom-webpack `@22`**. 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). + +## 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` | none | `@22` | +| migration auto transforms | deps, tsconfig, renames, mocks, zoneless(detected) | — | karma cleanup (gated) | +| migration advisories | Node16, removed mocks | — | 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. From 03e9a833a138d0f3551f4af9bf399719ccd7ca77 Mon Sep 17 00:00:00 2001 From: Jeb Date: Mon, 1 Jun 2026 13:06:44 +0200 Subject: [PATCH 02/48] docs(schematics): cover v22-held breaking PRs (#2191/#2212) and MIGRATION.MD pairing --- .../2026-06-01-builder-schematics-design.md | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-06-01-builder-schematics-design.md b/docs/superpowers/specs/2026-06-01-builder-schematics-design.md index d564c58d3..24cab951e 100644 --- a/docs/superpowers/specs/2026-06-01-builder-schematics-design.md +++ b/docs/superpowers/specs/2026-06-01-builder-schematics-design.md @@ -71,6 +71,13 @@ Each package that needs them: `src/schematics/migrations.json` + `migrations//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. @@ -104,30 +111,32 @@ Each package that needs them: `src/schematics/migrations.json` + `migrations/ 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. From 9788b5bfa9aed9728efdbf3c98d34177e5d4ce8d Mon Sep 17 00:00:00 2001 From: Jeb Date: Tue, 2 Jun 2026 12:32:46 +0200 Subject: [PATCH 03/48] =?UTF-8?q?docs(schematics):=20add=20Plan=200=20?= =?UTF-8?q?=E2=80=94=20common/schematics=20core=20+=20packaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-02-builder-schematics-00-common-core.md | 810 ++++++++++++++++++ 1 file changed, 810 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-builder-schematics-00-common-core.md 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 000000000..bdfd68534 --- /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). From 62790e1f05dfcc16463ee6db8ec69f16352be41f Mon Sep 17 00:00:00 2001 From: Jeb Date: Tue, 2 Jun 2026 13:07:51 +0200 Subject: [PATCH 04/48] =?UTF-8?q?docs(schematics):=20add=20Plans=2001-03?= =?UTF-8?q?=20=E2=80=94=20jest,=20custom-esbuild,=20custom-webpack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder implementation plans referencing Plan 0's locked common/schematics API contract. Reviewed for consistency: all import the shared helpers, none redefine them, no raw fs (tree-based edits only). --- .../2026-06-02-builder-schematics-01-jest.md | 1615 +++++++++++++++++ ...02-builder-schematics-02-custom-esbuild.md | 868 +++++++++ ...02-builder-schematics-03-custom-webpack.md | 1033 +++++++++++ 3 files changed, 3516 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md create mode 100644 docs/superpowers/plans/2026-06-02-builder-schematics-02-custom-esbuild.md create mode 100644 docs/superpowers/plans/2026-06-02-builder-schematics-03-custom-webpack.md 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 000000000..6080e33e3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md @@ -0,0 +1,1615 @@ +# 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 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). 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`). + +**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; +} + +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) => t !== 'jasmine'); + 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 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`). + +- [ ] **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`. ✅ +- 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) → Tasks 3,4. ✅ +- targets rewritten (`test`) → Task 3. ✅ +- files deleted (`karma.conf`,`test.ts`) → Task 4. ✅ +- tsconfig edits (spec `types`/`files`) → Task 4. ✅ +- detection (Karma?, zoneless?) → Tasks 4,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; migrations emit only `context.logger` advisories with safe detected defaults and never block → Tasks 2,10,12. ✅ + +**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. `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 and migrations. 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 000000000..b5e3ab2d5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-02-custom-esbuild.md @@ -0,0 +1,868 @@ +# 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), and keeps unit tests consistent by rewiring a Vitest `test` target to `:unit-test` (with `buildTarget`) so esbuild `codePlugins` apply to tests too — all auto-detected, zero prompts, with `--project` and `--unit-test` 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` 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` + +--- + +## 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" + } + }, + "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`. + +- [ ] **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; +} +``` + +- [ ] **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 + +Start with the core rewrite. 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'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const VERSION: string = require('../../../package.json').version; + +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)!; + + 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). + +- [ ] **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 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`, change the factory's async arrow to use the `context` parameter (rename `_context` → `context`): + +```ts + return async (tree: Tree, context: SchematicContext) => { +``` + +Then 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, 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 (custom-esbuild) coverage:** +- Add self to devDeps via `addBuilderDevDependency` → Task 3 Step 3. ✅ +- Rewrite `build` → `:application`, `serve` → `:dev-server`, preserve options → Task 3 (asserts `tsConfig`/`outputPath`/`buildTarget` preserved). ✅ +- 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`, `test`(if Vitest) → Tasks 3–4. ✅ +- files created/deleted: — (none) → no task needed; `copy:schematics` has no `files/**` line (Task 1). ✅ +- tsconfig edits: — (none). ✅ +- detection: test builder kind → `detectTestBuilder` usage Task 4. ✅ +- flags: `--project`, `--unit-test` → schema.json Task 2, used Tasks 3/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–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. ✅ No cross-import of other builder packages (only `@angular-builders/common/schematics`). ✅ + +**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?`) defined in Task 2, used in Tasks 3/6. Builder-name constants (`BUILD_BUILDER`, `SERVE_BUILDER`, `TEST_BUILDER`, `PACKAGE_NAME`) consistent across Tasks 3–6. 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 000000000..7dceb3c41 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-03-custom-webpack.md @@ -0,0 +1,1033 @@ +# Builder Schematics — Plan 03: `custom-webpack` ng-add + `@22` Migration 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) and a v22-gated `ng update` `@22` migration that advises on the `:karma` removal without breaking `ng test`. + +**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`/`ng-update` fields. All workspace/JSON edits go through the shared `@angular-builders/common/schematics` helpers locked by Plan 0 (`setBuilderForTarget`, `addBuilderDevDependency`, `removeDevDependencies`, `removeFilesIfPresent`, `getProjectsToTarget`, `detectTestBuilder`, `isAtLeast`) 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; +removeDevDependencies(names: string[]): Rule; +removeFilesIfPresent(paths: string[]): Rule; +getProjectsToTarget(workspace, optionProject?): string[]; +detectTestBuilder(workspace, projectName): 'karma'|'jest'|'vitest'|'other'|'none'; +isAtLeast(version: string, major: number): boolean; + +// 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`. + +--- + +## 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`/`ng-update` fields, 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. +- Create: `packages/custom-webpack/src/schematics/migrations.json` — declares the `@22` migration with a `22.0.0` semver threshold. +- Create: `packages/custom-webpack/src/schematics/migrations/v22/index.ts` — the advisory Karma-removal migration. +- Create: `packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` — migration 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"`): + +```json + "schematics": "./dist/schematics/collection.json", + "ng-add": { + "save": "devDependencies" + }, + "ng-update": { + "migrations": "./dist/schematics/migrations.json" + }, +``` + +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`, `migrations.json`, every `schema.json`, AND the `files/**` template (`-u 2` strips the `src/schematics` prefix so assets land at `dist/schematics/...`). 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/ng-update fields)" +``` + +--- + +## 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: `@22` migration manifest + +**Files:** +- Create: `packages/custom-webpack/src/schematics/migrations.json` + +- [ ] **Step 1: Write the migrations manifest** + +Create `packages/custom-webpack/src/schematics/migrations.json`: + +```json +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "migration-v22": { + "version": "22.0.0", + "description": "Advise on the v22 :karma builder removal; clean dead karma assets only when a replacement test target exists.", + "factory": "./migrations/v22/index#migrateV22" + } + } +} +``` + +> `version: "22.0.0"` is the semver threshold: `ng update` runs this migration when `installedVersion < 22.0.0 <= targetVersion` (spec §3.3, §4.3). The package.json `ng-update.migrations` field (Task 1) points here. + +- [ ] **Step 2: Commit** + +```bash +git add packages/custom-webpack/src/schematics/migrations.json +git commit --no-verify -m "feat(custom-webpack): register @22 migration manifest" +``` + +--- + +## Task 6: `@22` migration implementation (advisory Karma removal) + +**Files:** +- Create: `packages/custom-webpack/src/schematics/migrations/v22/index.ts` +- Test: `packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` + +Behavior (spec §4.3, §6): +- The `:karma` builder is removed in v22 with NO drop-in replacement. **Never** delete the `test` target (that would leave the project without `ng test`). +- Emit a `context.logger` advisory + leave a TODO comment pointing users at `@angular-builders/custom-esbuild:unit-test` (Vitest) or `@angular-builders/jest` (replacement tracked in #1928). +- Dead `karma.conf.*` files + karma/jasmine-puppeteer devDeps: clean (via `removeFilesIfPresent` / `removeDevDependencies`) **only** once a replacement test target exists (`detectTestBuilder` returns `vitest` or `jest`); otherwise advisory only. +- Headless: NO prompts, never block. +- The migration runs across all projects in the workspace. + +- [ ] **Step 1: Write the failing tests** + +Create `packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts`: + +```ts +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { getWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; + +const MIGRATIONS = require.resolve('../../../../src/schematics/migrations.json'); + +function runner(): SchematicTestRunner { + return new SchematicTestRunner('migrations', MIGRATIONS); +} + +async function setTestBuilder( + tree: UnitTestTree, + project: string, + builder: string, +): Promise { + return (await runner() + .callRule( + updateWorkspace((ws) => { + ws.projects.get(project)!.targets.set('test', { builder, options: {} }); + }), + tree, + ) + .toPromise()) as UnitTestTree; +} + +async function hasTestTarget(tree: UnitTestTree, project: string): Promise { + const ws = await getWorkspace(tree); + return ws.projects.get(project)!.targets.has('test'); +} + +describe('custom-webpack @22 migration', () => { + it('logs an advisory and does NOT delete the karma test target (no replacement)', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-webpack:karma'); + + const run = runner(); + const logs: string[] = []; + run.logger.subscribe((e) => logs.push(e.message)); + + tree = (await run.runSchematic('migration-v22', {}, tree)) as UnitTestTree; + + // test target is preserved — user still has `ng test` + expect(await hasTestTarget(tree, 'app')).toBe(true); + // advisory points at the replacement options + const joined = logs.join('\n'); + expect(joined).toContain('karma'); + expect(joined).toContain('custom-esbuild:unit-test'); + expect(joined).toContain('@angular-builders/jest'); + }); + + it('does NOT clean karma.conf / karma devDeps when no replacement test target exists', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-webpack:karma'); + tree.create('/karma.conf.js', '// karma config'); + tree.overwrite( + '/package.json', + JSON.stringify( + { devDependencies: { 'karma-jasmine-html-reporter': '^2.0.0', karma: '^6.4.0' } }, + null, + 2, + ), + ); + + tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; + + // advisory-only: dead assets remain because there is no replacement runner yet + expect(tree.exists('/karma.conf.js')).toBe(true); + const pkg = JSON.parse(tree.readText('/package.json')); + expect(pkg.devDependencies.karma).toBe('^6.4.0'); + }); + + it('cleans karma.conf / karma devDeps when a replacement test target exists', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + // replacement already in place (e.g. user migrated test to Vitest unit-test) + tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-esbuild:unit-test'); + tree.create('/karma.conf.js', '// karma config'); + tree.overwrite( + '/package.json', + JSON.stringify( + { + devDependencies: { + karma: '^6.4.0', + 'karma-jasmine': '^5.1.0', + 'karma-jasmine-html-reporter': '^2.0.0', + 'karma-chrome-launcher': '^3.2.0', + 'karma-coverage': '^2.2.0', + 'jasmine-core': '^5.1.0', + typescript: '5.9.3', + }, + }, + null, + 2, + ), + ); + + tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; + + expect(tree.exists('/karma.conf.js')).toBe(false); + const pkg = JSON.parse(tree.readText('/package.json')); + expect(pkg.devDependencies.karma).toBeUndefined(); + expect(pkg.devDependencies['karma-jasmine']).toBeUndefined(); + expect(pkg.devDependencies['karma-chrome-launcher']).toBeUndefined(); + // unrelated dep untouched + expect(pkg.devDependencies.typescript).toBe('5.9.3'); + // and the test target (now the replacement) is still there + expect(await hasTestTarget(tree, 'app')).toBe(true); + }); + + it('is a no-op for projects with no karma test target', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + tree = await setTestBuilder(tree, 'app', '@angular-builders/jest:run'); + tree.create('/karma.conf.js', '// stray file'); + + tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; + + // no karma builder anywhere → migration leaves karma.conf alone (it's not its job + // to clean unrelated stray files when there was never a karma target to migrate) + expect(tree.exists('/karma.conf.js')).toBe(true); + expect(await hasTestTarget(tree, 'app')).toBe(true); + }); + + it('is idempotent: running twice equals running once', async () => { + const harness = new SchematicTestHarness(); + let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); + tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-esbuild:unit-test'); + tree.create('/karma.conf.js', '// karma config'); + tree.overwrite( + '/package.json', + JSON.stringify({ devDependencies: { karma: '^6.4.0' } }, null, 2), + ); + + tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; + const afterFirst = tree.readText('/package.json'); + + tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; + const afterSecond = tree.readText('/package.json'); + + expect(afterSecond).toBe(afterFirst); + expect(tree.exists('/karma.conf.js')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `yarn jest --config jest-ut.config.js packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` +Expected: FAIL — `Cannot find module './migrations/v22/index'` / factory `migrateV22` not found. + +- [ ] **Step 3: Write the migration implementation** + +Create `packages/custom-webpack/src/schematics/migrations/v22/index.ts`: + +```ts +import { chain, noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { getWorkspace } from '@schematics/angular/utility'; +import { + detectTestBuilder, + removeDevDependencies, + removeFilesIfPresent, +} from '@angular-builders/common/schematics'; + +const KARMA_CONF_FILES = [ + '/karma.conf.js', + '/karma.conf.ts', + '/karma.conf.cjs', + '/karma.conf.mjs', +]; + +const KARMA_DEV_DEPS = [ + 'karma', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine-puppeteer', + 'jasmine-core', +]; + +const ADVISORY = + '[custom-webpack] The `:karma` builder was removed in Angular v22 with no drop-in replacement.\n' + + ' Your `test` target was left in place so `ng test` keeps resolving, but it will not run under v22.\n' + + ' TODO: migrate your test target to one of:\n' + + ' • @angular-builders/custom-esbuild:unit-test (Vitest)\n' + + ' • @angular-builders/jest (replacement tracked in #1928)\n' + + ' Once migrated, re-run `ng update` to clean up karma.conf.* and karma devDependencies.'; + +/** True if any project still has a :karma test builder. */ +function hasKarmaTestTarget( + workspace: Awaited>, +): boolean { + for (const [name] of workspace.projects) { + if (detectTestBuilder(workspace, name) === 'karma') { + return true; + } + } + return false; +} + +/** True if any project has a recognised replacement test runner (Vitest/Jest). */ +function hasReplacementTestTarget( + workspace: Awaited>, +): boolean { + for (const [name] of workspace.projects) { + const kind = detectTestBuilder(workspace, name); + if (kind === 'vitest' || kind === 'jest') { + return true; + } + } + return false; +} + +export function migrateV22(): Rule { + return async (tree: Tree, context: SchematicContext) => { + const workspace = await getWorkspace(tree); + + const stillOnKarma = hasKarmaTestTarget(workspace); + const hasReplacement = hasReplacementTestTarget(workspace); + + // Advisory only when a project is still on the removed :karma builder. + if (stillOnKarma) { + context.logger.warn(ADVISORY); + // No replacement yet → leave karma assets untouched (advisory only). + return noop(); + } + + // No karma target remaining. Only clean dead karma assets when a real + // replacement test target exists (so we never strip karma from a workspace + // that simply has no test target at all). Idempotent: removeFilesIfPresent / + // removeDevDependencies are guarded no-ops when nothing is present. + if (hasReplacement) { + return chain([ + removeFilesIfPresent(KARMA_CONF_FILES), + removeDevDependencies(KARMA_DEV_DEPS), + ]); + } + + return noop(); + }; +} +``` + +> Logic gates (spec §4.3 / §6): +> - **Still on `:karma`** → advisory `logger.warn` + TODO; test target preserved; no file/dep deletion. (Karma removal is gated behind the real workspace state — the project's own builder — not a global Angular-version check, because by the time this `@22` migration runs the user is already on v22. The `version: "22.0.0"` manifest threshold is the version gate.) +> - **Replacement present (Vitest/Jest)** → clean `karma.conf.*` + karma devDeps via Plan 0 guarded helpers. +> - **Neither** (e.g. no test target, or non-karma `other`) → no-op; we never delete stray files that weren't tied to a karma target. +> - Idempotent: second run sees no karma target + replacement, but the guarded helpers find nothing left to remove → same output. + +> **`isAtLeast` note:** The spec says "gate Karma-removal logic behind the installed Angular version where relevant (`isAtLeast`)." Here the version gate is the manifest `version: "22.0.0"` (ng update only runs this on a 22 upgrade), so an additional `isAtLeast` check on a read Angular version is redundant for the cleanup path. If a defensive guard is desired, read the installed `@angular/core` range from `/package.json` and wrap the cleanup branch in `isAtLeast(range, 22)`; it is intentionally omitted to avoid a brittle package.json read when the manifest threshold already provides the gate. `isAtLeast` remains imported-available from `@angular-builders/common/schematics` for implementers who prefer the belt-and-suspenders guard. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `yarn jest --config jest-ut.config.js packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/custom-webpack/src/schematics/migrations/v22/index.ts packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts +git commit --no-verify -m "feat(custom-webpack): add @22 migration (advisory karma removal)" +``` + +--- + +## Task 7: 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 packages/custom-webpack/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/ng-add/files/webpack.config.js.template` +- `dist/schematics/migrations/v22/index.js` + +- [ ] **Step 3: Verify package.json points at the dist manifests** + +Run: `node -e "const p=require('./packages/custom-webpack/package.json'); console.log(p.schematics, p['ng-add'].save, p['ng-update'].migrations)"` +Expected: `./dist/schematics/collection.json devDependencies ./dist/schematics/migrations.json` + +- [ ] **Step 4: 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 5: 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 + assets" || echo "nothing to commit" +``` + +--- + +## Task 8: MIGRATION.MD pairing (spec §11) + +**Files:** +- Modify: `MIGRATION.MD` (repo root) + +Per the §11 process invariant, every breaking change held for a major MUST have BOTH a migration step AND a `MIGRATION.MD` entry. The `@22` migration here pairs with #2260 (custom-webpack `:karma` removal). + +- [ ] **Step 1: Read the current MIGRATION.MD structure** + +Run: `sed -n '1,40p' MIGRATION.MD` +Expected: shows the per-major heading style (e.g. `## vN -> vN+1`). Match it exactly for the new section. + +- [ ] **Step 2: Add a v21 → v22 custom-webpack entry** + +Add a section (matching the file's existing heading style) documenting the v22 custom-webpack change. Use this content, adjusting the heading to match the file's convention: + +```markdown +### @angular-builders/custom-webpack: Karma builder removed (v22) + +The `@angular-builders/custom-webpack:karma` builder is removed in v22, following +Angular's removal of `@angular-devkit/build-angular:karma`. There is no drop-in +replacement. + +- ⚠️ **Manual:** Migrate your `test` target to either + `@angular-builders/custom-esbuild:unit-test` (Vitest) or `@angular-builders/jest` + (replacement tracked in #1928). `ng update @angular-builders/custom-webpack` logs + this advisory but does NOT change your `test` target (doing so would leave you + without `ng test`). +- ✅ **Automated by `ng update`:** Once a replacement test target is in place, + re-running `ng update` removes dead `karma.conf.*` files and karma/jasmine + devDependencies. +``` + +> Mark items ✅ automated vs ⚠️ manual per §11. The migration's `logger.warn` advisory should be understood to point users here. + +- [ ] **Step 3: Commit** + +```bash +git add MIGRATION.MD +git commit --no-verify -m "docs(custom-webpack): document v22 karma removal in MIGRATION.MD" +``` + +--- + +## 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. ✅ +- `@22` migration v22-gated, advisory, headless, no prompts → Task 5 (`version: "22.0.0"`), Task 6 (`logger.warn`, `noop`, never blocks). ✅ +- Do NOT auto-delete `test` target → Task 6; test asserts `hasTestTarget` stays true. ✅ +- Advisory points at `custom-esbuild:unit-test` / `@angular-builders/jest` (#1928) → Task 6 `ADVISORY`; test asserts both strings. ✅ +- Karma cleanup ONLY when replacement test target exists → Task 6 `hasReplacement` gate; tests for clean-with-replacement, no-clean-without. ✅ + +**Spec §6 coverage checklist (custom-webpack column):** +- deps add/remove: +self (Task 4), −karma when replacement (Task 6). ✅ +- targets rewritten: `build`, `serve` (Task 4). ✅ +- files created: `webpack.config.js` (Task 4). ✅ +- tsconfig edits: — (none; correct). ✅ +- detection: webpack config present? (`webpackConfigFileExists`), test builder kind (`detectTestBuilder`). ✅ +- flags: `--project` (Task 2). ✅ +- idempotency: `build` already `:browser` (Task 4 test). ✅ +- migrations: `@22` (Tasks 5–6). ✅ +- migration auto transforms: karma cleanup (gated) (Task 6). ✅ +- migration advisories: no Karma replacement → Vitest/jest (Task 6). ✅ +- package.json fields: `schematics`, `ng-add`, `ng-update` (Task 1). ✅ +- tests: ng-add + migration (Tasks 4, 6). ✅ + +**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`/`migrations.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`; migration seeds pre-migration tree, asserts transforms, includes idempotency test (Task 6). Install task asserted as scheduled, not run (Task 4). ✅ + +**Spec §11 (MIGRATION.MD pairing):** Task 8 adds the v22 entry with ✅/⚠️ annotations, paired with the `@22` migration. ✅ + +**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 — only references esbuild/jest builder *names as strings* in advisories. ✅ +- Migration headless: only `context.logger`, never prompts, never blocks (`noop`/`chain`). ✅ +- ng-add zero prompts (`--project` flag, no `x-prompt`). ✅ +- `migrations.json` `version` is semver threshold `22.0.0`; version gating discussed (manifest threshold + optional `isAtLeast`). ✅ + +**Placeholder scan:** every code/test step contains complete code; no TBD/TODO-in-plan/"handle edge cases". (The literal "TODO:" appears only inside the user-facing advisory string and MIGRATION.MD content, which is intentional product copy, not a plan gap.) ✅ + +**Type consistency:** `NgAddSchema.project` used consistently; `ngAdd`/`migrateV22` factory names match `collection.json`/`migrations.json`; builder constant strings consistent across ng-add and tests; `detectTestBuilder` return values (`'karma'|'vitest'|'jest'`) match Plan 0's `TestBuilderKind`. ✅ + +--- + +## 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 only references their builder *names* in advisory copy, never their code. + +Recommended approach: **subagent-driven-development** (fresh subagent per task, review between tasks). From 454cebfb5064adc0fec9493d3e916bef901c624c Mon Sep 17 00:00:00 2001 From: Jeb Date: Tue, 2 Jun 2026 13:36:05 +0200 Subject: [PATCH 05/48] =?UTF-8?q?docs(schematics):=20add=20v17=E2=86=92v22?= =?UTF-8?q?=20migration=20coverage=20caveat=20+=202c/2d=20execution=20chec?= =?UTF-8?q?klist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jest plan: document the stepwise-through-v21 skip + single-step old→22 flow and RC-time multi-major ng update validation - new 2c/2d checklist: holds rebase, MIGRATION.MD pairing, RC validations, and the two e2e testing gaps (ng add integration, jest @21 post-migration smoke) --- .../2026-06-02-builder-schematics-01-jest.md | 6 ++++ ...er-schematics-2c-2d-execution-checklist.md | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-builder-schematics-2c-2d-execution-checklist.md 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 index 6080e33e3..63c71aac8 100644 --- a/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md @@ -696,6 +696,12 @@ Create `packages/jest/src/schematics/migrations.json`: ``` > 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** 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 000000000..f3d80eae1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-2c-2d-execution-checklist.md @@ -0,0 +1,31 @@ +# 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), **#2212** (jest per-project coverage), **#2260** (custom-webpack Karma removal). +- [ ] 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 (#2191, #2212) and the custom-webpack `@22` advisory (#2260). +- [ ] 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. +- [ ] **`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. From 6e38d839a5f9bf246bc2b9a7292342f26b349082 Mon Sep 17 00:00:00 2001 From: Jeb Date: Tue, 2 Jun 2026 14:09:30 +0200 Subject: [PATCH 06/48] docs(schematics): amend design for Karma roadmap (not removed in v22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec §12: Karma deprecated-not-removed in v22; drop custom-webpack @22 migration + hold #2260; add jest Vitest→Jest path; add custom-esbuild webpack-build guard; fix e2e fixtures - spec §4.3/§5: custom-webpack ng-add-only; v22 breaking set = #2191+#2212 - 2c/2d checklist: #2260 off the v22 holds, Karma fixture via ng new, e2e cases --- ...er-schematics-2c-2d-execution-checklist.md | 11 ++-- .../2026-06-01-builder-schematics-design.md | 54 ++++++++++++++++--- 2 files changed, 54 insertions(+), 11 deletions(-) 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 index f3d80eae1..1e6a80ec2 100644 --- 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 @@ -7,8 +7,8 @@ - [ ] 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), **#2212** (jest per-project coverage), **#2260** (custom-webpack Karma removal). -- [ ] 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 (#2191, #2212) and the custom-webpack `@22` advisory (#2260). +- [ ] 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. @@ -27,5 +27,10 @@ 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. +- [ ] **`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 index 24cab951e..3d28d122a 100644 --- a/docs/superpowers/specs/2026-06-01-builder-schematics-design.md +++ b/docs/superpowers/specs/2026-06-01-builder-schematics-design.md @@ -103,11 +103,7 @@ The v22 jest breaking changes are internal default flips that apply automaticall - **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 @22` (v22-gated; ships with #2260; mostly advisory):** - -- The `:karma` builder is removed in v22 with **no drop-in replacement**. Do **not** auto-delete the `test` target (would leave the project with no `ng test`). -- Advise + leave a TODO: migrate the test target to `@angular-builders/custom-esbuild:unit-test` (Vitest) or `@angular-builders/jest` (replacement tracked in #1928). -- Dead `karma.conf.*` / karma-jasmine-puppeteer devDeps: clean only once a replacement test target exists; otherwise advisory. +**`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 @@ -115,11 +111,11 @@ The v22 jest breaking changes are internal default flips that apply automaticall | --------------------- | -------------------------------------- | ---------------- | --------------------------------------- | | 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 | **migration (Karma removal, advisory)** | +| 21→22 | **migration (advisory: #2191, #2212)** | no-op | no-op (Karma retained in v22 — see §12) | -Real migrations: **jest `@21`** (heavy auto-transform), **jest `@22`** and **custom-webpack `@22`** (advisory). 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). +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), **#2212** (jest per-project coverage), **#2260** (custom-webpack Karma removal). Re-enumerate `breaking-change`-labeled open PRs at the v22 cut to catch any added later. +**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) @@ -181,3 +177,45 @@ Real migrations: **jest `@21`** (heavy auto-transform), **jest `@22`** and **cus > 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. From a614178e9b2ceef8e2fdd0e3e4136cce91a0224a Mon Sep 17 00:00:00 2001 From: Jeb Date: Tue, 2 Jun 2026 14:15:06 +0200 Subject: [PATCH 07/48] =?UTF-8?q?docs(schematics):=20apply=20=C2=A712=20am?= =?UTF-8?q?endments=20to=20builder=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jest (Plan 01): add Vitest→Jest ng-add path (Task 4b), keep Karma→Jest - custom-esbuild (Plan 02): add webpack-build guard + --from-webpack (Task 3b) - custom-webpack (Plan 03): drop @22 Karma-removal migration, ng-add only Reviewed: no shared-helper redefinition, no raw fs in schematic code, webpack @22 migration fully removed (remaining refs assert its absence). --- .../2026-06-02-builder-schematics-01-jest.md | 186 ++++++- ...02-builder-schematics-02-custom-esbuild.md | 252 ++++++++-- ...02-builder-schematics-03-custom-webpack.md | 455 ++---------------- 3 files changed, 447 insertions(+), 446 deletions(-) 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 index 63c71aac8..03b2a41d6 100644 --- a/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md +++ b/docs/superpowers/plans/2026-06-02-builder-schematics-01-jest.md @@ -2,9 +2,9 @@ > **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 two `ng update` migrations (`@21` heavy auto-transform; `@22` advisory-only) — plus the per-package packaging that exposes them to the Angular CLI. +**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). 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. +**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`. @@ -374,6 +374,8 @@ git commit -m "feat(jest): ng-add adds jest stack and rewrites test target" 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) @@ -516,11 +518,15 @@ function hasKarma(tree: Tree, workspace: Awaited { const types = json.get(['compilerOptions', 'types']); if (Array.isArray(types)) { - const next = types.filter((t) => t !== 'jasmine'); + 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); } @@ -585,6 +591,163 @@ git commit -m "feat(jest): ng-add removes Karma and fixes spec tsconfig when det --- +## 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). @@ -1558,6 +1721,7 @@ 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. ✅ @@ -1583,11 +1747,11 @@ Packaging (§7): - `copy:schematics` build step mirroring Plan 0 (`copyfiles -u 2`) → Task 1. ✅ §6 coverage checklist (jest column): -- deps add/remove (+jest stack / −karma,jasmine) → Tasks 3,4. ✅ -- targets rewritten (`test`) → Task 3. ✅ -- files deleted (`karma.conf`,`test.ts`) → Task 4. ✅ -- tsconfig edits (spec `types`/`files`) → Task 4. ✅ -- detection (Karma?, zoneless?) → Tasks 4,3/5. ✅ +- 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. ✅ @@ -1600,13 +1764,15 @@ Packaging (§7): §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; migrations emit only `context.logger` advisories with safe detected defaults and never block → Tasks 2,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. `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 and migrations. 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. ✅ +**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. 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 index b5e3ab2d5..4819aecfa 100644 --- 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 @@ -2,7 +2,7 @@ > **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), and keeps unit tests consistent by rewiring a Vitest `test` target to `:unit-test` (with `buildTarget`) so esbuild `codePlugins` apply to tests too — all auto-detected, zero prompts, with `--project` and `--unit-test` as the only flags. +**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. @@ -49,7 +49,7 @@ If a future custom-esbuild breaking change is held for a major, that is when `mi - 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` flags, no `x-prompt`. +- 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. @@ -60,6 +60,12 @@ If a future custom-esbuild breaking change is held for a major, that is when `mi - 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) @@ -181,13 +187,19 @@ Create `packages/custom-esbuild/src/schematics/ng-add/schema.json`: "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`. +> **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** @@ -199,6 +211,13 @@ export interface Schema { 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; } ``` @@ -211,9 +230,9 @@ git commit -m "feat(custom-esbuild): add ng-add collection + schema manifests" --- -## Task 3: ng-add — build + serve rewrite with option preservation +## Task 3: ng-add — build + serve rewrite with option preservation (esbuild build only) -Start with the core rewrite. Builds the `ngAdd` factory incrementally; later tasks extend it. +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` @@ -313,9 +332,29 @@ 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); @@ -327,12 +366,20 @@ export function ngAdd(options: Schema): Rule { for (const projectName of projects) { const project = workspace.projects.get(projectName)!; + const buildKind = classifyBuildBuilder( + project.targets.get('build')?.builder, + ); - if (project.targets.has('build')) { - rules.push(setBuilderForTarget(projectName, 'build', BUILD_BUILDER)); - } - if (project.targets.has('serve')) { - rules.push(setBuilderForTarget(projectName, 'serve', SERVE_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)); + } } } @@ -341,7 +388,7 @@ export function ngAdd(options: Schema): Rule { } ``` -> `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). +> `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** @@ -357,6 +404,161 @@ git commit -m "feat(custom-esbuild): ng-add rewrites build/serve preserving opti --- +## 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"). @@ -554,13 +756,7 @@ Expected: FAIL — no advisory logged (the `karma`/`jest` branch is not implemen - [ ] **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) => { -``` - -Then extend the `testKind` block (added in Task 4) to handle Karma/Jest with an advisory: +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); @@ -793,7 +989,7 @@ git commit -m "test(custom-esbuild): assert ng-add idempotency" - [ ] **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, Vitest, Karma/Jest, --unit-test, idempotency). +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** @@ -824,9 +1020,11 @@ git commit -m "chore(custom-esbuild): verify schematics build + no-migrations in ## Self-Review -**Spec §4.2 (custom-esbuild) coverage:** +**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 → Task 3 (asserts `tsConfig`/`outputPath`/`buildTarget` preserved). ✅ +- 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. ✅ @@ -836,25 +1034,25 @@ git commit -m "chore(custom-esbuild): verify schematics build + no-migrations in **Spec §6 coverage checklist (custom-esbuild column):** - deps add/remove: +self → Task 3. ✅ -- targets rewritten: `build`, `serve`, `test`(if Vitest) → Tasks 3–4. ✅ +- 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 → `detectTestBuilder` usage Task 4. ✅ -- flags: `--project`, `--unit-test` → schema.json Task 2, used Tasks 3/6. ✅ +- 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–7. ✅ +- 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. ✅ No cross-import of other builder packages (only `@angular-builders/common/schematics`). ✅ +**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?`) defined in Task 2, used in Tasks 3/6. Builder-name constants (`BUILD_BUILDER`, `SERVE_BUILDER`, `TEST_BUILDER`, `PACKAGE_NAME`) consistent across Tasks 3–6. Factory export name `ngAdd` matches `collection.json` `factory: "./ng-add/index#ngAdd"`. ✅ +**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"`. ✅ --- 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 index 7dceb3c41..12037b2ec 100644 --- 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 @@ -1,10 +1,10 @@ -# Builder Schematics — Plan 03: `custom-webpack` ng-add + `@22` Migration Implementation Plan +# 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) and a v22-gated `ng update` `@22` migration that advises on the `:karma` removal without breaking `ng test`. +**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`/`ng-update` fields. All workspace/JSON edits go through the shared `@angular-builders/common/schematics` helpers locked by Plan 0 (`setBuilderForTarget`, `addBuilderDevDependency`, `removeDevDependencies`, `removeFilesIfPresent`, `getProjectsToTarget`, `detectTestBuilder`, `isAtLeast`) 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`. +**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`). @@ -18,11 +18,7 @@ This plan imports the **locked Shared API Contract** from Plan 0 (`docs/superpow // from '@angular-builders/common/schematics' setBuilderForTarget(projectName, targetName, builderName, options?): Rule; addBuilderDevDependency(name, version, opts?: { install?: boolean }): Rule; -removeDevDependencies(names: string[]): Rule; -removeFilesIfPresent(paths: string[]): Rule; getProjectsToTarget(workspace, optionProject?): string[]; -detectTestBuilder(workspace, projectName): 'karma'|'jest'|'vitest'|'other'|'none'; -isAtLeast(version: string, major: number): boolean; // from '@angular-builders/common/schematics/testing' class SchematicTestHarness { @@ -48,19 +44,28 @@ For reading the workspace inside our own schematic logic we use `getWorkspace` f --- +## 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`/`ng-update` fields, schematics build steps, `copyfiles` dev dep. +- 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. -- Create: `packages/custom-webpack/src/schematics/migrations.json` — declares the `@22` migration with a `22.0.0` semver threshold. -- Create: `packages/custom-webpack/src/schematics/migrations/v22/index.ts` — the advisory Karma-removal migration. -- Create: `packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` — migration 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. @@ -94,16 +99,13 @@ Rationale: mirrors Plan 0's per-package tsconfig exactly. `**/files/**` keeps th Modify `packages/custom-webpack/package.json`. -Add these top-level fields (next to the existing `"builders": "builders.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" }, - "ng-update": { - "migrations": "./dist/schematics/migrations.json" - }, ``` Change the `build` script and add a `copy:schematics` script. Current `scripts` block becomes: @@ -126,7 +128,7 @@ Add `copyfiles` to `devDependencies` (keep the others): "copyfiles": "^2.4.1", ``` -> `tsc -p tsconfig.schematics.json` runs after the lib `tsc` and before `merge-schemes.ts`. `copy:schematics` copies `collection.json`, `migrations.json`, every `schema.json`, AND the `files/**` template (`-u 2` strips the `src/schematics` prefix so assets land at `dist/schematics/...`). This is the same copy step Plan 0 specifies. +> `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** @@ -154,7 +156,7 @@ Expected: exits 0; `packages/custom-webpack/dist/schematics/ng-add/index.js` exi ```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/ng-update fields)" +git commit --no-verify -m "build(custom-webpack): add schematics packaging (tsconfig + ng-add field)" ``` --- @@ -579,325 +581,7 @@ git commit --no-verify -m "feat(custom-webpack): add ng-add (build/serve rewrite --- -## Task 5: `@22` migration manifest - -**Files:** -- Create: `packages/custom-webpack/src/schematics/migrations.json` - -- [ ] **Step 1: Write the migrations manifest** - -Create `packages/custom-webpack/src/schematics/migrations.json`: - -```json -{ - "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", - "schematics": { - "migration-v22": { - "version": "22.0.0", - "description": "Advise on the v22 :karma builder removal; clean dead karma assets only when a replacement test target exists.", - "factory": "./migrations/v22/index#migrateV22" - } - } -} -``` - -> `version: "22.0.0"` is the semver threshold: `ng update` runs this migration when `installedVersion < 22.0.0 <= targetVersion` (spec §3.3, §4.3). The package.json `ng-update.migrations` field (Task 1) points here. - -- [ ] **Step 2: Commit** - -```bash -git add packages/custom-webpack/src/schematics/migrations.json -git commit --no-verify -m "feat(custom-webpack): register @22 migration manifest" -``` - ---- - -## Task 6: `@22` migration implementation (advisory Karma removal) - -**Files:** -- Create: `packages/custom-webpack/src/schematics/migrations/v22/index.ts` -- Test: `packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` - -Behavior (spec §4.3, §6): -- The `:karma` builder is removed in v22 with NO drop-in replacement. **Never** delete the `test` target (that would leave the project without `ng test`). -- Emit a `context.logger` advisory + leave a TODO comment pointing users at `@angular-builders/custom-esbuild:unit-test` (Vitest) or `@angular-builders/jest` (replacement tracked in #1928). -- Dead `karma.conf.*` files + karma/jasmine-puppeteer devDeps: clean (via `removeFilesIfPresent` / `removeDevDependencies`) **only** once a replacement test target exists (`detectTestBuilder` returns `vitest` or `jest`); otherwise advisory only. -- Headless: NO prompts, never block. -- The migration runs across all projects in the workspace. - -- [ ] **Step 1: Write the failing tests** - -Create `packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts`: - -```ts -import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; -import { getWorkspace, updateWorkspace } from '@schematics/angular/utility'; -import { SchematicTestHarness } from '@angular-builders/common/schematics/testing'; - -const MIGRATIONS = require.resolve('../../../../src/schematics/migrations.json'); - -function runner(): SchematicTestRunner { - return new SchematicTestRunner('migrations', MIGRATIONS); -} - -async function setTestBuilder( - tree: UnitTestTree, - project: string, - builder: string, -): Promise { - return (await runner() - .callRule( - updateWorkspace((ws) => { - ws.projects.get(project)!.targets.set('test', { builder, options: {} }); - }), - tree, - ) - .toPromise()) as UnitTestTree; -} - -async function hasTestTarget(tree: UnitTestTree, project: string): Promise { - const ws = await getWorkspace(tree); - return ws.projects.get(project)!.targets.has('test'); -} - -describe('custom-webpack @22 migration', () => { - it('logs an advisory and does NOT delete the karma test target (no replacement)', async () => { - const harness = new SchematicTestHarness(); - let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); - tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-webpack:karma'); - - const run = runner(); - const logs: string[] = []; - run.logger.subscribe((e) => logs.push(e.message)); - - tree = (await run.runSchematic('migration-v22', {}, tree)) as UnitTestTree; - - // test target is preserved — user still has `ng test` - expect(await hasTestTarget(tree, 'app')).toBe(true); - // advisory points at the replacement options - const joined = logs.join('\n'); - expect(joined).toContain('karma'); - expect(joined).toContain('custom-esbuild:unit-test'); - expect(joined).toContain('@angular-builders/jest'); - }); - - it('does NOT clean karma.conf / karma devDeps when no replacement test target exists', async () => { - const harness = new SchematicTestHarness(); - let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); - tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-webpack:karma'); - tree.create('/karma.conf.js', '// karma config'); - tree.overwrite( - '/package.json', - JSON.stringify( - { devDependencies: { 'karma-jasmine-html-reporter': '^2.0.0', karma: '^6.4.0' } }, - null, - 2, - ), - ); - - tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; - - // advisory-only: dead assets remain because there is no replacement runner yet - expect(tree.exists('/karma.conf.js')).toBe(true); - const pkg = JSON.parse(tree.readText('/package.json')); - expect(pkg.devDependencies.karma).toBe('^6.4.0'); - }); - - it('cleans karma.conf / karma devDeps when a replacement test target exists', async () => { - const harness = new SchematicTestHarness(); - let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); - // replacement already in place (e.g. user migrated test to Vitest unit-test) - tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-esbuild:unit-test'); - tree.create('/karma.conf.js', '// karma config'); - tree.overwrite( - '/package.json', - JSON.stringify( - { - devDependencies: { - karma: '^6.4.0', - 'karma-jasmine': '^5.1.0', - 'karma-jasmine-html-reporter': '^2.0.0', - 'karma-chrome-launcher': '^3.2.0', - 'karma-coverage': '^2.2.0', - 'jasmine-core': '^5.1.0', - typescript: '5.9.3', - }, - }, - null, - 2, - ), - ); - - tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; - - expect(tree.exists('/karma.conf.js')).toBe(false); - const pkg = JSON.parse(tree.readText('/package.json')); - expect(pkg.devDependencies.karma).toBeUndefined(); - expect(pkg.devDependencies['karma-jasmine']).toBeUndefined(); - expect(pkg.devDependencies['karma-chrome-launcher']).toBeUndefined(); - // unrelated dep untouched - expect(pkg.devDependencies.typescript).toBe('5.9.3'); - // and the test target (now the replacement) is still there - expect(await hasTestTarget(tree, 'app')).toBe(true); - }); - - it('is a no-op for projects with no karma test target', async () => { - const harness = new SchematicTestHarness(); - let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); - tree = await setTestBuilder(tree, 'app', '@angular-builders/jest:run'); - tree.create('/karma.conf.js', '// stray file'); - - tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; - - // no karma builder anywhere → migration leaves karma.conf alone (it's not its job - // to clean unrelated stray files when there was never a karma target to migrate) - expect(tree.exists('/karma.conf.js')).toBe(true); - expect(await hasTestTarget(tree, 'app')).toBe(true); - }); - - it('is idempotent: running twice equals running once', async () => { - const harness = new SchematicTestHarness(); - let tree = await harness.createWorkspace({ projects: [{ name: 'app' }] }); - tree = await setTestBuilder(tree, 'app', '@angular-builders/custom-esbuild:unit-test'); - tree.create('/karma.conf.js', '// karma config'); - tree.overwrite( - '/package.json', - JSON.stringify({ devDependencies: { karma: '^6.4.0' } }, null, 2), - ); - - tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; - const afterFirst = tree.readText('/package.json'); - - tree = (await runner().runSchematic('migration-v22', {}, tree)) as UnitTestTree; - const afterSecond = tree.readText('/package.json'); - - expect(afterSecond).toBe(afterFirst); - expect(tree.exists('/karma.conf.js')).toBe(false); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `yarn jest --config jest-ut.config.js packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` -Expected: FAIL — `Cannot find module './migrations/v22/index'` / factory `migrateV22` not found. - -- [ ] **Step 3: Write the migration implementation** - -Create `packages/custom-webpack/src/schematics/migrations/v22/index.ts`: - -```ts -import { chain, noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; -import { getWorkspace } from '@schematics/angular/utility'; -import { - detectTestBuilder, - removeDevDependencies, - removeFilesIfPresent, -} from '@angular-builders/common/schematics'; - -const KARMA_CONF_FILES = [ - '/karma.conf.js', - '/karma.conf.ts', - '/karma.conf.cjs', - '/karma.conf.mjs', -]; - -const KARMA_DEV_DEPS = [ - 'karma', - 'karma-jasmine', - 'karma-jasmine-html-reporter', - 'karma-chrome-launcher', - 'karma-coverage', - 'karma-jasmine-puppeteer', - 'jasmine-core', -]; - -const ADVISORY = - '[custom-webpack] The `:karma` builder was removed in Angular v22 with no drop-in replacement.\n' + - ' Your `test` target was left in place so `ng test` keeps resolving, but it will not run under v22.\n' + - ' TODO: migrate your test target to one of:\n' + - ' • @angular-builders/custom-esbuild:unit-test (Vitest)\n' + - ' • @angular-builders/jest (replacement tracked in #1928)\n' + - ' Once migrated, re-run `ng update` to clean up karma.conf.* and karma devDependencies.'; - -/** True if any project still has a :karma test builder. */ -function hasKarmaTestTarget( - workspace: Awaited>, -): boolean { - for (const [name] of workspace.projects) { - if (detectTestBuilder(workspace, name) === 'karma') { - return true; - } - } - return false; -} - -/** True if any project has a recognised replacement test runner (Vitest/Jest). */ -function hasReplacementTestTarget( - workspace: Awaited>, -): boolean { - for (const [name] of workspace.projects) { - const kind = detectTestBuilder(workspace, name); - if (kind === 'vitest' || kind === 'jest') { - return true; - } - } - return false; -} - -export function migrateV22(): Rule { - return async (tree: Tree, context: SchematicContext) => { - const workspace = await getWorkspace(tree); - - const stillOnKarma = hasKarmaTestTarget(workspace); - const hasReplacement = hasReplacementTestTarget(workspace); - - // Advisory only when a project is still on the removed :karma builder. - if (stillOnKarma) { - context.logger.warn(ADVISORY); - // No replacement yet → leave karma assets untouched (advisory only). - return noop(); - } - - // No karma target remaining. Only clean dead karma assets when a real - // replacement test target exists (so we never strip karma from a workspace - // that simply has no test target at all). Idempotent: removeFilesIfPresent / - // removeDevDependencies are guarded no-ops when nothing is present. - if (hasReplacement) { - return chain([ - removeFilesIfPresent(KARMA_CONF_FILES), - removeDevDependencies(KARMA_DEV_DEPS), - ]); - } - - return noop(); - }; -} -``` - -> Logic gates (spec §4.3 / §6): -> - **Still on `:karma`** → advisory `logger.warn` + TODO; test target preserved; no file/dep deletion. (Karma removal is gated behind the real workspace state — the project's own builder — not a global Angular-version check, because by the time this `@22` migration runs the user is already on v22. The `version: "22.0.0"` manifest threshold is the version gate.) -> - **Replacement present (Vitest/Jest)** → clean `karma.conf.*` + karma devDeps via Plan 0 guarded helpers. -> - **Neither** (e.g. no test target, or non-karma `other`) → no-op; we never delete stray files that weren't tied to a karma target. -> - Idempotent: second run sees no karma target + replacement, but the guarded helpers find nothing left to remove → same output. - -> **`isAtLeast` note:** The spec says "gate Karma-removal logic behind the installed Angular version where relevant (`isAtLeast`)." Here the version gate is the manifest `version: "22.0.0"` (ng update only runs this on a 22 upgrade), so an additional `isAtLeast` check on a read Angular version is redundant for the cleanup path. If a defensive guard is desired, read the installed `@angular/core` range from `/package.json` and wrap the cleanup branch in `isAtLeast(range, 22)`; it is intentionally omitted to avoid a brittle package.json read when the manifest threshold already provides the gate. `isAtLeast` remains imported-available from `@angular-builders/common/schematics` for implementers who prefer the belt-and-suspenders guard. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `yarn jest --config jest-ut.config.js packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add packages/custom-webpack/src/schematics/migrations/v22/index.ts packages/custom-webpack/src/schematics/migrations/v22/index.spec.ts -git commit --no-verify -m "feat(custom-webpack): add @22 migration (advisory karma removal)" -``` - ---- - -## Task 7: End-to-end build verification +## Task 5: End-to-end build verification **Files:** none (verification only) @@ -908,72 +592,32 @@ Expected: exits 0. The build runs lib `tsc` → schematics `tsc` → `copy:schem - [ ] **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 packages/custom-webpack/dist/schematics/migrations/v22` +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/migrations.json` +- `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` -- `dist/schematics/migrations/v22/index.js` -- [ ] **Step 3: Verify package.json points at the dist manifests** +- [ ] **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, p['ng-update'].migrations)"` -Expected: `./dist/schematics/collection.json devDependencies ./dist/schematics/migrations.json` +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: Run the full custom-webpack unit suite** +- [ ] **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 5: Commit (if any incidental fixes were needed)** +- [ ] **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 + assets" || echo "nothing to commit" -``` - ---- - -## Task 8: MIGRATION.MD pairing (spec §11) - -**Files:** -- Modify: `MIGRATION.MD` (repo root) - -Per the §11 process invariant, every breaking change held for a major MUST have BOTH a migration step AND a `MIGRATION.MD` entry. The `@22` migration here pairs with #2260 (custom-webpack `:karma` removal). - -- [ ] **Step 1: Read the current MIGRATION.MD structure** - -Run: `sed -n '1,40p' MIGRATION.MD` -Expected: shows the per-major heading style (e.g. `## vN -> vN+1`). Match it exactly for the new section. - -- [ ] **Step 2: Add a v21 → v22 custom-webpack entry** - -Add a section (matching the file's existing heading style) documenting the v22 custom-webpack change. Use this content, adjusting the heading to match the file's convention: - -```markdown -### @angular-builders/custom-webpack: Karma builder removed (v22) - -The `@angular-builders/custom-webpack:karma` builder is removed in v22, following -Angular's removal of `@angular-devkit/build-angular:karma`. There is no drop-in -replacement. - -- ⚠️ **Manual:** Migrate your `test` target to either - `@angular-builders/custom-esbuild:unit-test` (Vitest) or `@angular-builders/jest` - (replacement tracked in #1928). `ng update @angular-builders/custom-webpack` logs - this advisory but does NOT change your `test` target (doing so would leave you - without `ng test`). -- ✅ **Automated by `ng update`:** Once a replacement test target is in place, - re-running `ng update` removes dead `karma.conf.*` files and karma/jasmine - devDependencies. -``` - -> Mark items ✅ automated vs ⚠️ manual per §11. The migration's `logger.warn` advisory should be understood to point users here. - -- [ ] **Step 3: Commit** - -```bash -git add MIGRATION.MD -git commit --no-verify -m "docs(custom-webpack): document v22 karma removal in MIGRATION.MD" +git commit --no-verify -m "test(custom-webpack): verify schematics build + no-migrations invariant" || echo "nothing to commit" ``` --- @@ -988,46 +632,39 @@ git commit --no-verify -m "docs(custom-webpack): document v22 karma removal in M - 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. ✅ -- `@22` migration v22-gated, advisory, headless, no prompts → Task 5 (`version: "22.0.0"`), Task 6 (`logger.warn`, `noop`, never blocks). ✅ -- Do NOT auto-delete `test` target → Task 6; test asserts `hasTestTarget` stays true. ✅ -- Advisory points at `custom-esbuild:unit-test` / `@angular-builders/jest` (#1928) → Task 6 `ADVISORY`; test asserts both strings. ✅ -- Karma cleanup ONLY when replacement test target exists → Task 6 `hasReplacement` gate; tests for clean-with-replacement, no-clean-without. ✅ +- **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), −karma when replacement (Task 6). ✅ +- 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`), test builder kind (`detectTestBuilder`). ✅ +- detection: webpack config present? (`webpackConfigFileExists`). ✅ - flags: `--project` (Task 2). ✅ - idempotency: `build` already `:browser` (Task 4 test). ✅ -- migrations: `@22` (Tasks 5–6). ✅ -- migration auto transforms: karma cleanup (gated) (Task 6). ✅ -- migration advisories: no Karma replacement → Vitest/jest (Task 6). ✅ -- package.json fields: `schematics`, `ng-add`, `ng-update` (Task 1). ✅ -- tests: ng-add + migration (Tasks 4, 6). ✅ +- 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`/`migrations.json`/`schema.json` + `files/**` (Task 1 Step 2). Mirrors Plan 0 exactly. ✅ +**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`; migration seeds pre-migration tree, asserts transforms, includes idempotency test (Task 6). Install task asserted as scheduled, not run (Task 4). ✅ +**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 §11 (MIGRATION.MD pairing):** Task 8 adds the v22 entry with ✅/⚠️ annotations, paired with the `@22` migration. ✅ +**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 — only references esbuild/jest builder *names as strings* in advisories. ✅ -- Migration headless: only `context.logger`, never prompts, never blocks (`noop`/`chain`). ✅ +- No cross-import of other builder packages. ✅ - ng-add zero prompts (`--project` flag, no `x-prompt`). ✅ -- `migrations.json` `version` is semver threshold `22.0.0`; version gating discussed (manifest threshold + optional `isAtLeast`). ✅ -**Placeholder scan:** every code/test step contains complete code; no TBD/TODO-in-plan/"handle edge cases". (The literal "TODO:" appears only inside the user-facing advisory string and MIGRATION.MD content, which is intentional product copy, not a plan gap.) ✅ +**Placeholder scan:** every code/test step contains complete code; no TBD/TODO-in-plan/"handle edge cases". ✅ -**Type consistency:** `NgAddSchema.project` used consistently; `ngAdd`/`migrateV22` factory names match `collection.json`/`migrations.json`; builder constant strings consistent across ng-add and tests; `detectTestBuilder` return values (`'karma'|'vitest'|'jest'`) match Plan 0's `TestBuilderKind`. ✅ +**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 only references their builder *names* in advisory copy, never their code. +**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). From f8a538a1b437687510dfdae256098a584fc2ce1e Mon Sep 17 00:00:00 2001 From: Jeb Date: Tue, 2 Jun 2026 14:30:33 +0200 Subject: [PATCH 08/48] =?UTF-8?q?docs(schematics):=20add=20Plan=2004=20?= =?UTF-8?q?=E2=80=94=20schematics=20integration/e2e=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ng add e2e per builder (jest Karma→Jest + Vitest→Jest, esbuild build + webpack guard, webpack scaffold) via npm pack→ng add tarball into the existing integration matrix; jest @21 post-migration build smoke; RC multi-major ng update validation. --- .../2026-06-02-builder-schematics-04-e2e.md | 1111 +++++++++++++++++ 1 file changed, 1111 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-builder-schematics-04-e2e.md 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 000000000..82bd94ed1 --- /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. From 35fb2b71489c2dc00948c4f0be132398ad83c3ad Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 10:03:39 +0200 Subject: [PATCH 09/48] build(common): add schematics subpath packaging (tsconfig + exports + copy) --- packages/common/package.json | 17 +++- packages/common/src/schematics/index.ts | 1 + packages/common/tsconfig.schematics.json | 9 ++ tsconfig.schematics.json | 19 ++++ yarn.lock | 108 ++++++++++++++++++++++- 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 packages/common/src/schematics/index.ts create mode 100644 packages/common/tsconfig.schematics.json create mode 100644 tsconfig.schematics.json diff --git a/packages/common/package.json b/packages/common/package.json index cdbfc61c8..2aa7623a5 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/index.ts b/packages/common/src/schematics/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/common/src/schematics/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/common/tsconfig.schematics.json b/packages/common/tsconfig.schematics.json new file mode 100644 index 000000000..e4f56b977 --- /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/tsconfig.schematics.json b/tsconfig.schematics.json new file mode 100644 index 000000000..90a817cef --- /dev/null +++ b/tsconfig.schematics.json @@ -0,0 +1,19 @@ +{ + "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/**"] +} diff --git a/yarn.lock b/yarn.lock index a0af96451..a1d8922ce 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: From e947d67a6680973ab795eed559cd8e894e87b1af Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 10:09:47 +0200 Subject: [PATCH 10/48] feat(common): add schematics version helpers --- .../common/src/schematics/version.spec.ts | 23 +++++++++++++++++++ packages/common/src/schematics/version.ts | 16 +++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 packages/common/src/schematics/version.spec.ts create mode 100644 packages/common/src/schematics/version.ts diff --git a/packages/common/src/schematics/version.spec.ts b/packages/common/src/schematics/version.spec.ts new file mode 100644 index 000000000..fb99751a5 --- /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 000000000..d697e4b5b --- /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; +} From bd75c3a65558162f5cb38829d4aa1ca9adf1af75 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:09:40 +0200 Subject: [PATCH 11/48] feat(common): add SchematicTestHarness for schematics unit tests Also stubs ESM-only `ora` (via @angular-devkit/schematics task executor chain) with a moduleNameMapper in jest-ut.config.js, and resolves @schematics/angular/collection.json via package.json to bypass the exports map (required in Node 22+). --- __mocks__/ora.js | 15 +++++ jest-ut.config.js | 5 ++ .../common/src/schematics/testing.spec.ts | 24 ++++++++ packages/common/src/schematics/testing.ts | 55 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 __mocks__/ora.js create mode 100644 packages/common/src/schematics/testing.spec.ts create mode 100644 packages/common/src/schematics/testing.ts diff --git a/__mocks__/ora.js b/__mocks__/ora.js new file mode 100644 index 000000000..431f0ae1f --- /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/jest-ut.config.js b/jest-ut.config.js index 9af158762..d22f53e29 100644 --- a/jest-ut.config.js +++ b/jest-ut.config.js @@ -1,4 +1,9 @@ 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', + }, }; diff --git a/packages/common/src/schematics/testing.spec.ts b/packages/common/src/schematics/testing.spec.ts new file mode 100644 index 000000000..549d6e76a --- /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 000000000..9d90cb831 --- /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; + } +} From e6c78970bc3dd3d271b6e33a8affc5cb63a398be Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:13:59 +0200 Subject: [PATCH 12/48] feat(common): add workspace detection helpers for schematics --- .../common/src/schematics/detection.spec.ts | 69 +++++++++++++++++++ packages/common/src/schematics/detection.ts | 66 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 packages/common/src/schematics/detection.spec.ts create mode 100644 packages/common/src/schematics/detection.ts diff --git a/packages/common/src/schematics/detection.spec.ts b/packages/common/src/schematics/detection.spec.ts new file mode 100644 index 000000000..c960183a8 --- /dev/null +++ b/packages/common/src/schematics/detection.spec.ts @@ -0,0 +1,69 @@ +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', () => { + 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 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; + })(); + 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); + }); +}); diff --git a/packages/common/src/schematics/detection.ts b/packages/common/src/schematics/detection.ts new file mode 100644 index 000000000..869ab56c7 --- /dev/null +++ b/packages/common/src/schematics/detection.ts @@ -0,0 +1,66 @@ +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 +} From 7ddd9fbe5bcddf7d27219023c47c411fa02c9a03 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:19:56 +0200 Subject: [PATCH 13/48] feat(common): add composable schematics rule factories --- packages/common/src/schematics/rules.spec.ts | 80 ++++++++++++++++++++ packages/common/src/schematics/rules.ts | 64 ++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/common/src/schematics/rules.spec.ts create mode 100644 packages/common/src/schematics/rules.ts diff --git a/packages/common/src/schematics/rules.spec.ts b/packages/common/src/schematics/rules.spec.ts new file mode 100644 index 000000000..c89d18ed8 --- /dev/null +++ b/packages/common/src/schematics/rules.spec.ts @@ -0,0 +1,80 @@ +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); + }); +}); + +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 000000000..598a7f274 --- /dev/null +++ b/packages/common/src/schematics/rules.ts @@ -0,0 +1,64 @@ +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 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; + }; +} From 6ca961f998cd32139675613bf6cd0d746df10dd9 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:21:58 +0200 Subject: [PATCH 14/48] feat(common): export schematics core via ./schematics subpath --- packages/common/src/schematics/index.ts | 6 +++++- packages/common/src/schematics/rules.ts | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/common/src/schematics/index.ts b/packages/common/src/schematics/index.ts index cb0ff5c3b..c5308f332 100644 --- a/packages/common/src/schematics/index.ts +++ b/packages/common/src/schematics/index.ts @@ -1 +1,5 @@ -export {}; +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.ts b/packages/common/src/schematics/rules.ts index 598a7f274..b7e75f642 100644 --- a/packages/common/src/schematics/rules.ts +++ b/packages/common/src/schematics/rules.ts @@ -1,3 +1,4 @@ +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'; @@ -14,9 +15,9 @@ export function setBuilderForTarget( const target = project.targets.get(targetName); if (target) { target.builder = builderName; - if (options) target.options = { ...(target.options ?? {}), ...options }; + if (options) target.options = { ...(target.options ?? {}), ...(options as Record) }; } else { - project.targets.add({ name: targetName, builder: builderName, options: options ?? {} }); + project.targets.add({ name: targetName, builder: builderName, options: (options ?? {}) as Record }); } }); } From f0abe337e59911da6cda7eb0970094235a7051e5 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:26:35 +0200 Subject: [PATCH 15/48] build(jest): add schematics packaging (tsconfig + fields + copy) --- packages/jest/package.json | 13 ++++++++++++- packages/jest/src/schematics/index.ts | 1 + packages/jest/tsconfig.schematics.json | 9 +++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/jest/src/schematics/index.ts create mode 100644 packages/jest/tsconfig.schematics.json diff --git a/packages/jest/package.json b/packages/jest/package.json index 55567a260..90b02c344 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/index.ts b/packages/jest/src/schematics/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/jest/src/schematics/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/jest/tsconfig.schematics.json b/packages/jest/tsconfig.schematics.json new file mode 100644 index 000000000..e4f56b977 --- /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/**"] +} From f0704e3a8f3566751df28d1fb2ad62ff3f3afe94 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:27:01 +0200 Subject: [PATCH 16/48] feat(jest): add ng-add collection + schema (project flag only) --- packages/jest/src/schematics/collection.json | 10 ++++++++++ packages/jest/src/schematics/ng-add/schema.json | 16 ++++++++++++++++ packages/jest/src/schematics/ng-add/schema.ts | 3 +++ 3 files changed, 29 insertions(+) create mode 100644 packages/jest/src/schematics/collection.json create mode 100644 packages/jest/src/schematics/ng-add/schema.json create mode 100644 packages/jest/src/schematics/ng-add/schema.ts diff --git a/packages/jest/src/schematics/collection.json b/packages/jest/src/schematics/collection.json new file mode 100644 index 000000000..2baa88188 --- /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/ng-add/schema.json b/packages/jest/src/schematics/ng-add/schema.json new file mode 100644 index 000000000..e031c39d0 --- /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 000000000..d468fd714 --- /dev/null +++ b/packages/jest/src/schematics/ng-add/schema.ts @@ -0,0 +1,3 @@ +export interface NgAddOptions { + project?: string; +} From edbd18d3ed4d9232dabdd5b5ddea0bfd99408cae Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:42:45 +0200 Subject: [PATCH 17/48] feat(jest): ng-add adds jest stack and rewrites test target --- .../jest/src/schematics/ng-add/index.spec.ts | 217 ++++++++++++++++++ packages/jest/src/schematics/ng-add/index.ts | 132 +++++++++++ 2 files changed, 349 insertions(+) create mode 100644 packages/jest/src/schematics/ng-add/index.spec.ts create mode 100644 packages/jest/src/schematics/ng-add/index.ts 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 000000000..ae7c5009c --- /dev/null +++ b/packages/jest/src/schematics/ng-add/index.spec.ts @@ -0,0 +1,217 @@ +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); + }); +}); + +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 000000000..4dc19112e --- /dev/null +++ b/packages/jest/src/schematics/ng-add/index.ts @@ -0,0 +1,132 @@ +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 { + for (const name of workspace.projects.keys()) { + if (detectTestBuilder(workspace, 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, 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, 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, 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}`))); + 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); + }; +} From cdb29b5018e13703c026c90190b9f3c962e785ae Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:43:32 +0200 Subject: [PATCH 18/48] feat(jest): declare ng-update migrations manifest (v21, v22) --- packages/jest/src/schematics/migrations.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/jest/src/schematics/migrations.json diff --git a/packages/jest/src/schematics/migrations.json b/packages/jest/src/schematics/migrations.json new file mode 100644 index 000000000..f1c0ca3f5 --- /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" + } + } +} From 675fa975ab2b663d13c463e113afe7163265bad4 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:45:07 +0200 Subject: [PATCH 19/48] feat(jest): v21 migration bumps deps and applies Node16 tsconfig --- .../schematics/migrations/v21/index.spec.ts | 243 ++++++++++++++++++ .../src/schematics/migrations/v21/index.ts | 118 +++++++++ 2 files changed, 361 insertions(+) create mode 100644 packages/jest/src/schematics/migrations/v21/index.spec.ts create mode 100644 packages/jest/src/schematics/migrations/v21/index.ts 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 000000000..6d740b51a --- /dev/null +++ b/packages/jest/src/schematics/migrations/v21/index.spec.ts @@ -0,0 +1,243 @@ +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', 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(); + }); +}); + +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']).toBe('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 000000000..77ef899ba --- /dev/null +++ b/packages/jest/src/schematics/migrations/v21/index.ts @@ -0,0 +1,118 @@ +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +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]; + } + } + test.options = options; + } + }); +} + +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; + } + }); +} + +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, name)) { + const options = (test.options ?? {}) as Record; + options['zoneless'] = false; + test.options = options; + } + } + }); + }; +} + +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(), + ]); + }; +} From 6ff5c15601d50e42c97dd2249582554022dae7e5 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:46:08 +0200 Subject: [PATCH 20/48] feat(jest): v22 advisory migration (isolatedModules, coverage path) --- .../schematics/migrations/v22/index.spec.ts | 66 +++++++++++++++++++ .../src/schematics/migrations/v22/index.ts | 44 +++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 packages/jest/src/schematics/migrations/v22/index.spec.ts create mode 100644 packages/jest/src/schematics/migrations/v22/index.ts 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 000000000..2a67a613c --- /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 000000000..a34d97d3f --- /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; + }; +} From 3138e17c65ea921737de4b5885f408b419bd93d4 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:48:03 +0200 Subject: [PATCH 21/48] build(custom-esbuild): add schematics packaging (tsconfig + ng-add fields + copy) --- packages/custom-esbuild/package.json | 12 ++++++++++-- packages/custom-esbuild/tsconfig.schematics.json | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 packages/custom-esbuild/tsconfig.schematics.json diff --git a/packages/custom-esbuild/package.json b/packages/custom-esbuild/package.json index 55dc41342..1351a36b9 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/tsconfig.schematics.json b/packages/custom-esbuild/tsconfig.schematics.json new file mode 100644 index 000000000..e4f56b977 --- /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/**"] +} From 7328ca92100c5982d58f3bb9032ebcb3f0e31774 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:48:49 +0200 Subject: [PATCH 22/48] feat(custom-esbuild): add ng-add collection + schema manifests --- packages/custom-esbuild/.gitignore | 1 + .../src/schematics/collection.json | 10 +++++++ .../src/schematics/ng-add/schema.json | 26 +++++++++++++++++++ .../src/schematics/ng-add/schema.ts | 5 ++++ 4 files changed, 42 insertions(+) create mode 100644 packages/custom-esbuild/src/schematics/collection.json create mode 100644 packages/custom-esbuild/src/schematics/ng-add/schema.json create mode 100644 packages/custom-esbuild/src/schematics/ng-add/schema.ts diff --git a/packages/custom-esbuild/.gitignore b/packages/custom-esbuild/.gitignore index 28ffec918..e176d0bd1 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/src/schematics/collection.json b/packages/custom-esbuild/src/schematics/collection.json new file mode 100644 index 000000000..e8ce29364 --- /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/schema.json b/packages/custom-esbuild/src/schematics/ng-add/schema.json new file mode 100644 index 000000000..b409b7f77 --- /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 000000000..9f1b213a8 --- /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; +} From 5760f54160d74f18134422b04fca050c9ebdb4a2 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:50:32 +0200 Subject: [PATCH 23/48] feat(custom-esbuild): ng-add rewrites build/serve preserving options --- jest-ut.config.js | 6 ++ .../src/schematics/ng-add/index.spec.ts | 55 ++++++++++++++++++ .../src/schematics/ng-add/index.ts | 56 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 packages/custom-esbuild/src/schematics/ng-add/index.spec.ts create mode 100644 packages/custom-esbuild/src/schematics/ng-add/index.ts diff --git a/jest-ut.config.js b/jest-ut.config.js index d22f53e29..054564583 100644 --- a/jest-ut.config.js +++ b/jest-ut.config.js @@ -5,5 +5,11 @@ module.exports = { // 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/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 000000000..908c3a90e --- /dev/null +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -0,0 +1,55 @@ +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(); + }); +}); 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 000000000..98afb2635 --- /dev/null +++ b/packages/custom-esbuild/src/schematics/ng-add/index.ts @@ -0,0 +1,56 @@ +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'; + +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); + 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); + + 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); + }; +} From 6da27e9c41e5841dc6a4b1fe6dd66e46a773315f Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:51:26 +0200 Subject: [PATCH 24/48] =?UTF-8?q?feat(custom-esbuild):=20ng-add=20guards?= =?UTF-8?q?=20webpack=20builds,=20adds=20--from-webpack=20(spec=20=C2=A712?= =?UTF-8?q?.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/schematics/ng-add/index.spec.ts | 52 +++++++++++++++++++ .../src/schematics/ng-add/index.ts | 16 +++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts index 908c3a90e..d25ab945c 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -53,3 +53,55 @@ describe('custom-esbuild ng-add: build + serve rewrite', () => { 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'); + }); +}); diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.ts b/packages/custom-esbuild/src/schematics/ng-add/index.ts index 98afb2635..59f9e4fd8 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.ts @@ -29,7 +29,7 @@ function classifyBuildBuilder(builder: string | undefined): 'esbuild' | 'webpack } export function ngAdd(options: Schema): Rule { - return async (tree: Tree, _context: SchematicContext) => { + return async (tree: Tree, context: SchematicContext) => { const workspace = await readWorkspace(tree); const projects = getProjectsToTarget(workspace, options.project); @@ -40,14 +40,26 @@ export function ngAdd(options: Schema): Rule { 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') { + 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.`, + ); } } From 37a16bf5c5aa945e8e4060642ec41ed3a86d615c Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:52:24 +0200 Subject: [PATCH 25/48] feat(custom-esbuild): ng-add auto-rewrites Vitest test target with buildTarget --- .../src/schematics/ng-add/index.spec.ts | 23 +++++++++++++++++++ .../src/schematics/ng-add/index.ts | 11 +++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts index d25ab945c..a5185a0e7 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -105,3 +105,26 @@ describe('custom-esbuild ng-add: webpack-build guard (spec §12.3)', () => { 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'); + }); +}); diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.ts b/packages/custom-esbuild/src/schematics/ng-add/index.ts index 59f9e4fd8..dde107a57 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.ts @@ -2,6 +2,7 @@ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics' import { readWorkspace } from '@schematics/angular/utility'; import { addBuilderDevDependency, + detectTestBuilder, getProjectsToTarget, setBuilderForTarget, } from '@angular-builders/common/schematics'; @@ -11,6 +12,7 @@ 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 = [ @@ -61,6 +63,15 @@ export function ngAdd(options: Schema): Rule { `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); + if (testKind === 'vitest') { + rules.push( + setBuilderForTarget(projectName, 'test', TEST_BUILDER, { + buildTarget: `${projectName}:build`, + }), + ); + } } return chain(rules); From 42506a0173d396819a04b144f3eabe758cd6aad0 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:53:12 +0200 Subject: [PATCH 26/48] feat(custom-esbuild): ng-add leaves Karma/Jest tests, logs unit-test advisory --- .../src/schematics/ng-add/index.spec.ts | 47 +++++++++++++++++++ .../src/schematics/ng-add/index.ts | 12 ++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts index a5185a0e7..d7e84ad55 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -128,3 +128,50 @@ describe('custom-esbuild ng-add: Vitest test target', () => { 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); + }); +}); diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.ts b/packages/custom-esbuild/src/schematics/ng-add/index.ts index dde107a57..dbfa4da5e 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.ts @@ -65,12 +65,22 @@ export function ngAdd(options: Schema): Rule { } const testKind = detectTestBuilder(workspace, projectName); - if (testKind === 'vitest') { + 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.`, + ); } } From 78d04b514f7022f8adf077f31e3599074e3eb533 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:53:43 +0200 Subject: [PATCH 27/48] feat(custom-esbuild): ng-add --unit-test force-creates Vitest target --- .../src/schematics/ng-add/index.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts index d7e84ad55..066747aa8 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -175,3 +175,46 @@ describe('custom-esbuild ng-add: Karma / Jest test target', () => { 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'); + }); +}); From 8b408a7d0717ffe297086524f8af3295c5225531 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:54:07 +0200 Subject: [PATCH 28/48] test(custom-esbuild): assert ng-add idempotency --- .../src/schematics/ng-add/index.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts index 066747aa8..63ba5ab16 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.spec.ts @@ -218,3 +218,38 @@ describe('custom-esbuild ng-add: --unit-test flag', () => { 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')); + }); +}); From d6fe87b06156c4fd1e38b5e938860dc867d5a924 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:56:45 +0200 Subject: [PATCH 29/48] build(custom-esbuild): verify schematics build --- packages/custom-esbuild/src/schematics/ng-add/index.ts | 3 ++- packages/custom-esbuild/tsconfig.schematics.json | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/custom-esbuild/src/schematics/ng-add/index.ts b/packages/custom-esbuild/src/schematics/ng-add/index.ts index dbfa4da5e..bae89b5b7 100644 --- a/packages/custom-esbuild/src/schematics/ng-add/index.ts +++ b/packages/custom-esbuild/src/schematics/ng-add/index.ts @@ -1,4 +1,5 @@ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { workspaces } from '@angular-devkit/core'; import { readWorkspace } from '@schematics/angular/utility'; import { addBuilderDevDependency, @@ -32,7 +33,7 @@ function classifyBuildBuilder(builder: string | undefined): 'esbuild' | 'webpack export function ngAdd(options: Schema): Rule { return async (tree: Tree, context: SchematicContext) => { - const workspace = await readWorkspace(tree); + const workspace = (await readWorkspace(tree)) as unknown as workspaces.WorkspaceDefinition; const projects = getProjectsToTarget(workspace, options.project); const rules: Rule[] = [ diff --git a/packages/custom-esbuild/tsconfig.schematics.json b/packages/custom-esbuild/tsconfig.schematics.json index e4f56b977..28f1ddc3a 100644 --- a/packages/custom-esbuild/tsconfig.schematics.json +++ b/packages/custom-esbuild/tsconfig.schematics.json @@ -2,7 +2,11 @@ "extends": "../../tsconfig.schematics.json", "compilerOptions": { "rootDir": "src/schematics", - "outDir": "dist/schematics" + "outDir": "dist/schematics", + "paths": { + "@angular-builders/common/schematics": ["../common/dist/schematics/index.d.ts"], + "@angular-builders/common/schematics/testing": ["../common/dist/schematics/testing.d.ts"] + } }, "include": ["src/schematics/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] From 805355d6f325b03420df090da781e7bae437ecbc Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:47:01 +0200 Subject: [PATCH 30/48] build(custom-webpack): add schematics packaging (tsconfig + ng-add field) --- packages/custom-webpack/package.json | 8 +++++++- packages/custom-webpack/src/schematics/collection.json | 4 ++++ packages/custom-webpack/src/schematics/ng-add/index.ts | 1 + packages/custom-webpack/tsconfig.schematics.json | 9 +++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/custom-webpack/src/schematics/collection.json create mode 100644 packages/custom-webpack/src/schematics/ng-add/index.ts create mode 100644 packages/custom-webpack/tsconfig.schematics.json diff --git a/packages/custom-webpack/package.json b/packages/custom-webpack/package.json index b15f5e7b4..f8a026c96 100644 --- a/packages/custom-webpack/package.json +++ b/packages/custom-webpack/package.json @@ -31,13 +31,18 @@ ], "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", @@ -52,6 +57,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 000000000..68146ca0c --- /dev/null +++ b/packages/custom-webpack/src/schematics/collection.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": {} +} 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 000000000..cb0ff5c3b --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/custom-webpack/tsconfig.schematics.json b/packages/custom-webpack/tsconfig.schematics.json new file mode 100644 index 000000000..e4f56b977 --- /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/**"] +} From 4e07ee63affa0ca8a1d44f6db5a1f1a793992da7 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:47:23 +0200 Subject: [PATCH 31/48] feat(custom-webpack): add ng-add schema (project flag, no prompts) --- .../src/schematics/ng-add/schema.json | 14 ++++++++++++++ .../custom-webpack/src/schematics/ng-add/schema.ts | 3 +++ 2 files changed, 17 insertions(+) create mode 100644 packages/custom-webpack/src/schematics/ng-add/schema.json create mode 100644 packages/custom-webpack/src/schematics/ng-add/schema.ts 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 000000000..4153d5423 --- /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 000000000..cb7cd8a48 --- /dev/null +++ b/packages/custom-webpack/src/schematics/ng-add/schema.ts @@ -0,0 +1,3 @@ +export interface NgAddSchema { + project?: string; +} From d2b783925226d4489325aec527275303ab8bd006 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:47:39 +0200 Subject: [PATCH 32/48] feat(custom-webpack): add starter webpack.config scaffold template --- .../ng-add/files/webpack.config.js.template | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/custom-webpack/src/schematics/ng-add/files/webpack.config.js.template 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 000000000..0bb73ed50 --- /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 = {}; From 75129b44291b6ac6603fcb66a7359ca57a5ef785 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:55:25 +0200 Subject: [PATCH 33/48] feat(custom-webpack): add ng-add (build/serve rewrite + config scaffold) --- .../src/schematics/collection.json | 8 +- .../src/schematics/ng-add/index.spec.ts | 128 ++++++++++++++++++ .../src/schematics/ng-add/index.ts | 102 +++++++++++++- 3 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 packages/custom-webpack/src/schematics/ng-add/index.spec.ts diff --git a/packages/custom-webpack/src/schematics/collection.json b/packages/custom-webpack/src/schematics/collection.json index 68146ca0c..81bbf76e9 100644 --- a/packages/custom-webpack/src/schematics/collection.json +++ b/packages/custom-webpack/src/schematics/collection.json @@ -1,4 +1,10 @@ { "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", - "schematics": {} + "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/index.spec.ts b/packages/custom-webpack/src/schematics/ng-add/index.spec.ts new file mode 100644 index 000000000..4f99155ae --- /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 index cb0ff5c3b..47b2a71ee 100644 --- a/packages/custom-webpack/src/schematics/ng-add/index.ts +++ b/packages/custom-webpack/src/schematics/ng-add/index.ts @@ -1 +1,101 @@ -export {}; +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 { + 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, 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, + ]); + }; +} From a0d6c516a40ce892cec9361ea086fbe33ae71e32 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 11:57:43 +0200 Subject: [PATCH 34/48] test(custom-webpack): verify schematics build + no-migrations invariant --- packages/custom-webpack/src/schematics/ng-add/index.ts | 3 ++- packages/custom-webpack/tsconfig.schematics.json | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/custom-webpack/src/schematics/ng-add/index.ts b/packages/custom-webpack/src/schematics/ng-add/index.ts index 47b2a71ee..398d55dc8 100644 --- a/packages/custom-webpack/src/schematics/ng-add/index.ts +++ b/packages/custom-webpack/src/schematics/ng-add/index.ts @@ -11,6 +11,7 @@ import { url, } from '@angular-devkit/schematics'; import { readWorkspace as getWorkspace, updateWorkspace } from '@schematics/angular/utility'; +import { workspaces } from '@angular-devkit/core'; import { addBuilderDevDependency, getProjectsToTarget, @@ -80,7 +81,7 @@ function scaffoldConfig(projectName: string): Rule { export function ngAdd(options: NgAddSchema): Rule { return async (tree: Tree, context: SchematicContext) => { const workspace = await getWorkspace(tree); - const projects = getProjectsToTarget(workspace, options.project); + 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.'); diff --git a/packages/custom-webpack/tsconfig.schematics.json b/packages/custom-webpack/tsconfig.schematics.json index e4f56b977..ac7fb4fa4 100644 --- a/packages/custom-webpack/tsconfig.schematics.json +++ b/packages/custom-webpack/tsconfig.schematics.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.schematics.json", "compilerOptions": { "rootDir": "src/schematics", - "outDir": "dist/schematics" + "outDir": "dist/schematics", + "paths": { + "@angular-builders/common/schematics": ["../common/dist/schematics/index.d.ts"] + } }, "include": ["src/schematics/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] From 778603ee15376a5c94cb2ac0fb2f1b76d1747aff Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 12:04:28 +0200 Subject: [PATCH 35/48] fix(jest): add WorkspaceDefinition casts and JsonValue fixes for schematics tsc --- packages/jest/src/schematics/migrations/v21/index.ts | 9 +++++---- packages/jest/src/schematics/ng-add/index.ts | 9 +++++---- packages/jest/tsconfig.schematics.json | 6 +++++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/jest/src/schematics/migrations/v21/index.ts b/packages/jest/src/schematics/migrations/v21/index.ts index 77ef899ba..75d36faef 100644 --- a/packages/jest/src/schematics/migrations/v21/index.ts +++ b/packages/jest/src/schematics/migrations/v21/index.ts @@ -1,4 +1,5 @@ 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'; @@ -50,7 +51,7 @@ function renameBuilderOptions(): Rule { delete options[from]; } } - test.options = options; + test.options = options as unknown as Record; } }); } @@ -73,7 +74,7 @@ function stripRemovedOptions(): Rule { for (const removed of REMOVED_JEST_OPTIONS) { if (removed in options) delete options[removed]; } - test.options = options; + test.options = options as unknown as Record; } }); } @@ -85,10 +86,10 @@ function setZonelessByDetection(): Rule { 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, name)) { + if (!isZoneless(tree, workspace as unknown as workspaces.WorkspaceDefinition, name)) { const options = (test.options ?? {}) as Record; options['zoneless'] = false; - test.options = options; + test.options = options as unknown as Record; } } }); diff --git a/packages/jest/src/schematics/ng-add/index.ts b/packages/jest/src/schematics/ng-add/index.ts index 4dc19112e..9f1529395 100644 --- a/packages/jest/src/schematics/ng-add/index.ts +++ b/packages/jest/src/schematics/ng-add/index.ts @@ -1,4 +1,5 @@ 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 { @@ -37,7 +38,7 @@ 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, name) === 'karma') return true; + 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')) { @@ -50,7 +51,7 @@ function hasKarma(tree: Tree, workspace: Awaited>): boolean { for (const name of workspace.projects.keys()) { - if (detectTestBuilder(workspace, name) === 'vitest') return true; + if (detectTestBuilder(workspace as unknown as workspaces.WorkspaceDefinition, name) === 'vitest') return true; } return false; } @@ -78,7 +79,7 @@ function fixSpecTsconfig(path: string): Rule { export function ngAdd(options: NgAddOptions): Rule { return async (tree: Tree, context: SchematicContext) => { const workspace = await readWorkspace(tree); - const projects = getProjectsToTarget(workspace, options.project); + const projects = getProjectsToTarget(workspace as unknown as workspaces.WorkspaceDefinition, options.project); const rules: Rule[] = []; @@ -92,7 +93,7 @@ export function ngAdd(options: NgAddOptions): Rule { }); for (const projectName of projects) { - const zoneless = isZoneless(tree, workspace, projectName); + const zoneless = isZoneless(tree, workspace as unknown as workspaces.WorkspaceDefinition, projectName); rules.push(setBuilderForTarget(projectName, 'test', JEST_BUILDER, { zoneless })); } diff --git a/packages/jest/tsconfig.schematics.json b/packages/jest/tsconfig.schematics.json index e4f56b977..28f1ddc3a 100644 --- a/packages/jest/tsconfig.schematics.json +++ b/packages/jest/tsconfig.schematics.json @@ -2,7 +2,11 @@ "extends": "../../tsconfig.schematics.json", "compilerOptions": { "rootDir": "src/schematics", - "outDir": "dist/schematics" + "outDir": "dist/schematics", + "paths": { + "@angular-builders/common/schematics": ["../common/dist/schematics/index.d.ts"], + "@angular-builders/common/schematics/testing": ["../common/dist/schematics/testing.d.ts"] + } }, "include": ["src/schematics/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] From c02cc6a589243d67795b6b73254091e43038767b Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 12:20:19 +0200 Subject: [PATCH 36/48] test(schematics): add local-only ng add e2e harness helpers --- scripts/__fixtures__/e2e-smoke/angular.json | 12 ++ scripts/__fixtures__/e2e-smoke/karma.conf.js | 1 + scripts/__fixtures__/e2e-smoke/package.json | 1 + scripts/e2e-assert.js | 63 ++++++++ scripts/e2e-ng-add.js | 149 +++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 scripts/__fixtures__/e2e-smoke/angular.json create mode 100644 scripts/__fixtures__/e2e-smoke/karma.conf.js create mode 100644 scripts/__fixtures__/e2e-smoke/package.json create mode 100644 scripts/e2e-assert.js create mode 100644 scripts/e2e-ng-add.js diff --git a/scripts/__fixtures__/e2e-smoke/angular.json b/scripts/__fixtures__/e2e-smoke/angular.json new file mode 100644 index 000000000..270871d0c --- /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 000000000..ea41b01de --- /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 000000000..813a62375 --- /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 000000000..439347d83 --- /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-ng-add.js b/scripts/e2e-ng-add.js new file mode 100644 index 000000000..f56e4a5e8 --- /dev/null +++ b/scripts/e2e-ng-add.js @@ -0,0 +1,149 @@ +#!/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); +} From 384b476086e5a0f77033773b937cdd6abd91a7c1 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:10:04 +0200 Subject: [PATCH 37/48] build(schematics): use Node16 module resolution for schematics build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schematics tsconfig used module:commonjs + moduleResolution:node (node10). node10 is deprecated in TypeScript 6 (Angular 22) AND ignores package exports maps — the latter is why each package's schematics tsconfig carried a 'paths' override just to resolve @angular-builders/common/schematics. Align with the main builder tsconfig (module/moduleResolution Node16): all packages are CJS (no type:module), so Node16 still emits CommonJS as schematics require, while honoring exports maps. This drops the paths workaround in all three packages and needs no deprecation suppression. Add types:[node] since TS6 no longer auto-includes node ambients. --- packages/custom-esbuild/tsconfig.schematics.json | 6 +----- packages/custom-webpack/tsconfig.schematics.json | 5 +---- packages/jest/tsconfig.schematics.json | 6 +----- tsconfig.schematics.json | 5 +++-- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/custom-esbuild/tsconfig.schematics.json b/packages/custom-esbuild/tsconfig.schematics.json index 28f1ddc3a..e4f56b977 100644 --- a/packages/custom-esbuild/tsconfig.schematics.json +++ b/packages/custom-esbuild/tsconfig.schematics.json @@ -2,11 +2,7 @@ "extends": "../../tsconfig.schematics.json", "compilerOptions": { "rootDir": "src/schematics", - "outDir": "dist/schematics", - "paths": { - "@angular-builders/common/schematics": ["../common/dist/schematics/index.d.ts"], - "@angular-builders/common/schematics/testing": ["../common/dist/schematics/testing.d.ts"] - } + "outDir": "dist/schematics" }, "include": ["src/schematics/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] diff --git a/packages/custom-webpack/tsconfig.schematics.json b/packages/custom-webpack/tsconfig.schematics.json index ac7fb4fa4..e4f56b977 100644 --- a/packages/custom-webpack/tsconfig.schematics.json +++ b/packages/custom-webpack/tsconfig.schematics.json @@ -2,10 +2,7 @@ "extends": "../../tsconfig.schematics.json", "compilerOptions": { "rootDir": "src/schematics", - "outDir": "dist/schematics", - "paths": { - "@angular-builders/common/schematics": ["../common/dist/schematics/index.d.ts"] - } + "outDir": "dist/schematics" }, "include": ["src/schematics/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] diff --git a/packages/jest/tsconfig.schematics.json b/packages/jest/tsconfig.schematics.json index 28f1ddc3a..e4f56b977 100644 --- a/packages/jest/tsconfig.schematics.json +++ b/packages/jest/tsconfig.schematics.json @@ -2,11 +2,7 @@ "extends": "../../tsconfig.schematics.json", "compilerOptions": { "rootDir": "src/schematics", - "outDir": "dist/schematics", - "paths": { - "@angular-builders/common/schematics": ["../common/dist/schematics/index.d.ts"], - "@angular-builders/common/schematics/testing": ["../common/dist/schematics/testing.d.ts"] - } + "outDir": "dist/schematics" }, "include": ["src/schematics/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/files/**"] diff --git a/tsconfig.schematics.json b/tsconfig.schematics.json index 90a817cef..142aab874 100644 --- a/tsconfig.schematics.json +++ b/tsconfig.schematics.json @@ -1,9 +1,10 @@ { "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "Node16", "target": "ES2022", "lib": ["ES2022"], + "types": ["node"], "declaration": true, "strict": true, "strictNullChecks": false, From b51ed65d489f3f8d5d3c339ae2b952a63ad788ed Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:14:48 +0200 Subject: [PATCH 38/48] test(schematics): generate ng-add e2e fixtures inline, default to no-install collection run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ng add e2e now generates the target app inline with the workspace CLI (ng new) instead of committing a fresh-scaffold fixture — self-describing and immune to fixture drift across Angular majors. Default ng add path resolves the collection from the workspace-linked package with --skip-install (the schematic under test runs fully; no package-manager step, so it can't mutate the symlinked node_modules). npm-pack tarball path kept behind useTarball:true for a future isolated-install CI. --- scripts/e2e-ng-add.js | 132 +++++++++++++++++++++++++++++------------- 1 file changed, 93 insertions(+), 39 deletions(-) diff --git a/scripts/e2e-ng-add.js b/scripts/e2e-ng-add.js index f56e4a5e8..cd93af209 100644 --- a/scripts/e2e-ng-add.js +++ b/scripts/e2e-ng-add.js @@ -2,35 +2,57 @@ '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. +// 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. +// 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): // { -// "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) +// "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 +// "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. +// 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'); @@ -39,6 +61,7 @@ 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 = {}; @@ -75,37 +98,59 @@ function run(cmd, args, opts) { 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'); + } +} + +// 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 fixtureAbs = path.resolve(REPO_ROOT, spec.fixture); - if (!fs.existsSync(fixtureAbs)) throw new Error(`Fixture not found: ${spec.fixture}`); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-add-e2e-')); + console.log(`[e2e-ng-add] tmp = ${tmp}`); - const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-add-e2e-')); + 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}`); - copyDir(fixtureAbs, workdir); + linkNodeModules(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'); + // 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.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 ./`. + 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, @@ -114,8 +159,17 @@ function main() { 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 }); + 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, no install. + const args = ['add', spec.package, '--collection', spec.package, '--skip-confirmation', + '--skip-install', ...(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}`); @@ -132,7 +186,7 @@ function main() { console.log(`[e2e-ng-add] OK assert ${a.fn}(${JSON.stringify(a.args)})`); } - // Post-add verification commands (real build/test under v22). + // 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}`); From b33864d798dec7203c2a36026b77bfc6b18e0ef4 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:27:58 +0200 Subject: [PATCH 39/48] fix(schematics): handle Angular 22 unified :unit-test runner model Angular 22 expresses Karma and Vitest as the same @angular/build:unit-test builder, distinguished by an options.runner ('karma' | 'vitest') field, rather than a dedicated :karma builder (which still exists for webpack projects). The schematics, written for the v21 model, broke on v22: - detectTestBuilder classified every :unit-test target as Vitest, so Karma on a default (esbuild) v22 app was invisible. Now it reads options.runner for :unit-test builders and keeps the :karma suffix branch for webpack projects. - jest ng-add merged {zoneless} onto the previous target's options, leaving the foreign builder's runner/buildTarget behind; the Jest builder then forwarded --runner to the Jest CLI ('Runner is not a constructor'). setBuilderForTarget gains an opt-in replaceOptions (default still merges, preserving other callers); jest ng-add uses it to start from a clean Jest option set. Surfaced by the Plan 04 ng-add e2e (ng new --test-runner karma -> ng add -> ng test). --- .../common/src/schematics/detection.spec.ts | 56 +++++++++++++------ packages/common/src/schematics/detection.ts | 10 +++- packages/common/src/schematics/rules.spec.ts | 21 +++++++ packages/common/src/schematics/rules.ts | 10 +++- .../jest/src/schematics/ng-add/index.spec.ts | 41 ++++++++++++++ packages/jest/src/schematics/ng-add/index.ts | 5 +- 6 files changed, 122 insertions(+), 21 deletions(-) diff --git a/packages/common/src/schematics/detection.spec.ts b/packages/common/src/schematics/detection.spec.ts index c960183a8..6d8293e27 100644 --- a/packages/common/src/schematics/detection.spec.ts +++ b/packages/common/src/schematics/detection.spec.ts @@ -29,6 +29,27 @@ describe('getProjectsToTarget', () => { }); 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 @@ -38,26 +59,27 @@ describe('detectTestBuilder', () => { } }); - it('detects karma', async () => { + it('detects a dedicated :karma builder (webpack projects)', 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 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; - })(); + 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', () => { diff --git a/packages/common/src/schematics/detection.ts b/packages/common/src/schematics/detection.ts index 869ab56c7..39a73fd94 100644 --- a/packages/common/src/schematics/detection.ts +++ b/packages/common/src/schematics/detection.ts @@ -27,11 +27,17 @@ export function detectTestBuilder( projectName: string, ): TestBuilderKind { const project = workspace.projects.get(projectName); - const builder = project?.targets.get('test')?.builder; + 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'; - if (builder.endsWith(':unit-test')) return 'vitest'; + // 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'; } diff --git a/packages/common/src/schematics/rules.spec.ts b/packages/common/src/schematics/rules.spec.ts index c89d18ed8..59e0fdacd 100644 --- a/packages/common/src/schematics/rules.spec.ts +++ b/packages/common/src/schematics/rules.spec.ts @@ -24,6 +24,27 @@ describe('setBuilderForTarget', () => { 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', () => { diff --git a/packages/common/src/schematics/rules.ts b/packages/common/src/schematics/rules.ts index b7e75f642..0d1bfbc33 100644 --- a/packages/common/src/schematics/rules.ts +++ b/packages/common/src/schematics/rules.ts @@ -8,6 +8,7 @@ export function setBuilderForTarget( targetName: string, builderName: string, options?: Record, + opts: { replaceOptions?: boolean } = {}, ): Rule { return updateWorkspace((workspace) => { const project = workspace.projects.get(projectName); @@ -15,7 +16,14 @@ export function setBuilderForTarget( const target = project.targets.get(targetName); if (target) { target.builder = builderName; - if (options) target.options = { ...(target.options ?? {}), ...(options as Record) }; + // 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 }); } diff --git a/packages/jest/src/schematics/ng-add/index.spec.ts b/packages/jest/src/schematics/ng-add/index.spec.ts index ae7c5009c..63fee2300 100644 --- a/packages/jest/src/schematics/ng-add/index.spec.ts +++ b/packages/jest/src/schematics/ng-add/index.spec.ts @@ -181,6 +181,47 @@ describe('jest ng-add (Vitest present)', () => { } 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)', () => { diff --git a/packages/jest/src/schematics/ng-add/index.ts b/packages/jest/src/schematics/ng-add/index.ts index 9f1529395..b19176c5c 100644 --- a/packages/jest/src/schematics/ng-add/index.ts +++ b/packages/jest/src/schematics/ng-add/index.ts @@ -94,7 +94,10 @@ export function ngAdd(options: NgAddOptions): Rule { for (const projectName of projects) { const zoneless = isZoneless(tree, workspace as unknown as workspaces.WorkspaceDefinition, projectName); - rules.push(setBuilderForTarget(projectName, 'test', JEST_BUILDER, { zoneless })); + // 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)) { From d93357329311b125cf671354da6d71ffb6b726bd Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:29:03 +0200 Subject: [PATCH 40/48] test(jest): add Karma->Jest ng add e2e (inline fixture, real ng test) --- packages/jest/tests/e2e/karma-to-jest.ng-add.json | 14 ++++++++++++++ packages/jest/tests/integration.js | 10 ++++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/jest/tests/e2e/karma-to-jest.ng-add.json 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 000000000..54ce8c81e --- /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/integration.js b/packages/jest/tests/integration.js index 3ff15587e..eeb08b5ad 100644 --- a/packages/jest/tests/integration.js +++ b/packages/jest/tests/integration.js @@ -107,6 +107,16 @@ 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', + }, + // E2E sanity { id: 'e2e-simple-app', From da95039559ea702a06a324e394b9cb7e50829195 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:29:47 +0200 Subject: [PATCH 41/48] test(jest): add Vitest->Jest ng add e2e (ng build + ng test green) --- packages/jest/tests/e2e/vitest-to-jest.ng-add.json | 13 +++++++++++++ packages/jest/tests/integration.js | 8 ++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/jest/tests/e2e/vitest-to-jest.ng-add.json 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 000000000..ef108a3f7 --- /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 eeb08b5ad..443a7fbe5 100644 --- a/packages/jest/tests/integration.js +++ b/packages/jest/tests/integration.js @@ -116,6 +116,14 @@ module.exports = [ 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', + }, // E2E sanity { From ed82f02ded0c7a25d33b5012dfe1e9ffe8cf4800 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:30:37 +0200 Subject: [PATCH 42/48] test(custom-esbuild): add build/serve rewrite ng add e2e --- .../tests/e2e/esbuild-add.ng-add.json | 14 ++++++++++++++ packages/custom-esbuild/tests/integration.js | 12 ++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 packages/custom-esbuild/tests/e2e/esbuild-add.ng-add.json 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 000000000..49c740f9c --- /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/integration.js b/packages/custom-esbuild/tests/integration.js index 4932b6ed7..a9df2bcaf 100644 --- a/packages/custom-esbuild/tests/integration.js +++ b/packages/custom-esbuild/tests/integration.js @@ -1,4 +1,16 @@ 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', + }, + // Vitest builder tests { id: 'vitest-builder-esm-config', From 68e8809dd40c3ec5ca6e4436c30dae376ff802b0 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:31:32 +0200 Subject: [PATCH 43/48] test(custom-esbuild): add webpack-build guard ng add e2e --- .../tests/e2e/webpack-guard.ng-add.json | 18 ++++++++++++++++++ packages/custom-esbuild/tests/integration.js | 8 ++++++++ 2 files changed, 26 insertions(+) create mode 100644 packages/custom-esbuild/tests/e2e/webpack-guard.ng-add.json 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 000000000..f0138df87 --- /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 a9df2bcaf..edc26bdcb 100644 --- a/packages/custom-esbuild/tests/integration.js +++ b/packages/custom-esbuild/tests/integration.js @@ -10,6 +10,14 @@ module.exports = [ 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 { From 5af058fd0be463efb9a1a15d3b2b66af19640346 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:33:36 +0200 Subject: [PATCH 44/48] test(custom-webpack): add build/serve rewrite + scaffold ng add e2e --- .../tests/e2e/webpack-add.ng-add.json | 18 ++++++++++++++++++ packages/custom-webpack/tests/integration.js | 10 ++++++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/custom-webpack/tests/e2e/webpack-add.ng-add.json 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 000000000..54c230d9e --- /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 c16f6a56b..16f212fa0 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', From f91f8634a3b985cd54425170b0bf640fb90f381d Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:37:02 +0200 Subject: [PATCH 45/48] test(jest): add @21 migration post-build smoke; fix testPathPatterns string->array The @21 migration renamed testPathPattern -> testPathPatterns but carried the value over verbatim. Jest 30's testPathPatterns is a string array, so a migrated config failed builder schema validation ('testPathPatterns must be array') at ng test. Wrap a carried-over string in an array. Surfaced by the new ng update --migrate-only post-build smoke (Plan 04 Task 6), which seeds a pre-21 config on an inline-generated app, runs the migration, then ng build + ng test. --- .../schematics/migrations/v21/index.spec.ts | 7 +- .../src/schematics/migrations/v21/index.ts | 5 + .../jest/tests/e2e/migration-v21.smoke.json | 7 ++ packages/jest/tests/integration.js | 7 ++ scripts/e2e-jest-migration.js | 117 ++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 packages/jest/tests/e2e/migration-v21.smoke.json create mode 100644 scripts/e2e-jest-migration.js diff --git a/packages/jest/src/schematics/migrations/v21/index.spec.ts b/packages/jest/src/schematics/migrations/v21/index.spec.ts index 6d740b51a..79fdfa3c7 100644 --- a/packages/jest/src/schematics/migrations/v21/index.spec.ts +++ b/packages/jest/src/schematics/migrations/v21/index.spec.ts @@ -70,12 +70,13 @@ describe('jest @21 migration — builder option renames', () => { expect(opts['configPath']).toBeUndefined(); }); - it('renames testPathPattern → testPathPatterns', async () => { + 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; - expect(opts['testPathPatterns']).toBe('foo'); + // Jest 30's testPathPatterns is a string array, not the old single-string testPathPattern. + expect(opts['testPathPatterns']).toEqual(['foo']); expect(opts['testPathPattern']).toBeUndefined(); }); }); @@ -234,7 +235,7 @@ describe('jest @21 migration — idempotency', () => { expect(optsTwice['config']).toBe('jest.config.js'); expect(optsTwice['configPath']).toBeUndefined(); - expect(optsTwice['testPathPatterns']).toBe('foo'); + expect(optsTwice['testPathPatterns']).toEqual(['foo']); expect(optsTwice['globalMocks']).toEqual(['matchMedia']); expect(optsTwice['browser']).toBeUndefined(); expect(optsTwice['zoneless']).toBe(false); diff --git a/packages/jest/src/schematics/migrations/v21/index.ts b/packages/jest/src/schematics/migrations/v21/index.ts index 75d36faef..6e3cbdf42 100644 --- a/packages/jest/src/schematics/migrations/v21/index.ts +++ b/packages/jest/src/schematics/migrations/v21/index.ts @@ -51,6 +51,11 @@ function renameBuilderOptions(): Rule { 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; } }); 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 000000000..989ab7de9 --- /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/integration.js b/packages/jest/tests/integration.js index 443a7fbe5..460b6cba2 100644 --- a/packages/jest/tests/integration.js +++ b/packages/jest/tests/integration.js @@ -124,6 +124,13 @@ module.exports = [ 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 { diff --git a/scripts/e2e-jest-migration.js b/scripts/e2e-jest-migration.js new file mode 100644 index 000000000..3ad90e6e3 --- /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); +} From 1d7f1cdb7bb47482df477e67db02ef3349679666 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:37:43 +0200 Subject: [PATCH 46/48] docs(runbook): record RC-validated multi-major ng update window --- docs/runbooks/angular-major-upgrade.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/runbooks/angular-major-upgrade.md b/docs/runbooks/angular-major-upgrade.md index 724bbe07c..a5b47b392 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`. From d2501fa421616a4d9fa4e7f9e8492d7272b2677d Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:43:01 +0200 Subject: [PATCH 47/48] test(schematics): neutralise package manager during ng add via PATH shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ng add collection run can't use --skip-install (ng add forwards it to the schematic, whose schema rejects unknown options). Instead prepend a no-op npm/yarn/pnpm shim to PATH for the ng add spawn so the schematic's NodePackageInstallTask runs harmlessly — it cannot write through the workdir's node_modules symlink into the workspace. Every tree transform still happens; ng build/test afterwards resolve from the workspace-linked modules. --- scripts/e2e-ng-add.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/e2e-ng-add.js b/scripts/e2e-ng-add.js index cd93af209..d21a406b3 100644 --- a/scripts/e2e-ng-add.js +++ b/scripts/e2e-ng-add.js @@ -107,6 +107,22 @@ function linkNodeModules(workdir) { } } +// 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; @@ -166,10 +182,16 @@ function main() { throw new Error(`ng add failed with status ${r.status}`); } } else { - // Default: real CLI, collection resolved from the workspace-linked package, no install. + // 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', - '--skip-install', ...(spec.ngAddArgs || [])]; - const r = run(NG_BIN, args, { cwd: workdir }); + ...(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}`); From 61f4649b1eee94b400b0a91fbdd81e6ed00abb74 Mon Sep 17 00:00:00 2001 From: Jeb Date: Wed, 3 Jun 2026 13:49:36 +0200 Subject: [PATCH 48/48] fix(custom-webpack): declare @angular-devkit/schematics and @schematics/angular deps custom-webpack's ng-add imports from both packages but didn't declare them, relying on them being present transitively via the user's @angular/cli install. Declare them explicitly (matching jest and custom-esbuild) so ng add resolves regardless of hoisting. --- packages/custom-webpack/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/custom-webpack/package.json b/packages/custom-webpack/package.json index f8a026c96..a67c59753 100644 --- a/packages/custom-webpack/package.json +++ b/packages/custom-webpack/package.json @@ -48,7 +48,9 @@ "@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" },