feat(editor): Group scenarios + inline runner + lazy-fetch historical (no-changelog)

- Group the scenarios list by test type (Success paths / Failure
  paths / Any) with section counts and hidden empty sections.
- Drop the scenario runner modal. The run now happens inline in the
  detail pane; rich result rendering moved into a reusable
  ScenarioRunResult component.
- Lazy-fetch historical executions: when a scenario with a
  lastExecutionId is selected but no live result is in memory, the
  scenarios store pulls the execution via workflowsStore.getExecution
  and rebuilds a degraded result (node outputs + start times; no
  intercepted requests since those weren't persisted). Cached in the
  session. The rich result card calls out the degraded trace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose 2026-04-24 11:03:08 +01:00
parent 83af4bc2cd
commit f95cfbfaea
No known key found for this signature in database
5 changed files with 626 additions and 717 deletions

View File

@ -4896,12 +4896,18 @@
"scenarios.detail.expectationLabel": "Test type",
"scenarios.detail.expectationHelper": "What this scenario is testing — we'll flag a mismatch when a run doesn't match.",
"scenarios.detail.run": "Run scenario",
"scenarios.detail.rerun": "Run again",
"scenarios.detail.notRunYet": "Not run yet",
"scenarios.result.degradedNote": "Intercepted request details are only shown for runs from the current session. Open the execution to see the full trace.",
"scenarios.result.loadingHistorical": "Loading execution…",
"scenarios.expectation.pass": "Success path",
"scenarios.expectation.fail": "Failure path",
"scenarios.expectation.any": "Any",
"scenarios.expectation.badge.pass": "Success",
"scenarios.expectation.badge.fail": "Failure",
"scenarios.group.pass": "Success paths",
"scenarios.group.fail": "Failure paths",
"scenarios.group.any": "Any",
"scenarios.displayStatus.unrun": "Not run yet",
"scenarios.displayStatus.passed": "Passed",
"scenarios.displayStatus.passedWithIssues": "Ran with issues",

View File

@ -0,0 +1,330 @@
<script setup lang="ts">
import type { InstanceAiEvalExecutionResult, InstanceAiEvalNodeResult } from '@n8n/api-types';
import { N8nIcon, N8nText, type IconName } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import ScenarioNodeResult from './ScenarioNodeResult.vue';
const props = defineProps<{
result: InstanceAiEvalExecutionResult;
durationMs?: number | null;
/** When true, hide sections that won't have data for historical runs
* (e.g. intercepted requests aren't persisted beyond the originating session). */
degraded?: boolean;
}>();
const i18n = useI18n();
const orderedNodeResults = computed(() =>
Object.entries(props.result.nodeResults)
.map(([name, result]) => ({ name, result: result as InstanceAiEvalNodeResult }))
.sort((a, b) => (a.result.startTime ?? 0) - (b.result.startTime ?? 0)),
);
const warnings = computed(() => props.result.hints?.warnings ?? []);
const errors = computed(() => props.result.errors ?? []);
const nodesWithConfigIssues = computed(() =>
orderedNodeResults.value.filter(
({ result }) => result.configIssues && Object.keys(result.configIssues).length > 0,
),
);
const mockedCallCount = computed(() =>
orderedNodeResults.value.reduce(
(total, { result }) => total + (result.interceptedRequests?.length ?? 0),
0,
),
);
const issueCount = computed(
() => warnings.value.length + errors.value.length + nodesWithConfigIssues.value.length,
);
type VerdictKind = 'passed' | 'passedWithIssues' | 'failed';
const verdictKind = computed<VerdictKind>(() => {
if (!props.result.success || errors.value.length > 0) return 'failed';
if (issueCount.value > 0) return 'passedWithIssues';
return 'passed';
});
const verdictIcon = computed<IconName>(() => {
switch (verdictKind.value) {
case 'passed':
return 'circle-check';
case 'passedWithIssues':
return 'triangle-alert';
case 'failed':
return 'circle-x';
}
});
const verdictColor = computed<'success' | 'warning' | 'danger'>(() => {
switch (verdictKind.value) {
case 'passed':
return 'success';
case 'passedWithIssues':
return 'warning';
case 'failed':
return 'danger';
}
});
const verdictLabel = computed(() => i18n.baseText(`scenarioRunner.verdict.${verdictKind.value}`));
</script>
<template>
<div :class="$style.wrapper">
<section :class="[$style.verdict, $style[`verdict_${verdictKind}`]]">
<N8nIcon :icon="verdictIcon" :color="verdictColor" size="medium" />
<div :class="$style.verdictText">
<N8nText bold size="medium" :color="verdictColor">
{{ verdictLabel }}
</N8nText>
<div :class="$style.verdictStats">
<N8nText
v-if="durationMs !== null && durationMs !== undefined"
size="xsmall"
color="text-light"
tag="span"
>
{{
i18n.baseText('scenarioRunner.verdict.duration', {
interpolate: { s: (durationMs / 1000).toFixed(1) },
})
}}
</N8nText>
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
<N8nText size="xsmall" color="text-light" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.nodesRan', {
interpolate: { count: orderedNodeResults.length },
})
}}
</N8nText>
<template v-if="!degraded && mockedCallCount > 0">
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
<N8nText size="xsmall" color="text-light" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.mockedCalls', {
interpolate: { count: mockedCallCount },
})
}}
</N8nText>
</template>
<template v-if="issueCount > 0">
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
<N8nText size="xsmall" color="warning" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.issues', {
interpolate: { count: issueCount },
})
}}
</N8nText>
</template>
</div>
</div>
</section>
<details v-if="issueCount > 0" :class="$style.details" :open="issueCount > 0">
<summary :class="$style.detailsSummary">
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.issues.header') }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{ i18n.baseText('scenarioRunner.issues.count', { interpolate: { count: issueCount } }) }}
</N8nText>
</summary>
<div :class="$style.issuesList">
<div v-for="(err, i) in errors" :key="`err-${i}`" :class="$style.issueRow">
<N8nIcon icon="circle-x" color="danger" size="small" />
<N8nText size="small" color="text-base">{{ err }}</N8nText>
</div>
<div v-for="(w, i) in warnings" :key="`warn-${i}`" :class="$style.issueRow">
<N8nIcon icon="triangle-alert" color="warning" size="small" />
<N8nText size="small" color="text-base">{{ w }}</N8nText>
</div>
<div
v-for="{ name, result } in nodesWithConfigIssues"
:key="`cfg-${name}`"
:class="$style.issueRow"
>
<N8nIcon icon="triangle-alert" color="warning" size="small" />
<div :class="$style.issueText">
<N8nText size="small" color="text-base">
{{ i18n.baseText('scenarioRunner.issues.configPrefix', { interpolate: { name } }) }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{ Object.keys(result.configIssues ?? {}).join(', ') }}
</N8nText>
</div>
</div>
</div>
</details>
<details :class="$style.details" open>
<summary :class="$style.detailsSummary">
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.trace.header') }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{
i18n.baseText('scenarioRunner.trace.count', {
interpolate: { count: orderedNodeResults.length },
})
}}
</N8nText>
</summary>
<div :class="$style.nodesList">
<ScenarioNodeResult
v-for="{ name, result } in orderedNodeResults"
:key="name"
:node-name="name"
:result="result"
/>
</div>
</details>
<details v-if="result.hints?.globalContext" :class="$style.details">
<summary :class="$style.detailsSummary">
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.technical.header') }}
</N8nText>
</summary>
<pre :class="$style.contextText">{{ result.hints.globalContext }}</pre>
</details>
<N8nText v-if="degraded" size="xsmall" color="text-light" :class="$style.degradedNote">
{{ i18n.baseText('scenarios.result.degradedNote') }}
</N8nText>
</div>
</template>
<style module lang="scss">
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.verdict {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius);
background-color: var(--color--background--light-2);
border: var(--border);
border-left-width: 3px;
}
.verdict_passed {
border-left-color: var(--color--success);
}
.verdict_passedWithIssues {
border-left-color: var(--color--warning);
}
.verdict_failed {
border-left-color: var(--color--danger);
}
.verdictText {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.verdictStats {
display: flex;
align-items: baseline;
gap: var(--spacing--3xs);
flex-wrap: wrap;
line-height: var(--line-height--md);
}
.details {
border: var(--border);
border-radius: var(--radius);
background-color: var(--color--background);
overflow: hidden;
&[open] .detailsChevron {
transform: rotate(90deg);
}
}
.detailsSummary {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs);
cursor: pointer;
list-style: none;
user-select: none;
&::-webkit-details-marker {
display: none;
}
&:hover {
background-color: var(--color--background--light-2);
}
}
.detailsChevron {
flex-shrink: 0;
transition: transform 0.15s ease;
}
.issuesList {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
border-top: var(--border);
}
.issueRow {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
}
.issueText {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.nodesList {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
border-top: var(--border);
}
.contextText {
font-size: var(--font-size--2xs);
line-height: var(--line-height--md);
margin: 0;
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
white-space: pre-wrap;
word-break: break-word;
color: var(--color--text);
border-top: var(--border);
background-color: var(--color--background--light-2);
}
.degradedNote {
font-style: italic;
padding: var(--spacing--3xs) var(--spacing--xs);
}
</style>

View File

@ -1,612 +0,0 @@
<script setup lang="ts">
import type { InstanceAiEvalNodeResult } from '@n8n/api-types';
import {
N8nButton,
N8nCallout,
N8nIcon,
N8nIconButton,
N8nInput,
N8nSpinner,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { ElDialog } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { useScenarioRunnerStore } from '../scenarioRunner.store';
import ScenarioNodeResult from './ScenarioNodeResult.vue';
const props = defineProps<{
modelValue: boolean;
workflowId: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const i18n = useI18n();
const store = useScenarioRunnerStore();
const MAX_SCENARIO_LENGTH = 2000;
const scenarioText = ref(store.lastScenarioHints);
const showGlobalContext = ref(false);
const editingScenario = ref(!store.hasResult);
const charCount = computed(() => scenarioText.value.length);
const overLimit = computed(() => charCount.value > MAX_SCENARIO_LENGTH);
const canRun = computed(() => !store.isRunning && !overLimit.value);
const canClose = computed(() => !store.isRunning);
const orderedNodeResults = computed(() => {
if (!store.result) return [];
return Object.entries(store.result.nodeResults)
.map(([name, result]) => ({ name, result: result as InstanceAiEvalNodeResult }))
.sort((a, b) => (a.result.startTime ?? 0) - (b.result.startTime ?? 0));
});
const warnings = computed(() => store.result?.hints.warnings ?? []);
const errors = computed(() => store.result?.errors ?? []);
const nodesWithConfigIssues = computed(() =>
orderedNodeResults.value.filter(
({ result }) => result.configIssues && Object.keys(result.configIssues).length > 0,
),
);
const mockedCallCount = computed(() =>
orderedNodeResults.value.reduce(
(total, { result }) => total + (result.interceptedRequests?.length ?? 0),
0,
),
);
const issueCount = computed(
() => warnings.value.length + errors.value.length + nodesWithConfigIssues.value.length,
);
type VerdictKind = 'passed' | 'passedWithIssues' | 'failed';
const verdictKind = computed<VerdictKind>(() => {
if (!store.result) return 'passed';
if (!store.result.success || errors.value.length > 0) return 'failed';
if (issueCount.value > 0) return 'passedWithIssues';
return 'passed';
});
const verdictIcon = computed(() => {
switch (verdictKind.value) {
case 'passed':
return 'circle-check';
case 'passedWithIssues':
return 'triangle-alert';
case 'failed':
return 'circle-x';
}
});
const verdictColor = computed(() => {
switch (verdictKind.value) {
case 'passed':
return 'success';
case 'passedWithIssues':
return 'warning';
case 'failed':
return 'danger';
}
});
const verdictLabel = computed(() => i18n.baseText(`scenarioRunner.verdict.${verdictKind.value}`));
const scenarioSummary = computed(() => {
const text = store.lastScenarioHints?.trim() ?? '';
if (!text) return i18n.baseText('scenarioRunner.summary.empty');
return text;
});
function close() {
if (!canClose.value) return;
emit('update:modelValue', false);
}
async function run() {
if (!canRun.value) return;
editingScenario.value = false;
await store.runScenario(props.workflowId, scenarioText.value.trim());
}
function editScenario() {
editingScenario.value = true;
}
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) {
scenarioText.value = store.lastScenarioHints;
editingScenario.value = !store.hasResult;
showGlobalContext.value = false;
}
},
);
</script>
<template>
<ElDialog
:model-value="modelValue"
:close-on-click-modal="canClose"
:close-on-press-escape="canClose"
:show-close="false"
:width="720"
append-to-body
aria-labelledby="scenario-runner-title"
custom-class="scenario-runner-dialog"
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
>
<div :class="$style.container">
<header :class="$style.header">
<div>
<N8nText id="scenario-runner-title" tag="h2" bold size="large" color="text-dark">
{{ i18n.baseText('scenarioRunner.title') }}
</N8nText>
<N8nText tag="p" size="small" color="text-light" :class="$style.subtitle">
{{ i18n.baseText('scenarioRunner.subtitle') }}
</N8nText>
</div>
<N8nIconButton
icon="x"
type="tertiary"
size="small"
:disabled="!canClose"
:title="i18n.baseText('generic.close')"
data-test-id="scenario-runner-close"
@click="close"
/>
</header>
<section v-if="store.errorMessage && !store.isRunning" :class="$style.section">
<N8nCallout theme="danger" icon="triangle-alert">
{{ i18n.baseText('scenarioRunner.error.requestFailed') }}
<template #trailingContent>
<N8nText size="xsmall" color="text-base">{{ store.errorMessage }}</N8nText>
</template>
</N8nCallout>
</section>
<section v-if="editingScenario || !store.hasResult" :class="$style.inputSection">
<label :class="$style.label" for="scenario-runner-textarea">
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.input.label') }}
</N8nText>
<N8nText size="xsmall" color="text-light" tag="span">
{{ i18n.baseText('scenarioRunner.input.optional') }}
</N8nText>
</label>
<N8nInput
id="scenario-runner-textarea"
v-model="scenarioText"
type="textarea"
:rows="4"
:placeholder="i18n.baseText('scenarioRunner.input.placeholder')"
:maxlength="MAX_SCENARIO_LENGTH"
:disabled="store.isRunning"
data-test-id="scenario-runner-textarea"
/>
<div :class="$style.inputMeta">
<N8nText size="xsmall" color="text-light">
{{ i18n.baseText('scenarioRunner.input.helper') }}
</N8nText>
<N8nText
size="xsmall"
:color="overLimit ? 'danger' : 'text-light'"
:class="$style.counter"
>
{{ charCount }} / {{ MAX_SCENARIO_LENGTH }}
</N8nText>
</div>
</section>
<section v-else :class="$style.inputCollapsed">
<div :class="$style.inputCollapsedLabel">
<N8nText bold size="xsmall" color="text-light">
{{ i18n.baseText('scenarioRunner.summary.label') }}
</N8nText>
<N8nButton
type="tertiary"
size="mini"
icon="pencil"
:label="i18n.baseText('scenarioRunner.summary.edit')"
@click="editScenario"
/>
</div>
<N8nText size="small" color="text-base" :class="$style.inputCollapsedText">
{{ scenarioSummary }}
</N8nText>
</section>
<footer :class="$style.actionBar">
<N8nButton
type="secondary"
size="small"
:disabled="!canClose"
:label="i18n.baseText('generic.close')"
@click="close"
/>
<N8nButton
type="primary"
size="small"
:loading="store.isRunning"
:disabled="!canRun"
:label="
store.hasResult
? i18n.baseText('scenarioRunner.rerunButton')
: i18n.baseText('scenarioRunner.runButton')
"
data-test-id="scenario-runner-run"
@click="run"
/>
</footer>
<section v-if="store.isRunning" :class="$style.runningBlock">
<N8nSpinner size="small" />
<N8nText size="small" color="text-light">
{{ i18n.baseText('scenarioRunner.running') }}
</N8nText>
</section>
<section
v-if="store.result && !store.isRunning"
:class="[$style.verdict, $style[`verdict_${verdictKind}`]]"
>
<N8nIcon :icon="verdictIcon" :color="verdictColor" size="medium" />
<div :class="$style.verdictText">
<N8nText bold size="medium" :color="verdictColor">
{{ verdictLabel }}
</N8nText>
<div :class="$style.verdictStats">
<N8nText v-if="store.durationMs !== null" size="xsmall" color="text-light" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.duration', {
interpolate: { s: (store.durationMs / 1000).toFixed(1) },
})
}}
</N8nText>
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
<N8nText size="xsmall" color="text-light" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.nodesRan', {
interpolate: { count: orderedNodeResults.length },
})
}}
</N8nText>
<template v-if="mockedCallCount > 0">
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
<N8nText size="xsmall" color="text-light" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.mockedCalls', {
interpolate: { count: mockedCallCount },
})
}}
</N8nText>
</template>
<template v-if="issueCount > 0">
<N8nText size="xsmall" color="text-xlight" tag="span">·</N8nText>
<N8nText size="xsmall" color="warning" tag="span">
{{
i18n.baseText('scenarioRunner.verdict.issues', {
interpolate: { count: issueCount },
})
}}
</N8nText>
</template>
</div>
</div>
</section>
<section v-if="store.result && !store.isRunning" :class="$style.resultsBlock">
<details v-if="issueCount > 0" :class="$style.details" :open="issueCount > 0">
<summary :class="$style.detailsSummary">
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.issues.header') }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{
i18n.baseText('scenarioRunner.issues.count', {
interpolate: { count: issueCount },
})
}}
</N8nText>
</summary>
<div :class="$style.issuesList">
<div v-for="(err, i) in errors" :key="`err-${i}`" :class="$style.issueRow">
<N8nIcon icon="circle-x" color="danger" size="small" />
<N8nText size="small" color="text-base">{{ err }}</N8nText>
</div>
<div v-for="(w, i) in warnings" :key="`warn-${i}`" :class="$style.issueRow">
<N8nIcon icon="triangle-alert" color="warning" size="small" />
<N8nText size="small" color="text-base">{{ w }}</N8nText>
</div>
<div
v-for="{ name, result } in nodesWithConfigIssues"
:key="`cfg-${name}`"
:class="$style.issueRow"
>
<N8nIcon icon="triangle-alert" color="warning" size="small" />
<div :class="$style.issueText">
<N8nText size="small" color="text-base">
{{
i18n.baseText('scenarioRunner.issues.configPrefix', {
interpolate: { name },
})
}}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{ Object.keys(result.configIssues ?? {}).join(', ') }}
</N8nText>
</div>
</div>
</div>
</details>
<details :class="$style.details" open>
<summary :class="$style.detailsSummary">
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.trace.header') }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{
i18n.baseText('scenarioRunner.trace.count', {
interpolate: { count: orderedNodeResults.length },
})
}}
</N8nText>
</summary>
<div :class="$style.nodesList">
<ScenarioNodeResult
v-for="{ name, result } in orderedNodeResults"
:key="name"
:node-name="name"
:result="result"
/>
</div>
</details>
<details v-if="store.result.hints.globalContext" :class="$style.details">
<summary :class="$style.detailsSummary">
<N8nIcon icon="chevron-right" size="small" :class="$style.detailsChevron" />
<N8nText bold size="small" color="text-dark">
{{ i18n.baseText('scenarioRunner.technical.header') }}
</N8nText>
</summary>
<pre :class="$style.contextText">{{ store.result.hints.globalContext }}</pre>
</details>
</section>
</div>
</ElDialog>
</template>
<style module lang="scss">
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
max-height: 80vh;
overflow: hidden;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing--sm);
}
.subtitle {
margin-top: var(--spacing--3xs);
}
.verdict {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius);
background-color: var(--color--background--light-2);
border: var(--border);
border-left-width: 3px;
}
.verdict_passed {
border-left-color: var(--color--success);
}
.verdict_passedWithIssues {
border-left-color: var(--color--warning);
}
.verdict_failed {
border-left-color: var(--color--danger);
}
.verdictText {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.verdictStats {
display: flex;
align-items: baseline;
gap: var(--spacing--3xs);
flex-wrap: wrap;
line-height: var(--line-height--md);
}
.inputSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.label {
display: flex;
align-items: baseline;
gap: var(--spacing--2xs);
}
.inputMeta {
display: flex;
justify-content: space-between;
align-items: center;
}
.counter {
font-variant-numeric: tabular-nums;
}
.inputCollapsed {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: var(--spacing--2xs) var(--spacing--xs);
background-color: var(--color--background--light-2);
border: var(--border);
border-radius: var(--radius);
}
.inputCollapsedLabel {
display: flex;
align-items: center;
justify-content: space-between;
}
.inputCollapsedText {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-clamp: 2;
}
.actionBar {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
.runningBlock {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--sm);
background-color: var(--color--background--light-2);
border-radius: var(--radius);
border: var(--border);
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.resultsBlock {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
overflow-y: auto;
padding-top: var(--spacing--2xs);
border-top: var(--border);
}
.details {
border: var(--border);
border-radius: var(--radius);
background-color: var(--color--background);
overflow: hidden;
&[open] .detailsChevron {
transform: rotate(90deg);
}
}
.detailsSummary {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs);
cursor: pointer;
list-style: none;
user-select: none;
&::-webkit-details-marker {
display: none;
}
&:hover {
background-color: var(--color--background--light-2);
}
}
.detailsChevron {
flex-shrink: 0;
transition: transform 0.15s ease;
}
.issuesList {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
border-top: var(--border);
}
.issueRow {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
}
.issueText {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.nodesList {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
border-top: var(--border);
}
.contextText {
font-size: var(--font-size--2xs);
line-height: var(--line-height--md);
margin: 0;
padding: var(--spacing--2xs) var(--spacing--xs) var(--spacing--xs);
white-space: pre-wrap;
word-break: break-word;
color: var(--color--text);
border-top: var(--border);
background-color: var(--color--background--light-2);
}
</style>
<style lang="scss">
.scenario-runner-dialog {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: var(--spacing--lg);
}
}
</style>

View File

@ -6,6 +6,7 @@ import {
N8nIconButton,
N8nInput,
N8nRadioButtons,
N8nSpinner,
N8nText,
N8nTooltip,
type IconName,
@ -21,7 +22,7 @@ import type {
ScenarioExpectation,
ScenarioRecord,
} from '../scenarios.store';
import ScenarioRunnerDialog from './ScenarioRunnerDialog.vue';
import ScenarioRunResult from './ScenarioRunResult.vue';
const props = defineProps<{
workflowId: string;
@ -40,9 +41,9 @@ const editingName = ref(false);
// Draft for the inline "new scenario" form. Null when closed.
const draft = ref<{ name: string; description: string } | null>(null);
// Dialog state opens when user clicks Run on a scenario
const dialogOpen = ref(false);
const runningScenarioId = ref<string | null>(null);
// Which scenario's result is currently held in runnerStore. Lets the detail
// pane render the rich trace only for the scenario that produced it.
const lastRunScenarioId = ref<string | null>(null);
const selectedScenario = computed(() =>
selectedId.value ? (scenarios.value.find((s) => s.id === selectedId.value) ?? null) : null,
@ -126,16 +127,28 @@ const expectationOptions = computed<ExpectationOption[]>(() => [
},
]);
function expectationBadge(
expectation: ScenarioExpectation,
): { label: string; tone: 'success' | 'danger' } | null {
if (expectation === 'pass')
return { label: i18n.baseText('scenarios.expectation.badge.pass'), tone: 'success' };
if (expectation === 'fail')
return { label: i18n.baseText('scenarios.expectation.badge.fail'), tone: 'danger' };
return null;
interface ScenarioGroup {
key: ScenarioExpectation;
labelKey: 'scenarios.group.pass' | 'scenarios.group.fail' | 'scenarios.group.any';
items: ScenarioRecord[];
}
const groupedScenarios = computed<ScenarioGroup[]>(() => {
const groups: Record<ScenarioExpectation, ScenarioRecord[]> = {
pass: [],
fail: [],
any: [],
};
for (const scenario of scenarios.value) {
groups[scenario.expectedOutcome].push(scenario);
}
return [
{ key: 'pass', labelKey: 'scenarios.group.pass', items: groups.pass },
{ key: 'fail', labelKey: 'scenarios.group.fail', items: groups.fail },
{ key: 'any', labelKey: 'scenarios.group.any', items: groups.any },
].filter((g) => g.items.length > 0) as ScenarioGroup[];
});
function commitExpectation(value: ScenarioExpectation) {
if (!selectedScenario.value) return;
if (value === selectedScenario.value.expectedOutcome) return;
@ -233,40 +246,68 @@ async function deleteSelected() {
}
}
function runSelected() {
if (!selectedScenario.value) return;
runnerStore.reset();
runningScenarioId.value = selectedScenario.value.id;
// Seed the runner store's textarea with this scenario's description so the
// dialog opens with the right text. See ScenarioRunnerDialog's open watcher.
runnerStore.setScenarioText(selectedScenario.value.description);
dialogOpen.value = true;
async function runSelected() {
if (!selectedScenario.value || runnerStore.isRunning) return;
const scenario = selectedScenario.value;
lastRunScenarioId.value = scenario.id;
await runnerStore.runScenario(props.workflowId, scenario.description);
const result = runnerStore.result;
if (result) {
store.persistRunResult(
props.workflowId,
scenario.id,
result,
runnerStore.durationMs ?? undefined,
);
}
}
// When a run completes inside the dialog, persist the result on the scenario record.
watch(
() => runnerStore.status,
(status, prev) => {
if (!runningScenarioId.value) return;
if (prev === 'running' && (status === 'succeeded' || status === 'failed')) {
const result = runnerStore.result;
if (result) {
store.persistRunResult(
props.workflowId,
runningScenarioId.value,
result,
runnerStore.durationMs ?? undefined,
);
}
}
},
// True when runnerStore holds a live result for the currently-selected scenario.
// Drives the rich inline trace. For any other scenario, the detail pane shows
// only the summary (rich trace for historical runs is the lazy-fetch task).
const hasLiveResult = computed(
() =>
runnerStore.result !== null &&
selectedScenario.value !== null &&
lastRunScenarioId.value === selectedScenario.value.id,
);
watch(dialogOpen, (open) => {
if (!open) {
runningScenarioId.value = null;
}
const isRunningSelected = computed(
() =>
runnerStore.isRunning &&
selectedScenario.value !== null &&
lastRunScenarioId.value === selectedScenario.value.id,
);
// Lazy-fetch the historical execution when selecting a scenario that hasn't
// been run this session. Rich trace comes back degraded (no intercepted
// requests, no hints) but node outputs + timings make it useful.
watch(
selectedScenario,
async (scenario) => {
if (!scenario?.lastExecutionId) return;
if (hasLiveResult.value) return;
if (store.cachedHistoricalResult(scenario.lastExecutionId)) return;
try {
await store.fetchHistoricalResult(scenario.lastExecutionId);
} catch {
// Silent the detail pane falls back to summary-only display.
}
},
{ immediate: true },
);
const historicalResult = computed(() => {
if (hasLiveResult.value) return null;
if (!selectedScenario.value?.lastExecutionId) return null;
return store.cachedHistoricalResult(selectedScenario.value.lastExecutionId);
});
const isFetchingHistorical = computed(
() =>
!!selectedScenario.value?.lastExecutionId &&
store.fetchingExecutionId === selectedScenario.value.lastExecutionId,
);
</script>
<template>
@ -341,42 +382,49 @@ watch(dialogOpen, (open) => {
</div>
</div>
<button
v-for="scenario in scenarios"
:key="scenario.id"
type="button"
:class="[$style.listRow, selectedId === scenario.id && $style.listRowActive]"
:data-test-id="`scenario-row-${scenario.id}`"
@click="selectedId = scenario.id"
>
<N8nTooltip placement="top" :content="statusLabel(deriveDisplayStatus(scenario))">
<N8nIcon
:icon="statusIcon(deriveDisplayStatus(scenario))"
:color="statusColor(deriveDisplayStatus(scenario))"
size="small"
:class="$style.listDot"
/>
</N8nTooltip>
<div :class="$style.listText">
<div :class="$style.listTitleRow">
<N8nText bold size="small" color="text-dark" :class="$style.listName">
{{ scenario.name }}
</N8nText>
<span
v-if="expectationBadge(scenario.expectedOutcome)"
:class="[
$style.listBadge,
$style[`listBadge_${expectationBadge(scenario.expectedOutcome)!.tone}`],
]"
>
{{ expectationBadge(scenario.expectedOutcome)!.label }}
</span>
</div>
<N8nText size="xsmall" color="text-light" :class="$style.listPreview">
{{ descriptionPreview(scenario) }}
<div v-for="group in groupedScenarios" :key="group.key" :class="$style.listGroup">
<header :class="$style.listGroupHeader">
<N8nText
tag="span"
size="xsmall"
bold
color="text-light"
:class="$style.listGroupLabel"
>
{{ i18n.baseText(group.labelKey) }}
</N8nText>
</div>
</button>
<N8nText tag="span" size="xsmall" color="text-xlight">
{{ group.items.length }}
</N8nText>
</header>
<button
v-for="scenario in group.items"
:key="scenario.id"
type="button"
:class="[$style.listRow, selectedId === scenario.id && $style.listRowActive]"
:data-test-id="`scenario-row-${scenario.id}`"
@click="selectedId = scenario.id"
>
<N8nTooltip placement="top" :content="statusLabel(deriveDisplayStatus(scenario))">
<N8nIcon
:icon="statusIcon(deriveDisplayStatus(scenario))"
:color="statusColor(deriveDisplayStatus(scenario))"
size="small"
:class="$style.listDot"
/>
</N8nTooltip>
<div :class="$style.listText">
<div :class="$style.listTitleRow">
<N8nText bold size="small" color="text-dark" :class="$style.listName">
{{ scenario.name }}
</N8nText>
</div>
<N8nText size="xsmall" color="text-light" :class="$style.listPreview">
{{ descriptionPreview(scenario) }}
</N8nText>
</div>
</button>
</div>
</aside>
<section v-if="selectedScenario" :class="$style.detail">
@ -468,8 +516,14 @@ watch(dialogOpen, (open) => {
<N8nButton
type="primary"
size="small"
icon="play"
:label="i18n.baseText('scenarios.detail.run')"
:icon="isRunningSelected ? undefined : 'play'"
:loading="isRunningSelected"
:disabled="runnerStore.isRunning"
:label="
selectedScenario.lastRunStatus
? i18n.baseText('scenarios.detail.rerun')
: i18n.baseText('scenarios.detail.run')
"
data-test-id="scenarios-run"
@click="runSelected"
/>
@ -500,10 +554,44 @@ watch(dialogOpen, (open) => {
{{ i18n.baseText('scenarios.detail.notRunYet') }}
</N8nText>
</div>
<section v-if="isRunningSelected" :class="$style.runningBlock">
<N8nSpinner size="small" />
<N8nText size="small" color="text-light">
{{ i18n.baseText('scenarioRunner.running') }}
</N8nText>
</section>
<section v-if="runnerStore.errorMessage && !isRunningSelected" :class="$style.field">
<N8nCallout theme="danger" icon="triangle-alert">
{{ i18n.baseText('scenarioRunner.error.requestFailed') }}
<template #trailingContent>
<N8nText size="xsmall" color="text-base">{{ runnerStore.errorMessage }}</N8nText>
</template>
</N8nCallout>
</section>
<ScenarioRunResult
v-if="hasLiveResult && runnerStore.result && !isRunningSelected"
:result="runnerStore.result"
:duration-ms="runnerStore.durationMs"
/>
<section v-else-if="isFetchingHistorical" :class="$style.runningBlock">
<N8nSpinner size="small" />
<N8nText size="small" color="text-light">
{{ i18n.baseText('scenarios.result.loadingHistorical') }}
</N8nText>
</section>
<ScenarioRunResult
v-else-if="historicalResult"
:result="historicalResult"
:duration-ms="selectedScenario.lastRunDurationMs ?? null"
degraded
/>
</section>
</div>
<ScenarioRunnerDialog v-model="dialogOpen" :workflow-id="workflowId" />
</div>
</template>
@ -558,11 +646,29 @@ watch(dialogOpen, (open) => {
.list {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
gap: var(--spacing--sm);
overflow-y: auto;
padding-right: var(--spacing--2xs);
}
.listGroup {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.listGroupHeader {
display: flex;
align-items: baseline;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) var(--spacing--xs);
}
.listGroupLabel {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.draftCard {
display: flex;
flex-direction: column;
@ -633,29 +739,6 @@ watch(dialogOpen, (open) => {
min-width: 0;
}
.listBadge {
flex-shrink: 0;
font-size: var(--font-size--3xs);
font-weight: var(--font-weight--bold);
line-height: 1;
padding: var(--spacing--5xs) var(--spacing--3xs);
border-radius: var(--radius--sm);
border: var(--border-width) var(--border-style) transparent;
letter-spacing: 0.02em;
}
.listBadge_success {
color: var(--color--success--shade-1);
background-color: var(--color--success--tint-4);
border-color: var(--color--success--tint-2);
}
.listBadge_danger {
color: var(--color--danger--shade-1);
background-color: var(--color--danger--tint-4);
border-color: var(--color--danger--tint-3);
}
.expectationOption {
display: inline-flex;
align-items: center;
@ -724,6 +807,16 @@ watch(dialogOpen, (open) => {
flex-wrap: wrap;
}
.runningBlock {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--sm);
background-color: var(--color--background--light-2);
border-radius: var(--radius);
border: var(--border);
}
.lastRun {
display: flex;
align-items: baseline;

View File

@ -1,9 +1,10 @@
import type { InstanceAiEvalExecutionResult } from '@n8n/api-types';
import type { InstanceAiEvalExecutionResult, InstanceAiEvalNodeResult } from '@n8n/api-types';
import { STORES } from '@n8n/stores';
import { useRootStore } from '@n8n/stores/useRootStore';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import * as scenarioRunnerApi from './scenarioRunner.api';
export type ScenarioRunStatus = 'passed' | 'passedWithIssues' | 'failed';
@ -91,10 +92,18 @@ function deriveStatus(result: InstanceAiEvalExecutionResult): ScenarioRunStatus
export const useScenariosStore = defineStore(STORES.SCENARIOS, () => {
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const byWorkflowId = ref<StorageShape>(loadFromStorage());
const runningScenarioId = ref<string | null>(null);
// In-session cache of degraded results reconstructed from persisted
// executions. Keyed by executionId. Lost on reload — that's fine, the
// fetch is cheap and avoids duplicating data we already persisted as
// part of the normal execution record.
const historicalByExecutionId = ref<Record<string, InstanceAiEvalExecutionResult>>({});
const fetchingExecutionId = ref<string | null>(null);
watch(
byWorkflowId,
(value) => {
@ -216,9 +225,90 @@ export const useScenariosStore = defineStore(STORES.SCENARIOS, () => {
}
}
/**
* Build a degraded InstanceAiEvalExecutionResult from a persisted execution
* record. Node outputs and start times come from runData; intercepted
* requests and hints were never persisted so they come back empty (that's
* what the `degraded` flag in ScenarioRunResult acknowledges).
*/
function mapExecutionToResult(
executionId: string,
execution: {
data?: {
resultData?: {
runData?: Record<string, unknown>;
error?: { message?: string };
};
};
},
): InstanceAiEvalExecutionResult {
const runData =
(execution.data?.resultData?.runData as
| Record<string, Array<{ startTime?: number; data?: { main?: unknown[][] } }>>
| undefined) ?? {};
const nodeResults: Record<string, InstanceAiEvalNodeResult> = {};
for (const [nodeName, tasks] of Object.entries(runData)) {
const lastTask = tasks?.[tasks.length - 1];
const mainOutput = lastTask?.data?.main?.[0] as Array<{ json?: unknown }> | undefined;
const items = Array.isArray(mainOutput) ? mainOutput : [];
nodeResults[nodeName] = {
output: items.slice(0, 10).map((i) => i?.json ?? null),
outputCount: items.length,
interceptedRequests: [],
executionMode: 'mocked',
startTime: lastTask?.startTime,
};
}
const errorMessage = execution.data?.resultData?.error?.message;
return {
executionId,
success: !errorMessage,
nodeResults,
errors: errorMessage ? [errorMessage] : [],
hints: {
globalContext: '',
triggerContent: {},
nodeHints: {},
warnings: [],
bypassPinData: {},
},
};
}
async function fetchHistoricalResult(
executionId: string,
): Promise<InstanceAiEvalExecutionResult | null> {
const cached = historicalByExecutionId.value[executionId];
if (cached) return cached;
fetchingExecutionId.value = executionId;
try {
const execution = await workflowsStore.getExecution(executionId);
if (!execution) return null;
const mapped = mapExecutionToResult(executionId, execution);
historicalByExecutionId.value = {
...historicalByExecutionId.value,
[executionId]: mapped,
};
return mapped;
} finally {
fetchingExecutionId.value = null;
}
}
function cachedHistoricalResult(
executionId: string | undefined,
): InstanceAiEvalExecutionResult | null {
if (!executionId) return null;
return historicalByExecutionId.value[executionId] ?? null;
}
return {
byWorkflowId,
runningScenarioId,
fetchingExecutionId,
scenariosFor,
hasScenariosFor,
create,
@ -226,5 +316,7 @@ export const useScenariosStore = defineStore(STORES.SCENARIOS, () => {
remove,
run,
persistRunResult,
fetchHistoricalResult,
cachedHistoricalResult,
};
});