-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
test(e2e): Cover soft navigation web vitals across router instrumentations #24324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; | ||
|
|
||
| // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction | ||
| // that triggered it, so it only holds while the navigation span is started before the interaction's | ||
| // Event Timing entry is delivered. Angular's router instrumentation starts the navigation span off the router's own event stream. | ||
| test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => { | ||
| const spansPromise = collectStreamedSpans( | ||
| 'angular-22', | ||
| spansOfTrace => | ||
| spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) && | ||
| spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'), | ||
| ); | ||
|
|
||
| await page.goto('/'); | ||
| await page.locator('#navLink').click(); | ||
|
|
||
| // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing | ||
| // is reported for it until the page goes away. | ||
| await hidePage(page); | ||
|
|
||
| const spans = await spansPromise; | ||
|
|
||
| const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; | ||
| const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!; | ||
|
|
||
| expect(navigationSpan.name).toBe('/users/:id/'); | ||
|
|
||
| const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value; | ||
| expect(softNavigationId).toEqual(expect.any(Number)); | ||
|
|
||
| // Both sides of the correlation carry the id, so the navigation and its vitals are joinable. | ||
| expect(clsSpan.attributes).toMatchObject({ | ||
| 'browser.navigation.id': { value: softNavigationId, type: 'integer' }, | ||
| 'browser.navigation.type': { value: 'soft-navigation', type: 'string' }, | ||
| }); | ||
| expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; | ||
|
|
||
| // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction | ||
| // that triggered it, so it only holds while the navigation span is started before the interaction's | ||
| // Event Timing entry is delivered. `reactRouterV6BrowserTracingIntegration` on a data router defers the navigation span until the | ||
| // router's navigation state goes idle, i.e. after loaders resolve, which is the latest any of our | ||
| // instrumentations starts one. | ||
| test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => { | ||
| const spansPromise = collectStreamedSpans( | ||
| 'react-create-browser-router', | ||
| spansOfTrace => | ||
| spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) && | ||
| spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'), | ||
| ); | ||
|
|
||
| await page.goto('/'); | ||
| await page.locator('#navigation').click(); | ||
|
|
||
| // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing | ||
| // is reported for it until the page goes away. | ||
| await hidePage(page); | ||
|
|
||
| const spans = await spansPromise; | ||
|
|
||
| const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; | ||
| const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!; | ||
|
|
||
| expect(navigationSpan.name).toBe('/user/:id'); | ||
|
|
||
| const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value; | ||
| expect(softNavigationId).toEqual(expect.any(Number)); | ||
|
|
||
| // Both sides of the correlation carry the id, so the navigation and its vitals are joinable. | ||
| expect(clsSpan.attributes).toMatchObject({ | ||
| 'browser.navigation.id': { value: softNavigationId, type: 'integer' }, | ||
| 'browser.navigation.type': { value: 'soft-navigation', type: 'string' }, | ||
| }); | ||
| expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; | ||
|
|
||
| // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction | ||
| // that triggered it, so it only holds while the navigation span is started before the interaction's | ||
| // Event Timing entry is delivered. `reactRouterV6BrowserTracingIntegration` starts it from a layout | ||
| // effect rather than from the history change, which is what this exercises. | ||
| test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => { | ||
| const spansPromise = collectStreamedSpans( | ||
| 'react-router-6', | ||
| spansOfTrace => | ||
| spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) && | ||
| spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'), | ||
| ); | ||
|
|
||
| await page.goto('/'); | ||
| await page.click('#navigation'); | ||
|
|
||
| // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing | ||
| // is reported for it until the page goes away. | ||
| await hidePage(page); | ||
|
|
||
| const spans = await spansPromise; | ||
|
|
||
| const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; | ||
| const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!; | ||
|
|
||
| expect(navigationSpan.name).toBe('/user/:id'); | ||
|
|
||
| const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value; | ||
| expect(softNavigationId).toEqual(expect.any(Number)); | ||
|
|
||
| // Both sides of the correlation carry the id, so the navigation and its vitals are joinable. | ||
| expect(clsSpan.attributes).toMatchObject({ | ||
| 'browser.navigation.id': { value: softNavigationId, type: 'integer' }, | ||
| 'browser.navigation.type': { value: 'soft-navigation', type: 'string' }, | ||
| }); | ||
| expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id); | ||
| expect(clsSpan.attributes['sentry.pageload.span_id']).toBeUndefined(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils'; | ||
|
|
||
| // The correlation between a soft navigation and the SDK's navigation span hangs off the interaction | ||
| // that triggered it, so it only holds while the navigation span is started before the interaction's | ||
| // Event Timing entry is delivered. `vueIntegration` starts the navigation span from a `router.beforeEach` guard. | ||
| test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => { | ||
| const spansPromise = collectStreamedSpans( | ||
| 'vue-3', | ||
| spansOfTrace => | ||
| spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) && | ||
| spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'), | ||
| ); | ||
|
|
||
| await page.goto('/'); | ||
| await page.locator('#navLink').click(); | ||
|
|
||
| // A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing | ||
| // is reported for it until the page goes away. | ||
| await hidePage(page); | ||
|
|
||
| const spans = await spansPromise; | ||
|
|
||
| const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; | ||
| const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!; | ||
|
|
||
| expect(navigationSpan.name).toBe('/users/:id'); | ||
|
|
||
| const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value; | ||
| expect(softNavigationId).toEqual(expect.any(Number)); | ||
|
|
||
| // Both sides of the correlation carry the id, so the navigation and its vitals are joinable. | ||
| expect(clsSpan.attributes).toMatchObject({ | ||
| 'browser.navigation.id': { value: softNavigationId, type: 'integer' }, | ||
| 'browser.navigation.type': { value: 'soft-navigation', type: 'string' }, | ||
| }); | ||
| expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import type { Page } from '@playwright/test'; | ||
|
|
||
| /** | ||
| * Hides the page so the SDK reports the web vitals that are only finalized on pagehide. | ||
| */ | ||
| export async function hidePage(page: Page): Promise<void> { | ||
| // web-vitals defers processing an interaction's event entries into | ||
| // `requestIdleCallback(..., { timeout: 1000 })`, and Chromium only reaches idle here once that | ||
| // timeout elapses. Hiding the page first forces a report while the metric is still unset, so no | ||
| // vital is emitted at all. Idle callbacks run in scheduling order, so waiting for one queued now | ||
| // means web-vitals' earlier callback has already run. | ||
| await page.evaluate(() => { | ||
| return new Promise<void>(resolve => { | ||
| if (typeof requestIdleCallback !== 'function') { | ||
| resolve(); | ||
| return; | ||
| } | ||
| requestIdleCallback(() => resolve(), { timeout: 1000 }); | ||
| }); | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. INP hide can race idle callbackMedium Severity
Additional Locations (2)Triggered by project rule: PR Review Guidelines for Cursor Bot Reviewed by Cursor Bugbot for commit c0813a4. Configure here. |
||
|
|
||
| // The callback below runs in the page, so `document` is the browser's, not Node's. | ||
| /* oxlint-disable no-restricted-globals */ | ||
| await page.evaluate(() => { | ||
| Object.defineProperty(document, 'visibilityState', { | ||
| configurable: true, | ||
| get: function () { | ||
| return 'hidden'; | ||
| }, | ||
| }); | ||
|
|
||
| document.dispatchEvent(new Event('visibilitychange')); | ||
| }); | ||
| /* oxlint-enable no-restricted-globals */ | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.