fix(editor): Show execution link on failed evaluation test cases (#32873)

This commit is contained in:
Benjamin Schroth
2026-06-24 12:57:24 +00:00
committed by GitHub
parent 4364046858
commit 30fd834c5b
4 changed files with 72 additions and 11 deletions
@@ -1,5 +1,6 @@
import { mockLogger, mockInstance } from '@n8n/backend-test-utils';
import { ExecutionsConfig } from '@n8n/config';
import { TestCaseExecutionErrorCode } from '@n8n/db';
import type {
EvaluationCollectionRepository,
EvaluationConfigRepository,
@@ -2217,6 +2218,45 @@ describe('TestRunnerService', () => {
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
});
test('records executionId on a case that errors after running (so the UI can link to its execution)', async () => {
setupHappyPathMocks(2);
// Case 2's execution completes, but emits a non-numeric metric so
// metric extraction throws INVALID_METRICS *after* the execution ran.
// Its executionId must still be persisted on the error row.
activeExecutions.getPostExecutePromise.mockImplementation(async (executionId) => {
if (executionId === 'dataset-exec') {
return buildDatasetExecution(2);
}
if (executionId === 'case-exec-2') {
return {
data: {
resultData: {
runData: {
[METRICS_NODE_NAME]: [
{
data: {
[NodeConnectionTypes.Main]: [[{ json: { score: 'not-a-number' } }]],
},
},
],
},
},
},
} as unknown as IRun;
}
return buildCaseExecution(0.5);
});
await testRunnerService.runTest(USER as never, WORKFLOW_ID, 2);
const invalidMetricRow = testCaseExecutionRepository.update.mock.calls.find(
([, row]) => row.errorCode === TestCaseExecutionErrorCode.INVALID_METRICS,
);
expect(invalidMetricRow).toBeDefined();
expect(invalidMetricRow?.[1].executionId).toBe('case-exec-2');
});
test('throttle is called once per case and release is called once per case', async () => {
setupHappyPathMocks(4);
@@ -925,6 +925,10 @@ export class TestRunnerService {
const runAt = new Date();
try {
// Hoisted so the catch below can still link the failed case to
// its execution: errors thrown during metric extraction (e.g.
// INVALID_METRICS) happen after the execution already ran.
let testCaseExecutionId: string | undefined;
try {
const testCaseMetadata = { ...testRunMetadata };
@@ -949,8 +953,8 @@ export class TestRunnerService {
return [];
}
const { executionId: testCaseExecutionId, executionData: testCaseExecution } =
testCaseResult;
const { executionData: testCaseExecution } = testCaseResult;
testCaseExecutionId = testCaseResult.executionId;
assert(testCaseExecution);
assert(testCaseExecutionId);
@@ -1032,8 +1036,11 @@ export class TestRunnerService {
telemetryMeta.errored_test_case_count++;
// `executionId` is left undefined when the failure happened before
// an execution was created; TypeORM skips undefined fields on update.
if (e instanceof TestCaseExecutionError) {
await this.testCaseExecutionRepository.update(seededCase.id, {
executionId: testCaseExecutionId,
runAt,
completedAt,
status: 'error',
@@ -1042,6 +1049,7 @@ export class TestRunnerService {
});
} else {
await this.testCaseExecutionRepository.update(seededCase.id, {
executionId: testCaseExecutionId,
runAt,
completedAt,
status: 'error',
@@ -88,16 +88,9 @@ const cyclingVerbKey = useCyclingVerb(isRunning);
{{ locale.baseText('evaluation.runDetail.testCase.cancelled') }}
</N8nText>
</template>
<template v-else-if="isFailed">
<N8nButton
variant="outline"
size="mini"
:label="locale.baseText('evaluation.runDetail.testCase.rerun')"
data-test-id="test-case-rerun-button"
@click.stop="emit('rerun')"
/>
</template>
<template v-else>
<!-- View link shows for any finished case (success or failed); the
rerun button is appended only when the case failed. -->
<N8nTooltip
v-if="executionId"
:content="locale.baseText('evaluation.runDetail.testCase.viewLink')"
@@ -114,6 +107,14 @@ const cyclingVerbKey = useCyclingVerb(isRunning);
<N8nIcon icon="external-link" size="small" />
</button>
</N8nTooltip>
<N8nButton
v-if="isFailed"
variant="outline"
size="mini"
:label="locale.baseText('evaluation.runDetail.testCase.rerun')"
data-test-id="test-case-rerun-button"
@click.stop="emit('rerun')"
/>
</template>
</div>
</div>
@@ -206,6 +206,18 @@ describe('TestRunDetailView', () => {
});
});
it('renders the execution view link on failed cases alongside the rerun button', async () => {
// mockTestCases has one success and one error case, both with an
// executionId. The link must show for both so a failed case is still
// clickable through to its execution; only the failed case also gets
// the rerun button.
const { getAllByTestId } = renderComponent();
await waitFor(() => {
expect(getAllByTestId('test-case-view-link')).toHaveLength(mockTestCases.length);
expect(getAllByTestId('test-case-rerun-button')).toHaveLength(1);
});
});
it('does not render a partial-failure callout — failures are surfaced per-card via RunStatusPill', async () => {
const { container, queryByText } = renderComponent();
await waitFor(() => {