diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd19c79c..95804928 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,8 @@ jobs: timeout-minutes: 10 strategy: matrix: - wizard: [Nuxt-3, Nuxt-4, NextJS, Remix, Sveltekit] + wizard: + [Angular-17, Angular-19, Nuxt-3, Nuxt-4, NextJS, Remix, Sveltekit] env: SENTRY_TEST_AUTH_TOKEN: ${{ secrets.E2E_TEST_SENTRY_AUTH_TOKEN }} SENTRY_TEST_ORG: 'sentry-javascript-sdks' diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f30f8e..513ad8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- feat: Add Angular Wizard ([#741](https://github.com/getsentry/sentry-wizard/pull/741)) - feat(nuxt): Add `import-in-the-middle` install step when using pnpm ([#727](https://github.com/getsentry/sentry-wizard/pull/727)) - fix(nuxt): Remove unused parameter in sentry-example-api template ([#734](https://github.com/getsentry/sentry-wizard/pull/734)) @@ -15,7 +16,7 @@ - feat: Pin JS SDK versions to v8 (#712) - Remove enableTracing for Cocoa ([#715](https://github.com/getsentry/sentry-wizard/pull/715)) - feat(nuxt): Add nuxt wizard ([#719](https://github.com/getsentry/sentry-wizard/pull/719)) - + Set up the Sentry Nuxt SDK in your app with one command: ```sh diff --git a/e2e-tests/test-applications/angular-17-test-app/.gitignore b/e2e-tests/test-applications/angular-17-test-app/.gitignore new file mode 100644 index 00000000..cc7b1413 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/.gitignore @@ -0,0 +1,42 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/e2e-tests/test-applications/angular-17-test-app/angular.json b/e2e-tests/test-applications/angular-17-test-app/angular.json new file mode 100644 index 00000000..09ab967a --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/angular.json @@ -0,0 +1,68 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-test-app": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/angular-test-app", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "angular-test-app:build:production" + }, + "development": { + "buildTarget": "angular-test-app:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "angular-test-app:build" + } + } + } + } + } +} diff --git a/e2e-tests/test-applications/angular-17-test-app/package.json b/e2e-tests/test-applications/angular-17-test-app/package.json new file mode 100644 index 00000000..3dbe7b91 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/package.json @@ -0,0 +1,37 @@ +{ + "name": "angular-17-test-app", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "dev": "ng build --watch --configuration development" + }, + "private": true, + "dependencies": { + "@angular/animations": "^17.3.0", + "@angular/common": "^17.3.0", + "@angular/compiler": "^17.3.0", + "@angular/core": "^17.3.0", + "@angular/forms": "^17.3.0", + "@angular/platform-browser": "^17.3.0", + "@angular/platform-browser-dynamic": "^17.3.0", + "@angular/router": "^17.3.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.3" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^17.3.11", + "@angular/cli": "^17.3.11", + "@angular/compiler-cli": "^17.3.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.4.2" + } +} diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.html b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.html new file mode 100644 index 00000000..6ad6ff44 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.html @@ -0,0 +1,8 @@ +
+
+

Hello, {{ title }}

+

Congratulations! Your app is running. 🎉

+
+
+ + diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.ts b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.ts new file mode 100644 index 00000000..99539624 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + templateUrl: './app.component.html', +}) +export class AppComponent { + title = 'angular-test-app'; +} diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.config.ts b/e2e-tests/test-applications/angular-17-test-app/src/app/app.config.ts new file mode 100644 index 00000000..6c6ef603 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes)] +}; diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.routes.ts b/e2e-tests/test-applications/angular-17-test-app/src/app/app.routes.ts new file mode 100644 index 00000000..dc39edb5 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/e2e-tests/test-applications/angular-17-test-app/src/index.html b/e2e-tests/test-applications/angular-17-test-app/src/index.html new file mode 100644 index 00000000..2df4d484 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/src/index.html @@ -0,0 +1,12 @@ + + + + + AngularTestApp + + + + + + + diff --git a/e2e-tests/test-applications/angular-17-test-app/src/main.ts b/e2e-tests/test-applications/angular-17-test-app/src/main.ts new file mode 100644 index 00000000..35b00f34 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/e2e-tests/test-applications/angular-17-test-app/tsconfig.app.json b/e2e-tests/test-applications/angular-17-test-app/tsconfig.app.json new file mode 100644 index 00000000..374cc9d2 --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/e2e-tests/test-applications/angular-17-test-app/tsconfig.json b/e2e-tests/test-applications/angular-17-test-app/tsconfig.json new file mode 100644 index 00000000..eb49734a --- /dev/null +++ b/e2e-tests/test-applications/angular-17-test-app/tsconfig.json @@ -0,0 +1,32 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/e2e-tests/test-applications/angular-19-test-app b/e2e-tests/test-applications/angular-19-test-app new file mode 160000 index 00000000..9412cfcc --- /dev/null +++ b/e2e-tests/test-applications/angular-19-test-app @@ -0,0 +1 @@ +Subproject commit 9412cfccfc649bafc43a36af09bab91e25dbc83e diff --git a/e2e-tests/tests/angular-17.test.ts b/e2e-tests/tests/angular-17.test.ts new file mode 100644 index 00000000..11c9eec2 --- /dev/null +++ b/e2e-tests/tests/angular-17.test.ts @@ -0,0 +1,168 @@ +/* eslint-disable jest/expect-expect */ +import { Integration } from "../../lib/Constants"; +import { checkFileContents, checkFileExists, checkIfBuilds, checkIfRunsOnDevMode, checkIfRunsOnProdMode, checkPackageJson, cleanupGit, KEYS, revertLocalChanges, startWizardInstance } from "../utils"; +import * as path from 'path'; +import { TEST_ARGS } from "../utils"; + +async function runWizardOnAngularProject(projectDir: string, integration: Integration) { + const wizardInstance = startWizardInstance(integration, projectDir); + const packageManagerPrompted = await wizardInstance.waitForOutput( + 'Please select your package manager.', + ); + + const tracingOptionPrompted = + packageManagerPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // Selecting `yarn` as the package manager + [KEYS.DOWN, KEYS.ENTER], + // "Do you want to enable Tracing", sometimes doesn't work as `Tracing` can be printed in bold. + 'to track the performance of your application?', + { + timeout: 240_000, + optional: true, + }, + )); + + const replayOptionPrompted = + tracingOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + // "Do you want to enable Sentry Session Replay", sometimes doesn't work as `Sentry Session Replay` can be printed in bold. + 'to get a video-like reproduction of errors during a user session?', + )); + + const sourcemapsPrompted = replayOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // The first choice here is Angular + [KEYS.ENTER], + 'Where are your build artifacts located?', + )); + + + const sourcemapsConfigured = sourcemapsPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + ["./dist", KEYS.ENTER], + 'Verify that your build tool is generating source maps.', + )); + + const buildScriptPrompted = sourcemapsConfigured && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Do you want to automatically run the sentry:sourcemaps script after each production build?', + )); + + const defaultBuildCommandPrompted = buildScriptPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Is yarn build your production build command?', + )); + + const ciCdPrompted = defaultBuildCommandPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Are you using a CI/CD tool to build and deploy your application?', + )); + + ciCdPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.DOWN, KEYS.ENTER], + 'Sentry has been successfully configured for your Angular project', + )); + + wizardInstance.kill(); +}; + +function checkAngularProject(projectDir: string, integration: Integration) { + test('package.json is updated correctly', () => { + checkPackageJson(projectDir, integration); + + const packageJsonFile = path.resolve(projectDir, 'package.json'); + checkFileContents(packageJsonFile, [ + `"sentry:sourcemaps": "sentry-cli sourcemaps inject --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist && sentry-cli sourcemaps upload --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist"`, + `"build": "ng build && yarn sentry:sourcemaps"`, + ]); + }) + + test('Sentry is correctly injected into Angular app config', () => { + const appConfigFile = path.resolve(projectDir, 'src/main.ts'); + checkFileExists(appConfigFile); + + checkFileContents(appConfigFile, [ + `import * as Sentry from "@sentry/angular"`, + 'Sentry.init({', + TEST_ARGS.PROJECT_DSN, + 'Sentry.browserTracingIntegration()', + 'Sentry.replayIntegration()', + 'tracesSampleRate: 1', + 'replaysSessionSampleRate: 0.1', + 'replaysOnErrorSampleRate: 1', + ]); + }); + + test('Sentry is correctly injected into Angular app module', () => { + const appModuleFile = path.resolve(projectDir, 'src/app/app.config.ts'); + checkFileExists(appModuleFile); + + checkFileContents(appModuleFile, [ + `import * as Sentry from "@sentry/angular"`, + `{ + provide: ErrorHandler, + useValue: Sentry.createErrorHandler() + }`, + `{ + provide: Sentry.TraceService, + deps: [Router] + }`, + `{ + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [Sentry.TraceService], + multi: true + }`, + ]); + }); + + test('angular.json is updated correctly', () => { + const angularJsonFile = path.resolve(projectDir, 'angular.json'); + checkFileExists(angularJsonFile); + + const angularJson = require(angularJsonFile); + + for (const [, project] of Object.entries(angularJson.projects) as any) { + expect(project?.architect?.build?.configurations?.production?.sourceMap).toBe(true); + } + }); + + test('builds successfully', async () => { + await checkIfBuilds(projectDir, 'Application bundle generation complete.'); + }); + + test('runs on prod mode correctly', async () => { + await checkIfRunsOnProdMode(projectDir, 'Application bundle generation complete.'); + }); + + test('runs on dev mode correctly', async () => { + await checkIfRunsOnDevMode(projectDir, 'Application bundle generation complete.'); + }); +}; + +describe('Angular-17', () => { + describe('with empty project', () => { + const integration = Integration.angular; + const projectDir = path.resolve( + __dirname, + '../test-applications/angular-17-test-app', + ); + + beforeAll(async () => { + await runWizardOnAngularProject(projectDir, integration); + }); + + afterAll(() => { + revertLocalChanges(projectDir); + cleanupGit(projectDir); + }); + + checkAngularProject(projectDir, integration); + }); +}); diff --git a/e2e-tests/tests/angular-19.test.ts b/e2e-tests/tests/angular-19.test.ts new file mode 100644 index 00000000..ce323148 --- /dev/null +++ b/e2e-tests/tests/angular-19.test.ts @@ -0,0 +1,165 @@ +/* eslint-disable jest/expect-expect */ +import { Integration } from "../../lib/Constants"; +import { checkFileContents, checkFileExists, checkIfBuilds, checkIfRunsOnDevMode, checkIfRunsOnProdMode, checkPackageJson, cleanupGit, KEYS, revertLocalChanges, startWizardInstance } from "../utils"; +import * as path from 'path'; +import { TEST_ARGS } from "../utils"; + +async function runWizardOnAngularProject(projectDir: string, integration: Integration) { + const wizardInstance = startWizardInstance(integration, projectDir); + const packageManagerPrompted = await wizardInstance.waitForOutput( + 'Please select your package manager.', + ); + + const tracingOptionPrompted = + packageManagerPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // Selecting `yarn` as the package manager + [KEYS.DOWN, KEYS.ENTER], + // "Do you want to enable Tracing", sometimes doesn't work as `Tracing` can be printed in bold. + 'to track the performance of your application?', + { + timeout: 240_000, + optional: true, + }, + )); + + const replayOptionPrompted = + tracingOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + // "Do you want to enable Sentry Session Replay", sometimes doesn't work as `Sentry Session Replay` can be printed in bold. + 'to get a video-like reproduction of errors during a user session?', + )); + + const sourcemapsPrompted = replayOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // The first choice here is Angular + [KEYS.ENTER], + 'Where are your build artifacts located?', + )); + + + const sourcemapsConfigured = sourcemapsPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + ["./dist", KEYS.ENTER], + 'Verify that your build tool is generating source maps.', + )); + + const buildScriptPrompted = sourcemapsConfigured && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Do you want to automatically run the sentry:sourcemaps script after each production build?', + )); + + const defaultBuildCommandPrompted = buildScriptPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Is yarn build your production build command?', + )); + + const ciCdPrompted = defaultBuildCommandPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Are you using a CI/CD tool to build and deploy your application?', + )); + + ciCdPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.DOWN, KEYS.ENTER], + 'Sentry has been successfully configured for your Angular project', + )); + + wizardInstance.kill(); +}; + +function checkAngularProject(projectDir: string, integration: Integration) { + test('package.json is updated correctly', () => { + checkPackageJson(projectDir, integration); + + const packageJsonFile = path.resolve(projectDir, 'package.json'); + checkFileContents(packageJsonFile, [ + `"sentry:sourcemaps": "sentry-cli sourcemaps inject --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist && sentry-cli sourcemaps upload --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist"`, + `"build": "ng build && yarn sentry:sourcemaps"`, + ]); + }) + + test('Sentry is correctly injected into Angular app config', () => { + const appConfigFile = path.resolve(projectDir, 'src/main.ts'); + checkFileExists(appConfigFile); + + checkFileContents(appConfigFile, [ + `import * as Sentry from "@sentry/angular"`, + 'Sentry.init({', + TEST_ARGS.PROJECT_DSN, + 'Sentry.browserTracingIntegration()', + 'Sentry.replayIntegration()', + 'tracesSampleRate: 1', + 'replaysSessionSampleRate: 0.1', + 'replaysOnErrorSampleRate: 1', + ]); + }); + + test('Sentry is correctly injected into Angular app module', () => { + const appModuleFile = path.resolve(projectDir, 'src/app/app.config.ts'); + checkFileExists(appModuleFile); + + checkFileContents(appModuleFile, [ + `import * as Sentry from "@sentry/angular"`, + `{ + provide: ErrorHandler, + useValue: Sentry.createErrorHandler() + }`, + `{ + provide: Sentry.TraceService, + deps: [Router] + }`, + `provideAppInitializer(() => { + inject(Sentry.TraceService); + })`, + ]); + }); + + test('angular.json is updated correctly', () => { + const angularJsonFile = path.resolve(projectDir, 'angular.json'); + checkFileExists(angularJsonFile); + + const angularJson = require(angularJsonFile); + + for (const [, project] of Object.entries(angularJson.projects) as any) { + expect(project?.architect?.build?.configurations?.production?.sourceMap).toBe(true); + } + }); + + test('builds successfully', async () => { + await checkIfBuilds(projectDir, 'Application bundle generation complete.'); + }); + + test('runs on prod mode correctly', async () => { + await checkIfRunsOnProdMode(projectDir, 'Application bundle generation complete.'); + }); + + test('runs on dev mode correctly', async () => { + await checkIfRunsOnDevMode(projectDir, 'Application bundle generation complete.'); + }); +}; + +describe('Angular-19', () => { + describe('with empty project', () => { + const integration = Integration.angular; + const projectDir = path.resolve( + __dirname, + '../test-applications/angular-19-test-app', + ); + + beforeAll(async () => { + await runWizardOnAngularProject(projectDir, integration); + }); + + afterAll(() => { + revertLocalChanges(projectDir); + cleanupGit(projectDir); + }); + + checkAngularProject(projectDir, integration); + }); +}); diff --git a/e2e-tests/tests/remix.test.ts b/e2e-tests/tests/remix.test.ts index 8a282123..60616bf1 100644 --- a/e2e-tests/tests/remix.test.ts +++ b/e2e-tests/tests/remix.test.ts @@ -78,7 +78,6 @@ async function runWizardOnRemixProject(projectDir: string, integration: Integrat 'Please select your package manager.', ); } else { - packageManagerPrompted = await wizardInstance.waitForOutput( 'Please select your package manager.', ); diff --git a/lib/Constants.ts b/lib/Constants.ts index 4350d9a0..09eb6a4b 100644 --- a/lib/Constants.ts +++ b/lib/Constants.ts @@ -4,6 +4,7 @@ export enum Integration { ios = 'ios', android = 'android', cordova = 'cordova', + angular = 'angular', electron = 'electron', nextjs = 'nextjs', nuxt = 'nuxt', @@ -68,6 +69,8 @@ export function mapIntegrationToPlatform(type: string): string | undefined { return 'react-native'; case Integration.cordova: return 'cordova'; + case Integration.angular: + return 'javascript-angular'; case Integration.electron: return 'javascript-electron'; case Integration.nextjs: diff --git a/src/angular/angular-wizard.ts b/src/angular/angular-wizard.ts new file mode 100644 index 00000000..8c60b9d1 --- /dev/null +++ b/src/angular/angular-wizard.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; + +import chalk from 'chalk'; +import type { WizardOptions } from '../utils/types'; +import { traceStep, withTelemetry } from '../telemetry'; +import { + confirmContinueIfNoOrDirtyGitRepo, + ensurePackageIsInstalled, + featureSelectionPrompt, + getOrAskForProjectData, + getPackageDotJson, + installPackage, + printWelcome, +} from '../utils/clack-utils'; +import { getPackageVersion, hasPackageInstalled } from '../utils/package-json'; +import { gte, minVersion } from 'semver'; +import { initalizeSentryOnAppModule, updateAppConfig } from './sdk-setup'; +import { addSourcemapEntryToAngularJSON } from './codemods/sourcemaps'; +import { runSourcemapsWizard } from '../sourcemaps/sourcemaps-wizard'; + +const MIN_SUPPORTED_ANGULAR_VERSION = '14.0.0'; + +export async function runAngularWizard(options: WizardOptions): Promise { + return withTelemetry( + { + enabled: options.telemetryEnabled, + integration: 'angular', + wizardOptions: options, + }, + () => runAngularWizardWithTelemetry(options), + ); +} + +async function runAngularWizardWithTelemetry( + options: WizardOptions, +): Promise { + printWelcome({ + wizardName: 'Sentry Remix Wizard', + promoCode: options.promoCode, + telemetryEnabled: options.telemetryEnabled, + }); + + await confirmContinueIfNoOrDirtyGitRepo(); + + const packageJson = await getPackageDotJson(); + + await ensurePackageIsInstalled(packageJson, '@angular/core', 'Angular'); + + const installedAngularVersion = getPackageVersion( + '@angular/core', + packageJson, + ); + + if (!installedAngularVersion) { + clack.log.warn('Could not determine installed Angular version.'); + + return; + } + + const installedMinVersion = minVersion(installedAngularVersion); + + if (!installedMinVersion) { + clack.log.warn('Could not determine minimum Angular version.'); + + return; + } + + const isSupportedAngularVersion = gte( + installedMinVersion, + MIN_SUPPORTED_ANGULAR_VERSION, + ); + + if (!isSupportedAngularVersion) { + clack.log.warn( + `Angular version ${MIN_SUPPORTED_ANGULAR_VERSION} or higher is required.`, + ); + + return; + } + + const { selectedProject, authToken, sentryUrl, selfHosted } = + await getOrAskForProjectData(options, 'javascript-angular'); + + await installPackage({ + packageName: '@sentry/angular@^8', + packageNameDisplayLabel: '@sentry/angular', + alreadyInstalled: hasPackageInstalled('@sentry/angular', packageJson), + }); + + const dsn = selectedProject.keys[0].dsn.public; + + const selectedFeatures = await featureSelectionPrompt([ + { + id: 'performance', + prompt: `Do you want to enable ${chalk.bold( + 'Tracing', + )} to track the performance of your application?`, + enabledHint: 'recommended', + }, + { + id: 'replay', + prompt: `Do you want to enable ${chalk.bold( + 'Sentry Session Replay', + )} to get a video-like reproduction of errors during a user session?`, + enabledHint: 'recommended, but increases bundle size', + }, + ] as const); + + await traceStep('Inject Sentry to Angular app config', async () => { + await initalizeSentryOnAppModule(dsn, selectedFeatures); + }); + + await traceStep('Update Angular project configuration', async () => { + await updateAppConfig(installedMinVersion, selectedFeatures.performance); + }); + + await traceStep('Setup for sourcemap uploads', async () => { + addSourcemapEntryToAngularJSON(); + + if (!options.preSelectedProject) { + options.preSelectedProject = { + authToken, + selfHosted, + project: { + organization: { + id: selectedProject.organization.id, + name: selectedProject.organization.name, + slug: selectedProject.organization.slug, + }, + id: selectedProject.id, + slug: selectedProject.slug, + keys: [ + { + dsn: { + public: dsn, + }, + }, + ], + }, + }; + + options.url = sentryUrl; + } + + await runSourcemapsWizard(options, 'angular'); + }); + + clack.log.success( + 'Sentry has been successfully configured for your Angular project', + ); +} diff --git a/src/angular/codemods/app-config.ts b/src/angular/codemods/app-config.ts new file mode 100644 index 00000000..d7264777 --- /dev/null +++ b/src/angular/codemods/app-config.ts @@ -0,0 +1,232 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import type { ArrayExpression, Identifier, ObjectProperty } from '@babel/types'; + +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import type { ProxifiedModule } from 'magicast'; +import { gte, type SemVer } from 'semver'; +import * as recast from 'recast'; + +export function updateAppConfigMod( + originalAppConfigMod: ProxifiedModule, + angularVersion: SemVer, + isTracingEnabled: boolean, +): ProxifiedModule { + const isAboveAngularV19 = gte(angularVersion, '19.0.0'); + + addImports(originalAppConfigMod, isAboveAngularV19, isTracingEnabled); + addProviders(originalAppConfigMod, isAboveAngularV19, isTracingEnabled); + + return originalAppConfigMod; +} + +function addSentryImport(originalAppConfigMod: ProxifiedModule): void { + const imports = originalAppConfigMod.imports; + const hasSentryImport = imports.$items.some( + (item) => + item.from === '@sentry/angular' && + item.imported === '*' && + item.local === 'Sentry', + ); + + if (!hasSentryImport) { + imports.$add({ + from: '@sentry/angular', + imported: '*', + local: 'Sentry', + }); + } +} + +function addErrorHandlerImport( + originalAppConfigMod: ProxifiedModule, +): void { + const imports = originalAppConfigMod.imports; + const hasErrorHandler = imports.$items.some( + (item) => item.local === 'ErrorHandler', + ); + + if (!hasErrorHandler) { + imports.$add({ + from: '@angular/core', + imported: 'ErrorHandler', + local: 'ErrorHandler', + }); + } +} + +function addRouterImport(originalAppConfigMod: ProxifiedModule): void { + const imports = originalAppConfigMod.imports; + const hasRouter = imports.$items.some((item) => item.local === 'Router'); + + if (!hasRouter) { + imports.$add({ + from: '@angular/router', + imported: 'Router', + local: 'Router', + }); + } +} + +function addMissingImportsV19( + originalAppConfigMod: ProxifiedModule, +): void { + const imports = originalAppConfigMod.imports; + + const hasProvideAppInitializer = imports.$items.some( + (item) => item.local === 'provideAppInitializer', + ); + + if (!hasProvideAppInitializer) { + imports.$add({ + from: '@angular/core', + imported: 'provideAppInitializer', + local: 'provideAppInitializer', + }); + } + + const hasInject = imports.$items.some((item) => item.local === 'inject'); + + if (!hasInject) { + imports.$add({ + from: '@angular/core', + imported: 'inject', + local: 'inject', + }); + } +} + +function addAppInitializer(originalAppConfigMod: ProxifiedModule): void { + const imports = originalAppConfigMod.imports; + + const hasAppInitializer = imports.$items.some( + (item) => item.local === 'APP_INITIALIZER', + ); + + if (!hasAppInitializer) { + imports.$add({ + from: '@angular/core', + imported: 'APP_INITIALIZER', + local: 'APP_INITIALIZER', + }); + } +} + +function addImports( + originalAppConfigMod: ProxifiedModule, + isAboveAngularV19: boolean, + isTracingEnabled: boolean, +): void { + addSentryImport(originalAppConfigMod); + addErrorHandlerImport(originalAppConfigMod); + addRouterImport(originalAppConfigMod); + + if (isAboveAngularV19) { + addMissingImportsV19(originalAppConfigMod); + } else if (isTracingEnabled) { + addAppInitializer(originalAppConfigMod); + } +} + +function addProviders( + originalAppConfigMod: ProxifiedModule, + isAboveAngularV19: boolean, + isTracingEnabled: boolean, +): void { + const b = recast.types.builders; + + recast.visit(originalAppConfigMod.exports.$ast, { + visitExportNamedDeclaration(path) { + // @ts-expect-error - declaration should always be present in this case + if (path.node.declaration.declarations[0].id.name === 'appConfig') { + const appConfigProps = + // @ts-expect-error - declaration should always be present in this case + path.node.declaration.declarations[0].init.properties; + + const providers = appConfigProps.find( + (prop: ObjectProperty) => + (prop.key as Identifier).name === 'providers', + ).value as ArrayExpression; + + const errorHandlerObject = b.objectExpression([ + b.objectProperty( + b.identifier('provide'), + b.identifier('ErrorHandler'), + ), + b.objectProperty( + b.identifier('useValue'), + b.identifier('Sentry.createErrorHandler()'), + ), + ]); + + providers.elements.push( + // @ts-expect-error - errorHandlerObject is an objectExpression + errorHandlerObject, + ); + + if (isTracingEnabled) { + const traceServiceObject = b.objectExpression([ + b.objectProperty( + b.identifier('provide'), + b.identifier('Sentry.TraceService'), + ), + b.objectProperty( + b.identifier('deps'), + b.arrayExpression([b.identifier('Router')]), + ), + ]); + + // @ts-expect-error - traceServiceObject is an objectExpression + providers.elements.push(traceServiceObject); + + if (isAboveAngularV19) { + const provideAppInitializerCall = b.callExpression( + b.identifier('provideAppInitializer'), + [ + b.arrowFunctionExpression( + [], + b.blockStatement([ + b.expressionStatement( + b.callExpression(b.identifier('inject'), [ + b.identifier('Sentry.TraceService'), + ]), + ), + ]), + ), + ], + ); + + // @ts-expect-error - provideAppInitializerCall is an objectExpression + providers.elements.push(provideAppInitializerCall); + } else { + const provideAppInitializerObject = b.objectExpression([ + b.objectProperty( + b.identifier('provide'), + b.identifier('APP_INITIALIZER'), + ), + b.objectProperty( + b.identifier('useFactory'), + b.arrowFunctionExpression( + [], + b.arrowFunctionExpression([], b.blockStatement([])), + ), + ), + b.objectProperty( + b.identifier('deps'), + b.arrayExpression([b.identifier('Sentry.TraceService')]), + ), + b.objectProperty(b.identifier('multi'), b.booleanLiteral(true)), + ]); + + // @ts-expect-error - provideAppInitializerObject is an objectExpression + providers.elements.push(provideAppInitializerObject); + } + } + } + + this.traverse(path); + }, + }); +} diff --git a/src/angular/codemods/main.ts b/src/angular/codemods/main.ts new file mode 100644 index 00000000..ff750bcf --- /dev/null +++ b/src/angular/codemods/main.ts @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import type { Program } from '@babel/types'; + +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { builders, generateCode, type ProxifiedModule } from 'magicast'; + +export function updateAppModuleMod( + originalAppModuleMod: ProxifiedModule, + dsn: string, + selectedFeatures: { + performance: boolean; + replay: boolean; + }, +): ProxifiedModule { + originalAppModuleMod.imports.$add({ + from: '@sentry/angular', + imported: '*', + local: 'Sentry', + }); + + insertInitCall(originalAppModuleMod, dsn, selectedFeatures); + + return originalAppModuleMod; +} + +export function insertInitCall( + originalAppModuleMod: ProxifiedModule, + dsn: string, + selectedFeatures: { + performance: boolean; + replay: boolean; + }, +): void { + const initCallArgs = getInitCallArgs(dsn, selectedFeatures); + const initCall = builders.functionCall('Sentry.init', initCallArgs); + const originalAppModuleModAst = originalAppModuleMod.$ast as Program; + + const initCallInsertionIndex = getAfterImportsInsertionIndex( + originalAppModuleModAst, + ); + + originalAppModuleModAst.body.splice( + initCallInsertionIndex, + 0, + // @ts-expect-error - string works here because the AST is proxified by magicast + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + generateCode(initCall).code, + ); +} + +export function getInitCallArgs( + dsn: string, + selectedFeatures: { + performance: boolean; + replay: boolean; + }, +): Record { + const initCallArgs = { + dsn, + } as Record; + + if (selectedFeatures.replay || selectedFeatures.performance) { + initCallArgs.integrations = []; + + if (selectedFeatures.performance) { + // @ts-expect-error - Adding Proxified AST node to the array + initCallArgs.integrations.push( + builders.functionCall('Sentry.browserTracingIntegration'), + ); + initCallArgs.tracesSampleRate = 1.0; + } + + if (selectedFeatures.replay) { + // @ts-expect-error - Adding Proxified AST node to the array + initCallArgs.integrations.push( + builders.functionCall('Sentry.replayIntegration'), + ); + + initCallArgs.replaysSessionSampleRate = 0.1; + initCallArgs.replaysOnErrorSampleRate = 1.0; + } + } + + return initCallArgs; +} + +/** + * We want to insert the handleError function just after all imports + */ +export function getAfterImportsInsertionIndex( + originalEntryServerModAST: Program, +): number { + for (let x = originalEntryServerModAST.body.length - 1; x >= 0; x--) { + if (originalEntryServerModAST.body[x].type === 'ImportDeclaration') { + return x + 1; + } + } + + return 0; +} diff --git a/src/angular/codemods/sourcemaps.ts b/src/angular/codemods/sourcemaps.ts new file mode 100644 index 00000000..34607107 --- /dev/null +++ b/src/angular/codemods/sourcemaps.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import * as path from 'path'; +import * as fs from 'fs'; + +export function addSourcemapEntryToAngularJSON(): void { + const angularJsonPath = path.join(process.cwd(), 'angular.json'); + + const angularJSONFile = fs.readFileSync(angularJsonPath, 'utf-8'); + + const angularJson = JSON.parse(angularJSONFile); + + if (!angularJson) { + throw new Error('Could not find in angular.json in your project'); + } + + const projects = Object.keys(angularJson.projects as Record); + + if (!projects.length) { + throw new Error('Could not find any projects in angular.json'); + } + + // Emit sourcemaps from all projects in angular.json + for (const project of projects) { + const projectConfig = angularJson.projects[project]; + + if (!projectConfig.architect) { + projectConfig.architect = {}; + } + + if (!projectConfig.architect.build) { + projectConfig.architect.build = {}; + } + + if (!projectConfig.architect.build.configurations) { + projectConfig.architect.build.configurations = {}; + } + + projectConfig.architect.build.configurations.production = { + sourceMap: true, + }; + } + + fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2)); +} diff --git a/src/angular/sdk-setup.ts b/src/angular/sdk-setup.ts new file mode 100644 index 00000000..32c21b1f --- /dev/null +++ b/src/angular/sdk-setup.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { loadFile, ProxifiedModule, writeFile } from 'magicast'; + +import * as path from 'path'; + +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import chalk from 'chalk'; +import { updateAppModuleMod } from './codemods/main'; +import { updateAppConfigMod } from './codemods/app-config'; +import type { SemVer } from 'semver'; + +export function hasSentryContent( + fileName: string, + fileContent: string, + expectedContent = '@sentry/angular', +): boolean { + const includesContent = fileContent.includes(expectedContent); + + if (includesContent) { + clack.log.warn( + `File ${chalk.cyan( + path.basename(fileName), + )} already contains ${expectedContent}. +Skipping adding Sentry functionality to ${chalk.cyan( + path.basename(fileName), + )}.`, + ); + } + + return includesContent; +} + +export async function initalizeSentryOnAppModule( + dsn: string, + selectedFeatures: { + performance: boolean; + replay: boolean; + }, +): Promise { + const appModuleFilename = 'main.ts'; + const appModulePath = path.join(process.cwd(), 'src', appModuleFilename); + + const originalAppModule = await loadFile(appModulePath); + + if (hasSentryContent(appModulePath, originalAppModule.$code)) { + return; + } + + const updatedAppModuleMod = updateAppModuleMod( + originalAppModule, + dsn, + selectedFeatures, + ); + + await writeFile(updatedAppModuleMod.$ast, appModulePath); + + clack.log.success( + `Successfully initialized Sentry on your app module ${chalk.cyan( + appModuleFilename, + )}`, + ); +} + +export async function updateAppConfig( + angularVersion: SemVer, + isTracingEnabled: boolean, +): Promise { + const appConfigFilename = 'app.config.ts'; + const appConfigPath = path.join( + process.cwd(), + 'src', + 'app', + appConfigFilename, + ); + + const appConfig = await loadFile(appConfigPath); + + if (hasSentryContent(appConfigPath, appConfig.$code)) { + return; + } + let updatedAppConfigMod: ProxifiedModule; + + try { + updatedAppConfigMod = updateAppConfigMod( + appConfig, + angularVersion, + isTracingEnabled, + ); + + await writeFile(updatedAppConfigMod.$ast, appConfigPath); + } catch (error) { + clack.log.error( + `Failed to update your app config ${chalk.cyan(appConfigFilename)}`, + ); + + clack.log.error(error); + + return; + } + + clack.log.success( + `Successfully updated your app config ${chalk.cyan(appConfigFilename)}`, + ); +} diff --git a/src/run.ts b/src/run.ts index a0dc6192..0eaae784 100644 --- a/src/run.ts +++ b/src/run.ts @@ -6,6 +6,7 @@ import { runReactNativeWizard } from './react-native/react-native-wizard'; import { run as legacyRun } from '../lib/Setup'; import type { PreselectedProject, WizardOptions } from './utils/types'; import { runAndroidWizard } from './android/android-wizard'; +import { runAngularWizard } from './angular/angular-wizard'; import { runAppleWizard } from './apple/apple-wizard'; import { runNextjsWizard } from './nextjs/nextjs-wizard'; import { runNuxtWizard } from './nuxt/nuxt-wizard'; @@ -17,6 +18,7 @@ import type { Platform } from '../lib/Constants'; import type { PackageDotJson } from './utils/package-json'; type WizardIntegration = + | 'angular' | 'reactNative' | 'ios' | 'android' @@ -101,6 +103,7 @@ export async function run(argv: Args) { options: [ { value: 'reactNative', label: 'React Native' }, { value: 'ios', label: 'iOS' }, + { value: 'angular', label: 'Angular' }, { value: 'android', label: 'Android' }, { value: 'cordova', label: 'Cordova' }, { value: 'electron', label: 'Electron' }, @@ -147,6 +150,10 @@ export async function run(argv: Args) { await runAndroidWizard(wizardOptions); break; + case 'angular': + await runAngularWizard(wizardOptions); + break; + case 'nextjs': await runNextjsWizard(wizardOptions); break; diff --git a/src/sourcemaps/sourcemaps-wizard.ts b/src/sourcemaps/sourcemaps-wizard.ts index a1ca2047..691b8303 100644 --- a/src/sourcemaps/sourcemaps-wizard.ts +++ b/src/sourcemaps/sourcemaps-wizard.ts @@ -35,6 +35,7 @@ import { getIssueStreamUrl } from '../utils/url'; export async function runSourcemapsWizard( options: WizardOptions, + preSelectedTool?: SupportedTools, ): Promise { return withTelemetry( { @@ -42,26 +43,29 @@ export async function runSourcemapsWizard( integration: 'sourcemaps', wizardOptions: options, }, - () => runSourcemapsWizardWithTelemetry(options), + () => runSourcemapsWizardWithTelemetry(options, preSelectedTool), ); } async function runSourcemapsWizardWithTelemetry( options: WizardOptions, + preSelectedTool?: SupportedTools, ): Promise { - printWelcome({ - wizardName: 'Sentry Source Maps Upload Configuration Wizard', - message: `This wizard will help you upload source maps to Sentry as part of your build. + if (!preSelectedTool) { + printWelcome({ + wizardName: 'Sentry Source Maps Upload Configuration Wizard', + message: `This wizard will help you upload source maps to Sentry as part of your build. Thank you for using Sentry :)${ - options.telemetryEnabled - ? ` + options.telemetryEnabled + ? ` (This setup wizard sends telemetry data and crash reports to Sentry. You can turn this off by running the wizard with the '--disable-telemetry' flag.)` - : '' - }`, - promoCode: options.promoCode, - }); + : '' + }`, + promoCode: options.promoCode, + }); + } const moreSuitableWizard = await traceStep( 'check-framework-wizard', @@ -72,7 +76,9 @@ You can turn this off by running the wizard with the '--disable-telemetry' flag. return; } - await confirmContinueIfNoOrDirtyGitRepo(); + if (!preSelectedTool) { + await confirmContinueIfNoOrDirtyGitRepo(); + } await traceStep('check-sdk-version', ensureMinimumSdkVersionIsInstalled); @@ -88,7 +94,8 @@ You can turn this off by running the wizard with the '--disable-telemetry' flag. }, }; - const selectedTool = await traceStep('select-tool', askForUsedBundlerTool); + const selectedTool = + preSelectedTool || (await traceStep('select-tool', askForUsedBundlerTool)); Sentry.setTag('selected-tool', selectedTool); @@ -111,6 +118,7 @@ You can turn this off by running the wizard with the '--disable-telemetry' flag. authToken, }, wizardOptionsWithPreSelectedProject, + preSelectedTool, ), ); @@ -194,11 +202,12 @@ async function askForUsedBundlerTool(): Promise { } async function startToolSetupFlow( - selctedTool: SupportedTools, + selectedTool: SupportedTools, options: SourceMapUploadToolConfigurationOptions, wizardOptions: WizardOptions, + preSelectedTool?: SupportedTools, ): Promise { - switch (selctedTool) { + switch (selectedTool) { case 'webpack': await configureWebPackPlugin(options); break; @@ -220,7 +229,10 @@ async function startToolSetupFlow( case 'angular': await configureSentryCLI( options, - configureAngularSourcemapGenerationFlow, + // Angular wizard handles the angular.json setup itself + !preSelectedTool || preSelectedTool !== 'angular' + ? configureAngularSourcemapGenerationFlow + : undefined, ); break; case 'nextjs': diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index b31fcae3..3b1d0bfd 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -875,6 +875,7 @@ export function isUsingTypeScript() { export async function getOrAskForProjectData( options: WizardOptions, platform?: + | 'javascript-angular' | 'javascript-nextjs' | 'javascript-nuxt' | 'javascript-remix' @@ -1037,6 +1038,7 @@ async function askForWizardLogin(options: { url: string; promoCode?: string; platform?: + | 'javascript-angular' | 'javascript-nextjs' | 'javascript-nuxt' | 'javascript-remix'