UNPKG

@angular/core

Version:

Angular - the core framework

503 lines (497 loc) • 21.9 kB
'use strict'; /** * @license Angular v21.0.5 * (c) 2010-2025 Google LLC. https://angular.io/ * License: MIT */ 'use strict'; require('@angular-devkit/core'); require('node:path/posix'); var project_paths = require('./project_paths-DvD50ouC.cjs'); require('@angular/compiler-cli'); var migrations = require('@angular/compiler-cli/private/migrations'); var ts = require('typescript'); require('node:path'); var apply_import_manager = require('./apply_import_manager-1Zs_gpB6.cjs'); require('@angular-devkit/schematics'); require('./project_tsconfig_paths-CDVxT6Ov.cjs'); const ROUTER_TESTING_MODULE = 'RouterTestingModule'; const SPY_LOCATION = 'SpyLocation'; const ROUTER_MODULE = 'RouterModule'; const PROVIDE_LOCATION_MOCKS = 'provideLocationMocks'; const ANGULAR_ROUTER_TESTING = '@angular/router/testing'; const ANGULAR_ROUTER = '@angular/router'; const ANGULAR_COMMON_TESTING = '@angular/common/testing'; const IMPORTS_PROPERTY = 'imports'; const PROVIDERS_PROPERTY = 'providers'; const WITH_ROUTES_STATIC_METHOD = 'withRoutes'; const TESTBED_IDENTIFIER = 'TestBed'; const CONFIGURE_TESTING_MODULE = 'configureTestingModule'; function hasImportFromModule(sourceFile, modulePath, ...symbolNames) { const symbolSet = new Set(symbolNames); let hasImport = false; ts.forEachChild(sourceFile, (node) => { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === modulePath && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) { for (const element of node.importClause.namedBindings.elements) { if (symbolSet.has(element.name.text)) { hasImport = true; break; } } } }); return hasImport; } function detectSpyLocationUrlChangesUsage(sourceFile) { const hasSpyLocationImport = hasImportFromModule(sourceFile, ANGULAR_COMMON_TESTING, SPY_LOCATION); let usesUrlChangesFeature = false; function walk(node) { if (usesUrlChangesFeature) { return; } if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && node.name.text === 'urlChanges') { usesUrlChangesFeature = true; return; } node.forEachChild(walk); } walk(sourceFile); return hasSpyLocationImport && usesUrlChangesFeature; } function createArrayLiteralReplacement(file, arrayLiteral, newElements, sourceFile) { const elementNodes = newElements.map((element) => { if (typeof element === 'string') { return parseStringToExpression(element); } return element; }); const newArray = ts.factory.updateArrayLiteralExpression(arrayLiteral, elementNodes); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, }); const newText = printer.printNode(ts.EmitHint.Unspecified, newArray, sourceFile); return new project_paths.Replacement(file, new project_paths.TextUpdate({ position: arrayLiteral.getStart(), end: arrayLiteral.getEnd(), toInsert: newText, })); } function createImportRemovalReplacement(file, importDeclaration, namedBindings, symbolToRemove, sourceFile) { const otherImports = namedBindings.elements.filter((el) => el.name.text !== symbolToRemove); if (otherImports.length === 0) { return new project_paths.Replacement(file, new project_paths.TextUpdate({ position: importDeclaration.getStart(), end: importDeclaration.getEnd() + 1, toInsert: '', })); } else { const newNamedBindings = ts.factory.updateNamedImports(namedBindings, otherImports); const printer = ts.createPrinter(); const newText = printer.printNode(ts.EmitHint.Unspecified, newNamedBindings, sourceFile); return new project_paths.Replacement(file, new project_paths.TextUpdate({ position: namedBindings.getStart(), end: namedBindings.getEnd(), toInsert: newText, })); } } function isEmptyArrayExpression(expression) { return ts.isArrayLiteralExpression(expression) && expression.elements.length === 0; } function getRoutesArgumentForMigration(routesNode, optionsNode) { if (!routesNode) { return undefined; } if (!isEmptyArrayExpression(routesNode)) { return routesNode; } return optionsNode ? routesNode : undefined; } function createRouterModuleExpression(routesArg, optionsArg) { const routerModuleIdentifier = ts.factory.createIdentifier(ROUTER_MODULE); if (routesArg) { // Build args list and include options if present const args = [routesArg]; if (optionsArg) { args.push(optionsArg); } // Create RouterModule.forRoot(routes, options?) expression return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(routerModuleIdentifier, ts.factory.createIdentifier('forRoot')), undefined, args); } return routerModuleIdentifier; } function createProviderCallExpression(functionName, argument) { return ts.factory.createCallExpression(ts.factory.createIdentifier(functionName), undefined, []); } function createArrayLiteralFromExpressions(expressions) { return ts.factory.createArrayLiteralExpression(Array.from(expressions), true); } function parseStringToExpression(text) { const wrapped = `(${text})`; const sourceFile = ts.createSourceFile('temp.ts', wrapped, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); if (sourceFile.statements.length === 1) { const statement = sourceFile.statements[0]; if (ts.isExpressionStatement(statement) && ts.isParenthesizedExpression(statement.expression)) { return statement.expression.expression; } } return parseExpressionWithPatternRecognition(text); } function parseExpressionWithPatternRecognition(text) { const callPattern = analyzeCallPattern(text); if (callPattern) { return ts.factory.createCallExpression(callPattern.expression, undefined, Array.from(callPattern.arguments)); } const arrayPattern = analyzeArrayPattern(text); if (arrayPattern) { return ts.factory.createArrayLiteralExpression(Array.from(arrayPattern.elements), true); } const literalPattern = analyzeLiteralPattern(text); if (literalPattern) { return literalPattern.expression; } return ts.factory.createIdentifier(text); } function analyzeCallPattern(text) { const testExpression = `(${text})`; const sourceFile = ts.createSourceFile('temp.ts', testExpression, ts.ScriptTarget.Latest, true); if (sourceFile.statements.length === 1) { const statement = sourceFile.statements[0]; if (ts.isExpressionStatement(statement) && ts.isParenthesizedExpression(statement.expression) && ts.isCallExpression(statement.expression.expression)) { const callExpr = statement.expression.expression; return { type: 'call', expression: callExpr.expression, arguments: callExpr.arguments, }; } } return null; } function analyzeArrayPattern(text) { const sourceFile = ts.createSourceFile('temp.ts', text, ts.ScriptTarget.Latest, true); if (sourceFile.statements.length === 1) { const statement = sourceFile.statements[0]; if (ts.isExpressionStatement(statement) && ts.isArrayLiteralExpression(statement.expression)) { return { type: 'array', elements: statement.expression.elements, }; } } return null; } function analyzeLiteralPattern(text) { const sourceFile = ts.createSourceFile('temp.ts', text, ts.ScriptTarget.Latest, true); if (sourceFile.statements.length === 1) { const statement = sourceFile.statements[0]; if (ts.isExpressionStatement(statement)) { const expr = statement.expression; if (ts.isStringLiteral(expr) || ts.isNumericLiteral(expr) || ts.isLiteralExpression(expr)) { return { type: 'literal', expression: expr, }; } } } return null; } function removeRouterTestingModuleImport(sourceFile, file, replacements) { ts.forEachChild(sourceFile, (node) => { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === ANGULAR_ROUTER_TESTING && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) { const namedBindings = node.importClause.namedBindings; replacements.push(createImportRemovalReplacement(file, node, namedBindings, ROUTER_TESTING_MODULE, sourceFile)); } }); } function migrateToRouterModule(usage, file, routesNode, optionsNode, replacements) { const neededImportsExpressions = new Set(); const neededProvidersExpressions = new Set(); const optionsExpression = optionsNode ? optionsNode : undefined; const routesExpression = getRoutesArgumentForMigration(routesNode, optionsNode); const routerModuleExpression = createRouterModuleExpression(routesExpression, optionsExpression); neededImportsExpressions.add(routerModuleExpression); if (usage.usesSpyLocationUrlChanges) { const provideLocationMocksExpression = createProviderCallExpression(PROVIDE_LOCATION_MOCKS); neededProvidersExpressions.add(provideLocationMocksExpression); } if (usage.importsProperty && ts.isArrayLiteralExpression(usage.importsProperty.initializer)) { const importsArray = usage.importsProperty.initializer; const otherImportExpressions = usage.importsArrayElements.filter((el) => el !== usage.routerTestingModuleElement); const allImportExpressions = [ ...otherImportExpressions, ...Array.from(neededImportsExpressions), ]; replacements.push(createArrayLiteralReplacement(file, importsArray, allImportExpressions, usage.sourceFile)); } if (neededProvidersExpressions.size > 0) { if (usage.providersProperty && ts.isArrayLiteralExpression(usage.providersProperty.initializer)) { const existingProvidersArray = usage.providersProperty.initializer; const allProviderExpressions = [ ...existingProvidersArray.elements, ...Array.from(neededProvidersExpressions), ]; replacements.push(createArrayLiteralReplacement(file, existingProvidersArray, allProviderExpressions, usage.sourceFile)); } else { const providersArray = createArrayLiteralFromExpressions(neededProvidersExpressions); const printer = ts.createPrinter(); const providersText = printer.printNode(ts.EmitHint.Unspecified, providersArray, usage.sourceFile); const insertPosition = usage.importsProperty.getEnd(); replacements.push(new project_paths.Replacement(file, new project_paths.TextUpdate({ position: insertPosition, end: insertPosition, toInsert: `,\n ${PROVIDERS_PROPERTY}: ${providersText}`, }))); } } } function analyzeRouterTestingModuleUsage(usage) { const neededProviders = new Set(); const neededImports = new Set(); let hasLocationMocks = false; const optionsExpression = usage.optionsNode ? usage.optionsNode : undefined; const routesExpression = getRoutesArgumentForMigration(usage.routesNode, usage.optionsNode); // Add RouterModule to imports (preserve options when present) const routerModuleExpression = createRouterModuleExpression(routesExpression, optionsExpression); neededImports.add(routerModuleExpression); // Add location mocks ONLY if: // 1. SpyLocation is imported from @angular/common/testing, AND // 2. urlChanges property is accessed in the test // 3. provideLocationMocks() is not already present if (usage.usesSpyLocationUrlChanges) { const provideLocationMocksExpression = createProviderCallExpression(PROVIDE_LOCATION_MOCKS); neededProviders.add(provideLocationMocksExpression); hasLocationMocks = true; } return { neededProviders, neededImports, canRemoveRouterTestingModule: true, replacementCount: 1, hasLocationMocks, }; } function findRouterTestingModuleUsages(sourceFile) { const usages = []; const hasRouterTestingModule = hasImportFromModule(sourceFile, ANGULAR_ROUTER_TESTING, ROUTER_TESTING_MODULE); const usesSpyLocationUrlChanges = detectSpyLocationUrlChangesUsage(sourceFile); if (!hasRouterTestingModule) { return usages; } function walk(node) { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === CONFIGURE_TESTING_MODULE && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === TESTBED_IDENTIFIER && node.arguments.length > 0 && ts.isObjectLiteralExpression(node.arguments[0])) { const config = node.arguments[0]; let importsProperty = null; let providersProperty = null; for (const prop of config.properties) { if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { if (prop.name.text === IMPORTS_PROPERTY) { importsProperty = prop; } else if (prop.name.text === PROVIDERS_PROPERTY) { providersProperty = prop; } } } if (!importsProperty || !ts.isArrayLiteralExpression(importsProperty.initializer)) { node.forEachChild(walk); return; } const importsArray = importsProperty.initializer; let routerTestingModuleElement = null; let routesNode = null; let optionsNode = null; for (const element of importsArray.elements) { if (ts.isIdentifier(element) && element.text === ROUTER_TESTING_MODULE) { routerTestingModuleElement = element; break; } else if (ts.isCallExpression(element) && ts.isPropertyAccessExpression(element.expression) && ts.isIdentifier(element.expression.expression) && element.expression.expression.text === ROUTER_TESTING_MODULE && element.expression.name.text === WITH_ROUTES_STATIC_METHOD) { routerTestingModuleElement = element; if (element.arguments.length > 0) { routesNode = element.arguments[0]; } if (element.arguments.length > 1) { optionsNode = element.arguments[1]; } break; } } if (routerTestingModuleElement) { usages.push({ sourceFile, configObject: config, importsProperty, providersProperty, routerTestingModuleElement, routesNode, optionsNode, importsArrayElements: Array.from(importsArray.elements), usesSpyLocationUrlChanges, }); } } node.forEachChild(walk); } walk(sourceFile); return usages; } function processRouterTestingModuleUsage(usage, sourceFile, info, importManager, replacements) { const file = project_paths.projectFile(sourceFile, info); const routesNode = usage.routesNode; const optionsNode = usage.optionsNode; const analysis = analyzeRouterTestingModuleUsage(usage); migrateToRouterModule(usage, file, routesNode, optionsNode, replacements); importManager.addImport({ exportModuleSpecifier: ANGULAR_ROUTER, exportSymbolName: ROUTER_MODULE, requestedFile: sourceFile, }); if (analysis.hasLocationMocks) { importManager.addImport({ exportModuleSpecifier: ANGULAR_COMMON_TESTING, exportSymbolName: PROVIDE_LOCATION_MOCKS, requestedFile: sourceFile, }); } removeRouterTestingModuleImport(sourceFile, file, replacements); } /** * Migration that converts RouterTestingModule usages to the recommended API: * - Replace RouterTestingModule with RouterModule for all tests (respecting existing imports) * - Adds provideLocationMocks only when needed and not conflicting */ class RouterTestingModuleMigration extends project_paths.TsurgeFunnelMigration { config; constructor(config = {}) { super(); this.config = config; } async analyze(info) { const replacements = []; const migratedUsages = []; const filesWithLocationMocks = new Map(); const importManager = new migrations.ImportManager({ shouldUseSingleQuotes: () => true, }); for (const sourceFile of info.sourceFiles) { const file = project_paths.projectFile(sourceFile, info); if (this.config.shouldMigrate && !this.config.shouldMigrate(file)) { continue; } const usages = findRouterTestingModuleUsages(sourceFile); for (const usage of usages) { processRouterTestingModuleUsage(usage, sourceFile, info, importManager, replacements); migratedUsages.push(usage); if (usage.usesSpyLocationUrlChanges) { filesWithLocationMocks.set(sourceFile.fileName, true); } } } apply_import_manager.applyImportManagerChanges(importManager, replacements, info.sourceFiles, info); return project_paths.confirmAsSerializable({ replacements, migratedUsages, filesWithLocationMocks, }); } async migrate(globalData) { return { replacements: globalData.replacements, }; } async combine(unitA, unitB) { const combinedFilesWithLocationMocks = new Map(unitA.filesWithLocationMocks); for (const [fileName, hasLocationMocks] of unitB.filesWithLocationMocks) { combinedFilesWithLocationMocks.set(fileName, hasLocationMocks || combinedFilesWithLocationMocks.get(fileName) || false); } return project_paths.confirmAsSerializable({ replacements: [...unitA.replacements, ...unitB.replacements], migratedUsages: [...unitA.migratedUsages, ...unitB.migratedUsages], filesWithLocationMocks: combinedFilesWithLocationMocks, }); } async globalMeta(combinedData) { return project_paths.confirmAsSerializable(combinedData); } async stats(globalMetadata) { const stats = { counters: { replacements: globalMetadata.replacements.length, migratedUsages: globalMetadata.migratedUsages.length, filesWithLocationMocks: globalMetadata.filesWithLocationMocks.size, totalFiles: new Set(globalMetadata.migratedUsages.map((usage) => usage.sourceFile.fileName)) .size, }, }; return stats; } } function migrate(options) { return async (tree, context) => { await project_paths.runMigrationInDevkit({ tree, getMigration: (fs) => new RouterTestingModuleMigration({ shouldMigrate: (file) => { return (file.rootRelativePath.startsWith(fs.normalize(options.path)) && !/(^|\/)node_modules\//.test(file.rootRelativePath) && /\.spec\.ts$/.test(file.rootRelativePath)); }, }), beforeProgramCreation: (tsconfigPath, stage) => { if (stage === project_paths.MigrationStage.Analysis) { context.logger.info(`Preparing analysis for: ${tsconfigPath}...`); } else { context.logger.info(`Running migration for: ${tsconfigPath}...`); } }, beforeUnitAnalysis: (tsconfigPath) => { context.logger.info(`Scanning for RouterTestingModule usage: ${tsconfigPath}...`); }, afterAllAnalyzed: () => { context.logger.info(``); context.logger.info(`Processing analysis data between targets...`); context.logger.info(``); }, afterAnalysisFailure: () => { context.logger.error('Migration failed unexpectedly with no analysis data'); }, whenDone: (stats) => { context.logger.info(''); context.logger.info(`Successfully migrated RouterTestingModule to RouterModule 🎉`); context.logger.info(` -> Migrated ${stats.counters.migratedUsages} RouterTestingModule usages in ${stats.counters.totalFiles} test files.`); if (stats.counters.filesWithLocationMocks > 0) { context.logger.info(` -> Added provideLocationMocks() to ${stats.counters.filesWithLocationMocks} files with SpyLocation.urlChanges usage.`); } }, }); }; } exports.migrate = migrate;