feat(editor): Add "Stop Test" button to stop running evaluations (#17328)

This commit is contained in:
Mutasem Aldmour
2025-07-15 15:10:39 +02:00
committed by GitHub
parent ded2e71d41
commit df80673c96
5 changed files with 98 additions and 25 deletions

View File

@@ -3144,6 +3144,7 @@
"evaluation.listRuns.error.unknownError": "Execution error{description}",
"evaluation.listRuns.error.cantFetchTestRuns": "Couldnt fetch test runs",
"evaluation.listRuns.error.cantStartTestRun": "Couldnt start test run",
"evaluation.listRuns.error.cantStopTestRun": "Couldnt stop test run",
"evaluation.listRuns.error.unknownError.description": "Click for more details",
"evaluation.listRuns.error.evaluationTriggerNotFound": "Evaluation trigger missing",
"evaluation.listRuns.error.evaluationTriggerNotConfigured": "Evaluation trigger is not configured",
@@ -3171,6 +3172,7 @@
"evaluation.runDetail.error.noMetricsCollected": "No 'Set metrics' node executed",
"evaluation.runDetail.error.partialCasesFailed": "Finished with errors",
"evaluation.runTest": "Run Test",
"evaluation.stopTest": "Stop Test",
"evaluation.cancelTestRun": "Cancel Test Run",
"evaluation.notImplemented": "This feature is not implemented yet!",
"evaluation.viewDetails": "View Details",

View File

@@ -5,11 +5,11 @@ export interface TestRunRecord {
id: string;
workflowId: string;
status: 'new' | 'running' | 'completed' | 'error' | 'cancelled' | 'warning' | 'success';
metrics?: Record<string, number>;
metrics?: Record<string, number> | null;
createdAt: string;
updatedAt: string;
runAt: string;
completedAt: string;
completedAt: string | null;
errorCode?: string;
errorDetails?: Record<string, unknown>;
finalResult?: 'success' | 'error' | 'warning';

View File

@@ -14,7 +14,6 @@ export const useEvaluationStore = defineStore(
() => {
// State
const loadingTestRuns = ref(false);
const fetchedAll = ref(false);
const testRunsById = ref<Record<string, TestRunRecord>>({});
const testCaseExecutionsById = ref<Record<string, TestCaseExecutionRecord>>({});
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
@@ -170,7 +169,6 @@ export const useEvaluationStore = defineStore(
return {
// State
fetchedAll,
testRunsById,
testCaseExecutionsById,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import RunsSection from '@/components/Evaluations.ee/ListRuns/RunsSection.vue';
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
@@ -18,6 +18,9 @@ const toast = useToast();
const evaluationStore = useEvaluationStore();
const selectedMetric = ref<string>('');
const cancellingTestRun = ref<boolean>(false);
const runningTestRun = computed(() => runs.value.find((run) => run.status === 'running'));
async function runTest() {
try {
@@ -33,6 +36,22 @@ async function runTest() {
}
}
async function stopTest() {
if (!runningTestRun.value) {
return;
}
try {
cancellingTestRun.value = true;
await evaluationStore.cancelTestRun(runningTestRun.value.workflowId, runningTestRun.value.id);
// we don't reset cancellingTestRun flag here, because we want the button to stay disabled
// until the "running" testRun is updated and cancelled in the list of test runs
} catch (error) {
toast.showError(error, locale.baseText('evaluation.listRuns.error.cantStopTestRun'));
cancellingTestRun.value = false;
}
}
const runs = computed(() => {
const testRuns = Object.values(evaluationStore.testRunsById ?? {}).filter(
({ workflowId }) => workflowId === props.name,
@@ -44,29 +63,36 @@ const runs = computed(() => {
}));
});
const isRunning = computed(() => runs.value.some((run) => run.status === 'running'));
const isRunTestEnabled = computed(() => !isRunning.value);
watch(runningTestRun, (run) => {
if (!run) {
// reset to ensure next run can be stopped
cancellingTestRun.value = false;
}
});
</script>
<template>
<div :class="$style.evaluationsView">
<div :class="$style.header">
<N8nTooltip :disabled="isRunTestEnabled" :placement="'left'">
<N8nButton
:disabled="!isRunTestEnabled"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('evaluation.runTest')"
type="primary"
@click="runTest"
/>
<template #content>
<template v-if="isRunning">
{{ locale.baseText('evaluation.testIsRunning') }}
</template>
</template>
</N8nTooltip>
<N8nButton
v-if="runningTestRun"
:disabled="cancellingTestRun"
:class="$style.runOrStopTestButton"
size="small"
data-test-id="stop-test-button"
:label="locale.baseText('evaluation.stopTest')"
type="secondary"
@click="stopTest"
/>
<N8nButton
v-else
:class="$style.runOrStopTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('evaluation.runTest')"
type="primary"
@click="runTest"
/>
</div>
<div :class="$style.wrapper">
<div :class="$style.content">
@@ -112,7 +138,7 @@ const isRunTestEnabled = computed(() => !isRunning.value);
padding-left: 58px;
}
.runTestButton {
.runOrStopTestButton {
white-space: nowrap;
}

View File

@@ -8,7 +8,6 @@ import { useEvaluationStore } from '@/stores/evaluation.store.ee';
import userEvent from '@testing-library/user-event';
import type { TestRunRecord } from '@/api/evaluation.ee';
import { waitFor } from '@testing-library/vue';
// import { useWorkflowsStore } from '@/stores/workflows.store';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -98,5 +97,53 @@ describe('EvaluationsView', () => {
expect(evaluationStore.startTestRun).toHaveBeenCalledWith('workflow-id');
expect(evaluationStore.fetchTestRuns).toHaveBeenCalledWith('workflow-id');
});
it('should display stop button when a test is running', async () => {
const evaluationStore = mockedStore(useEvaluationStore);
evaluationStore.testRunsById = {
run1: {
id: 'run1',
workflowId: 'workflow-id',
status: 'running',
runAt: '2023-01-01',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
completedAt: null,
metrics: {},
},
};
const { getByTestId } = renderComponent();
await waitFor(() => expect(getByTestId('stop-test-button')).toBeInTheDocument());
});
it('should call cancelTestRun when stop button is clicked', async () => {
const evaluationStore = mockedStore(useEvaluationStore);
evaluationStore.cancelTestRun.mockResolvedValue({ success: true });
evaluationStore.testRunsById = {
run1: {
id: 'run1',
workflowId: 'workflow-id',
status: 'running',
runAt: '2023-01-01',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
completedAt: null,
metrics: {},
},
};
const { getByTestId } = renderComponent();
await waitFor(() => expect(getByTestId('stop-test-button')).toBeInTheDocument());
await userEvent.click(getByTestId('stop-test-button'));
expect(getByTestId('stop-test-button')).toBeDisabled();
expect(evaluationStore.cancelTestRun).toHaveBeenCalledWith('workflow-id', 'run1');
expect(getByTestId('stop-test-button')).toBeDisabled();
});
});
});