diff --git a/packages/migrate/README.md b/packages/migrate/README.md index 9518b838cd1..d13a742b3ff 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -35,17 +35,20 @@ tree, so git is your undo. Then review the diff and the checklist it prints. ## What it does -Every breaking change is one of two kinds: +Every breaking change is one of three kinds: - Auto-fix: a deterministic edit that preserves behavior, applied for you. - Report-only: a change that needs judgement (semantic rework, a dialect choice). The tool finds it and explains it, but won't rewrite it. +- Experimental: an edit that is correct but whose consequence is a judgement + call, so it only runs with `--experimental`. A report-only migration covers + the same change by default. ### Coverage Each major upgrade has its own page, listing every change the tool covers, -whether it is auto-fixed or report-only, and the changes left for you to make by -hand: +whether it is auto-fixed, report-only, or experimental, and the changes left for +you to make by hand: - [v8 to v9](./docs/v9.md) diff --git a/packages/migrate/docs/v9.md b/packages/migrate/docs/v9.md index 50613f15f9c..af4a25f6134 100644 --- a/packages/migrate/docs/v9.md +++ b/packages/migrate/docs/v9.md @@ -13,6 +13,8 @@ Find your framework below, then read the `@ionic/core`, so they apply on top of the framework-specific ones, and they are the whole list for a vanilla app. +`experimental` migrations only run with `--experimental`. + ### Angular | Change | Mode | @@ -28,6 +30,8 @@ the whole list for a vanilla app. | Angular below the 18 floor | report | | Angular 22's `OnPush` default and its Node floor | report | | `@ionic/angular-toolkit` version bump | report | +| `browserslist` entries below Angular 20+'s own browser policy | report | +| `browserslist` entries raised to Angular's policy floors | experimental | ### React @@ -51,11 +55,12 @@ the whole list for a vanilla app. | `@ionic/core` package bump | auto | | `autocorrect="off"` on `ion-input`/`ion-searchbar` | auto | | `browserslist` entries raised to the v9 browser floors | auto | +| Browsers from the v9 list a `browserslist` doesn't name | report | | Legacy picker (`ion-picker-legacy`, `pickerController`, removed types) | report | | `ion-img` deprecation | report | | `ion-nav` router removal (`setRouteId`/`getRouteId`/`updateURL`) | report | | `@ionic/core` imports outside the new `exports` allowlist | report | -| Capacitor 2 no longer detected as a native platform | report | +| Capacitor below version 7 (Ionic 9's minimum) and the Capacitor 2 native-detection change | report | | `ion-input`/`ion-textarea`/`ion-select` internal DOM and shadow part changes | report | | `label-placement="floating"` with slotted start/end content | report | | `ion-textarea` md min-height `56px` -> `72px` | report | @@ -80,13 +85,22 @@ The tool can't point at the code these changes affect, so check them against the ## Notes on individual migrations - Angular zoneless is auto-fixed only for the standalone `bootstrapApplication` - shape. NgModule apps are flagged for manual migration instead. + shape. NgModule apps are flagged for manual migration instead. Neither fires + unless the app loads Zone.js, since there is nothing to preserve otherwise and + the provider fails to bootstrap without it. - The component DOM/shadow-part changes are report-only. The right replacement depends on what the CSS rule was doing, and `ion-select`'s `part="inner"` has none at all. -- Only a `.browserslistrc` or `browserslist` file is read, so an app that keeps - the list in `package.json` (the CRA and Vite starters do) needs its browser - floors raised by hand. +- A `browserslist` list is read from `.browserslistrc`, a `browserslist` file, or + the `package.json` field. A query-style list (`last 2 versions`, `> 0.5%`) + names no browser, so nothing in it is raised or reported. +- Angular's own browser policy, which the CLI enforces from Angular 20 on, is + read from the installed `@angular/build` and resolved with the project's + `browserslist`. Without `node_modules` the report names the policy but not the + versions. The two things it reads are Angular internals (a `.browserslistrc` in + the package on 20, a `BASELINE_DATE` constant on 21+), so when a new Angular + major lands, check the reported floors against the `ng build` warning before + trusting them. - Angular's `moduleResolution` fix skips a tsconfig whose `module` is CommonJS. TypeScript rejects `bundler` resolution there, and a Node-side config doesn't resolve `@ionic/angular` subpaths anyway. diff --git a/packages/migrate/src/ast/browserslist.ts b/packages/migrate/src/ast/browserslist.ts new file mode 100644 index 00000000000..0c14389a182 --- /dev/null +++ b/packages/migrate/src/ast/browserslist.ts @@ -0,0 +1,188 @@ +import { compareVersions } from '../detect.js'; +import { writePackageJson } from './package-json.js'; +import type { PackageJson } from './package-json.js'; +import type { MigrationContext } from '../context.js'; + +/** + * Reading and rewriting the browserslist a project declares, shared by the four + * browserslist migrations: `core-browserslist` and `angular-browser-policy` raise + * floors, their `-manual` companions report against them. Entries are edited in + * place. + */ +/** + * A `Name >=Version` entry, the shape the Ionic starters generate. The version + * is captured whole so raising `Safari >=15.4` writes `>=16`, not `>=16.4`. The + * optional `\r` keeps a CRLF checkout from matching nothing. + */ +const ENTRY = /^(\s*)([A-Za-z_]+)(\s*>=\s*)(\d+(?:\.\d+)*)(.*?)\r?$/; + +const BROWSERSLIST_GLOBS = ['**/.browserslistrc', '**/browserslist']; + +/** + * The browser an entry names, lowercased, or `undefined` for a query + * (`last 2 versions`) rather than a named entry. + */ +export function entryBrowser(entry: string): string | undefined { + return ENTRY.exec(entry)?.[2].toLowerCase(); +} + +/** A browserslist entry rewritten to meet a floor. */ +export interface RaisedEntry { + /** Browser name as the project wrote it. */ + name: string; + from: string; + to: string; + /** The whole line, rewritten. */ + line: string; +} + +/** + * The raised version of a browserslist line against a set of floors, or + * `undefined` when the line is not a named entry or already meets its floor. + * Shared by detect/fix so the report and the edit can never disagree. + * + * Floors compare as dotted versions, so an integer floor of `16` is met by + * `Safari >=16.3`, and a floor of `16.4` is not. + */ +export function raiseEntry(line: string, floors: Record): RaisedEntry | undefined { + const m = ENTRY.exec(line); + if (!m) return undefined; + const [, indent, name, op, version, rest] = m; + const floor = floors[name.toLowerCase()]; + if (floor === undefined) return undefined; + const to = String(floor); + if (compareVersions(version, to) >= 0) return undefined; + const crlf = line.endsWith('\r') ? '\r' : ''; + return { name, from: version, to, line: `${indent}${name}${op}${to}${rest}${crlf}` }; +} + +/** + * The `browserslist` field's entries, flattened. The field is a query string, an + * array of them, or an object keyed by environment (`production`, `development`) + * whose values are either - so all three shapes are walked the same way. + */ +function fieldEntries(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(fieldEntries); + if (value && typeof value === 'object') return Object.values(value).flatMap(fieldEntries); + return []; +} + +/** Every entry of a `browserslist` field value, rewritten through `map`. */ +function mapField(value: unknown, map: (entry: string) => string): unknown { + if (typeof value === 'string') return map(value); + if (Array.isArray(value)) return value.map((v) => mapField(v, map)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([env, v]) => [env, mapField(v, map)])); + } + return value; +} + +/** 1-based line of the character at `index`. A missing index (`-1`) reads as line 1. */ +function lineOf(text: string, index: number): number { + return index === -1 ? 1 : text.slice(0, index).split('\n').length; +} + +/** + * Where the `browserslist` field itself starts. Matched with its opening bracket + * so a `"browserslist"` devDependency, which holds a string, isn't taken for it. + */ +function fieldLine(pkgText: string): number { + return lineOf(pkgText, pkgText.search(/"browserslist"\s*:\s*[[{]/)); +} + +/** A manifest that declares a `browserslist` field, with its raw text. */ +interface PackageBrowserslist { + filePath: string; + pkg: PackageJson; + text: string; + field: unknown; +} + +/** + * Every `package.json` declaring a `browserslist`, globbed like the list files + * are: a workspace keeps one manifest per app, and only some of them set it. + */ +function packageBrowserslists(ctx: MigrationContext): PackageBrowserslist[] { + const found: PackageBrowserslist[] = []; + for (const filePath of ctx.glob(['**/package.json'])) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + let pkg: PackageJson; + try { + pkg = JSON.parse(text) as PackageJson; + } catch { + continue; + } + if (pkg.browserslist !== undefined) found.push({ filePath, pkg, text, field: pkg.browserslist }); + } + return found; +} + +/** One place a project declares its browserslist, flattened for reporting. */ +export interface BrowserslistSource { + /** Path to report findings against. */ + filePath: string; + /** 1-based line the list itself starts at. */ + line: number; + /** Every entry in the list, with the line it sits on. */ + entries: { text: string; line: number }[]; +} + +/** + * Every browserslist the project declares: the `.browserslistrc`/`browserslist` + * files, and the `package.json` field the Angular starters generate. + */ +export function browserslistSources(ctx: MigrationContext): BrowserslistSource[] { + const sources: BrowserslistSource[] = []; + + for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + sources.push({ + filePath, + line: 1, + entries: text.split('\n').map((line, i) => ({ text: line, line: i + 1 })), + }); + } + + for (const pkg of packageBrowserslists(ctx)) { + // Entries are located in order from a moving cursor, so the same query under + // two environment keys reports two lines rather than the first one twice. + let cursor = 0; + sources.push({ + filePath: pkg.filePath, + line: fieldLine(pkg.text), + entries: fieldEntries(pkg.field).map((entry) => { + const index = pkg.text.indexOf(JSON.stringify(entry), cursor); + if (index !== -1) cursor = index + 1; + return { text: entry, line: lineOf(pkg.text, index) }; + }), + }); + } + + return sources; +} + +/** + * Rewrite every entry of every browserslist the project declares through `map`, + * writing back only the sources that changed. + * + * The manifest path reserializes the whole file (2-space, trailing newline), the + * same way the version bump already does. + */ +export function rewriteBrowserslists(ctx: MigrationContext, map: (entry: string) => string): void { + for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { + const text = ctx.readFile(filePath); + if (text === undefined) continue; + const next = text.split('\n').map(map).join('\n'); + if (next !== text) ctx.writeFile(filePath, next); + } + + for (const pkg of packageBrowserslists(ctx)) { + const nextField = mapField(pkg.field, map); + if (JSON.stringify(nextField) !== JSON.stringify(pkg.field)) { + writePackageJson(ctx, { ...pkg.pkg, browserslist: nextField }, pkg.filePath); + } + } +} diff --git a/packages/migrate/src/ast/package-json.ts b/packages/migrate/src/ast/package-json.ts index 75da7bb1054..aacfba89e2e 100644 --- a/packages/migrate/src/ast/package-json.ts +++ b/packages/migrate/src/ast/package-json.ts @@ -17,9 +17,12 @@ export function readPackageJson(ctx: MigrationContext): { pkg: PackageJson } | u } } -/** Serialize and write `package.json`, keeping 2-space indent and a trailing newline. */ -export function writePackageJson(ctx: MigrationContext, pkg: PackageJson): void { - ctx.writeFile('package.json', `${JSON.stringify(pkg, null, 2)}\n`); +/** + * Serialize and write a `package.json`, keeping 2-space indent and a trailing + * newline. Defaults to the project's own; `filePath` targets a workspace manifest. + */ +export function writePackageJson(ctx: MigrationContext, pkg: PackageJson, filePath = 'package.json'): void { + ctx.writeFile(filePath, `${JSON.stringify(pkg, null, 2)}\n`); } /** Which dependency block a package lives in. */ diff --git a/packages/migrate/src/context.ts b/packages/migrate/src/context.ts index 418eae34803..41e1555716b 100644 --- a/packages/migrate/src/context.ts +++ b/packages/migrate/src/context.ts @@ -1,3 +1,5 @@ +import { createRequire } from 'node:module'; + import { Project, QuoteKind } from 'ts-morph'; /** ts-morph should emit single-quoted strings to match Ionic/Angular style. */ @@ -48,18 +50,34 @@ function EXCLUDE_GLOBS(root: string): string[] { * after the ts-morph save, so writing a `.ts`/`.tsx` file ts-morph also holds * would override its edits. Today no migration edits a loaded file both ways, so * a given file is only ever touched through one view. + * + * {@link requireFromProject}/{@link resolveFromProject} are the exception: they + * reach the real module graph rather than that filesystem, so a migration can + * read a tool's own config from the version the project builds with. Tests hand + * over stubs instead. */ export interface MigrationContext { /** Project root; all relative paths resolve against it. */ readonly rootDir: string; /** ts-morph project holding the loaded `.ts`/`.tsx` source files. */ readonly project: Project; - /** Read a file's text, or `undefined` if it does not exist. */ - readFile(relPath: string): string | undefined; + /** Read a file's text by project-relative or absolute path, or `undefined`. */ + readFile(path: string): string | undefined; /** Buffer a file's text (creating it if needed); persisted by {@link save}. */ writeFile(relPath: string, content: string): void; /** Return paths (relative to {@link rootDir}) matching the given glob patterns. */ glob(patterns: string[]): string[]; + /** + * Load a package from the project's own `node_modules`, or `undefined` when it + * is not installed. A fresh clone has none, so callers need a report-only path. + */ + requireFromProject(specifier: string): T | undefined; + /** + * Absolute path a specifier resolves to in the project, or `undefined` when it + * is not installed. Locates a package whose layout differs per package manager + * (pnpm links, hoisting) instead of guessing at `node_modules` paths. + */ + resolveFromProject(specifier: string): string | undefined; /** Convert an absolute path to one relative to {@link rootDir}. */ relative(absPath: string): string; /** @@ -71,7 +89,13 @@ export interface MigrationContext { save(): void; } -function buildContext(rootDir: string, project: Project): MigrationContext { +/** How a context reaches the packages the project has installed. */ +interface ProjectModules { + require(specifier: string): T | undefined; + resolve(specifier: string): string | undefined; +} + +function buildContext(rootDir: string, project: Project, modules: ProjectModules): MigrationContext { const fs = project.getFileSystem(); const touched = new Set(); // Text writes are buffered here and flushed only by `save()`. A run that @@ -88,9 +112,13 @@ function buildContext(rootDir: string, project: Project): MigrationContext { rootDir, project, touchedFiles: touched, - readFile(relPath) { - if (pendingWrites.has(relPath)) return pendingWrites.get(relPath); - const abs = join(rootDir, relPath); + readFile(path) { + if (pendingWrites.has(path)) return pendingWrites.get(path); + // Absolute paths pass through, so a caller can read a file located by + // `resolveFromProject`, which can land outside `rootDir` entirely (a + // hoisted workspace, a pnpm store). Windows keeps its drive letter through + // that normalization, so it counts as absolute too. + const abs = /^(\/|[A-Za-z]:\/)/.test(path) ? path : join(rootDir, path); return fs.fileExistsSync(abs) ? fs.readFileSync(abs) : undefined; }, writeFile(relPath, content) { @@ -109,6 +137,8 @@ function buildContext(rootDir: string, project: Project): MigrationContext { .filter((rel) => !EXCLUDE_RE.test(rel)); }, relative: toRelative, + requireFromProject: modules.require, + resolveFromProject: modules.resolve, save() { // Record files ts-morph is about to write so the formatter can find them. for (const file of project.getSourceFiles()) { @@ -130,7 +160,11 @@ function buildContext(rootDir: string, project: Project): MigrationContext { * entries are loaded as ts-morph source files. Everything else is written as a * plain file. Used by tests. */ -export function createInMemoryContext(files: Record, rootDir = '/app'): MigrationContext { +export function createInMemoryContext( + files: Record, + rootDir = '/app', + modules: Record = {} +): MigrationContext { const project = new Project({ useInMemoryFileSystem: true, manipulationSettings: MANIPULATION_SETTINGS, @@ -146,7 +180,16 @@ export function createInMemoryContext(files: Record, rootDir = ' join(rootDir, '**/*.tsx'), ...EXCLUDE_GLOBS(rootDir), ]); - return buildContext(rootDir, project); + // Nothing is installed in memory, so only what a test hands over is loadable. + return buildContext(rootDir, project, { + require: (specifier: string) => modules[specifier] as T | undefined, + // Resolved against the in-memory `node_modules` so a test can lay out a + // package the way the real one is installed. + resolve: (specifier: string) => { + const path = join(rootDir, `node_modules/${specifier}`); + return fs.fileExistsSync(path) ? path : undefined; + }, + }); } /** @@ -169,5 +212,20 @@ export function createDiskContext(rootDir: string): MigrationContext { join(rootDir, '**/*.tsx'), ...EXCLUDE_GLOBS(rootDir), ]); - return buildContext(rootDir, project); + // Resolved from the project root, not this package, so a migration reads the + // project's installed copy. + const projectRequire = createRequire(join(rootDir, 'package.json')); + const attempt = (load: () => T): T | undefined => { + try { + return load(); + } catch { + return undefined; + } + }; + return buildContext(rootDir, project, { + require: (specifier: string) => attempt(() => projectRequire(specifier) as T), + // Normalized to posix separators: callers slice these paths and hand them to + // `readFile`, which is posix-relative. + resolve: (specifier: string) => attempt(() => projectRequire.resolve(specifier).replace(/\\/g, '/')), + }); } diff --git a/packages/migrate/src/detect.ts b/packages/migrate/src/detect.ts index ff84b72d2c1..aa7b0fac390 100644 --- a/packages/migrate/src/detect.ts +++ b/packages/migrate/src/detect.ts @@ -27,6 +27,17 @@ export function parseMajor(range: string | undefined): number | undefined { return match ? Number(match[1]) : undefined; } +/** Compare dotted numeric versions, so `16.10` sorts above `16.4`. */ +export function compareVersions(a: string, b: string): number { + const left = a.split('.').map(Number); + const right = b.split('.').map(Number); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const diff = (left[i] ?? 0) - (right[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + /** * Whether a range is a plain, comparable semver range rather than a * protocol/alias reference (`workspace:`, `catalog:`, `npm:`, `file:`, `link:`, diff --git a/packages/migrate/src/main.ts b/packages/migrate/src/main.ts index 48557f65556..c0012b87824 100644 --- a/packages/migrate/src/main.ts +++ b/packages/migrate/src/main.ts @@ -201,6 +201,11 @@ export function main(argv: string[]): number { const formatted = formatTouched(ctx, prettierFormatter); if (formatted.length > 0) { console.log(dim(`\nFormatted ${formatted.length} changed file(s) with Prettier.`)); + } else if (ctx.touchedFiles.size > 0) { + // Silence here reads as "formatted", so say the project has no Prettier. + console.log( + dim(`\nLeft ${ctx.touchedFiles.size} changed file(s) as written: no Prettier installed in this project.`) + ); } } catch (e) { // Formatting is cosmetic and runs after the edits are already on disk, so diff --git a/packages/migrate/src/migrations/index.ts b/packages/migrate/src/migrations/index.ts index 5cd3f0ce7a3..65ae39e5b78 100644 --- a/packages/migrate/src/migrations/index.ts +++ b/packages/migrate/src/migrations/index.ts @@ -9,12 +9,15 @@ import { angularIonicModule } from './v9/angular-ionic-module.js'; import { angularModuleResolution } from './v9/angular-module-resolution.js'; import { angularTypescript } from './v9/angular-typescript.js'; import { angularVersion } from './v9/angular-version.js'; +import { angularBrowserPolicy } from './v9/angular-browser-policy.js'; +import { angularBrowserPolicyManual } from './v9/angular-browser-policy-manual.js'; import { reactDeps } from './v9/react-deps.js'; import { reactRouter6Routes } from './v9/react-router-6-routes.js'; import { reactRouter6Code } from './v9/react-router-6-code.js'; import { vueDeps } from './v9/vue-deps.js'; import { vueRouterNextGuard } from './v9/vue-router-next-guard.js'; import { coreBrowserslist } from './v9/core-browserslist.js'; +import { coreBrowserslistManual } from './v9/core-browserslist-manual.js'; import { coreCapacitor } from './v9/core-capacitor.js'; import { coreDeps } from './v9/core-deps.js'; import { coreFloatingLabel } from './v9/core-floating-label.js'; @@ -45,12 +48,15 @@ export const allMigrations: Migration[] = [ angularModuleResolution, angularTypescript, angularVersion, + angularBrowserPolicy, + angularBrowserPolicyManual, reactDeps, reactRouter6Routes, reactRouter6Code, vueDeps, vueRouterNextGuard, coreBrowserslist, + coreBrowserslistManual, coreCapacitor, coreDeps, coreFloatingLabel, diff --git a/packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts b/packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts new file mode 100644 index 00000000000..52cd78faac0 --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-browser-policy-manual.ts @@ -0,0 +1,66 @@ +import type { Finding, Migration } from '../../types.js'; +import { angularMajor, angularPolicyFloors, BROWSER_POLICY_ANGULAR } from './angular-browser-policy.js'; +import { browserslistSources, raiseEntry } from '../../ast/browserslist.js'; + +/** + * Report-only companion to the experimental `angular-browser-policy` auto-fix. + * + * The v9 guide prints one browserslist for every framework, but from Angular 20 + * on the CLI enforces its own, higher floors on top of it: `ng build` warns for + * every browser version below them and Angular supports none of those browsers. + * So the guide's block cannot be taken at face value in an Angular app, and this + * says which entries fall short. + * + * Report-only by default. `--experimental` applies it, and `angular-browser-policy` + * says why. + * + * Refer to https://ionicframework.com/docs/updating/9-0#browser-support + */ +export const angularBrowserPolicyManual: Migration = { + id: 'angular-browser-policy-manual', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const major = angularMajor(ctx); + if (major === undefined || major < BROWSER_POLICY_ANGULAR) return []; + + const sources = browserslistSources(ctx); + // No list of its own means Angular's defaults already apply, and there is + // nothing to align. + if (sources.length === 0) return []; + + const floors = angularPolicyFloors(ctx); + if (floors === undefined) { + // Dependencies are not installed (or Angular changed where it keeps the + // policy), so the exact floors are out of reach. + return [ + { + filePath: sources[0].filePath, + line: sources[0].line, + detail: + `Angular ${major} applies its own browser support policy on top of your browserslist. ` + + `It is stricter than Ionic 9's floors, and \`ng build\` warns for every browser below it`, + }, + ]; + } + + const findings: Finding[] = []; + for (const source of sources) { + for (const entry of source.entries) { + const raised = raiseEntry(entry.text, floors); + if (raised) { + findings.push({ + filePath: source.filePath, + line: entry.line, + detail: `${raised.name} >=${raised.from} is below Angular ${major}'s browser policy. Raise it to >=${raised.to}`, + }); + } + } + } + return findings; + }, +}; diff --git a/packages/migrate/src/migrations/v9/angular-browser-policy.ts b/packages/migrate/src/migrations/v9/angular-browser-policy.ts new file mode 100644 index 00000000000..cc057bce3aa --- /dev/null +++ b/packages/migrate/src/migrations/v9/angular-browser-policy.ts @@ -0,0 +1,188 @@ +import { compareVersions, isPlainSemverRange, parseMajor } from '../../detect.js'; +import { findDependency, readPackageJson } from '../../ast/package-json.js'; +import type { MigrationContext } from '../../context.js'; +import type { Finding, Migration } from '../../types.js'; +import { browserslistSources, raiseEntry, rewriteBrowserslists } from '../../ast/browserslist.js'; + +/** + * Angular's own browser support policy, resolved the way the Angular CLI + * resolves it. Shared by `angular-browser-policy-manual` and the auto-fix below. + * + * From Angular 20 on, `@angular/build` warns for every browser below its policy, + * so an app on the v9 guide's floors (Chrome 89, Safari 16) builds with warnings. + * The policy is read from the installed `@angular/build` and evaluated with the + * project's own `browserslist` rather than copied into a table here: on 21+ it is + * a rolling `baseline widely available on ` query whose resolved versions + * depend on the project's `caniuse-lite`, so a table would drift. + * + * Refer to https://ionicframework.com/docs/updating/9-0#browser-support + */ +/** The first Angular whose CLI enforces a browser support policy of its own. */ +export const BROWSER_POLICY_ANGULAR = 20; + +/** + * Packages to resolve `@angular/build` through. An app on the devkit builder has + * no direct dependency on it, so it resolves from that package instead. + */ +const BUILD_PACKAGE_OWNERS = ['@angular/build', '@angular-devkit/build-angular']; + +/** Angular 21+ resolves its policy from this constant instead of a static file. */ +const BASELINE_DATE = /BASELINE_DATE\s*=\s*['"](\d{4}-\d{2}-\d{2})['"]/; + +/** + * caniuse browser ids mapped to the browserslist config names projects write. + * + * `and_chr`/`and_ff` are left out on purpose. caniuse carries one version for the + * Android browsers, the current release, so any query resolves to that single + * entry - both Angular's policy and the project's own list. The CLI's comparison + * can never fail on them, and taking that version as a floor would report a + * warning the build does not emit and move with every `caniuse-lite` update. + */ +const CANIUSE_NAMES: Record = { + chrome: 'Chrome', + edge: 'Edge', + firefox: 'Firefox', + safari: 'Safari', + ios_saf: 'iOS', +}; + +/** The Angular major the project is on, when it is a comparable version. */ +export function angularMajor(ctx: MigrationContext): number | undefined { + const parsed = readPackageJson(ctx); + if (!parsed) return undefined; + const dep = findDependency(parsed.pkg, '@angular/core'); + if (!dep || !isPlainSemverRange(dep.range)) return undefined; + return parseMajor(dep.range); +} + +/** + * Absolute directories `@angular/build` is installed in. Resolved rather than + * guessed at: pnpm links it outside the importer's own `node_modules`, and a + * workspace can hoist it above the project. + */ +function buildPackageDirs(ctx: MigrationContext): string[] { + const dirs: string[] = []; + for (const owner of BUILD_PACKAGE_OWNERS) { + // Resolved through `package.json`, the one subpath a package's `exports` map + // can never block. + const manifest = ctx.resolveFromProject(`${owner}/package.json`); + if (manifest === undefined) continue; + // Kept absolute: the package can sit outside the project (hoisted, pnpm). + const dir = manifest.slice(0, manifest.lastIndexOf('/')); + // The devkit builder owns `@angular/build` as a dependency, so resolve on + // through it rather than reading the builder's own package. + dirs.push(owner === '@angular/build' ? dir : `${dir}/node_modules/@angular/build`); + } + return dirs; +} + +/** + * The browserslist query describing Angular's policy: the static file it ships + * on 20, or the baseline query it builds from `BASELINE_DATE` on 21+. + */ +function policyQuery(ctx: MigrationContext): string | string[] | undefined { + for (const dir of buildPackageDirs(ctx)) { + const staticList = ctx.readFile(`${dir}/.browserslistrc`); + if (staticList !== undefined) { + const queries = staticList + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); + if (queries.length > 0) return queries; + } + + const date = BASELINE_DATE.exec(ctx.readFile(`${dir}/src/utils/supported-browsers.js`) ?? '')?.[1]; + if (date !== undefined) return `baseline widely available on ${date}`; + } + return undefined; +} + +/** + * The lowest version Angular supports for each browser a project can name, or + * `undefined` when the policy cannot be resolved - no `@angular/build` + * installed, no `browserslist` to evaluate it with, or a query neither + * understands. Callers must treat that as "report only", never as "no floors". + */ +export function angularPolicyFloors(ctx: MigrationContext): Record | undefined { + const query = policyQuery(ctx); + if (query === undefined) return undefined; + + const browserslist = ctx.requireFromProject<(q: string | string[]) => string[]>('browserslist'); + if (typeof browserslist !== 'function') return undefined; + + let resolved: string[]; + try { + resolved = browserslist(query); + } catch { + return undefined; + } + + const floors: Record = {}; + for (const entry of resolved) { + const [id, versions] = entry.split(' '); + const name = CANIUSE_NAMES[id]; + if (name === undefined || versions === undefined) continue; + // A resolved entry can cover a range (`ios_saf 16.6-16.7`). Its lower bound + // is the version actually supported from. + const version = versions.split('-')[0]; + // A non-numeric version (`safari TP`) would compare as NaN and then be + // written into the project's list verbatim. + if (!/^\d+(\.\d+)*$/.test(version)) continue; + const current = floors[name.toLowerCase()]; + if (current === undefined || compareVersions(version, current) < 0) { + floors[name.toLowerCase()] = version; + } + } + return Object.keys(floors).length > 0 ? floors : undefined; +} + +function applicableFloors(ctx: MigrationContext): Record | undefined { + const major = angularMajor(ctx); + if (major === undefined || major < BROWSER_POLICY_ANGULAR) return undefined; + return angularPolicyFloors(ctx); +} + +/** + * Raises every entry below Angular's policy. Experimental because it narrows the + * app's support matrix and raises the build's syntax target with it, so output + * can stop working on a browser that previously only warned. + * + * Runs before `angular-browser-policy-manual`, which reports the same entries: + * migrations are selected in id order, so the report re-reads the rewritten list + * and stays quiet. Renaming either id breaks that. + */ +export const angularBrowserPolicy: Migration = { + id: 'angular-browser-policy', + framework: 'angular', + fromMajor: 8, + toMajor: 9, + status: 'experimental', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const floors = applicableFloors(ctx); + if (floors === undefined) return []; + + const major = angularMajor(ctx); + const findings: Finding[] = []; + for (const source of browserslistSources(ctx)) { + for (const entry of source.entries) { + const raised = raiseEntry(entry.text, floors); + if (raised) { + findings.push({ + filePath: source.filePath, + line: entry.line, + detail: `${raised.name} >=${raised.from} -> >=${raised.to} (Angular ${major} browser policy)`, + }); + } + } + } + return findings; + }, + + fix(ctx) { + const floors = applicableFloors(ctx); + if (floors === undefined) return; + rewriteBrowserslists(ctx, (entry) => raiseEntry(entry, floors)?.line ?? entry); + }, +}; diff --git a/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts b/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts index b81db5e8dd6..2a7c5da2857 100644 --- a/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts +++ b/packages/migrate/src/migrations/v9/angular-zoneless-manual.ts @@ -1,13 +1,16 @@ import { SyntaxKind } from 'ts-morph'; import type { Finding, Migration } from '../../types.js'; -import { ZONE_PROVIDER } from './angular-zoneless.js'; +import { loadsZoneJs, ZONE_PROVIDER } from './angular-zoneless.js'; /** * Report-only companion to `angular-zoneless`. NgModule apps bootstrap via * `bootstrapModule`, which the auto-fix can't edit, so flag them for a manual * zone-provider migration rather than dropping the warning they previously got. * + * Gated on Zone.js actually being loaded, for the same reason the auto-fix is: + * an app already running zoneless has no Zone.js behavior to preserve. + * * Refer to https://ionicframework.com/docs/updating/9-0#zoneless-change-detection */ const DETAIL = @@ -27,7 +30,7 @@ export const angularZonelessManual: Migration = { // Already configured (zone or zoneless)? Nothing to warn about. if (ZONE_PROVIDER.test(file.getFullText())) continue; for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) { - if (call.getExpression().getText().endsWith('bootstrapModule')) { + if (call.getExpression().getText().endsWith('bootstrapModule') && loadsZoneJs(ctx, file.getFilePath())) { findings.push({ filePath: ctx.relative(file.getFilePath()), line: call.getStartLineNumber(), diff --git a/packages/migrate/src/migrations/v9/angular-zoneless.ts b/packages/migrate/src/migrations/v9/angular-zoneless.ts index 5907f3b2d7d..f9e8263140a 100644 --- a/packages/migrate/src/migrations/v9/angular-zoneless.ts +++ b/packages/migrate/src/migrations/v9/angular-zoneless.ts @@ -21,6 +21,140 @@ export const ZONE_PROVIDER = /provide(Experimental)?Zone(less)?ChangeDetection/; const CORE_MODULE = '@angular/core'; const PROVIDER = 'provideZoneChangeDetection'; +/** + * A module specifier that loads Zone.js into the app: `zone.js`, + * `zone.js/dist/zone`. `zone.js/testing` is excluded - a karma setup that patches + * Zone.js for tests says nothing about how the app itself bootstraps. + */ +const ZONE_MODULE = /^zone\.js(?!\/testing)(\/|$)/; + +/** Workspace configs that declare the `polyfills` a build loads. */ +const WORKSPACE_CONFIGS = ['**/angular.json', '**/project.json', '**/workspace.json']; + +/** + * Every `polyfills` entry a parsed config declares. The `test` target is skipped: + * its polyfills belong to karma, not to the app's bootstrap, and the CLI still + * scaffolds Zone.js there for an app that runs zoneless. + */ +function polyfillsEntries(node: unknown): string[] { + if (Array.isArray(node)) return node.flatMap(polyfillsEntries); + if (node === null || typeof node !== 'object') return []; + const found: string[] = []; + for (const [key, value] of Object.entries(node as Record)) { + if (key === 'test') continue; + if (key === 'polyfills') { + for (const entry of Array.isArray(value) ? value : [value]) { + if (typeof entry === 'string') found.push(entry); + } + } + found.push(...polyfillsEntries(value)); + } + return found; +} + +/** A project declared by a workspace config, with the polyfills it loads. */ +interface DeclaredProject { + /** Directory the project lives in, relative to the workspace root. */ + root: string; + /** Entries its build loads, either module specifiers or workspace paths. */ + polyfills: string[]; +} + +/** + * A project's root as a plain relative prefix. A config can spell "here" as `''`, + * `'.'`, or `'./'`, and any of the last two would fail every `underRoot` test. + */ +function normalizeRoot(dir: string, declared: string): string { + return [dir, declared.replace(/^\.\/?/, '').replace(/\/+$/, '')].filter(Boolean).join('/'); +} + +/** Whether a project-relative path sits under `root` (`''` is the whole tree). */ +function underRoot(path: string, root: string): boolean { + return root === '' || path === root || path.startsWith(`${root}/`); +} + +/** + * Every project any workspace config declares. A monorepo's `angular.json` lists + * one entry per app, each with its own `root` and `polyfills`, so they have to be + * read separately - one app keeping Zone.js says nothing about its neighbor. + */ +function declaredProjects(ctx: MigrationContext): DeclaredProject[] { + const projects: DeclaredProject[] = []; + for (const configPath of ctx.glob(WORKSPACE_CONFIGS)) { + const text = ctx.readFile(configPath); + if (text === undefined) continue; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + // A config we cannot parse tells us nothing; the import scan still applies. + continue; + } + const dir = configPath.includes('/') ? configPath.slice(0, configPath.lastIndexOf('/')) : ''; + const entries = (parsed as { projects?: unknown }).projects; + if (entries !== null && typeof entries === 'object') { + for (const project of Object.values(entries as Record)) { + const declared = (project as { root?: unknown }).root; + const root = typeof declared === 'string' ? declared : ''; + projects.push({ root: normalizeRoot(dir, root), polyfills: polyfillsEntries(project) }); + } + } else { + // Nx's `project.json` holds a single project, rooted where it sits. + projects.push({ root: normalizeRoot(dir, ''), polyfills: polyfillsEntries(parsed) }); + } + } + return projects; +} + +/** Whether any of these source files imports Zone.js. */ +function importsZone(ctx: MigrationContext, matches: (path: string) => boolean): boolean { + return ctx.project + .getSourceFiles() + .some( + (file) => + matches(ctx.relative(file.getFilePath())) && + file.getImportDeclarations().some((d) => ZONE_MODULE.test(d.getModuleSpecifierValue())) + ); +} + +/** + * Whether the project owning `filePath` loads Zone.js. + * `provideZoneChangeDetection()` throws at bootstrap without it, so an app + * already running zoneless (Angular 21+ scaffolds omit Zone.js entirely) must be + * left alone - there is no Zone.js behavior to preserve. + * + * A `zone.js` dependency in `package.json` is not enough on its own, so this + * looks at what the build actually loads: the project's `polyfills` entries, + * either naming the module directly or pointing at a file that imports it. A + * project with no declared polyfills falls back to scanning its own directory, + * which is all a config-less project (or an Nx target shape we don't read) leaves + * to go on. + */ +export function loadsZoneJs(ctx: MigrationContext, filePath: string): boolean { + const projects = declaredProjects(ctx); + // The innermost project containing a path. Several configs can declare the same + // root, so they merge rather than one shadowing the other by glob order. + const ownerOf = (path: string): { root: string; polyfills: string[] } | undefined => { + const matches = projects.filter((project) => underRoot(path, project.root)); + if (matches.length === 0) return undefined; + const root = matches.reduce((deepest, p) => (p.root.length > deepest.length ? p.root : deepest), ''); + return { root, polyfills: matches.filter((p) => p.root === root).flatMap((p) => p.polyfills) }; + }; + + const owner = ownerOf(ctx.relative(filePath)); + + if (owner !== undefined && owner.polyfills.length > 0) { + if (owner.polyfills.some((entry) => ZONE_MODULE.test(entry))) return true; + // Declared polyfills are workspace-relative paths, so only those files count. + const declared = new Set(owner.polyfills.map((entry) => entry.replace(/^\.\//, ''))); + return importsZone(ctx, (sourcePath) => declared.has(sourcePath)); + } + + // Nothing declared, so fall back to the project's own files, excluding any that + // belong to a project nested inside it. + return importsZone(ctx, (sourcePath) => ownerOf(sourcePath)?.root === owner?.root); +} + /** * The `providers` array of every standalone bootstrap that lacks any * zone/zoneless provider. Returns all matches so monorepo/multi-project @@ -39,12 +173,48 @@ function targetProvidersArrays(ctx: MigrationContext): ArrayLiteralExpression[] if (!arr) continue; // Respect an app that already configured (zone or zoneless) change detection. if (arr.getElements().some((el) => ZONE_PROVIDER.test(el.getText()))) continue; + if (!loadsZoneJs(ctx, file.getFilePath())) continue; arrays.push(arr); } } return arrays; } +/** + * Prepend the provider to a `providers` array as text, matching the indentation + * of the element it goes in front of. ts-morph's `insertElement` re-indents from + * its own settings rather than the file's, which lands an 8-space line in a + * 4-space array - and these apps often have no Prettier for the post-run format + * pass to clean up after. + */ +function prependProvider(arr: ArrayLiteralExpression): void { + const file = arr.getSourceFile(); + const first = arr.getElements()[0]; + if (!first) { + arr.insertElement(0, `${PROVIDER}()`); + return; + } + const start = first.getStart(); + const text = file.getFullText(); + const indent = text.slice(text.lastIndexOf('\n', start - 1) + 1, start); + // Reuse the indentation only when that element starts its own line; a + // single-line array (`providers: [a, b]`) stays on one line. + file.insertText(start, /^[ \t]*$/.test(indent) ? `${PROVIDER}(),\n${indent}` : `${PROVIDER}(), `); +} + +function addProviderImport(ctx: MigrationContext, filePath: string): void { + const file = ctx.project.getSourceFileOrThrow(filePath); + // A type-only import is skipped rather than extended: adding the provider to + // `import type { ... }` elides it at compile time, so the call in the providers + // array becomes a ReferenceError. + const coreImport = file.getImportDeclaration((d) => d.getModuleSpecifierValue() === CORE_MODULE && !d.isTypeOnly()); + if (!coreImport) { + file.addImportDeclaration({ moduleSpecifier: CORE_MODULE, namedImports: [PROVIDER] }); + } else if (!coreImport.getNamedImports().some((n) => n.getName() === PROVIDER)) { + coreImport.addNamedImport(PROVIDER); + } +} + export const angularZoneless: Migration = { id: 'angular-zoneless', framework: 'angular', @@ -62,16 +232,16 @@ export const angularZoneless: Migration = { }, fix(ctx) { - for (const arr of targetProvidersArrays(ctx)) { - arr.insertElement(0, `${PROVIDER}()`); - - const file = arr.getSourceFile(); - const coreImport = file.getImportDeclaration((d) => d.getModuleSpecifierValue() === CORE_MODULE); - if (!coreImport) { - file.addImportDeclaration({ moduleSpecifier: CORE_MODULE, namedImports: [PROVIDER] }); - } else if (!coreImport.getNamedImports().some((n) => n.getName() === PROVIDER)) { - coreImport.addNamedImport(PROVIDER); - } + // A text insert forgets every node in that file, so the match list is + // re-queried after each edit rather than iterated. Each fixed array now + // holds a zone provider, so it drops out of the next query and the loop + // converges; the counter is a backstop against an edit that does not. + for (let remaining = targetProvidersArrays(ctx).length; remaining > 0; remaining--) { + const [arr] = targetProvidersArrays(ctx); + if (!arr) break; + const filePath = arr.getSourceFile().getFilePath(); + prependProvider(arr); + addProviderImport(ctx, filePath); } }, }; diff --git a/packages/migrate/src/migrations/v9/core-browserslist-manual.ts b/packages/migrate/src/migrations/v9/core-browserslist-manual.ts new file mode 100644 index 00000000000..9739c31cea8 --- /dev/null +++ b/packages/migrate/src/migrations/v9/core-browserslist-manual.ts @@ -0,0 +1,56 @@ +import type { Migration } from '../../types.js'; +import { browserslistSources, entryBrowser } from '../../ast/browserslist.js'; +import { BROWSERS } from './core-browserslist.js'; + +/** + * Report-only companion to `core-browserslist`. That migration raises the + * versions of entries a project already has; this one names the browsers from + * the guide's block that the project has no entry for at all. + * + * Report-only on purpose: raising a floor keeps the same browsers, but adding + * one widens the support matrix, which changes the build's output. That is the + * developer's call, so the tool prints the entry to add rather than adding it. + * + * Refer to https://ionicframework.com/docs/updating/9-0#browser-support + */ +/** + * Ionic browsers these entries never name, as `Name >=Floor` strings. + * + * Only meaningful for a list already written in the guide's named shape. A + * query-style list (`last 2 versions`, `> 0.5%`) names no browser at all, so + * every one of them would read as missing - that returns nothing instead. + */ +function missingEntries(entries: string[]): string[] { + const ionic = new Set(BROWSERS.map((b) => b.name.toLowerCase())); + const named = new Set(entries.map(entryBrowser).filter((name) => name !== undefined && ionic.has(name))); + if (named.size === 0) return []; + return BROWSERS.filter((b) => !named.has(b.name.toLowerCase())).map((b) => `${b.name} >=${b.floor}`); +} + +export const coreBrowserslistManual: Migration = { + id: 'core-browserslist-manual', + framework: 'core', + fromMajor: 8, + toMajor: 9, + status: 'stable', + docsUrl: 'https://ionicframework.com/docs/updating/9-0#browser-support', + + detect(ctx) { + const sources = browserslistSources(ctx); + // A project can split its list across a `.browserslistrc` and a manifest, so + // a browser is only missing when no source names it. + const missing = missingEntries(sources.flatMap((source) => source.entries.map((entry) => entry.text))); + if (missing.length === 0) return []; + + // Non-empty only because `missingEntries` returns nothing for a list with no + // named browser, which includes the no-sources case. + const [first] = sources; + return [ + { + filePath: first.filePath, + line: first.line, + detail: `Ionic 9 supports browsers this list does not name, at least ${missing.join(', ')}. Add the entries you target`, + }, + ]; + }, +}; diff --git a/packages/migrate/src/migrations/v9/core-browserslist.ts b/packages/migrate/src/migrations/v9/core-browserslist.ts index 0bed30e88f3..6298258ab5c 100644 --- a/packages/migrate/src/migrations/v9/core-browserslist.ts +++ b/packages/migrate/src/migrations/v9/core-browserslist.ts @@ -1,3 +1,5 @@ +import { browserslistSources, raiseEntry, rewriteBrowserslists } from '../../ast/browserslist.js'; +import type { RaisedEntry } from '../../ast/browserslist.js'; import type { Finding, Migration } from '../../types.js'; /** @@ -10,40 +12,22 @@ import type { Finding, Migration } from '../../types.js'; * * Refer to https://ionicframework.com/docs/updating/9-0#browser-support */ -/** Minimum version Ionic 9 supports, by browserslist browser name. */ -const FLOORS: Record = { - chrome: 89, - chromeandroid: 89, - firefox: 75, - edge: 89, - safari: 16, - ios: 16, -}; - -/** - * A `Name >=Version` entry, the shape the Ionic starters generate. The version - * is captured whole so raising `Safari >=15.4` writes `>=16`, not `>=16.4`. The - * optional `\r` keeps a CRLF checkout from matching nothing. - */ -const ENTRY = /^(\s*)([A-Za-z_]+)(\s*>=\s*)(\d+(?:\.\d+)*)(.*?)\r?$/; +/** The browsers Ionic 9 supports and their minimum versions, in guide order. */ +export const BROWSERS: { name: string; floor: number }[] = [ + { name: 'Chrome', floor: 89 }, + { name: 'ChromeAndroid', floor: 89 }, + { name: 'Firefox', floor: 75 }, + { name: 'Edge', floor: 89 }, + { name: 'Safari', floor: 16 }, + { name: 'iOS', floor: 16 }, +]; -const BROWSERSLIST_GLOBS = ['**/.browserslistrc', '**/browserslist']; +/** Minimum version Ionic 9 supports, keyed by lowercased browserslist name. */ +const FLOORS: Record = Object.fromEntries(BROWSERS.map((b) => [b.name.toLowerCase(), b.floor])); -/** - * The raised version of a browserslist line, or `undefined` when the line is not - * an entry this owns or is already at or above the floor. Shared by detect/fix - * so the report and the edit can never disagree. - */ -function raise(line: string): { name: string; from: string; to: number; line: string } | undefined { - const m = ENTRY.exec(line); - if (!m) return undefined; - const [, indent, name, op, version, rest] = m; - const floor = FLOORS[name.toLowerCase()]; - if (floor === undefined) return undefined; - // Compare on the major alone, so `Safari >=16.3` counts as meeting a floor of 16. - if (Number.parseInt(version, 10) >= floor) return undefined; - const crlf = line.endsWith('\r') ? '\r' : ''; - return { name, from: version, to: floor, line: `${indent}${name}${op}${floor}${rest}${crlf}` }; +/** The raised version of a line against Ionic 9's own floors. */ +function raise(line: string): RaisedEntry | undefined { + return raiseEntry(line, FLOORS); } export const coreBrowserslist: Migration = { @@ -56,32 +40,22 @@ export const coreBrowserslist: Migration = { detect(ctx) { const findings: Finding[] = []; - for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { - const text = ctx.readFile(filePath); - if (text === undefined) continue; - text.split('\n').forEach((line, i) => { - const raised = raise(line); + for (const source of browserslistSources(ctx)) { + for (const entry of source.entries) { + const raised = raise(entry.text); if (raised) { findings.push({ - filePath, - line: i + 1, + filePath: source.filePath, + line: entry.line, detail: `${raised.name} >=${raised.from} is below Ionic 9's floor. Raise it to >=${raised.to}`, }); } - }); + } } return findings; }, fix(ctx) { - for (const filePath of ctx.glob(BROWSERSLIST_GLOBS)) { - const text = ctx.readFile(filePath); - if (text === undefined) continue; - const next = text - .split('\n') - .map((line) => raise(line)?.line ?? line) - .join('\n'); - if (next !== text) ctx.writeFile(filePath, next); - } + rewriteBrowserslists(ctx, (entry) => raise(entry)?.line ?? entry); }, }; diff --git a/packages/migrate/src/migrations/v9/core-capacitor.ts b/packages/migrate/src/migrations/v9/core-capacitor.ts index daa8e90d61a..3d2e95afd07 100644 --- a/packages/migrate/src/migrations/v9/core-capacitor.ts +++ b/packages/migrate/src/migrations/v9/core-capacitor.ts @@ -3,13 +3,19 @@ import { findDependency, readPackageJson } from '../../ast/package-json.js'; import type { Migration } from '../../types.js'; /** - * Ionic 9's `isCapacitorNative` relies solely on `Capacitor.isNativePlatform()`, - * added in Capacitor 3. Report-only: the fix is a Capacitor upgrade. + * Ionic 9 supports Capacitor 7 and later, and its `isCapacitorNative` relies + * solely on `Capacitor.isNativePlatform()`, added in Capacitor 3. Report-only: + * the fix is a Capacitor upgrade, which touches the native projects too. + * + * Below 3 the app stops being detected as native at all, which is the sharper of + * the two cases. * * Refer to https://ionicframework.com/docs/updating/9-0#capacitor */ +/** The oldest Capacitor Ionic 9 supports. */ +const MIN_CAPACITOR = 7; /** The first Capacitor with `isNativePlatform()`. */ -const MIN_CAPACITOR = 3; +const NATIVE_DETECTION_CAPACITOR = 3; const CAPACITOR_CORE = '@capacitor/core'; export const coreCapacitor: Migration = { @@ -31,14 +37,12 @@ export const coreCapacitor: Migration = { const major = parseMajor(dep.range); if (major === undefined || major >= MIN_CAPACITOR) return []; - return [ - { - filePath: 'package.json', - line: 1, - detail: - `Capacitor ${major} is no longer detected as a native platform. ` + - `isPlatform('capacitor'), isPlatform('hybrid'), and getPlatforms() will report web. Upgrade to Capacitor 7 or later`, - }, - ]; + const detail = + major < NATIVE_DETECTION_CAPACITOR + ? `Capacitor ${major} is no longer detected as a native platform. ` + + `isPlatform('capacitor'), isPlatform('hybrid'), and getPlatforms() will report web. Upgrade to Capacitor ${MIN_CAPACITOR} or later` + : `Capacitor ${major} is not supported by Ionic 9. Upgrade to Capacitor ${MIN_CAPACITOR} or later`; + + return [{ filePath: 'package.json', line: 1, detail }]; }, }; diff --git a/packages/migrate/src/types.ts b/packages/migrate/src/types.ts index 47a9ae60076..1c5c53b49fe 100644 --- a/packages/migrate/src/types.ts +++ b/packages/migrate/src/types.ts @@ -24,8 +24,8 @@ export type Framework = 'angular' | 'react' | 'vue' | 'core'; /** * Status of a migration. `experimental` migrations are skipped unless the user - * explicitly opts in, letting us ship transforms for changes that are not yet - * settled. + * explicitly opts in. Used for a transform that is not yet settled, or one whose + * consequence is the developer's call. */ export type MigrationStatus = 'stable' | 'experimental'; diff --git a/packages/migrate/test/angular-browser-policy-manual.test.ts b/packages/migrate/test/angular-browser-policy-manual.test.ts new file mode 100644 index 00000000000..a94779d88c2 --- /dev/null +++ b/packages/migrate/test/angular-browser-policy-manual.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { angularBrowserPolicyManual as migration } from '../src/migrations/v9/angular-browser-policy-manual.js'; +import { ANGULAR_20_POLICY, angularProject, fakeBrowserslist } from './helpers/angular-policy.js'; + +describe('angular-browser-policy-manual', () => { + it('names the version to raise an entry to, read from the installed Angular', () => { + const browserslist = fakeBrowserslist(['chrome 108', 'chrome 107', 'safari 16.0']); + const ctx = createInMemoryContext( + { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^20.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\nSafari >=16\n`, + 'node_modules/@angular/build/package.json': '{ "name": "@angular/build" }', + 'node_modules/@angular/build/.browserslistrc': ANGULAR_20_POLICY, + }, + '/app', + { browserslist } + ); + + const findings = migration.detect(ctx); + + // the static file is parsed into queries, with its comment and blank line dropped + expect(browserslist.queries).toEqual([ + ['Chrome >= 107', 'ChromeAndroid >= 107', 'Edge >= 107', 'Firefox >= 104', 'Safari >= 16', 'iOS >= 16'], + ]); + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('.browserslistrc'); + expect(findings[0].line).toBe(1); + expect(findings[0].detail).toContain('Chrome >=89'); + expect(findings[0].detail).toContain('>=107'); + // Safari >=16 already meets the policy, so it is not reported + expect(findings[0].detail).not.toContain('Safari'); + }); + + it('resolves the rolling baseline policy Angular 21+ ships instead of a file', () => { + const ctx = angularProject({ + major: 22, + files: { '.browserslistrc': `Safari >=16.1\n` }, + resolved: ['chrome 111', 'safari 16.4', 'ios_saf 16.6-16.7'], + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain("Angular 22's browser policy"); + // a decimal floor, and not confused with the 16.6 lower bound of the iOS range + expect(findings[0].detail).toContain('>=16.4'); + }); + + it('ignores the Android browsers, which caniuse tracks only at their latest', () => { + const ctx = angularProject({ + major: 22, + files: { '.browserslistrc': `ChromeAndroid >=89\nFirefoxAndroid >=89\n` }, + resolved: ['chrome 111', 'and_chr 149', 'firefox 112', 'and_ff 151'], + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('finds @angular/build nested under the devkit builder', () => { + // An app on @angular-devkit/build-angular has no top-level @angular/build, + // which is the layout the Ionic conference app ships. + const ctx = angularProject({ + major: 22, + files: { + '.browserslistrc': `Chrome >=107\n`, + 'node_modules/@angular-devkit/build-angular/package.json': '{ "name": "@angular-devkit/build-angular" }', + }, + resolved: ['chrome 111'], + buildDir: 'node_modules/@angular-devkit/build-angular/node_modules/@angular/build', + }); + + expect(migration.detect(ctx)[0]?.detail).toContain('>=111'); + }); + + it('falls back to naming the policy when dependencies are not installed', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('own browser support policy'); + }); + + it('says nothing on an Angular that enforces no policy of its own', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^19.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=79\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('says nothing when the app declares no browserslist of its own', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + }); + + expect(migration.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/angular-browser-policy.test.ts b/packages/migrate/test/angular-browser-policy.test.ts new file mode 100644 index 00000000000..54f5c84fb5d --- /dev/null +++ b/packages/migrate/test/angular-browser-policy.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { allMigrations } from '../src/migrations/index.js'; +import { selectMigrations } from '../src/registry.js'; +import { angularBrowserPolicy as migration } from '../src/migrations/v9/angular-browser-policy.js'; +import { angularBrowserPolicyManual } from '../src/migrations/v9/angular-browser-policy-manual.js'; +import { angularProject } from './helpers/angular-policy.js'; + +/** An Angular 22 project with the policy installed and resolvable. */ +function project(files: Record = {}) { + return angularProject({ + major: 22, + files, + resolved: ['chrome 111', 'firefox 112', 'safari 16.4', 'ios_saf 16.4'], + }); +} + +describe('angular-browser-policy', () => { + it("raises entries to Angular's own floors, including decimal ones", () => { + const ctx = project({ '.browserslistrc': `Chrome >=89\nSafari >=16\niOS >=16\n` }); + + migration.fix!(ctx); + + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=111\nSafari >=16.4\niOS >=16.4\n`); + }); + + it('reports each entry it would raise, with its file and line', () => { + const ctx = project({ '.browserslistrc': `Chrome >=89\nSafari >=16.4\n` }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('.browserslistrc'); + expect(findings[0].line).toBe(1); + expect(findings[0].detail).toBe('Chrome >=89 -> >=111 (Angular 22 browser policy)'); + }); + + it('raises the package.json field the Angular starters generate', () => { + const ctx = project({ + 'package.json': `${JSON.stringify( + { dependencies: { '@angular/core': '^22.0.0' }, browserslist: ['Chrome >=107', 'Safari >=16.1'] }, + null, + 2 + )}\n`, + }); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual(['Chrome >=111', 'Safari >=16.4']); + }); + + it('raises the env-keyed shape without flattening it', () => { + const ctx = project({ + 'package.json': `${JSON.stringify( + { + dependencies: { '@angular/core': '^22.0.0' }, + browserslist: { production: ['Chrome >=89', 'last 2 versions'], development: ['Chrome >=89'] }, + }, + null, + 2 + )}\n`, + }); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual({ + production: ['Chrome >=111', 'last 2 versions'], + development: ['Chrome >=111'], + }); + }); + + it('leaves a browser Angular policy does not name alone', () => { + const ctx = project({ '.browserslistrc': `Samsung >=15\nnot dead\n` }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Samsung >=15\nnot dead\n`); + }); + + it('writes nothing when the policy cannot be resolved', () => { + // Writing against empty floors is a silent no-op at best and wrong versions at worst. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('writes nothing when the project browserslist throws on the policy query', () => { + const ctx = createInMemoryContext( + { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^22.0.0' } }, null, 2), + '.browserslistrc': `Chrome >=89\n`, + 'node_modules/@angular/build/package.json': '{ "name": "@angular/build" }', + 'node_modules/@angular/build/src/utils/supported-browsers.js': `const BASELINE_DATE = '2025-10-20';\n`, + }, + '/app', + { + browserslist: () => { + throw new Error('Unknown browser query'); + }, + } + ); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('writes nothing on an Angular that enforces no policy of its own', () => { + const ctx = angularProject({ major: 19, files: { '.browserslistrc': `Chrome >=89\n` }, resolved: ['chrome 111'] }); + + expect(migration.detect(ctx)).toEqual([]); + migration.fix!(ctx); + expect(ctx.readFile('.browserslistrc')).toBe(`Chrome >=89\n`); + }); + + it('is experimental, so a default run only gets the report', () => { + const selected = selectMigrations(allMigrations, { + fromMajor: 8, + toMajor: 9, + frameworks: ['angular'], + }).map((m) => m.id); + + expect(selected).toContain('angular-browser-policy-manual'); + expect(selected).not.toContain('angular-browser-policy'); + }); + + it('runs before the report under --experimental, which then goes quiet', () => { + // Both are selected with the opt-in, so the report must not repeat what the + // fix just applied. + const ctx = project({ '.browserslistrc': `Chrome >=89\n` }); + const selected = selectMigrations(allMigrations, { + fromMajor: 8, + toMajor: 9, + frameworks: ['angular'], + includeExperimental: true, + }).map((m) => m.id); + + expect(selected.indexOf('angular-browser-policy')).toBeLessThan(selected.indexOf('angular-browser-policy-manual')); + + migration.fix!(ctx); + + expect(angularBrowserPolicyManual.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/angular-zoneless-manual.test.ts b/packages/migrate/test/angular-zoneless-manual.test.ts index e1321f5018b..7b406b6138a 100644 --- a/packages/migrate/test/angular-zoneless-manual.test.ts +++ b/packages/migrate/test/angular-zoneless-manual.test.ts @@ -9,6 +9,7 @@ describe('angular-zoneless-manual', () => { 'src/main.ts': `import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n` + `platformBrowserDynamic().bootstrapModule(AppModule).catch((e) => console.log(e));\n`, + 'src/polyfills.ts': `import 'zone.js';\n`, }); const findings = migration.detect(ctx); @@ -18,6 +19,16 @@ describe('angular-zoneless-manual', () => { expect(findings[0].detail).toContain('applicationProviders'); }); + it('does not flag an NgModule app that never loaded Zone.js', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': + `import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n` + + `platformBrowserDynamic().bootstrapModule(AppModule).catch((e) => console.log(e));\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + it('does not flag a standalone bootstrap (handled by the auto-fix)', () => { const ctx = createInMemoryContext({ 'src/main.ts': `bootstrapApplication(AppComponent, { providers: [] });\n`, diff --git a/packages/migrate/test/angular-zoneless.test.ts b/packages/migrate/test/angular-zoneless.test.ts index 4f066cb4a6d..411bf013347 100644 --- a/packages/migrate/test/angular-zoneless.test.ts +++ b/packages/migrate/test/angular-zoneless.test.ts @@ -10,8 +10,11 @@ const STANDALONE_MAIN = ` providers: [provideIonicAngular()],\n` + `});\n`; +/** A polyfills file that loads Zone.js, as every pre-Angular-21 app has. */ +const ZONE_POLYFILLS = `import 'zone.js';\n`; + function withMain(text: string) { - const ctx = createInMemoryContext({ 'src/main.ts': text }); + const ctx = createInMemoryContext({ 'src/main.ts': text, 'src/polyfills.ts': ZONE_POLYFILLS }); const read = () => ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/src/main.ts`).getFullText(); return { ctx, read }; } @@ -61,7 +64,9 @@ describe('angular-zoneless', () => { it('fixes every standalone bootstrap in a multi-project workspace', () => { const ctx = createInMemoryContext({ 'apps/a/src/main.ts': STANDALONE_MAIN, + 'apps/a/src/polyfills.ts': ZONE_POLYFILLS, 'apps/b/src/main.ts': STANDALONE_MAIN, + 'apps/b/src/polyfills.ts': ZONE_POLYFILLS, }); expect(migration.detect(ctx)).toHaveLength(2); @@ -75,6 +80,202 @@ describe('angular-zoneless', () => { } }); + it('leaves an app that does not load Zone.js alone', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/polyfills.ts': `// This app runs zoneless, so Zone.js is not imported here.\n`, + 'angular.json': JSON.stringify({ + projects: { app: { architect: { build: { options: { polyfills: ['src/polyfills.ts'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toEqual([]); + + migration.fix!(ctx); + + expect(ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/src/main.ts`).getFullText()).toBe(STANDALONE_MAIN); + }); + + it('fixes an app whose Zone.js is loaded by an angular.json polyfills entry', () => { + // The Angular CLI's current scaffold has no polyfills file: `zone.js` is + // listed straight in the build options, so no source file imports it. + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'angular.json': JSON.stringify({ + projects: { app: { architect: { build: { options: { polyfills: ['zone.js'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toHaveLength(1); + + migration.fix!(ctx); + + expect(ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/src/main.ts`).getFullText()).toContain( + 'provideZoneChangeDetection()' + ); + }); + + it('does not count a commented-out zone.js import as loading Zone.js', () => { + // The CLI scaffolds this exact commented line into `environments/environment.ts`, + // so a text scan would read every zoneless app as a Zone.js app. + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/environments/environment.ts': + `// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.\n` + + `export const environment = { production: false };\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('matches the indentation of the providers it is inserted in front of', () => { + const { ctx, read } = withMain( + `import { bootstrapApplication } from '@angular/platform-browser';\n` + + `import { provideIonicAngular } from '@ionic/angular';\n\n` + + `bootstrapApplication(AppComponent, {\n` + + ` providers: [\n` + + ` { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },\n` + + ` provideIonicAngular(),\n` + + ` ],\n` + + `});\n` + ); + + migration.fix!(ctx); + const lines = read().split('\n'); + + expect(lines).toContain(' provideZoneChangeDetection(),'); + // and the element it was inserted in front of keeps its own indentation + expect(lines).toContain(' { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },'); + }); + + it('keeps a single-line providers array on one line', () => { + const { ctx, read } = withMain(STANDALONE_MAIN); + + migration.fix!(ctx); + + expect(read()).toContain('providers: [provideZoneChangeDetection(), provideIonicAngular()]'); + }); + + it('leaves the zoneless app alone in a workspace where a sibling keeps Zone.js', () => { + const ctx = createInMemoryContext({ + 'angular.json': JSON.stringify({ + projects: { + a: { root: 'apps/a', architect: { build: { options: { polyfills: ['zone.js'] } } } }, + b: { root: 'apps/b', architect: { build: { options: { polyfills: [] } } } }, + }, + }), + 'apps/a/src/main.ts': STANDALONE_MAIN, + 'apps/b/src/main.ts': STANDALONE_MAIN, + }); + + expect(migration.detect(ctx).map((f) => f.filePath)).toEqual(['apps/a/src/main.ts']); + + migration.fix!(ctx); + + const read = (path: string) => ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/${path}`).getFullText(); + expect(read('apps/a/src/main.ts')).toContain('provideZoneChangeDetection()'); + expect(read('apps/b/src/main.ts')).toBe(STANDALONE_MAIN); + }); + + it('scopes a polyfills import to its own project in a workspace', () => { + const ctx = createInMemoryContext({ + 'angular.json': JSON.stringify({ + projects: { + a: { root: 'apps/a', architect: { build: { options: { polyfills: ['apps/a/src/polyfills.ts'] } } } }, + b: { root: 'apps/b', architect: { build: { options: { polyfills: [] } } } }, + }, + }), + 'apps/a/src/polyfills.ts': ZONE_POLYFILLS, + 'apps/a/src/main.ts': STANDALONE_MAIN, + 'apps/b/src/main.ts': STANDALONE_MAIN, + }); + + expect(migration.detect(ctx).map((f) => f.filePath)).toEqual(['apps/a/src/main.ts']); + }); + + it('does not treat a Zone.js test setup as an app that bootstraps with it', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/test.ts': `import 'zone.js/testing';\n`, + 'angular.json': JSON.stringify({ + projects: { + app: { + architect: { + build: { options: { polyfills: ['src/polyfills.ts'] } }, + test: { options: { polyfills: ['zone.js', 'zone.js/testing'] } }, + }, + }, + }, + }), + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('adds the provider to an empty providers array', () => { + const { ctx, read } = withMain( + `import { bootstrapApplication } from '@angular/platform-browser';\n` + + `bootstrapApplication(AppComponent, { providers: [] });\n` + ); + + migration.fix!(ctx); + + expect(read()).toContain('provideZoneChangeDetection()'); + }); + + it('reads a project root spelled as "." the same as an empty one', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'angular.json': JSON.stringify({ + projects: { app: { root: '.', architect: { build: { options: { polyfills: ['zone.js'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toHaveLength(1); + }); + + it('does not read a nested project polyfills as the outer project own', () => { + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'angular.json': JSON.stringify({ projects: { app: { root: '' } } }), + 'projects/admin/project.json': JSON.stringify({ targets: { build: { options: { polyfills: [] } } } }), + 'projects/admin/src/polyfills.ts': ZONE_POLYFILLS, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('ignores a zone.js import outside the polyfills the build loads', () => { + // A zoneless app can sit in a repo alongside code that still uses Zone.js, + // and adding the provider on the strength of that would break its bootstrap. + const ctx = createInMemoryContext({ + 'src/main.ts': STANDALONE_MAIN, + 'src/polyfills.ts': `// zoneless\n`, + 'functions/legacy/setup.ts': ZONE_POLYFILLS, + 'angular.json': JSON.stringify({ + projects: { app: { architect: { build: { options: { polyfills: ['src/polyfills.ts'] } } } } }, + }), + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('does not add the provider to a type-only @angular/core import', () => { + // Extending `import type { ... }` elides the provider at compile time, so the + // call in the providers array becomes a ReferenceError. + const { ctx, read } = withMain( + `import type { Provider } from '@angular/core';\n` + + `import { bootstrapApplication } from '@angular/platform-browser';\n\n` + + `bootstrapApplication(AppComponent, { providers: [] });\n` + ); + + migration.fix!(ctx); + const out = read(); + + expect(out).not.toMatch(/import type \{[^}]*provideZoneChangeDetection/); + expect(out).toMatch(/import \{ provideZoneChangeDetection \} from ['"]@angular\/core['"]/); + }); + it('ignores NgModule bootstrap (out of scope)', () => { const { ctx } = withMain( `import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n` + diff --git a/packages/migrate/test/context.test.ts b/packages/migrate/test/context.test.ts index 25b6805d39d..138340ce184 100644 --- a/packages/migrate/test/context.test.ts +++ b/packages/migrate/test/context.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import { createInMemoryContext } from '../src/context.js'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createDiskContext, createInMemoryContext } from '../src/context.js'; + +const dirs: string[] = []; + +afterEach(() => { + let dir: string | undefined; + while ((dir = dirs.pop())) rmSync(dir, { recursive: true, force: true }); +}); describe('context.glob', () => { it('excludes native (ios/android) and build output directories at the project root', () => { @@ -29,6 +40,55 @@ describe('context.glob', () => { }); }); +describe('context.requireFromProject', () => { + it('returns undefined for a package the project does not have installed', () => { + // Migrations that read a tool's own config (e.g. Angular's browser policy) + // must degrade to report-only rather than throw on a fresh clone. + const ctx = createInMemoryContext({ 'package.json': '{}' }); + + expect(ctx.requireFromProject('browserslist')).toBeUndefined(); + }); + + it('returns undefined rather than throwing on a real project without node_modules', () => { + // The in-memory stub can't exercise `createRequire`, which is the part that + // throws, and every migration reading a tool's config depends on it not to. + const dir = mkdtempSync(join(tmpdir(), 'ionic-migrate-ctx-')); + dirs.push(dir); + writeFileSync(join(dir, 'package.json'), '{ "name": "app" }'); + + const ctx = createDiskContext(dir); + + expect(ctx.requireFromProject('definitely-not-installed')).toBeUndefined(); + expect(ctx.resolveFromProject('definitely-not-installed')).toBeUndefined(); + }); + + it('reads a package hoisted above the project, through the path it resolves to', () => { + // A workspace installs shared tooling at its root, so the resolved path sits + // outside the project and cannot be read as a project-relative one. + const root = mkdtempSync(join(tmpdir(), 'ionic-migrate-ws-')); + dirs.push(root); + mkdirSync(join(root, 'node_modules/@angular/build'), { recursive: true }); + writeFileSync(join(root, 'node_modules/@angular/build/package.json'), '{ "name": "@angular/build" }'); + writeFileSync(join(root, 'node_modules/@angular/build/.browserslistrc'), 'Chrome >= 107\n'); + mkdirSync(join(root, 'apps/web'), { recursive: true }); + writeFileSync(join(root, 'apps/web/package.json'), '{ "name": "web" }'); + + const ctx = createDiskContext(join(root, 'apps/web')); + const manifest = ctx.resolveFromProject('@angular/build/package.json'); + + expect(manifest).toBeDefined(); + expect(ctx.readFile(`${manifest!.slice(0, manifest!.lastIndexOf('/'))}/.browserslistrc`)).toBe('Chrome >= 107\n'); + }); + + it('returns a stubbed package so migrations can be tested without node_modules', () => { + const ctx = createInMemoryContext({ 'package.json': '{}' }, '/app', { browserslist: () => ['chrome 111'] }); + + const browserslist = ctx.requireFromProject<(q: string) => string[]>('browserslist'); + + expect(browserslist?.('anything')).toEqual(['chrome 111']); + }); +}); + describe('context.touchedFiles', () => { it('tracks writeFile edits', () => { const ctx = createInMemoryContext({ 'a.scss': 'x' }); diff --git a/packages/migrate/test/core-browserslist-manual.test.ts b/packages/migrate/test/core-browserslist-manual.test.ts new file mode 100644 index 00000000000..febbfcaf5c2 --- /dev/null +++ b/packages/migrate/test/core-browserslist-manual.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryContext } from '../src/context.js'; +import { coreBrowserslistManual as migration } from '../src/migrations/v9/core-browserslist-manual.js'; + +describe('core-browserslist-manual', () => { + it('reports a browser missing from an otherwise-named list', () => { + // The guide's block lists six browsers; a list naming five silently drops one. + const ctx = createInMemoryContext({ + '.browserslistrc': `Chrome >=107\nFirefox >=106\nEdge >=107\nSafari >=16.1\niOS >=16.1\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('.browserslistrc'); + expect(findings[0].detail).toContain('ChromeAndroid >=89'); + }); + + it('treats a list split across a file and the manifest as one list', () => { + const ctx = createInMemoryContext({ + '.browserslistrc': `Chrome >=107\nChromeAndroid >=107\nEdge >=107\n`, + 'package.json': `${JSON.stringify( + { name: 'app', browserslist: ['Firefox >=106', 'Safari >=16.1', 'iOS >=16.1'] }, + null, + 2 + )}\n`, + }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('reports against the manifest when that is where the list lives', () => { + const ctx = createInMemoryContext({ + 'package.json': `${JSON.stringify({ name: 'app', browserslist: ['Chrome >=107'] }, null, 2)}\n`, + }); + + const findings = migration.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].filePath).toBe('package.json'); + expect(findings[0].line).toBe(3); + }); + + it('does not report missing browsers for a list of browsers Ionic does not name', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `Samsung >=15\nOpera >=90\n` }); + + expect(migration.detect(ctx)).toEqual([]); + }); + + it('does not report missing browsers for a query-style list', () => { + const ctx = createInMemoryContext({ '.browserslistrc': `last 2 versions\nnot dead\n` }); + + expect(migration.detect(ctx)).toEqual([]); + }); +}); diff --git a/packages/migrate/test/core-browserslist.test.ts b/packages/migrate/test/core-browserslist.test.ts index a4dcad35406..1bc99fb7ddd 100644 --- a/packages/migrate/test/core-browserslist.test.ts +++ b/packages/migrate/test/core-browserslist.test.ts @@ -64,6 +64,55 @@ describe('core-browserslist', () => { expect(ctx.readFile('.browserslistrc')).toBe(source); }); + it('raises a stale entry declared in package.json', () => { + const ctx = createInMemoryContext({ + 'package.json': `${JSON.stringify( + { name: 'app', devDependencies: { browserslist: '^4.24.0' }, browserslist: ['Chrome >=79', 'Safari >=14'] }, + null, + 2 + )}\n`, + }); + + // located at their own lines, not at the same-named devDependency above them + expect(migration.detect(ctx).map((f) => `${f.filePath}:${f.line}`)).toEqual(['package.json:7', 'package.json:8']); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual(['Chrome >=89', 'Safari >=16']); + }); + + it('raises the same entry under two environment keys, reporting each line', () => { + const ctx = createInMemoryContext({ + 'package.json': `${JSON.stringify( + { name: 'app', browserslist: { production: ['Chrome >=79'], development: ['Chrome >=79'] } }, + null, + 2 + )}\n`, + }); + + expect(migration.detect(ctx).map((f) => f.line)).toEqual([5, 8]); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('package.json')!).browserslist).toEqual({ + production: ['Chrome >=89'], + development: ['Chrome >=89'], + }); + }); + + it('raises a workspace app manifest, not just the root one', () => { + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ name: 'workspace' }, null, 2), + 'apps/web/package.json': `${JSON.stringify({ name: 'web', browserslist: ['Chrome >=79'] }, null, 2)}\n`, + }); + + expect(migration.detect(ctx).map((f) => f.filePath)).toEqual(['apps/web/package.json']); + + migration.fix!(ctx); + + expect(JSON.parse(ctx.readFile('apps/web/package.json')!).browserslist).toEqual(['Chrome >=89']); + }); + it('reports the file and line of each stale entry', () => { const ctx = createInMemoryContext({ '.browserslistrc': `# browsers\nChrome >=79\nSafari >=14\n` }); diff --git a/packages/migrate/test/core-capacitor.test.ts b/packages/migrate/test/core-capacitor.test.ts index 96d1f849154..f81d582c41b 100644 --- a/packages/migrate/test/core-capacitor.test.ts +++ b/packages/migrate/test/core-capacitor.test.ts @@ -15,6 +15,19 @@ describe('core-capacitor', () => { expect(findings[0].detail).toContain('Capacitor 2'); }); + it('flags a Capacitor below the minimum Ionic 9 supports', () => { + // Native detection still works on 6, so this gets the softer of the two messages. + const ctx = createInMemoryContext({ + 'package.json': JSON.stringify({ dependencies: { '@capacitor/core': '^6.0.0' } }, null, 2), + }); + + const findings = coreCapacitor.detect(ctx); + + expect(findings).toHaveLength(1); + expect(findings[0].detail).toContain('Capacitor 7'); + expect(findings[0].detail).not.toContain('isPlatform'); + }); + it('says nothing about a supported Capacitor', () => { const ctx = createInMemoryContext({ 'package.json': JSON.stringify({ dependencies: { '@capacitor/core': '^7.0.0' } }, null, 2), diff --git a/packages/migrate/test/engine.test.ts b/packages/migrate/test/engine.test.ts index 374393b6dee..4e383d6f432 100644 --- a/packages/migrate/test/engine.test.ts +++ b/packages/migrate/test/engine.test.ts @@ -200,7 +200,7 @@ describe('resolveTarget', () => { }); it('clamps a target above the newest known major instead of skipping the run', () => { - // Why clamp rather than refuse: see resolveTarget in registry.ts. + // Why clamp rather than refuse: refer to resolveTarget in registry.ts. expect(resolveTarget(all, 8, 10)).toEqual({ kind: 'run', toMajor: 9, clampedFrom: 10 }); }); diff --git a/packages/migrate/test/helpers/angular-policy.ts b/packages/migrate/test/helpers/angular-policy.ts new file mode 100644 index 00000000000..df8ef72de53 --- /dev/null +++ b/packages/migrate/test/helpers/angular-policy.ts @@ -0,0 +1,46 @@ +import { createInMemoryContext } from '../../src/context.js'; +import type { MigrationContext } from '../../src/context.js'; + +/** Angular 20 ships its policy as a static browserslist file. */ +export const ANGULAR_20_POLICY = `# Angular's supported browsers\n\nChrome >= 107\nChromeAndroid >= 107\nEdge >= 107\nFirefox >= 104\nSafari >= 16\niOS >= 16\n`; + +/** Angular 21+ builds its policy from this constant instead. */ +export const ANGULAR_BASELINE = `const BASELINE_DATE = '2025-10-20';\n`; + +/** + * A stand-in for the project's `browserslist`, resolving a fixed policy and + * recording the queries it was handed. + */ +export function fakeBrowserslist(resolved: string[]) { + const queries: unknown[] = []; + return Object.assign( + (query: unknown) => { + queries.push(query); + return resolved; + }, + { queries } + ); +} + +/** An Angular project with `@angular/build` installed and its policy resolvable. */ +export function angularProject(options: { + major: number; + files: Record; + resolved: string[]; + /** Where the installed `@angular/build` sits, for the devkit-nested layout. */ + buildDir?: string; +}): MigrationContext { + // Only the default layout installs `@angular/build` at the top level. The + // devkit-nested one must not, or the test stops discriminating. + const buildDir = options.buildDir ?? 'node_modules/@angular/build'; + return createInMemoryContext( + { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': `^${options.major}.0.0` } }, null, 2), + ...(options.buildDir === undefined ? { 'node_modules/@angular/build/package.json': '{}' } : {}), + [`${buildDir}/src/utils/supported-browsers.js`]: ANGULAR_BASELINE, + ...options.files, + }, + '/app', + { browserslist: fakeBrowserslist(options.resolved) } + ); +} diff --git a/packages/migrate/test/main.test.ts b/packages/migrate/test/main.test.ts index 88af50623aa..ebf12ecd52e 100644 --- a/packages/migrate/test/main.test.ts +++ b/packages/migrate/test/main.test.ts @@ -12,7 +12,8 @@ import { main } from '../src/main.js'; * `return 1` once left the type-checker and the whole suite green while CI * reported an unmigrated app as clean. * - * `--check` writes nothing and skips the git gate, so these need no repo. + * `--check` writes nothing and skips the git gate, so most need no repo. The + * formatter case writes, and passes `--force` to get past the gate instead. */ const dirs: string[] = []; @@ -99,6 +100,14 @@ describe('main', () => { expect(out).toContain('Nothing to do.'); }); + it('says the changed files went unformatted when the project has no Prettier', () => { + // The guide promises a Prettier pass, and a project without Prettier gets none. + const { code, out } = runCli(project('^8.4.0'), '--force', '--no-install'); + + expect(code).toBe(0); + expect(out).toContain('no Prettier'); + }); + it('throws on an unparseable --to rather than migrating to a guessed target', () => { expect(() => runCli(project('^8.4.0'), '--to', 'nine')).toThrow(/expected an integer/); });