mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 09:51:59 +08:00
fix(editor): Show execution link on failed evaluation test cases (#32873)
This commit is contained in:
@@ -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',
|
||||
|
||||
+10
-9
@@ -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>
|
||||
|
||||
+12
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user