diff --git a/.eslintrc.js b/.eslintrc.js index 0a0b662c9..3f626eee5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,6 +59,8 @@ module.exports = { 'checkIfRunsOnDevMode', 'checkEnvBuildPlugin', 'checkSentryCliRc', + 'assertTransform', + 'assertNoChange', ], }, ], diff --git a/e2e-tests/tests/help-message.test.ts b/e2e-tests/tests/help-message.test.ts index 9fc84bffc..1d711fb95 100644 --- a/e2e-tests/tests/help-message.test.ts +++ b/e2e-tests/tests/help-message.test.ts @@ -31,7 +31,7 @@ describe('--help command', () => { env: SENTRY_WIZARD_INTEGRATION [choices: "reactNative", "flutter", "ios", "android", "cordova", "angular", "cloudflare", "electron", "nextjs", "nuxt", "remix", "reactRouter", - "sveltekit", "sourcemaps"] + "sveltekit", "sourcemaps", "upgrade"] -p, --platform Choose platform(s) env: SENTRY_WIZARD_PLATFORM [array] [choices: "ios", "android"] diff --git a/lib/Constants.ts b/lib/Constants.ts index 92cab6c4a..22b1fe7ce 100644 --- a/lib/Constants.ts +++ b/lib/Constants.ts @@ -14,6 +14,7 @@ export enum Integration { reactRouter = 'reactRouter', sveltekit = 'sveltekit', sourcemaps = 'sourcemaps', + upgrade = 'upgrade', } /** Key value should be the same here */ diff --git a/src/run.ts b/src/run.ts index c3304b08e..8a1e76b26 100644 --- a/src/run.ts +++ b/src/run.ts @@ -17,6 +17,7 @@ import { runSourcemapsWizard } from './sourcemaps/sourcemaps-wizard'; import { runSvelteKitWizard } from './sveltekit/sveltekit-wizard'; import { runReactRouterWizard } from './react-router/react-router-wizard'; import { runCloudflareWizard } from './cloudflare/cloudflare-wizard'; +import { runUpgradeWizard } from './upgrade/upgrade-wizard'; import { enableDebugLogs } from './utils/debug'; import type { PreselectedProject, WizardOptions } from './utils/types'; import { WIZARD_VERSION } from './version'; @@ -35,7 +36,8 @@ type WizardIntegration = | 'reactRouter' | 'sveltekit' | 'cloudflare' - | 'sourcemaps'; + | 'sourcemaps' + | 'upgrade'; type Args = { integration?: WizardIntegration; @@ -132,6 +134,7 @@ export async function run(argv: Args) { { value: 'sveltekit', label: 'SvelteKit' }, { value: 'cloudflare', label: 'Cloudflare' }, { value: 'sourcemaps', label: 'Configure Source Maps Upload' }, + { value: 'upgrade', label: 'Upgrade SDK (apply codemods)' }, ], }), ); @@ -210,6 +213,10 @@ export async function run(argv: Args) { await runSourcemapsWizard(wizardOptions); break; + case 'upgrade': + await runUpgradeWizard({ projectDir: process.cwd() }); + break; + case 'cordova': argv.integration = 'cordova'; void legacyRun( diff --git a/src/upgrade/codemod-runner.ts b/src/upgrade/codemod-runner.ts new file mode 100644 index 000000000..5c88c4756 --- /dev/null +++ b/src/upgrade/codemod-runner.ts @@ -0,0 +1,94 @@ +import * as fs from 'fs'; +import * as recast from 'recast'; +import type { + CodemodTransform, + CodemodResult, + ManualReviewItem, +} from './types.js'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment +const babelTsParser = require('recast/parsers/babel-ts'); + +export interface RunResult { + filesModified: number; + totalChanges: string[]; + manualReviewItems: ManualReviewItem[]; + errors: { file: string; error: string }[]; +} + +export function runCodemodsOnFile( + filePath: string, + sourceCode: string, + transforms: CodemodTransform[], +): { output: string; result: CodemodResult } { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const ast = recast.parse(sourceCode, { parser: babelTsParser }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const program = ast.program as recast.types.namedTypes.Program; + + let anyModified = false; + const allChanges: string[] = []; + const allManualReview: ManualReviewItem[] = []; + + for (const transform of transforms) { + const result = transform.transform({ + program, + filePath, + sourceCode, + }); + + if (result.modified) { + anyModified = true; + } + allChanges.push(...result.changes); + allManualReview.push(...result.manualReviewItems); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const output = recast.print(ast).code; + + return { + output, + result: { + modified: anyModified, + changes: allChanges, + manualReviewItems: allManualReview, + }, + }; +} + +export function runCodemodsOnFiles( + files: string[], + transforms: CodemodTransform[], +): RunResult { + let filesModified = 0; + const totalChanges: string[] = []; + const manualReviewItems: ManualReviewItem[] = []; + const errors: { file: string; error: string }[] = []; + + for (const file of files) { + try { + const sourceCode = fs.readFileSync(file, 'utf-8'); + const { output, result } = runCodemodsOnFile( + file, + sourceCode, + transforms, + ); + + if (result.modified) { + fs.writeFileSync(file, output, 'utf-8'); + filesModified++; + totalChanges.push(...result.changes.map((c) => `${file}: ${c}`)); + } + + manualReviewItems.push(...result.manualReviewItems); + } catch (e) { + errors.push({ + file, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return { filesModified, totalChanges, manualReviewItems, errors }; +} diff --git a/src/upgrade/codemods/v8-to-v9/config-changes.ts b/src/upgrade/codemods/v8-to-v9/config-changes.ts new file mode 100644 index 000000000..6929e5f06 --- /dev/null +++ b/src/upgrade/codemods/v8-to-v9/config-changes.ts @@ -0,0 +1,149 @@ +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; +import type { + CodemodTransform, + TransformContext, + CodemodResult, + ManualReviewItem, +} from '../../types.js'; + +const b = recast.types.builders; + +// Config options to remove completely +const REMOVE_OPTIONS = ['hideSourceMaps', 'autoInstrumentRemix']; + +// Config options that need manual review +const MANUAL_REVIEW_OPTIONS: Record = { + autoSessionTracking: + "'autoSessionTracking' was removed in v9. Session tracking is now always enabled when a release is set. Remove this option and configure release tracking instead.", +}; + +function getLineNumber(node: t.Node): number { + return node.loc?.start.line ?? 0; +} + +export const configChanges: CodemodTransform = { + name: 'config-changes', + description: + 'Removes deprecated config options (enableTracing, hideSourceMaps, etc.) and flattens transactionContext', + + transform(ctx: TransformContext): CodemodResult { + let modified = false; + const changes: string[] = []; + const manualReviewItems: ManualReviewItem[] = []; + + recast.visit(ctx.program, { + visitObjectExpression(path) { + const props = path.node.properties; + + // Process in reverse to safely splice + for (let i = props.length - 1; i >= 0; i--) { + const prop = props[i]; + if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') { + continue; + } + + const key = prop.key; + let propName: string | null = null; + if (key.type === 'Identifier') { + propName = key.name; + } else if ( + (key.type === 'StringLiteral' || key.type === 'Literal') && + typeof key.value === 'string' + ) { + propName = key.value; + } + + if (!propName) { + continue; + } + + // enableTracing: true → tracesSampleRate: 1.0 + // enableTracing: false → remove + if (propName === 'enableTracing') { + const value = prop.value; + const isTruthy = + (value.type === 'BooleanLiteral' && value.value === true) || + (value.type === 'Literal' && value.value === true); + + if (isTruthy) { + // Replace with tracesSampleRate: 1.0, with a TODO comment + const newProp = b.objectProperty( + b.identifier('tracesSampleRate'), + b.numericLiteral(1.0), + ); + newProp.comments = [ + b.commentLine( + " TODO(sentry-upgrade): 'enableTracing' was removed. Use tracesSampleRate instead.", + true, + false, + ), + ]; + props.splice(i, 1, newProp); + } else { + props.splice(i, 1); + } + + modified = true; + changes.push("Removed 'enableTracing' option"); + continue; + } + + // Simple removals + if (REMOVE_OPTIONS.includes(propName)) { + props.splice(i, 1); + modified = true; + changes.push(`Removed '${propName}' option`); + continue; + } + + // Manual review options + if (propName in MANUAL_REVIEW_OPTIONS) { + manualReviewItems.push({ + file: ctx.filePath, + line: getLineNumber(prop), + description: MANUAL_REVIEW_OPTIONS[propName], + }); + // Still remove the property + props.splice(i, 1); + modified = true; + changes.push(`Removed '${propName}' (needs manual review)`); + continue; + } + } + + this.traverse(path); + }, + }); + + // Flatten transactionContext access in tracesSampler + recast.visit(ctx.program, { + visitMemberExpression(path) { + const node = path.node; + + // Look for: *.transactionContext.property + if ( + node.property.type === 'Identifier' && + node.object.type === 'MemberExpression' && + node.object.property.type === 'Identifier' && + node.object.property.name === 'transactionContext' + ) { + // Replace X.transactionContext.Y with X.Y + const outerObject = node.object.object; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + path.replace(b.memberExpression(outerObject as any, node.property)); + modified = true; + changes.push( + `Flattened transactionContext.${node.property.name} access`, + ); + return false; + } + + this.traverse(path); + }, + }); + + return { modified, changes, manualReviewItems }; + }, +}; diff --git a/src/upgrade/codemods/v8-to-v9/hub-removal.ts b/src/upgrade/codemods/v8-to-v9/hub-removal.ts new file mode 100644 index 000000000..8365579dd --- /dev/null +++ b/src/upgrade/codemods/v8-to-v9/hub-removal.ts @@ -0,0 +1,297 @@ +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; +import type { + CodemodTransform, + TransformContext, + CodemodResult, + ManualReviewItem, +} from '../../types.js'; + +const b = recast.types.builders; + +const HUB_FUNCTIONS = ['getCurrentHub', 'getCurrentHubShim']; + +// Methods that can be called directly on the Sentry namespace +const DIRECT_METHODS: Record = { + captureException: 'captureException', + captureMessage: 'captureMessage', + captureEvent: 'captureEvent', + addBreadcrumb: 'addBreadcrumb', + setUser: 'setUser', + setTags: 'setTags', + setTag: 'setTag', + setExtra: 'setExtra', + setExtras: 'setExtras', + setContext: 'setContext', +}; + +// Methods that map to different top-level functions +const SCOPE_METHODS: Record = { + getScope: 'getCurrentScope', + getClient: 'getClient', + getIsolationScope: 'getIsolationScope', +}; + +function getLineNumber(node: t.Node): number { + return node.loc?.start.line ?? 0; +} + +export const hubRemoval: CodemodTransform = { + name: 'hub-removal', + description: + 'Removes getCurrentHub() and getCurrentHubShim() calls, replacing with direct API calls', + + transform(ctx: TransformContext): CodemodResult { + let modified = false; + const changes: string[] = []; + const manualReviewItems: ManualReviewItem[] = []; + + // Track which hub function names are imported directly (not via namespace) + const directHubImports = new Set(); + // Track replacements needed for direct imports + const importReplacements = new Map>(); + + // First pass: find direct imports of getCurrentHub/getCurrentHubShim + recast.visit(ctx.program, { + visitImportDeclaration(path) { + const specifiers = path.node.specifiers; + if (!specifiers) { + this.traverse(path); + return; + } + for (const spec of specifiers) { + if ( + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + HUB_FUNCTIONS.includes(spec.imported.name) + ) { + directHubImports.add(spec.imported.name); + } + } + this.traverse(path); + }, + visitVariableDeclarator(_path) { + this.traverse(_path); + }, + }); + + // Second pass: transform hub method calls + recast.visit(ctx.program, { + visitExpressionStatement(path) { + const expr = path.node.expression; + if (expr.type !== 'CallExpression') { + this.traverse(path); + return; + } + + const result = tryTransformHubCall( + expr, + ctx, + directHubImports, + importReplacements, + manualReviewItems, + ); + + if (result) { + path.node.expression = result; + modified = true; + changes.push('Replaced getCurrentHub() chain with direct call'); + } + this.traverse(path); + }, + + visitVariableDeclarator(path) { + const init = path.node.init; + if (!init || init.type !== 'CallExpression') { + this.traverse(path); + return; + } + + // Check for: const hub = getCurrentHub() or const hub = Sentry.getCurrentHub() + if (isHubCreation(init, directHubImports)) { + manualReviewItems.push({ + file: ctx.filePath, + line: getLineNumber(path.node), + description: + 'getCurrentHub() stored in variable. Replace usages manually: hub.captureException() → Sentry.captureException(), hub.getScope() → Sentry.getCurrentScope(), etc.', + }); + this.traverse(path); + return; + } + + // Check for: const scope = Sentry.getCurrentHub().getScope() + const result = tryTransformHubCall( + init, + ctx, + directHubImports, + importReplacements, + manualReviewItems, + ); + + if (result) { + path.node.init = result; + modified = true; + changes.push('Replaced getCurrentHub() chain with direct call'); + } + this.traverse(path); + }, + }); + + // Third pass: update direct imports (replace getCurrentHub with the methods used) + if (importReplacements.size > 0) { + recast.visit(ctx.program, { + visitImportDeclaration(path) { + const specifiers = path.node.specifiers; + if (!specifiers) { + this.traverse(path); + return; + } + + const newSpecifiers: t.ImportSpecifier[] = []; + let changed = false; + + for (const spec of specifiers) { + if ( + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + HUB_FUNCTIONS.includes(spec.imported.name) + ) { + // Replace this specifier with the actual methods used + const replacements = importReplacements.get(spec.imported.name); + if (replacements) { + for (const methodName of replacements) { + newSpecifiers.push( + b.importSpecifier(b.identifier(methodName)), + ); + } + changed = true; + } + } else if (spec.type === 'ImportSpecifier') { + newSpecifiers.push(spec); + } + } + + if (changed) { + path.node.specifiers = newSpecifiers; + modified = true; + } + this.traverse(path); + }, + }); + } + + return { modified, changes, manualReviewItems }; + }, +}; + +function isHubCreation( + node: t.CallExpression, + directHubImports: Set, +): boolean { + // Direct call: getCurrentHub() + if ( + node.callee.type === 'Identifier' && + directHubImports.has(node.callee.name) + ) { + return true; + } + // Namespace call: Sentry.getCurrentHub() + if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + HUB_FUNCTIONS.includes(node.callee.property.name) + ) { + return true; + } + return false; +} + +function tryTransformHubCall( + callExpr: t.CallExpression, + ctx: TransformContext, + directHubImports: Set, + importReplacements: Map>, + manualReviewItems: ManualReviewItem[], +): t.CallExpression | null { + // Pattern: X.getCurrentHub().method(args) or getCurrentHub().method(args) + // The outer call is method(args), its callee is a MemberExpression: X.getCurrentHub().method + const callee = callExpr.callee; + if (callee.type !== 'MemberExpression') { + return null; + } + + const methodProp = callee.property; + if (methodProp.type !== 'Identifier') { + return null; + } + + const hubCall = callee.object; + if (hubCall.type !== 'CallExpression') { + return null; + } + + // Check if it's getCurrentHub() or Sentry.getCurrentHub() + let hubFnName: string | null = null; + let namespace: t.Expression | null = null; + + if ( + hubCall.callee.type === 'Identifier' && + directHubImports.has(hubCall.callee.name) + ) { + hubFnName = hubCall.callee.name; + } else if ( + hubCall.callee.type === 'MemberExpression' && + hubCall.callee.property.type === 'Identifier' && + HUB_FUNCTIONS.includes(hubCall.callee.property.name) + ) { + hubFnName = hubCall.callee.property.name; + namespace = hubCall.callee.object as t.Expression; + } + + if (!hubFnName) { + return null; + } + + const methodName = methodProp.name; + let replacementName: string | null = null; + + if (methodName in DIRECT_METHODS) { + replacementName = DIRECT_METHODS[methodName]; + } else if (methodName in SCOPE_METHODS) { + replacementName = SCOPE_METHODS[methodName]; + } + + if (!replacementName) { + manualReviewItems.push({ + file: ctx.filePath, + line: getLineNumber(callExpr), + description: `getCurrentHub().${methodName}() cannot be auto-migrated. See migration guide.`, + }); + return null; + } + + // Build the replacement call + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let newCallee: any; + if (namespace) { + // Sentry.getCurrentHub().method() → Sentry.method() + newCallee = b.memberExpression( + namespace as t.Identifier, + b.identifier(replacementName), + ); + } else { + // getCurrentHub().method() → method() + newCallee = b.identifier(replacementName); + // Track this for import rewriting + const existing = importReplacements.get(hubFnName); + if (existing) { + existing.add(replacementName); + } else { + importReplacements.set(hubFnName, new Set([replacementName])); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return b.callExpression(newCallee, callExpr.arguments); +} diff --git a/src/upgrade/codemods/v8-to-v9/index.ts b/src/upgrade/codemods/v8-to-v9/index.ts new file mode 100644 index 000000000..be3c065ea --- /dev/null +++ b/src/upgrade/codemods/v8-to-v9/index.ts @@ -0,0 +1,12 @@ +import type { CodemodTransform } from '../../types.js'; +import { packageRemapping } from './package-remapping.js'; +import { hubRemoval } from './hub-removal.js'; +import { methodRenames } from './method-renames.js'; +import { configChanges } from './config-changes.js'; + +export const v8ToV9Codemods: CodemodTransform[] = [ + packageRemapping, + hubRemoval, + methodRenames, + configChanges, +]; diff --git a/src/upgrade/codemods/v8-to-v9/method-renames.ts b/src/upgrade/codemods/v8-to-v9/method-renames.ts new file mode 100644 index 000000000..9cdf6069d --- /dev/null +++ b/src/upgrade/codemods/v8-to-v9/method-renames.ts @@ -0,0 +1,190 @@ +import * as recast from 'recast'; +import type { + CodemodTransform, + TransformContext, + CodemodResult, + ManualReviewItem, +} from '../../types.js'; + +// Import specifier renames: oldName → newName +const IMPORT_RENAMES: Record = { + WithSentry: 'SentryExceptionCaptured', + SentryGlobalGenericFilter: 'SentryGlobalFilter', + SentryGlobalGraphQLFilter: 'SentryGlobalFilter', +}; + +// Method renames on Sentry namespace: oldName → newName +const METHOD_RENAMES: Record = { + captureUserFeedback: 'captureFeedback', +}; + +// Methods that need manual review with a descriptive message +const MANUAL_REVIEW_METHODS: Record = { + addOpenTelemetryInstrumentation: + "addOpenTelemetryInstrumentation() was removed. Pass instrumentations via the 'openTelemetryInstrumentations' option in Sentry.init() instead.", +}; + +// Import specifiers that need manual review +const MANUAL_REVIEW_IMPORTS: Record = { + wrapUseRoutes: + 'wrapUseRoutes was removed. Use wrapUseRoutesV6 or wrapUseRoutesV7 depending on your React Router version.', + wrapCreateBrowserRouter: + 'wrapCreateBrowserRouter was removed. Use wrapCreateBrowserRouterV6 or wrapCreateBrowserRouterV7 depending on your React Router version.', +}; + +function getLineNumber(node: recast.types.namedTypes.Node): number { + return node.loc?.start.line ?? 0; +} + +export const methodRenames: CodemodTransform = { + name: 'method-renames', + description: + 'Renames removed/deprecated methods to their replacements (captureUserFeedback → captureFeedback, etc.)', + + transform(ctx: TransformContext): CodemodResult { + let modified = false; + const changes: string[] = []; + const manualReviewItems: ManualReviewItem[] = []; + + // Pass 1: Rename import specifiers + recast.visit(ctx.program, { + visitImportSpecifier(path) { + const imported = path.node.imported; + if (imported.type !== 'Identifier') { + this.traverse(path); + return; + } + + const name = imported.name; + + // Check for manual review imports + if (name in MANUAL_REVIEW_IMPORTS) { + manualReviewItems.push({ + file: ctx.filePath, + line: getLineNumber(path.node), + description: MANUAL_REVIEW_IMPORTS[name], + }); + this.traverse(path); + return; + } + + // Check for direct renames + if (name in IMPORT_RENAMES) { + const newName = IMPORT_RENAMES[name]; + imported.name = newName; + // Also rename local binding if it matches (no alias) + if ( + path.node.local && + path.node.local.type === 'Identifier' && + path.node.local.name === name + ) { + path.node.local.name = newName; + } + modified = true; + changes.push(`Renamed import '${name}' → '${newName}'`); + } + this.traverse(path); + }, + }); + + // Pass 2: Rename method calls and their arguments + recast.visit(ctx.program, { + visitCallExpression(path) { + const node = path.node; + const callee = node.callee; + + // Sentry.methodName() pattern + if (callee.type === 'MemberExpression') { + const prop = callee.property; + if (prop.type !== 'Identifier') { + this.traverse(path); + return; + } + + // Check for manual review methods + if (prop.name in MANUAL_REVIEW_METHODS) { + manualReviewItems.push({ + file: ctx.filePath, + line: getLineNumber(node), + description: MANUAL_REVIEW_METHODS[prop.name], + }); + this.traverse(path); + return; + } + + // Check for method renames + if (prop.name in METHOD_RENAMES) { + const oldName = prop.name; + const newName = METHOD_RENAMES[oldName]; + prop.name = newName; + modified = true; + changes.push(`Renamed method '${oldName}' → '${newName}'`); + + // Special handling: captureUserFeedback → captureFeedback + // also rename 'comments' field to 'message' + if (oldName === 'captureUserFeedback') { + renameFeedbackComments(node, changes); + } + } + } + + // Also handle renamed identifiers used as decorators or direct calls + // e.g. if they imported WithSentry and use it as WithSentry() + if (callee.type === 'Identifier' && callee.name in IMPORT_RENAMES) { + const oldName = callee.name; + callee.name = IMPORT_RENAMES[oldName]; + modified = true; + changes.push( + `Renamed call '${oldName}' → '${IMPORT_RENAMES[oldName]}'`, + ); + } + + this.traverse(path); + }, + + // Handle decorator usage: @WithSentry() → @SentryExceptionCaptured() + visitDecorator(path) { + const expr = path.node.expression; + if ( + expr.type === 'CallExpression' && + expr.callee.type === 'Identifier' + ) { + if (expr.callee.name in IMPORT_RENAMES) { + const oldName = expr.callee.name; + expr.callee.name = IMPORT_RENAMES[oldName]; + modified = true; + changes.push( + `Renamed decorator '@${oldName}' → '@${IMPORT_RENAMES[oldName]}'`, + ); + } + } + this.traverse(path); + }, + }); + + return { modified, changes, manualReviewItems }; + }, +}; + +function renameFeedbackComments( + callExpr: recast.types.namedTypes.CallExpression, + changes: string[], +): void { + // Look for the first object argument and rename 'comments' to 'message' + for (const arg of callExpr.arguments) { + if (arg.type !== 'ObjectExpression') { + continue; + } + + for (const prop of arg.properties) { + if ( + (prop.type === 'ObjectProperty' || prop.type === 'Property') && + prop.key.type === 'Identifier' && + prop.key.name === 'comments' + ) { + prop.key.name = 'message'; + changes.push("Renamed field 'comments' → 'message' in feedback object"); + } + } + } +} diff --git a/src/upgrade/codemods/v8-to-v9/package-remapping.ts b/src/upgrade/codemods/v8-to-v9/package-remapping.ts new file mode 100644 index 000000000..2bc403be1 --- /dev/null +++ b/src/upgrade/codemods/v8-to-v9/package-remapping.ts @@ -0,0 +1,61 @@ +import * as recast from 'recast'; +import type { + CodemodTransform, + TransformContext, + CodemodResult, +} from '../../types.js'; + +const PACKAGE_REMAP: Record = { + '@sentry/utils': '@sentry/core', + '@sentry/types': '@sentry/core', +}; + +export const packageRemapping: CodemodTransform = { + name: 'package-remapping', + description: + 'Remaps removed packages (@sentry/utils, @sentry/types) to @sentry/core', + + transform(ctx: TransformContext): CodemodResult { + let modified = false; + const changes: string[] = []; + + recast.visit(ctx.program, { + // ESM: import ... from '@sentry/utils' + visitImportDeclaration(path) { + const source = path.node.source; + if (source.type === 'StringLiteral' && source.value in PACKAGE_REMAP) { + const oldPkg = source.value; + const newPkg = PACKAGE_REMAP[oldPkg]; + source.value = newPkg; + modified = true; + changes.push(`Remapped import '${oldPkg}' → '${newPkg}'`); + } + this.traverse(path); + }, + + // CJS: require('@sentry/utils') + visitCallExpression(path) { + const node = path.node; + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length === 1 + ) { + const arg = node.arguments[0]; + if (arg.type === 'StringLiteral' && arg.value in PACKAGE_REMAP) { + const oldPkg = arg.value; + const newPkg = PACKAGE_REMAP[oldPkg]; + arg.value = newPkg; + modified = true; + changes.push( + `Remapped require('${oldPkg}') → require('${newPkg}')`, + ); + } + } + this.traverse(path); + }, + }); + + return { modified, changes, manualReviewItems: [] }; + }, +}; diff --git a/src/upgrade/file-discovery.ts b/src/upgrade/file-discovery.ts new file mode 100644 index 000000000..5b918808e --- /dev/null +++ b/src/upgrade/file-discovery.ts @@ -0,0 +1,48 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { glob } from 'glob'; + +const FILE_EXTENSIONS = ['js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs']; +const IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.next/**', + '**/.nuxt/**', + '**/coverage/**', + '**/*.min.js', + '**/*.d.ts', +]; + +export async function discoverFiles(projectDir: string): Promise { + const pattern = `**/*.{${FILE_EXTENSIONS.join(',')}}`; + const files = await glob(pattern, { + cwd: projectDir, + ignore: IGNORE_PATTERNS, + absolute: true, + }); + + // Filter to only files containing @sentry/ references + const sentryFiles: string[] = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf-8'); + if (content.includes('@sentry/')) { + sentryFiles.push(file); + } + } + + return sentryFiles; +} + +export function readPackageJson( + projectDir: string, +): Record | null { + const pkgPath = path.join(projectDir, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return null; + } + return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as Record< + string, + unknown + >; +} diff --git a/src/upgrade/types.ts b/src/upgrade/types.ts new file mode 100644 index 000000000..a36a08aab --- /dev/null +++ b/src/upgrade/types.ts @@ -0,0 +1,43 @@ +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + +export interface CodemodTransform { + name: string; + description: string; + transform(ctx: TransformContext): CodemodResult; +} + +export interface TransformContext { + program: t.Program; + filePath: string; + sourceCode: string; +} + +export interface CodemodResult { + modified: boolean; + changes: string[]; + manualReviewItems: ManualReviewItem[]; +} + +export interface ManualReviewItem { + file: string; + line: number; + description: string; +} + +export interface SentryPackageInfo { + name: string; + version: string; +} + +export interface RemovedPackageInfo { + name: string; + removedInVersion: number; +} + +export interface VersionDetectionResult { + majorVersion: number | null; + packages: SentryPackageInfo[]; + hasRemovedPackages: RemovedPackageInfo[]; +} diff --git a/src/upgrade/upgrade-wizard.ts b/src/upgrade/upgrade-wizard.ts new file mode 100644 index 000000000..85d9d52f9 --- /dev/null +++ b/src/upgrade/upgrade-wizard.ts @@ -0,0 +1,131 @@ +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import chalk from 'chalk'; + +import { + detectSentryVersion, + calculateMigrationPath, +} from './version-detection.js'; +import { discoverFiles, readPackageJson } from './file-discovery.js'; +import { runCodemodsOnFiles } from './codemod-runner.js'; +import { v8ToV9Codemods } from './codemods/v8-to-v9/index.js'; +import type { CodemodTransform } from './types.js'; + +const CODEMOD_REGISTRY: Record = { + 'v8-to-v9': v8ToV9Codemods, +}; + +export async function runUpgradeWizard(options: { + projectDir: string; + targetVersion?: number; +}): Promise { + clack.intro(chalk.inverse(' Sentry SDK Upgrade Wizard ')); + + const pkg = readPackageJson(options.projectDir); + if (!pkg) { + clack.log.error('No package.json found in project directory.'); + clack.outro('Upgrade cancelled.'); + return; + } + + const versionInfo = detectSentryVersion( + pkg as { + dependencies?: Record; + devDependencies?: Record; + }, + ); + + if (versionInfo.majorVersion === null) { + clack.log.error('No @sentry/* packages found in package.json.'); + clack.outro('Upgrade cancelled.'); + return; + } + + clack.log.info( + `Detected Sentry SDK v${versionInfo.majorVersion} (${versionInfo.packages.length} package(s))`, + ); + + if (versionInfo.hasRemovedPackages.length > 0) { + clack.log.warn( + `Found packages that will be removed: ${versionInfo.hasRemovedPackages + .map((p) => p.name) + .join(', ')}`, + ); + } + + const targetVersion = options.targetVersion ?? versionInfo.majorVersion + 1; + const migrationPath = calculateMigrationPath( + versionInfo.majorVersion, + targetVersion, + ); + + if (migrationPath.length === 0) { + clack.log.info('Already on the target version. No migration needed.'); + clack.outro('Done!'); + return; + } + + // Check that we have codemods for all steps + const missingSteps = migrationPath.filter( + (step) => !(step in CODEMOD_REGISTRY), + ); + if (missingSteps.length > 0) { + clack.log.error( + `No codemods available for: ${missingSteps.join( + ', ', + )}. Only v8→v9 is currently supported.`, + ); + clack.outro('Upgrade cancelled.'); + return; + } + + clack.log.info(`Migration path: ${migrationPath.join(' → ')}`); + + const spinner = clack.spinner(); + spinner.start('Discovering files with Sentry imports...'); + + const files = await discoverFiles(options.projectDir); + spinner.stop(`Found ${files.length} file(s) with Sentry imports.`); + + if (files.length === 0) { + clack.log.warn('No files with Sentry imports found.'); + clack.outro('Done!'); + return; + } + + // Run codemods for each step + for (const step of migrationPath) { + const transforms = CODEMOD_REGISTRY[step]; + clack.log.step(`Running ${step} codemods...`); + + const result = runCodemodsOnFiles(files, transforms); + + clack.log.info(`Modified ${result.filesModified} file(s).`); + + if (result.totalChanges.length > 0) { + for (const change of result.totalChanges) { + clack.log.info(` ${change}`); + } + } + + if (result.manualReviewItems.length > 0) { + clack.log.warn( + `${result.manualReviewItems.length} item(s) require manual review:`, + ); + for (const item of result.manualReviewItems) { + clack.log.warn(` ${item.file}:${item.line} — ${item.description}`); + } + } + + if (result.errors.length > 0) { + clack.log.error(`${result.errors.length} file(s) had errors:`); + for (const err of result.errors) { + clack.log.error(` ${err.file}: ${err.error}`); + } + } + } + + clack.outro( + chalk.green('Upgrade codemods applied! Review changes and run your tests.'), + ); +} diff --git a/src/upgrade/version-detection.ts b/src/upgrade/version-detection.ts new file mode 100644 index 000000000..37b92a1f6 --- /dev/null +++ b/src/upgrade/version-detection.ts @@ -0,0 +1,69 @@ +import type { + SentryPackageInfo, + RemovedPackageInfo, + VersionDetectionResult, +} from './types.js'; + +const REMOVED_PACKAGES: Record = { + '@sentry/utils': 9, + '@sentry/types': 9, +}; + +function parseMajorVersion(versionRange: string): number | null { + const match = versionRange.match(/(\d+)/); + return match ? parseInt(match[1], 10) : null; +} + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; +} + +export function detectSentryVersion(pkg: PackageJson): VersionDetectionResult { + const allDeps: Record = { + ...pkg.dependencies, + ...pkg.devDependencies, + }; + + const packages: SentryPackageInfo[] = []; + const hasRemovedPackages: RemovedPackageInfo[] = []; + let maxMajor: number | null = null; + + for (const [name, version] of Object.entries(allDeps)) { + if (!name.startsWith('@sentry/')) { + continue; + } + + packages.push({ name, version }); + + const major = parseMajorVersion(version); + if (major !== null && (maxMajor === null || major > maxMajor)) { + maxMajor = major; + } + + if (name in REMOVED_PACKAGES) { + hasRemovedPackages.push({ + name, + removedInVersion: REMOVED_PACKAGES[name], + }); + } + } + + return { + majorVersion: maxMajor, + packages, + hasRemovedPackages, + }; +} + +export function calculateMigrationPath(from: number, to: number): string[] { + if (from >= to) { + return []; + } + + const steps: string[] = []; + for (let v = from; v < to; v++) { + steps.push(`v${v}-to-v${v + 1}`); + } + return steps; +} diff --git a/test/upgrade/codemods/v8-to-v9/config-changes.test.ts b/test/upgrade/codemods/v8-to-v9/config-changes.test.ts new file mode 100644 index 000000000..94a1bc434 --- /dev/null +++ b/test/upgrade/codemods/v8-to-v9/config-changes.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + assertTransform, + assertNoChange, + runTransform, +} from '../../test-utils.js'; +import { configChanges } from '../../../../src/upgrade/codemods/v8-to-v9/config-changes.js'; + +describe('config-changes v8→v9', () => { + it('replaces enableTracing: true with tracesSampleRate', () => { + const input = `Sentry.init({ + dsn: '__DSN__', + enableTracing: true, +});`; + const expected = `Sentry.init({ + dsn: '__DSN__', + // TODO(sentry-upgrade): 'enableTracing' was removed. Use tracesSampleRate instead. + tracesSampleRate: 1, +});`; + assertTransform(configChanges, input, expected); + }); + + it('removes enableTracing: false', () => { + const input = `Sentry.init({ + dsn: '__DSN__', + enableTracing: false, +});`; + // Recast removes the trailing comma when the last prop is removed + const expected = `Sentry.init({ + dsn: '__DSN__' +});`; + assertTransform(configChanges, input, expected); + }); + + it('adds TODO for autoSessionTracking removal', () => { + const input = `Sentry.init({ + dsn: '__DSN__', + autoSessionTracking: true, +});`; + const result = runTransform(configChanges, input); + expect(result.manualReviewItems.length).toBeGreaterThan(0); + expect(result.manualReviewItems[0].description).toContain( + 'autoSessionTracking', + ); + }); + + it('flattens transactionContext in tracesSampler', () => { + const input = `Sentry.init({ + tracesSampler: (samplingContext) => { + if (samplingContext.transactionContext.name === '/health-check') { + return 0; + } + return 0.5; + }, +});`; + const expected = `Sentry.init({ + tracesSampler: (samplingContext) => { + if (samplingContext.name === '/health-check') { + return 0; + } + return 0.5; + }, +});`; + assertTransform(configChanges, input, expected); + }); + + it('removes hideSourceMaps option', () => { + const input = `module.exports = withSentryConfig(nextConfig, { + hideSourceMaps: true, +});`; + // Recast collapses empty object to single line + const expected = `module.exports = withSentryConfig(nextConfig, {});`; + assertTransform(configChanges, input, expected); + }); + + it('removes autoInstrumentRemix option', () => { + const input = `Sentry.init({ + dsn: '__DSN__', + autoInstrumentRemix: true, +});`; + const expected = `Sentry.init({ + dsn: '__DSN__' +});`; + assertTransform(configChanges, input, expected); + }); + + it('does not modify init call without deprecated options', () => { + const input = `Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, +});`; + assertNoChange(configChanges, input); + }); +}); diff --git a/test/upgrade/codemods/v8-to-v9/hub-removal.test.ts b/test/upgrade/codemods/v8-to-v9/hub-removal.test.ts new file mode 100644 index 000000000..dc8880217 --- /dev/null +++ b/test/upgrade/codemods/v8-to-v9/hub-removal.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { assertTransform, runTransform } from '../../test-utils.js'; +import { hubRemoval } from '../../../../src/upgrade/codemods/v8-to-v9/hub-removal.js'; + +describe('hub-removal v8→v9', () => { + it('replaces Sentry.getCurrentHub().captureException()', () => { + const input = `import * as Sentry from '@sentry/browser'; +Sentry.getCurrentHub().captureException(error);`; + const expected = `import * as Sentry from '@sentry/browser'; +Sentry.captureException(error);`; + assertTransform(hubRemoval, input, expected); + }); + + it('replaces Sentry.getCurrentHub().captureMessage()', () => { + const input = `import * as Sentry from '@sentry/browser'; +Sentry.getCurrentHub().captureMessage('hello');`; + const expected = `import * as Sentry from '@sentry/browser'; +Sentry.captureMessage('hello');`; + assertTransform(hubRemoval, input, expected); + }); + + it('replaces Sentry.getCurrentHub().getScope()', () => { + const input = `import * as Sentry from '@sentry/browser'; +const scope = Sentry.getCurrentHub().getScope();`; + const expected = `import * as Sentry from '@sentry/browser'; +const scope = Sentry.getCurrentScope();`; + assertTransform(hubRemoval, input, expected); + }); + + it('replaces Sentry.getCurrentHub().getClient()', () => { + const input = `import * as Sentry from '@sentry/browser'; +const client = Sentry.getCurrentHub().getClient();`; + const expected = `import * as Sentry from '@sentry/browser'; +const client = Sentry.getClient();`; + assertTransform(hubRemoval, input, expected); + }); + + it('replaces direct getCurrentHub() import call', () => { + const input = `import { getCurrentHub } from '@sentry/browser'; +getCurrentHub().captureMessage('hello');`; + const expected = `import { captureMessage } from '@sentry/browser'; +captureMessage('hello');`; + assertTransform(hubRemoval, input, expected); + }); + + it('adds manual review for stored hub variable', () => { + const input = `import * as Sentry from '@sentry/browser'; +const hub = Sentry.getCurrentHub(); +hub.captureException(error);`; + const result = runTransform(hubRemoval, input); + expect(result.manualReviewItems.length).toBeGreaterThan(0); + expect(result.manualReviewItems[0].description).toContain('getCurrentHub'); + }); + + it('replaces getCurrentHubShim() similarly', () => { + const input = `import * as Sentry from '@sentry/core'; +Sentry.getCurrentHubShim().captureException(error);`; + const expected = `import * as Sentry from '@sentry/core'; +Sentry.captureException(error);`; + assertTransform(hubRemoval, input, expected); + }); +}); diff --git a/test/upgrade/codemods/v8-to-v9/method-renames.test.ts b/test/upgrade/codemods/v8-to-v9/method-renames.test.ts new file mode 100644 index 000000000..ed9465bd5 --- /dev/null +++ b/test/upgrade/codemods/v8-to-v9/method-renames.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { assertTransform, runTransform } from '../../test-utils.js'; +import { methodRenames } from '../../../../src/upgrade/codemods/v8-to-v9/method-renames.js'; + +describe('method-renames v8→v9', () => { + it('renames captureUserFeedback to captureFeedback', () => { + const input = `import * as Sentry from '@sentry/browser'; +Sentry.captureUserFeedback({ comments: 'bug here', name: 'Jane' });`; + const expected = `import * as Sentry from '@sentry/browser'; +Sentry.captureFeedback({ message: 'bug here', name: 'Jane' });`; + assertTransform(methodRenames, input, expected); + }); + + it('renames comments field in captureFeedback object', () => { + const input = `Sentry.captureUserFeedback({ + comments: feedback, + email: user.email, +});`; + const expected = `Sentry.captureFeedback({ + message: feedback, + email: user.email, +});`; + assertTransform(methodRenames, input, expected); + }); + + it('renames @WithSentry to @SentryExceptionCaptured', () => { + const input = `import { WithSentry } from '@sentry/nestjs';`; + const expected = `import { SentryExceptionCaptured } from '@sentry/nestjs';`; + assertTransform(methodRenames, input, expected); + }); + + it('renames SentryGlobalGenericFilter to SentryGlobalFilter', () => { + const input = `import { SentryGlobalGenericFilter } from '@sentry/nestjs';`; + const expected = `import { SentryGlobalFilter } from '@sentry/nestjs';`; + assertTransform(methodRenames, input, expected); + }); + + it('renames SentryGlobalGraphQLFilter to SentryGlobalFilter', () => { + const input = `import { SentryGlobalGraphQLFilter } from '@sentry/nestjs';`; + const expected = `import { SentryGlobalFilter } from '@sentry/nestjs';`; + assertTransform(methodRenames, input, expected); + }); + + it('adds TODO for wrapUseRoutes (version-dependent)', () => { + const input = `import { wrapUseRoutes } from '@sentry/react';`; + const result = runTransform(methodRenames, input); + expect(result.manualReviewItems.length).toBeGreaterThan(0); + expect(result.manualReviewItems[0].description).toContain( + 'wrapUseRoutesV6 or wrapUseRoutesV7', + ); + }); + + it('adds TODO for wrapCreateBrowserRouter (version-dependent)', () => { + const input = `import { wrapCreateBrowserRouter } from '@sentry/react';`; + const result = runTransform(methodRenames, input); + expect(result.manualReviewItems.length).toBeGreaterThan(0); + }); + + it('adds TODO for addOpenTelemetryInstrumentation', () => { + const input = `import * as Sentry from '@sentry/node'; +Sentry.addOpenTelemetryInstrumentation(new GenericPoolInstrumentation());`; + const result = runTransform(methodRenames, input); + expect(result.manualReviewItems.length).toBeGreaterThan(0); + expect(result.manualReviewItems[0].description).toContain( + 'openTelemetryInstrumentations', + ); + }); +}); diff --git a/test/upgrade/codemods/v8-to-v9/package-remapping.test.ts b/test/upgrade/codemods/v8-to-v9/package-remapping.test.ts new file mode 100644 index 000000000..c128274f6 --- /dev/null +++ b/test/upgrade/codemods/v8-to-v9/package-remapping.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from 'vitest'; +import { assertTransform, assertNoChange } from '../../test-utils.js'; +import { packageRemapping } from '../../../../src/upgrade/codemods/v8-to-v9/package-remapping.js'; + +describe('package-remapping v8→v9', () => { + // ESM imports + it('remaps @sentry/utils to @sentry/core (ESM)', () => { + const input = `import { addBreadcrumb } from "@sentry/utils";`; + const expected = `import { addBreadcrumb } from "@sentry/core";`; + assertTransform(packageRemapping, input, expected); + }); + + it('remaps @sentry/types to @sentry/core (ESM)', () => { + const input = `import type { Event } from "@sentry/types";`; + const expected = `import type { Event } from "@sentry/core";`; + assertTransform(packageRemapping, input, expected); + }); + + it('remaps multiple imports from @sentry/utils', () => { + const input = `import { addBreadcrumb, logger } from "@sentry/utils";`; + const expected = `import { addBreadcrumb, logger } from "@sentry/core";`; + assertTransform(packageRemapping, input, expected); + }); + + it('remaps namespace import from @sentry/utils', () => { + const input = `import * as SentryUtils from "@sentry/utils";`; + const expected = `import * as SentryUtils from "@sentry/core";`; + assertTransform(packageRemapping, input, expected); + }); + + // CJS requires + it('remaps require @sentry/utils to @sentry/core (CJS)', () => { + const input = `const { addBreadcrumb } = require("@sentry/utils");`; + const expected = `const { addBreadcrumb } = require("@sentry/core");`; + assertTransform(packageRemapping, input, expected); + }); + + it('remaps require @sentry/types to @sentry/core (CJS)', () => { + const input = `const { Event } = require("@sentry/types");`; + const expected = `const { Event } = require("@sentry/core");`; + assertTransform(packageRemapping, input, expected); + }); + + // No-op cases + it('does not modify @sentry/browser imports', () => { + const input = `import * as Sentry from "@sentry/browser";`; + assertNoChange(packageRemapping, input); + }); + + it('does not modify @sentry/core imports', () => { + const input = `import { addBreadcrumb } from "@sentry/core";`; + assertNoChange(packageRemapping, input); + }); + + it('does not modify @sentry/node imports', () => { + const input = `import * as Sentry from "@sentry/node";`; + assertNoChange(packageRemapping, input); + }); +}); diff --git a/test/upgrade/e2e.test.ts b/test/upgrade/e2e.test.ts new file mode 100644 index 000000000..82ff1591d --- /dev/null +++ b/test/upgrade/e2e.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +import { discoverFiles } from '../../src/upgrade/file-discovery.js'; +import { runCodemodsOnFiles } from '../../src/upgrade/codemod-runner.js'; +import { v8ToV9Codemods } from '../../src/upgrade/codemods/v8-to-v9/index.js'; + +describe('upgrade e2e: v8 → v9', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-upgrade-e2e-')); + }); + + afterEach(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + function writeFile(name: string, content: string): void { + const filePath = path.join(tmpDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + + function readFile(name: string): string { + return fs.readFileSync(path.join(tmpDir, name), 'utf-8'); + } + + it('transforms a full v8 project to v9', async () => { + // package.json with v8 deps + writeFile( + 'package.json', + JSON.stringify({ + dependencies: { + '@sentry/browser': '^8.40.0', + '@sentry/utils': '^8.40.0', + }, + }), + ); + + // File with multiple v8 patterns + writeFile( + 'src/sentry.ts', + `import * as Sentry from "@sentry/browser"; +import { logger } from "@sentry/utils"; + +Sentry.init({ + dsn: "__DSN__", + enableTracing: true, +}); + +Sentry.getCurrentHub().captureException(new Error("test")); +Sentry.captureUserFeedback({ comments: "bug", name: "Jane" }); +`, + ); + + // File with CJS require + writeFile( + 'src/legacy.js', + `const { addBreadcrumb } = require("@sentry/utils"); +addBreadcrumb({ message: "hello" }); +`, + ); + + // File without sentry (should be skipped) + writeFile('src/other.ts', `console.log("no sentry here");`); + + // Run discovery + codemods + const files = await discoverFiles(tmpDir); + expect(files).toHaveLength(2); + + const result = runCodemodsOnFiles(files, v8ToV9Codemods); + expect(result.filesModified).toBe(2); + expect(result.errors).toHaveLength(0); + + // Verify sentry.ts transforms + const sentryTs = readFile('src/sentry.ts'); + // Package remapping + expect(sentryTs).toContain('"@sentry/core"'); + expect(sentryTs).not.toContain('"@sentry/utils"'); + // enableTracing replaced (the string still appears in the TODO comment, but not as a config key) + expect(sentryTs).not.toContain('enableTracing: true'); + expect(sentryTs).toContain('tracesSampleRate'); + // Hub removal + expect(sentryTs).not.toContain('getCurrentHub'); + expect(sentryTs).toContain('Sentry.captureException'); + // Method rename + expect(sentryTs).not.toContain('captureUserFeedback'); + expect(sentryTs).toContain('captureFeedback'); + expect(sentryTs).toContain('message: "bug"'); + + // Verify legacy.js transforms + const legacyJs = readFile('src/legacy.js'); + expect(legacyJs).toContain('"@sentry/core"'); + expect(legacyJs).not.toContain('"@sentry/utils"'); + }); + + it('reports manual review items for complex patterns', async () => { + writeFile( + 'package.json', + JSON.stringify({ dependencies: { '@sentry/browser': '^8.0.0' } }), + ); + + writeFile( + 'src/app.ts', + `import * as Sentry from "@sentry/browser"; +const hub = Sentry.getCurrentHub(); +hub.captureException(new Error("stored hub ref")); +`, + ); + + const files = await discoverFiles(tmpDir); + const result = runCodemodsOnFiles(files, v8ToV9Codemods); + + expect(result.manualReviewItems.length).toBeGreaterThan(0); + expect(result.manualReviewItems[0].description).toContain('getCurrentHub'); + }); + + it('skips node_modules and dist', async () => { + writeFile( + 'package.json', + JSON.stringify({ dependencies: { '@sentry/browser': '^8.0.0' } }), + ); + + writeFile( + 'node_modules/@sentry/browser/index.js', + `import * as Sentry from "@sentry/utils";`, + ); + + writeFile('dist/bundle.js', `import * as Sentry from "@sentry/utils";`); + + const files = await discoverFiles(tmpDir); + expect(files).toHaveLength(0); + }); +}); diff --git a/test/upgrade/test-utils.ts b/test/upgrade/test-utils.ts new file mode 100644 index 000000000..5ff978e5c --- /dev/null +++ b/test/upgrade/test-utils.ts @@ -0,0 +1,62 @@ +import { expect } from 'vitest'; +import * as recast from 'recast'; +import type { + CodemodTransform, + CodemodResult, +} from '../../src/upgrade/types.js'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment +const babelTsParser = require('recast/parsers/babel-ts'); + +export function parseCode(input: string): recast.types.namedTypes.Program { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const ast = recast.parse(input, { parser: babelTsParser }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return ast.program as recast.types.namedTypes.Program; +} + +export function printProgram(program: recast.types.namedTypes.Program): string { + const file = recast.types.builders.file(program); + return recast.print(file).code; +} + +export function assertTransform( + transform: CodemodTransform, + input: string, + expected: string, +): void { + const program = parseCode(input); + const result = transform.transform({ + program, + filePath: 'test.ts', + sourceCode: input, + }); + expect(result.modified).toBe(true); + const output = printProgram(program); + expect(output.trim()).toBe(expected.trim()); +} + +export function assertNoChange( + transform: CodemodTransform, + input: string, +): void { + const program = parseCode(input); + const result = transform.transform({ + program, + filePath: 'test.ts', + sourceCode: input, + }); + expect(result.modified).toBe(false); +} + +export function runTransform( + transform: CodemodTransform, + input: string, +): CodemodResult { + const program = parseCode(input); + return transform.transform({ + program, + filePath: 'test.ts', + sourceCode: input, + }); +} diff --git a/test/upgrade/version-detection.test.ts b/test/upgrade/version-detection.test.ts new file mode 100644 index 000000000..be20fc9e5 --- /dev/null +++ b/test/upgrade/version-detection.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + detectSentryVersion, + calculateMigrationPath, +} from '../../src/upgrade/version-detection.js'; + +describe('detectSentryVersion', () => { + it('detects v8 from @sentry/browser dependency', () => { + const pkg = { dependencies: { '@sentry/browser': '^8.40.0' } }; + expect(detectSentryVersion(pkg).majorVersion).toBe(8); + }); + + it('detects v8 from @sentry/node dependency', () => { + const pkg = { dependencies: { '@sentry/node': '~8.0.0' } }; + expect(detectSentryVersion(pkg).majorVersion).toBe(8); + }); + + it('detects removed packages (@sentry/utils)', () => { + const pkg = { dependencies: { '@sentry/utils': '^8.0.0' } }; + const info = detectSentryVersion(pkg); + expect(info.hasRemovedPackages).toContainEqual({ + name: '@sentry/utils', + removedInVersion: 9, + }); + }); + + it('detects removed packages (@sentry/types)', () => { + const pkg = { dependencies: { '@sentry/types': '^8.0.0' } }; + const info = detectSentryVersion(pkg); + expect(info.hasRemovedPackages).toContainEqual({ + name: '@sentry/types', + removedInVersion: 9, + }); + }); + + it('returns null for no sentry packages', () => { + const pkg = { dependencies: { react: '^18.0.0' } }; + expect(detectSentryVersion(pkg).majorVersion).toBeNull(); + }); + + it('returns null for empty dependencies', () => { + const pkg = {}; + expect(detectSentryVersion(pkg).majorVersion).toBeNull(); + }); + + it('handles devDependencies', () => { + const pkg = { devDependencies: { '@sentry/browser': '^8.5.0' } }; + expect(detectSentryVersion(pkg).majorVersion).toBe(8); + }); + + it('handles mixed versions and uses the highest', () => { + const pkg = { + dependencies: { + '@sentry/browser': '^8.0.0', + '@sentry/node': '^7.0.0', + }, + }; + const info = detectSentryVersion(pkg); + expect(info.packages).toHaveLength(2); + expect(info.majorVersion).toBe(8); + }); +}); + +describe('calculateMigrationPath', () => { + it('returns single step for adjacent versions', () => { + expect(calculateMigrationPath(8, 9)).toEqual(['v8-to-v9']); + }); + + it('returns multi-step for non-adjacent versions', () => { + expect(calculateMigrationPath(7, 9)).toEqual(['v7-to-v8', 'v8-to-v9']); + }); + + it('returns empty for same version', () => { + expect(calculateMigrationPath(9, 9)).toEqual([]); + }); + + it('returns empty when from > to', () => { + expect(calculateMigrationPath(9, 8)).toEqual([]); + }); +});