diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts index 09f93c49b7..3181bb9f4b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts @@ -98,6 +98,20 @@ test('operator can find and inspect a user session', async ({ e2eApi, e2eScenari expect(eventsUrl.searchParams.get('time')).toBe('all'); }); + await test.step('timestamp remains inside a single navigable link with its own hover title', async () => { + const link = page.getByRole('link', { exact: true, name: `Open event ${relatedEventId}` }); + const time = link.locator('time'); + await time.hover(); + await expect(time).toHaveAttribute('title', /.+/); + await expect(time.locator('.sr-only')).toContainText((await time.getAttribute('title'))!); + await expect(time).not.toHaveAttribute('tabindex'); + await link.locator('xpath=ancestor::tr').getByRole('link').nth(1).focus(); + await page.keyboard.press('Tab'); + await expect(link).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(new RegExp(`/event/${relatedEventId}(?:[?#]|$)`)); + }); + await test.step('event detail messages wrap and small alignment fixes render consistently', async () => { await page.goto(`/next/stack/${relatedStackId}/event/${relatedEventId}`); await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true'); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts new file mode 100644 index 0000000000..3ca11a6af0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts @@ -0,0 +1,46 @@ +import { expect, test } from '../fixtures/e2e-test'; + +for (const [locale, timezoneId] of [ + ['en-US', 'America/Los_Angeles'], + ['en-GB', 'Europe/London'], + ['de-DE', 'Europe/Berlin'], + ['ja-JP', 'Asia/Tokyo'], + ['ar-EG', 'Africa/Cairo'] +]) { + test.describe(`${locale} in ${timezoneId}`, () => { + test.use({ locale, timezoneId }); + + test('event and stack timestamps include the full date in the native hover title', async ({ e2eApi, e2eScenario, page }) => { + const date = new Date(); + date.setUTCHours(0, 0, 0, 0); + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + date: date.toISOString(), + message: 'Synthetic timestamp verification', + reference_id: e2eScenario.referenceId, + type: 'error' + }); + await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, e2eScenario.referenceId); + + for (const route of ['/next/event?time=all', '/next/stack?time=all']) { + await page.goto(route); + const timestamp = page.locator('time').first(); + await expect(timestamp).toBeVisible(); + await timestamp.hover(); + const expected = await page.evaluate( + ({ locale, timezoneId, value }) => + new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'long', + timeZone: timezoneId + }).format(new Date(value)), + { locale, timezoneId, value: date.toISOString() } + ); + await expect(timestamp).toHaveAttribute('title', expected); + await expect(timestamp.locator('.sr-only')).toContainText(expected); + const accessibleText = await timestamp.ariaSnapshot(); + expect(accessibleText).toContain(expected); + await expect(timestamp).not.toHaveAttribute('tabindex'); + } + }); + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte index 6a25a93e7e..a53a99b6ae 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte @@ -2,6 +2,7 @@ import type { PersistentEvent } from '$features/events/models'; import TimeAgo from '$comp/formatters/time-ago.svelte'; + import { A } from '$comp/typography'; import * as Alert from '$comp/ui/alert'; import { Button } from '$comp/ui/button'; import { Skeleton } from '$comp/ui/skeleton'; @@ -169,14 +170,9 @@ - + - + {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte index 1c822955cc..0efdd6c894 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte @@ -6,6 +6,20 @@ } let { value }: Props = $props(); + + const title = $derived( + value + ? new Date(value).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'long' + }) + : undefined + ); - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts index 07b5c4e611..d254a059ed 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts @@ -10,6 +10,47 @@ describe('TimeAgo', () => { vi.useRealTimers(); }); + it.each([new Date(2026, 7, 11, 12, 34, 56), '2026-08-11T12:34:56'])('includes the full local timestamp in the native hover title', (value) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 11, 12, 35, 56)); + const { container } = render(TimeAgo, { value }); + const time = container.querySelector('time'); + expect(time?.textContent).toContain('a minute ago'); + expect(time?.title).toBe(new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'long' })); + expect(time?.querySelector('.sr-only')?.textContent).toContain(time!.title); + expect(time?.hasAttribute('tabindex')).toBe(false); + }); + + it('keeps midnight and zero seconds in the hover title', () => { + const { container } = render(TimeAgo, { value: new Date(2026, 0, 2, 0, 0, 0) }); + expect(container.querySelector('time')?.title).toBe( + new Date(2026, 0, 2, 0, 0, 0).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'long' }) + ); + }); + + it('updates the hover title when the timestamp changes', async () => { + const { container, rerender } = render(TimeAgo, { value: new Date(2026, 0, 2, 0, 0, 0) }); + await rerender({ value: new Date(2026, 0, 3, 13, 4, 5) }); + expect(container.querySelector('time')?.title).toBe( + new Date(2026, 0, 3, 13, 4, 5).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'long' }) + ); + }); + + it('does not reformat the full timestamp when the relative clock ticks', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-11T12:35:56Z')); + const format = vi.spyOn(Date.prototype, 'toLocaleString'); + try { + render(TimeAgo, { value: '2026-08-11T12:34:56Z' }); + await tick(); + expect(format).toHaveBeenCalledExactlyOnceWith(undefined, { dateStyle: 'medium', timeStyle: 'long' }); + await vi.advanceTimersByTimeAsync(60_000); + expect(format).toHaveBeenCalledTimes(1); + } finally { + format.mockRestore(); + } + }); + it('does not loop when adaptive clocks straddle an age boundary', async () => { vi.useFakeTimers(); const base = new Date('2026-08-11T12:00:00Z'); @@ -35,6 +76,6 @@ describe('TimeAgo', () => { }); await tick(); - expect(screen.getByText('an hour ago')).toBeTruthy(); + expect(screen.getByText(/an hour ago/)).toBeTruthy(); }); });