diff --git a/static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx b/static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx new file mode 100644 index 00000000000000..e1d2de63145566 --- /dev/null +++ b/static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx @@ -0,0 +1,105 @@ +import {mapAssertionFormErrors} from 'sentry/views/alerts/rules/uptime/assertionFormErrors'; + +describe('mapAssertionFormErrors', () => { + it('returns null/undefined as-is', () => { + expect(mapAssertionFormErrors(null)).toBeNull(); + expect(mapAssertionFormErrors(undefined)).toBeUndefined(); + }); + + it('passes through responses without assertion errors', () => { + const response = {url: ['Invalid URL']}; + expect(mapAssertionFormErrors(response)).toEqual({url: ['Invalid URL']}); + }); + + it('handles direct assertion compilation errors (uptime alerts format)', () => { + const response = { + assertion: { + error: 'compilation_error', + details: 'Invalid JSON path expression: syntax error at position 5', + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: [ + 'Compilation Error: Invalid JSON path expression: syntax error at position 5', + ], + }); + }); + + it('handles direct assertion serialization errors (uptime alerts format)', () => { + const response = { + assertion: { + error: 'serialization_error', + details: 'unknown variant `invalid_op`, expected one of `and`, `or`', + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: [ + 'Serialization Error: unknown variant `invalid_op`, expected one of `and`, `or`', + ], + }); + }); + + it('handles nested assertion errors (detector forms format)', () => { + const response = { + dataSources: { + assertion: { + error: 'compilation_error', + details: + 'JSONPath Parser Error: --> 1:3\n |\n1 | $[oooooo\n | ^---\n |\n = expected selector', + }, + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: [ + 'Compilation Error: JSONPath Parser Error: --> 1:3\n |\n1 | $[oooooo\n | ^---\n |\n = expected selector', + ], + }); + }); + + it('preserves other dataSources fields when extracting assertion error', () => { + const response = { + dataSources: { + assertion: { + error: 'compilation_error', + details: 'Invalid expression', + }, + url: ['Invalid URL format'], + method: ['Method is required'], + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: ['Compilation Error: Invalid expression'], + dataSources: { + url: ['Invalid URL format'], + method: ['Method is required'], + }, + }); + }); + + it('handles unknown error types with fallback title', () => { + const response = { + assertion: { + error: 'unknown_error_type', + details: 'Something went wrong', + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: ['Validation Error: Something went wrong'], + }); + }); + + it('does not modify assertion if already an array', () => { + const response = { + assertion: ['Already formatted error'], + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: ['Already formatted error'], + }); + }); +}); diff --git a/static/app/views/alerts/rules/uptime/assertionFormErrors.tsx b/static/app/views/alerts/rules/uptime/assertionFormErrors.tsx new file mode 100644 index 00000000000000..29dd05b20b7f3f --- /dev/null +++ b/static/app/views/alerts/rules/uptime/assertionFormErrors.tsx @@ -0,0 +1,79 @@ +import {t} from 'sentry/locale'; + +/** + * Maps assertion error types to user-friendly titles. + */ +function getAssertionErrorTitle(errorType: string): string { + switch (errorType) { + case 'compilation_error': + return t('Compilation Error'); + case 'serialization_error': + return t('Serialization Error'); + default: + return t('Validation Error'); + } +} + +/** + * Checks if an object is an assertion error with error type and details. + */ +function isAssertionError(obj: unknown): obj is {details: string; error: string} { + return ( + typeof obj === 'object' && + obj !== null && + !Array.isArray(obj) && + 'details' in obj && + 'error' in obj + ); +} + +/** + * Formats an assertion error into a user-friendly message. + */ +function formatAssertionError(assertionError: { + details: string; + error: string; +}): string[] { + const title = getAssertionErrorTitle(assertionError.error); + return [`${title}: ${assertionError.details}`]; +} + +/** + * Maps form errors from the API response format to the format expected by FormModel. + * + * Handles assertion errors in two formats: + * + * 1. Direct format (uptime alerts): + * {"assertion": {"error": "compilation_error", "details": "..."}} + * + * 2. Nested format (detector forms): + * {"dataSources": {"assertion": {"error": "compilation_error", "details": "..."}}} + * + * Both are transformed to: {"assertion": ["Compilation Error: "]} + */ +export function mapAssertionFormErrors(responseJson: any): any { + if (!responseJson) { + return responseJson; + } + + const result = {...responseJson}; + + // Handle direct assertion errors (uptime alerts endpoint) + if (isAssertionError(result.assertion)) { + result.assertion = formatAssertionError(result.assertion); + } + + // Handle nested assertion errors (detector forms endpoint) + if (result.dataSources && isAssertionError(result.dataSources.assertion)) { + result.assertion = formatAssertionError(result.dataSources.assertion); + // Remove assertion from dataSources but preserve other fields + const {assertion: _, ...remainingDataSources} = result.dataSources; + if (Object.keys(remainingDataSources).length > 0) { + result.dataSources = remainingDataSources; + } else { + delete result.dataSources; + } + } + + return result; +} diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx index 9e7cf81f8bf474..60f998fa7fcc02 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx @@ -524,6 +524,86 @@ describe('Uptime Alert Form', () => { ); }); + it('displays assertion compilation errors', async () => { + const orgWithAssertions = OrganizationFixture({ + features: ['uptime-runtime-assertions'], + }); + OrganizationStore.onUpdate(orgWithAssertions); + + render(, {organization: orgWithAssertions}); + await screen.findByText('Verification'); + + await selectEvent.select(input('Project'), project.slug); + await selectEvent.select(input('Environment'), 'prod'); + await userEvent.clear(input('URL')); + await userEvent.type(input('URL'), 'http://example.com'); + + const name = input('Uptime rule name'); + await userEvent.clear(name); + await userEvent.type(name, 'Rule with Invalid Assertion'); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithAssertions.slug}/${project.slug}/uptime/`, + method: 'POST', + statusCode: 400, + body: { + assertion: { + error: 'compilation_error', + details: 'Invalid JSON path expression: syntax error at position 5', + }, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Create Rule'})); + + // The error message from the assertion compilation should be displayed with title + expect( + await screen.findByText( + 'Compilation Error: Invalid JSON path expression: syntax error at position 5' + ) + ).toBeInTheDocument(); + }); + + it('displays assertion serialization errors', async () => { + const orgWithAssertions = OrganizationFixture({ + features: ['uptime-runtime-assertions'], + }); + OrganizationStore.onUpdate(orgWithAssertions); + + render(, {organization: orgWithAssertions}); + await screen.findByText('Verification'); + + await selectEvent.select(input('Project'), project.slug); + await selectEvent.select(input('Environment'), 'prod'); + await userEvent.clear(input('URL')); + await userEvent.type(input('URL'), 'http://example.com'); + + const name = input('Uptime rule name'); + await userEvent.clear(name); + await userEvent.type(name, 'Rule with Invalid Assertion'); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithAssertions.slug}/${project.slug}/uptime/`, + method: 'POST', + statusCode: 400, + body: { + assertion: { + error: 'serialization_error', + details: 'unknown variant `invalid_op`, expected one of `and`, `or`', + }, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Create Rule'})); + + // The error message from the assertion serialization should be displayed with title + expect( + await screen.findByText( + 'Serialization Error: unknown variant `invalid_op`, expected one of `and`, `or`' + ) + ).toBeInTheDocument(); + }); + it('preserves null assertion when editing rule without assertions', async () => { const orgWithAssertions = OrganizationFixture({ features: ['uptime-runtime-assertions'], diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx index 06723e706ff98a..abefb7746ce3e6 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx @@ -39,6 +39,7 @@ import {makeAlertsPathname} from 'sentry/views/alerts/pathnames'; import type {UptimeRule} from 'sentry/views/alerts/rules/uptime/types'; import {createEmptyAssertionRoot, UptimeAssertionsField} from './assertions/field'; +import {mapAssertionFormErrors} from './assertionFormErrors'; import {HTTPSnippet} from './httpSnippet'; import {TestUptimeMonitorButton} from './testUptimeMonitorButton'; import {UptimeHeadersField} from './uptimeHeadersField'; @@ -202,6 +203,7 @@ export function UptimeAlertForm({handleDelete, rule}: Props) { saveOnBlur={false} initialData={initialData} submitLabel={rule ? t('Save Rule') : t('Create Rule')} + mapFormErrors={mapAssertionFormErrors} onPreSubmit={() => { if (!methodHasBody(formModel)) { formModel.setValue('body', null); diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index 54b7be14474ec8..83bf408dffba59 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -4,6 +4,7 @@ import {Stack} from 'sentry/components/core/layout'; import {t} from 'sentry/locale'; import type {UptimeDetector} from 'sentry/types/workflowEngine/detectors'; import useOrganization from 'sentry/utils/useOrganization'; +import {mapAssertionFormErrors} from 'sentry/views/alerts/rules/uptime/assertionFormErrors'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; @@ -73,6 +74,7 @@ export function NewUptimeDetectorForm() { extraFooterButton={ showTestButton ? : undefined } + mapFormErrors={mapAssertionFormErrors} > @@ -92,6 +94,7 @@ export function EditExistingUptimeDetectorForm({detector}: {detector: UptimeDete extraFooterButton={ showTestButton ? : undefined } + mapFormErrors={mapAssertionFormErrors} >