diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcbef8e06a8e..a846c6f90e77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -994,6 +994,7 @@ jobs: test-application: [ 'angular-17', + 'angular-18', 'cloudflare-astro', 'node-express', 'create-react-app', diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig b/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig new file mode 100644 index 000000000000..59d9a3a3e73f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.gitignore b/dev-packages/e2e-tests/test-applications/angular-18/.gitignore new file mode 100644 index 000000000000..0711527ef9d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-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/dev-packages/e2e-tests/test-applications/angular-18/.npmrc b/dev-packages/e2e-tests/test-applications/angular-18/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/angular-18/README.md b/dev-packages/e2e-tests/test-applications/angular-18/README.md new file mode 100644 index 000000000000..5a2f2e67c868 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/README.md @@ -0,0 +1,3 @@ +# Angular 18 + +E2E test app for Angular 18 and `@sentry/angular`. diff --git a/dev-packages/e2e-tests/test-applications/angular-18/angular.json b/dev-packages/e2e-tests/test-applications/angular-18/angular.json new file mode 100644 index 000000000000..cb3c0b70cec6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/angular.json @@ -0,0 +1,95 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-18": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/angular-18", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "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-18:build:production" + }, + "development": { + "buildTarget": "angular-18:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "angular-18:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/package.json b/dev-packages/e2e-tests/test-applications/angular-18/package.json new file mode 100644 index 000000000000..1c2bf8b723cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/package.json @@ -0,0 +1,51 @@ +{ + "name": "angular-18", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "dev": "ng serve", + "proxy": "node start-event-proxy.mjs", + "preview": "http-server dist/angular-18/browser --port 8080", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "playwright test", + "clean": "npx rimraf .angular node_modules pnpm-lock.yaml dist" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.0.0", + "@angular/common": "^18.0.0", + "@angular/compiler": "^18.0.0", + "@angular/core": "^18.0.0", + "@angular/forms": "^18.0.0", + "@angular/platform-browser": "^18.0.0", + "@angular/platform-browser-dynamic": "^18.0.0", + "@angular/router": "^18.0.0", + "@sentry/angular": "* || latest", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.3" + }, + "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "@angular-devkit/build-angular": "^18.0.0", + "@angular/cli": "^18.0.0", + "@angular/compiler-cli": "^18.0.0", + "@playwright/test": "^1.41.1", + "@types/jasmine": "~5.1.0", + "http-server": "^14.1.1", + "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.5", + "wait-port": "1.0.4" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts b/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts new file mode 100644 index 000000000000..df7c2d9758e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env['TEST_ENV'] || 'production'; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const angularPort = 8080; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + fullyParallel: false, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${angularPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}` + : `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}`, + port: angularPort, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts new file mode 100644 index 000000000000..ab3efd7e16f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/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], + template: ``, +}) +export class AppComponent { + title = 'angular-18'; +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts new file mode 100644 index 000000000000..8267759c8ba1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts @@ -0,0 +1,25 @@ +import { APP_INITIALIZER, ApplicationConfig, ErrorHandler } from '@angular/core'; +import { Router, provideRouter } from '@angular/router'; + +import { TraceService, createErrorHandler } from '@sentry/angular'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + { + provide: ErrorHandler, + useValue: createErrorHandler(), + }, + { + provide: TraceService, + deps: [Router], + }, + { + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [TraceService], + multi: true, + }, + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts new file mode 100644 index 000000000000..24bf8b769051 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts @@ -0,0 +1,42 @@ +import { Routes } from '@angular/router'; +import { cancelGuard } from './cancel-guard.guard'; +import { CancelComponent } from './cancel/cancel.components'; +import { ComponentTrackingComponent } from './component-tracking/component-tracking.components'; +import { HomeComponent } from './home/home.component'; +import { UserComponent } from './user/user.component'; + +export const routes: Routes = [ + { + path: 'users/:id', + component: UserComponent, + }, + { + path: 'home', + component: HomeComponent, + }, + { + path: 'cancel', + component: CancelComponent, + canActivate: [cancelGuard], + }, + { + path: 'component-tracking', + component: ComponentTrackingComponent, + }, + { + path: 'redirect1', + redirectTo: '/redirect2', + }, + { + path: 'redirect2', + redirectTo: '/redirect3', + }, + { + path: 'redirect3', + redirectTo: '/users/456', + }, + { + path: '**', + redirectTo: 'home', + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts new file mode 100644 index 000000000000..16ec4a2ab164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts @@ -0,0 +1,5 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; + +export const cancelGuard: CanActivateFn = (_next: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return false; +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts new file mode 100644 index 000000000000..b6ee1876e035 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-cancel', + standalone: true, + template: `
`, +}) +export class CancelComponent {} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts new file mode 100644 index 000000000000..d437a1d43fdd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts @@ -0,0 +1,18 @@ +import { AfterViewInit, Component, OnInit } from '@angular/core'; +import { TraceClass, TraceMethod, TraceModule } from '@sentry/angular'; +import { SampleComponent } from '../sample-component/sample-component.components'; + +@Component({ + selector: 'app-cancel', + standalone: true, + imports: [TraceModule, SampleComponent], + template: ``, +}) +@TraceClass({ name: 'ComponentTrackingComponent' }) +export class ComponentTrackingComponent implements OnInit, AfterViewInit { + @TraceMethod({ name: 'ngOnInit' }) + ngOnInit() {} + + @TraceMethod() + ngAfterViewInit() {} +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts new file mode 100644 index 000000000000..9f36814d6c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [RouterLink], + template: ` +
+

Welcome to Sentry's Angular 18 E2E test app

+ + +
+`, +}) +export class HomeComponent { + throwError() { + throw new Error('Error thrown from Angular 18 E2E test app'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts new file mode 100644 index 000000000000..da09425c7565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-sample-component', + standalone: true, + template: `
Component
`, +}) +export class SampleComponent implements OnInit { + ngOnInit() { + console.log('SampleComponent'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts new file mode 100644 index 000000000000..db02568d395f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts @@ -0,0 +1,25 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, map } from 'rxjs'; + +@Component({ + selector: 'app-user', + standalone: true, + imports: [AsyncPipe], + template: ` +

Hello User {{ userId$ | async }}

+ + `, +}) +export class UserComponent { + public userId$: Observable; + + constructor(private route: ActivatedRoute) { + this.userId$ = this.route.paramMap.pipe(map(params => params.get('id') || 'UNKNOWN USER')); + } + + throwError() { + throw new Error('Error thrown from user page'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico b/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico new file mode 100644 index 000000000000..57614f9c9675 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/index.html b/dev-packages/e2e-tests/test-applications/angular-18/src/index.html new file mode 100644 index 000000000000..075475aa383e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular 18 + + + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts new file mode 100644 index 000000000000..947f40691b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts @@ -0,0 +1,15 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +import * as Sentry from '@sentry/angular'; + +Sentry.init({ + dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + tracesSampleRate: 1.0, + integrations: [Sentry.browserTracingIntegration({})], + tunnel: `http://localhost:3031/`, // proxy server + debug: true, +}); + +bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css new file mode 100644 index 000000000000..90d4ee0072ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs new file mode 100644 index 000000000000..696f1807d8f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'angular-18', +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts new file mode 100644 index 000000000000..a255d2130dda --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('angular-18', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Angular 18 E2E test app', + mechanism: { + type: 'angular', + handled: false, + }, + }, + ], + }, + transaction: '/home/', + }); +}); + +test('assigns the correct transaction value after a navigation', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorPromise = waitForError('angular-18', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + await page.locator('#navLink').click(); + + const [_, error] = await Promise.all([page.locator('#userErrorBtn').click(), errorPromise]); + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from user page', + mechanism: { + type: 'angular', + handled: false, + }, + }, + ], + }, + transaction: '/users/:id/', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts new file mode 100644 index 000000000000..12f0fbd41133 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts @@ -0,0 +1,313 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/event-proxy-server'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, pageloadTxn, navigationTxn] = await Promise.all([ + page.locator('#navLink').click(), + pageloadTxnPromise, + navigationTxnPromise, + ]); + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('groups redirects within one navigation root span', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/redirect1'); +}); + +test.describe('finish routing span', () => { + test('finishes routing span on navigation cancel', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#cancelLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/cancel', + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/cancel'); + }); + + test('finishes routing span on navigation error', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#nonExistentLink').click(), navigationTxnPromise]); + + const nonExistentRoute = '/non-existent'; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: nonExistentRoute, + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe(nonExistentRoute); + }); +}); + +test.describe('TraceDirective', () => { + test('creates a child tracingSpan with component name as span name on ngOnInit', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const traceDirectiveSpan = navigationTxn.spans?.find( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_directive', + ); + + expect(traceDirectiveSpan).toBeDefined(); + expect(traceDirectiveSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + }, + description: '', + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_directive', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); + +test.describe('TraceClass Decorator', () => { + test('adds init span for decorated class', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const classDecoratorSpan = navigationTxn.spans?.find( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_class_decorator', + ); + + expect(classDecoratorSpan).toBeDefined(); + expect(classDecoratorSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator', + }, + description: '', + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_class_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); + +test.describe('TraceMethod Decorator', () => { + test('adds name to span description of decorated method `ngOnInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngOnInit'); + + expect(ngInitSpan).toBeDefined(); + expect(ngInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngOnInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: '', + op: 'ui.angular.ngOnInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); + + test('adds fallback name to span description of decorated method `ngAfterViewInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngAfterViewInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngAfterViewInit'); + + expect(ngAfterViewInitSpan).toBeDefined(); + expect(ngAfterViewInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngAfterViewInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: '', + op: 'ui.angular.ngAfterViewInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json new file mode 100644 index 000000000000..374cc9d294aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/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/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json new file mode 100644 index 000000000000..f37b67ff0277 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "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/packages/angular/package.json b/packages/angular/package.json index 5984a62c62f3..d0a363fc8d45 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -15,9 +15,9 @@ "access": "public" }, "peerDependencies": { - "@angular/common": ">= 14.x <= 17.x", - "@angular/core": ">= 14.x <= 17.x", - "@angular/router": ">= 14.x <= 17.x", + "@angular/common": ">= 14.x <= 18.x", + "@angular/core": ">= 14.x <= 18.x", + "@angular/router": ">= 14.x <= 18.x", "rxjs": "^6.5.5 || ^7.x" }, "dependencies": {