diff --git a/package.json b/package.json index 226209634..5af38ae51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.so", - "version": "1.3.1", + "version": "1.4.0", "private": true, "packageManager": "yarn@4.18.0", "scripts": { diff --git a/schema.graphql b/schema.graphql index 34e34b328..1d71bbed6 100644 --- a/schema.graphql +++ b/schema.graphql @@ -392,6 +392,18 @@ type Event { """Release data""" release: Release + """Release of the original event, regardless of the selected repetition""" + firstSeenInRelease: String + + """Whether the event first appeared in the latest valid registered project release""" + isNew: Boolean! + + """Release in which the event was resolved""" + resolvedInRelease: String + + """Release in which the event regressed""" + regressionInRelease: String + """Event repetitions portion""" repetitionsPortion(cursor: String = null, limit: Int = 10): RepetitionsPortion! @@ -1095,6 +1107,7 @@ type Project { """User who created project""" uidAdded: User! + """Project's Event""" event(eventId: ID!, originalEventId: ID!): Event diff --git a/src/api/events/index.ts b/src/api/events/index.ts index c3e6d8282..bb10bb10f 100644 --- a/src/api/events/index.ts +++ b/src/api/events/index.ts @@ -70,7 +70,7 @@ export const getEvent = withDemoMock( originalEventId, })).project; - if (!project) { + if (!project?.event) { return null; } diff --git a/src/api/fragments.ts b/src/api/fragments.ts index a3b0b4d81..4aba5673b 100644 --- a/src/api/fragments.ts +++ b/src/api/fragments.ts @@ -55,6 +55,10 @@ export const EVENT_FRAGMENT = ` timestamp originalTimestamp originalEventId + firstSeenInRelease + isNew + resolvedInRelease + regressionInRelease payload { title type diff --git a/src/api/projects/index.js b/src/api/projects/index.js index 49b8d4a51..66f2a5032 100644 --- a/src/api/projects/index.js +++ b/src/api/projects/index.js @@ -288,13 +288,14 @@ export const fetchChartData = withDemoMock( */ export const fetchProjectReleases = withDemoMock( async function fetchProjectReleases(projectId) { - const response = await api.call(QUERY_PROJECT_RELEASES, { projectId }); - - if (response.errors?.length) { - response.errors.forEach(console.error); - } + const response = await api.call(QUERY_PROJECT_RELEASES, { projectId }, undefined, { + allowErrors: true, + }); - return response.data.project.releases; + /* + * GraphQL may return usable release data alongside field-level errors. + */ + return response.data?.project?.releases ?? []; }, '/src/api/projects/mocks/fetchProjectReleases.mock.ts' ); diff --git a/src/components/event/EventHeader.vue b/src/components/event/EventHeader.vue index 652d6a469..cc28abda0 100644 --- a/src/components/event/EventHeader.vue +++ b/src/components/event/EventHeader.vue @@ -55,7 +55,14 @@
- {{ !loading ? event.payload.type || 'Application error' : $t('event.loading') }} + + {{ !loading ? event.payload.type || 'Application error' : $t('event.loading') }} + +

@@ -150,6 +157,7 @@ import EntityImage from '../utils/EntityImage.vue'; import { HawkEvent, HawkEventBacktraceFrame } from '@/types/events'; import { TOGGLE_EVENT_MARK } from '@/store/modules/events/actionTypes'; + import { Project } from '@/types/project'; import { Workspace } from '@/types/workspaces'; import { projectBadges } from '../../mixins/projectBadges'; @@ -158,6 +166,7 @@ import { JavaScriptAddons } from '@hawk.so/types'; import { Button as CdxButton, usePopover } from '@codexteam/ui/vue'; import EventActionsMenu from './EventActionsMenu.vue'; import AiSuggestionDialog from '../modals/AiSuggestionDialog.vue'; +import EventReleaseStatus from './EventReleaseStatus.vue'; export default defineComponent({ name: 'EventHeader', @@ -171,6 +180,7 @@ export default defineComponent({ EntityImage, ProjectBadge, AiSuggestionDialog, + EventReleaseStatus, }, mixins: [projectBadges], props: { @@ -202,7 +212,9 @@ export default defineComponent({ isAiSuggestionOpen: false as boolean, }; }, + computed: { + /** * Event location got from the first backtrace frame * Or got from a url if the backtrace is empty @@ -412,8 +424,17 @@ export default defineComponent({ } &__type { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; font-weight: 500; font-size: 12.2px; + } + + &__type-label { + min-width: 0; + overflow-wrap: anywhere; font-family: var(--font-monospace); letter-spacing: 0.21px; } diff --git a/src/components/event/EventReleaseStatus.test.ts b/src/components/event/EventReleaseStatus.test.ts new file mode 100644 index 000000000..2d5935f7c --- /dev/null +++ b/src/components/event/EventReleaseStatus.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createApp, h, nextTick, reactive, type App } from 'vue'; +import { createI18n } from 'vue-i18n'; +import en from '../../i18n/messages/en.json'; +import ru from '../../i18n/messages/ru.json'; +import type { HawkEvent } from '../../types/events'; +import EventReleaseStatus from './EventReleaseStatus.vue'; + +type ReleaseState = Pick; + +let app: App | undefined; +let host: HTMLDivElement; + +afterEach(() => { + app?.unmount(); + host.remove(); +}); + +/** + * Mount the badge with the real translations and reactive event state. + * @param event - lifecycle fields from the event + * @param locale - UI language + * @returns badge container + */ +function mount(event: ReleaseState, locale = 'en'): HTMLDivElement { + host = document.createElement('div'); + document.body.append(host); + app = createApp({ render: () => h(EventReleaseStatus, { event }) }); + app.use(createI18n({ legacy: false, + locale, + messages: { en, + ru } })); + app.mount(host); + + return host; +} + +describe('EventReleaseStatus', () => { + it.each([ + {}, + { firstSeenInRelease: null, + resolvedInRelease: null, + regressionInRelease: null }, + ])('hides the badge without lifecycle data: %j', (event) => { + expect(mount(event).querySelector('.event-release-status')).toBeNull(); + expect(host.textContent).toBe(''); + }); + + it.each([undefined, null, '', ' ', 'null', 'undefined', ' NULL ', ' Undefined ', 'NaN'])( + 'hides non-meaningful release values: %j', (release) => { + expect(mount({ firstSeenInRelease: release, + resolvedInRelease: release, + regressionInRelease: release, + isNew: true }).querySelector('.event-release-status')).toBeNull(); + } + ); + + it('falls back to valid metadata when higher-priority fields are placeholders', () => { + mount({ firstSeenInRelease: 'original', + resolvedInRelease: 'fixed', + regressionInRelease: 'null' }); + expect(host.textContent?.trim()).toBe('Resolved'); + }); + + it.each([ + ['en', 'firstSeen', { firstSeenInRelease: 'build/start#1' }, 'New', + 'The event first appeared in release build/start#1, the project\'s latest release.'], + ['ru', 'firstSeen', { firstSeenInRelease: 'build/start#1' }, 'Новое', + 'Событие впервые появилось в релизе build/start#1. Это последний релиз проекта.'], + ['en', 'resolved', { firstSeenInRelease: 'z-original', + resolvedInRelease: 'a-fixed' }, 'Resolved', + 'No occurrences observed starting with release a-fixed. This is an automatic estimate: the event may be fixed.'], + ['ru', 'resolved', { firstSeenInRelease: 'z-original', + resolvedInRelease: 'a-fixed' }, 'Исправлено', + 'Не наблюдалось начиная с релиза a-fixed. Это автоматическая оценка: событие потенциально исправлено.'], + ['en', 'regression', { firstSeenInRelease: 'z-original', + resolvedInRelease: 'z-fixed', + regressionInRelease: 'a/again#2' }, 'Regression', + 'The event reappeared in release a/again#2 after it was considered fixed.'], + ['ru', 'regression', { firstSeenInRelease: 'z-original', + resolvedInRelease: 'z-fixed', + regressionInRelease: 'a/again#2' }, 'Регрессия', + 'Событие вернулось в релизе a/again#2 после предполагаемого исправления.'], + ] as const)('renders %s %s with the server flag and lifecycle priority', (locale, status, event, label, tooltip) => { + const badge = mount({ ...event, + isNew: true }, locale).querySelector('.event-release-status'); + + expect(badge?.textContent?.trim()).toBe(label); + expect(badge?.getAttribute('title')).toBe(tooltip); + expect(badge?.classList.contains(`event-release-status--${status}`)).toBe(true); + expect(host.querySelectorAll('.event-release-status')).toHaveLength(1); + for (const release of Object.values(event)) { + expect(badge?.textContent).not.toContain(release); + } + }); + + it.each([undefined, false])( + 'hides the new badge when isNew is unavailable or false: %j', (isNew) => { + mount({ firstSeenInRelease: 'original', + isNew }); + expect(host.querySelector('.event-release-status')).toBeNull(); + } + ); + + it('removes the new badge when the server flag changes', async () => { + const event = reactive({ firstSeenInRelease: 'current', + isNew: true }); + + mount(event); + expect(host.textContent?.trim()).toBe('New'); + + event.isNew = false; + await nextTick(); + expect(host.querySelector('.event-release-status')).toBeNull(); + }); + + it('returns to resolved when a regression is cleared reactively', async () => { + const event = reactive({ + firstSeenInRelease: 'original', + resolvedInRelease: 'z-fixed', + regressionInRelease: 'a-regression', + }); + + mount(event); + expect(host.textContent?.trim()).toBe('Regression'); + + event.regressionInRelease = null; + await nextTick(); + + const badge = host.querySelector('.event-release-status'); + + expect(badge?.textContent?.trim()).toBe('Resolved'); + expect(badge?.getAttribute('title')).toBe( + 'No occurrences observed starting with release z-fixed. This is an automatic estimate: the event may be fixed.' + ); + expect(badge?.classList.contains('event-release-status--resolved')).toBe(true); + expect(badge?.classList.contains('event-release-status--regression')).toBe(false); + }); +}); diff --git a/src/components/event/EventReleaseStatus.vue b/src/components/event/EventReleaseStatus.vue new file mode 100644 index 000000000..e2f3fd7e8 --- /dev/null +++ b/src/components/event/EventReleaseStatus.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/event/Overview.vue b/src/components/event/Overview.vue index 3560fbee5..63789e922 100644 --- a/src/components/event/Overview.vue +++ b/src/components/event/Overview.vue @@ -79,9 +79,12 @@ :addons="addonsFiltered" />
0; }, + /** + * Whether any occurrence or lifecycle release is available. + */ + hasReleaseInformation(): boolean { + return [this.event.payload.release, this.event.firstSeenInRelease, + this.event.resolvedInRelease, this.event.regressionInRelease].some(isMeaningfulRelease); + }, + /** * Returns true if the event has no backtrace, context and addons * @@ -232,7 +244,7 @@ export default defineComponent({ const noContext = !this.hasContext; const noBreadcrumbs = !this.hasBreadcrumbs; - return noBacktrace && noAddons && noContext && noBreadcrumbs; + return noBacktrace && noAddons && noContext && noBreadcrumbs && !this.hasReleaseInformation; }, }, methods: { diff --git a/src/components/event/RepetitionsList.vue b/src/components/event/RepetitionsList.vue index 39c5157bb..515304580 100644 --- a/src/components/event/RepetitionsList.vue +++ b/src/components/event/RepetitionsList.vue @@ -2,6 +2,7 @@
+ + + +
@@ -360,7 +364,7 @@ export default defineComponent({ white-space: nowrap; } - tr:not(:first-child){ + tbody tr { cursor: pointer; &:hover { diff --git a/src/components/event/details/DetailsRelease.test.ts b/src/components/event/details/DetailsRelease.test.ts new file mode 100644 index 000000000..44de309f0 --- /dev/null +++ b/src/components/event/details/DetailsRelease.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createApp, h, type App } from 'vue'; +import { createI18n } from 'vue-i18n'; +import { createMemoryHistory, createRouter, type Router } from 'vue-router'; +import en from '../../../i18n/messages/en.json'; +import ru from '../../../i18n/messages/ru.json'; +import DetailsRelease from './DetailsRelease.vue'; + +type ReleaseProps = Partial>; + +let app: App | undefined; +let host: HTMLDivElement; + +afterEach(() => { + app?.unmount(); + host.remove(); +}); + +/** + * Use a memory router to check real link encoding without loading the app routes. + * @param props - payload and lifecycle releases + * @param locale - UI language + * @returns router for resolving rendered links + */ +async function mount(props: ReleaseProps, locale = 'en'): Promise { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/project/:projectId/events/:eventId', + name: 'event', + component: DetailsRelease }, + { path: '/project/:projectId/releases/:release', + name: 'project-release', + component: { render: () => null } }, + ], + }); + + await router.push('/project/project-42/events/event-1'); + await router.isReady(); + + host = document.createElement('div'); + document.body.append(host); + app = createApp({ render: () => h(DetailsRelease, props) }); + app.use(createI18n({ legacy: false, + locale, + messages: { en, + ru } })); + app.use(router); + app.mount(host); + + return router; +} + +/** + * Read visible row labels and release values rather than component internals. + * @returns rendered release rows + */ +function rows(): (string | undefined)[][] { + return Array.from(host.querySelectorAll('.event-details__content-block'), row => [ + row.querySelector('.event-details__key')?.textContent?.trim(), + row.querySelector('a')?.textContent?.trim(), + ]); +} + +describe('DetailsRelease', () => { + it.each([ + ['en', 'Current occurrence in', 'Introduced in', 'Potentially resolved in', 'Regressed in'], + ['ru', 'Текущее событие в', 'Появилось в', 'Потенциально исправлено в', 'Регрессия в'], + ])('preserves every %s lifecycle link and the selected repetition release', async (locale, releaseLabel, firstSeenLabel, resolvedLabel, regressionLabel) => { + const releases = ['original/build#1', 'selected/build#2', 'fixed/build#3', 'again/build#4']; + const router = await mount({ + release: releases[1], + firstSeenInRelease: releases[0], + resolvedInRelease: releases[2], + regressionInRelease: releases[3], + }, locale); + + expect(host.querySelector('.event-details__header')?.textContent?.trim()) + .toBe(locale === 'ru' ? 'Релиз' : 'Release'); + expect(rows()).toEqual([ + [firstSeenLabel, releases[0]], + [releaseLabel, releases[1]], + [resolvedLabel, releases[2]], + [regressionLabel, releases[3]], + ]); + + const links = host.querySelectorAll('a'); + + expect(links).toHaveLength(releases.length); + links.forEach((link, index) => { + const release = releases[index]; + const href = link.getAttribute('href'); + + expect(link.getAttribute('title')).toBe(release); + expect(href).toBe(`/project/project-42/releases/${encodeURIComponent(release)}`); + const target = router.resolve(href!); + + expect(target.name).toBe('project-release'); + expect(target.params).toEqual({ projectId: 'project-42', + release }); + expect(target.hash).toBe(''); + }); + }); + + it('keeps the current occurrence even when its release matches the first appearance', async () => { + await mount({ + release: 'original', + firstSeenInRelease: 'original', + resolvedInRelease: 'fixed', + regressionInRelease: 'again', + }); + + expect(rows()).toEqual([ + ['Introduced in', 'original'], + ['Current occurrence in', 'original'], + ['Potentially resolved in', 'fixed'], + ['Regressed in', 'again'], + ]); + }); + + it('keeps the current occurrence even when its release matches the regression', async () => { + await mount({ + release: 'again', + firstSeenInRelease: 'original', + resolvedInRelease: 'fixed', + regressionInRelease: 'again', + }, 'ru'); + + expect(rows()).toEqual([ + ['Появилось в', 'original'], + ['Текущее событие в', 'again'], + ['Потенциально исправлено в', 'fixed'], + ['Регрессия в', 'again'], + ]); + }); + + it('does not call an ordinary repetition a regression', async () => { + await mount({ release: 'repeated', + firstSeenInRelease: 'original' }); + + expect(rows()).toEqual([ + ['Introduced in', 'original'], + ['Current occurrence in', 'repeated'], + ]); + }); + + it.each([undefined, null, '', ' ', 'null', 'undefined', ' NULL ', 'NaN'])( + 'hides the entire section for non-meaningful releases: %j', async (release) => { + await mount({ release, + firstSeenInRelease: release, + resolvedInRelease: release, + regressionInRelease: release }); + expect(host.querySelector('.event-details')).toBeNull(); + } + ); + + it('only shows meaningful rows when release metadata is mixed', async () => { + await mount({ release: 'undefined', + firstSeenInRelease: 'null', + resolvedInRelease: 'fixed', + regressionInRelease: ' ' }); + expect(rows()).toEqual([['Potentially resolved in', 'fixed']]); + }); + + it('shows a legacy payload release without lifecycle data', async () => { + await mount({ release: 'legacy/build#1' }); + + expect(rows()).toEqual([['Current occurrence in', 'legacy/build#1']]); + expect(host.querySelector('a')?.getAttribute('href')).toBe('/project/project-42/releases/legacy%2Fbuild%231'); + }); + + it('shows lifecycle releases without a payload release', async () => { + await mount({ + firstSeenInRelease: 'original', + resolvedInRelease: 'fixed', + regressionInRelease: 'again', + }); + + expect(rows()).toEqual([ + ['Introduced in', 'original'], + ['Potentially resolved in', 'fixed'], + ['Regressed in', 'again'], + ]); + }); +}); diff --git a/src/components/event/details/DetailsRelease.vue b/src/components/event/details/DetailsRelease.vue index 73c9112e9..da7550c73 100644 --- a/src/components/event/details/DetailsRelease.vue +++ b/src/components/event/details/DetailsRelease.vue @@ -1,20 +1,27 @@