Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx
Original file line number Diff line number Diff line change
@@ -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'],
});
});
});
79 changes: 79 additions & 0 deletions static/app/views/alerts/rules/uptime/assertionFormErrors.tsx
Original file line number Diff line number Diff line change
@@ -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: <error details>"]}
*/
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;
}
80 changes: 80 additions & 0 deletions static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<UptimeAlertForm />, {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(<UptimeAlertForm />, {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'],
Expand Down
2 changes: 2 additions & 0 deletions static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,6 +74,7 @@ export function NewUptimeDetectorForm() {
extraFooterButton={
showTestButton ? <ConnectedTestUptimeMonitorButton /> : undefined
}
mapFormErrors={mapAssertionFormErrors}
>
<UptimeDetectorForm />
</NewDetectorLayout>
Expand All @@ -92,6 +94,7 @@ export function EditExistingUptimeDetectorForm({detector}: {detector: UptimeDete
extraFooterButton={
showTestButton ? <ConnectedTestUptimeMonitorButton size="sm" /> : undefined
}
mapFormErrors={mapAssertionFormErrors}
>
<UptimeDetectorForm />
</EditDetectorLayout>
Expand Down
Loading