This commit is contained in:
Csaba Tuncsik 2026-10-16 20:17:37 +02:00 committed by GitHub
commit c43ee0b13d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 398 additions and 0 deletions

View File

@ -2163,6 +2163,12 @@
"ndv.pinData.pin.binary": "Pin Data is disabled as this node's output contains binary data.",
"ndv.pinData.pin.link": "More info",
"ndv.pinData.unpin.title": "Unpin data",
"ndv.pinData.generate.title": "Generate sample data",
"ndv.pinData.generate.description": "Ask AI to produce realistic mock output for this node and pin it, so downstream nodes can be tested without running this one.",
"ndv.pinData.generate.success": "Sample data generated and pinned.",
"ndv.pinData.generate.error.invalidResponse": "AI did not return a valid JSON array. Try again.",
"ndv.pinData.generate.error.emptyResponse": "AI returned no items. Try again.",
"ndv.pinData.generate.error.generic": "Could not generate sample data",
"ndv.pinData.pin.multipleRuns.title": "Run #{index} was pinned",
"ndv.pinData.pin.multipleRuns.description": "This run will be outputted each time the node is run.",
"ndv.pinData.unpinAndExecute.title": "Unpin output data?",

View File

@ -210,6 +210,7 @@ exports[`InputPanel > should render 1`] = `
</div>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="editModeActions"
data-v-5b5900d0=""

View File

@ -49,6 +49,9 @@ import JsonEditor from '@/features/shared/editors/components/JsonEditor/JsonEdit
import { useInjectWorkflowId } from '@/app/composables/useInjectWorkflowId';
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
import RunDataPinButton from './RunDataPinButton.vue';
import RunDataGenerateSampleButton from './RunDataGenerateSampleButton.vue';
import { useGenerateSampleData } from '@/features/ndv/runData/composables/useGenerateSampleData';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
@ -262,6 +265,8 @@ const pinnedData = usePinnedData(node, {
runIndex: props.runIndex,
displayMode: props.displayMode,
});
const settingsStore = useSettingsStore();
const sampleGenerator = useGenerateSampleData(node);
const { isSubNodeType } = useNodeType({
node,
});
@ -677,6 +682,18 @@ const pinButtonDisabled = computed(
() => hasNoData.value || hasBinaryData.value || isReadOnly.value,
);
const showGenerateSampleButton = computed(
() =>
showPinButton.value &&
settingsStore.isAskAiEnabled &&
!isReadOnlyRoute.value &&
!readOnlyEnv.value &&
!isExecutionRedacted.value &&
sampleGenerator.canGenerate.value,
);
const generateSampleDisabled = computed(() => pinButtonDisabled.value || pinnedData.hasData.value);
const activeTaskMetadata = computed((): ITaskMetadata | null => {
if (!node.value) return null;
const errorMetadata = parseErrorMetadata(workflowRunErrorAsNodeError.value);
@ -1638,6 +1655,13 @@ defineExpose({ enterEditMode });
@click="enterEditMode({ origin: 'editIconButton' })"
/>
<RunDataGenerateSampleButton
v-if="showGenerateSampleButton"
:disabled="generateSampleDisabled"
:loading="sampleGenerator.isGenerating.value"
@generate="sampleGenerator.generate()"
/>
<RunDataPinButton
v-if="showPinButton"
:disabled="pinButtonDisabled"

View File

@ -0,0 +1,54 @@
import { cleanup, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { STORES } from '@n8n/stores';
import { createComponentRenderer } from '@/__tests__/render';
import { getTooltip } from '@/__tests__/utils';
import RunDataGenerateSampleButton from '@/features/ndv/runData/components/RunDataGenerateSampleButton.vue';
const renderComponent = createComponentRenderer(RunDataGenerateSampleButton, {
global: {
plugins: [
createTestingPinia({
initialState: { [STORES.SETTINGS]: { settings: {} } },
}),
],
},
props: { loading: false, disabled: false },
});
describe('RunDataGenerateSampleButton.vue', () => {
beforeEach(cleanup);
it('renders an enabled button with the correct testid', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('ndv-generate-sample-data')).toBeEnabled();
});
it('emits generate on click', async () => {
const { getByTestId, emitted } = renderComponent();
await userEvent.click(getByTestId('ndv-generate-sample-data'));
expect(emitted().generate).toBeDefined();
});
it('does not emit when disabled', async () => {
const { getByTestId, emitted } = renderComponent({
props: { disabled: true, loading: false },
});
await userEvent.click(getByTestId('ndv-generate-sample-data'));
expect(emitted().generate).toBeUndefined();
});
it('renders in loading state', () => {
const { getByTestId } = renderComponent({ props: { loading: true, disabled: false } });
expect(getByTestId('ndv-generate-sample-data')).toBeDisabled();
});
it('shows tooltip on hover', async () => {
const { getByTestId } = renderComponent();
await userEvent.hover(getByTestId('ndv-generate-sample-data'));
await waitFor(() => {
expect(getTooltip()).toHaveTextContent(/Generate sample data/);
});
});
});

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
const locale = useI18n();
defineProps<{
disabled: boolean;
loading: boolean;
}>();
defineEmits<{
generate: [];
}>();
</script>
<template>
<N8nTooltip placement="bottom-end">
<template #content>
<strong>{{ locale.baseText('ndv.pinData.generate.title') }}</strong>
<N8nText size="small" tag="p">
{{ locale.baseText('ndv.pinData.generate.description') }}
</N8nText>
</template>
<N8nIconButton
variant="subtle"
size="small"
icon="wand-sparkles"
:loading="loading"
:disabled="disabled"
:aria-label="locale.baseText('ndv.pinData.generate.title')"
data-test-id="ndv-generate-sample-data"
@click="$emit('generate')"
/>
</N8nTooltip>
</template>

View File

@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { ref } from 'vue';
import type { INodeUi } from '@/Interface';
const { generateCodeForPromptMock, setDataMock, canPinNodeMock, showMessageMock, showErrorMock } =
vi.hoisted(() => ({
generateCodeForPromptMock: vi.fn(),
setDataMock: vi.fn(),
canPinNodeMock: vi.fn().mockReturnValue(true),
showMessageMock: vi.fn(),
showErrorMock: vi.fn(),
}));
vi.mock('@/features/ai/assistant/assistant.api', () => ({
generateCodeForPrompt: generateCodeForPromptMock,
}));
vi.mock('@/app/composables/usePinnedData', () => ({
usePinnedData: vi.fn(() => ({
setData: setDataMock,
canPinNode: canPinNodeMock,
hasData: { value: false },
isValidNodeType: { value: true },
})),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showMessage: showMessageMock, showError: showErrorMock }),
}));
vi.mock('@/features/ndv/shared/ndv.store', () => ({
useNDVStore: () => ({ pushRef: 'ndv-push-test' }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({
pushRef: 'root-push-test',
restApiContext: { baseUrl: '/rest', sessionId: 's', pushRef: 'root-push-test' },
}),
}));
import { useGenerateSampleData } from './useGenerateSampleData';
const node = ref({
id: 'n1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2,
parameters: { resource: 'message', operation: 'post' },
} as unknown as INodeUi);
describe('useGenerateSampleData', () => {
beforeEach(() => {
setActivePinia(createPinia());
generateCodeForPromptMock.mockReset();
setDataMock.mockReset();
showMessageMock.mockReset();
showErrorMock.mockReset();
});
it('exposes generate() and isGenerating refs', () => {
const composable = useGenerateSampleData(node);
expect(typeof composable.generate).toBe('function');
expect(composable.isGenerating.value).toBe(false);
});
it('pins parsed array response and shows success toast', async () => {
generateCodeForPromptMock.mockResolvedValue({
code: '[{"channel":"#general","text":"hi"},{"channel":"#dev","text":"hello"}]',
});
const composable = useGenerateSampleData(node);
await composable.generate();
expect(setDataMock).toHaveBeenCalledWith(
[{ json: { channel: '#general', text: 'hi' } }, { json: { channel: '#dev', text: 'hello' } }],
'pin-icon-click',
);
expect(showMessageMock).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('caps items to 3', async () => {
generateCodeForPromptMock.mockResolvedValue({
code: JSON.stringify(Array.from({ length: 10 }, (_, i) => ({ i }))),
});
await useGenerateSampleData(node).generate();
const [items] = setDataMock.mock.calls[0];
expect(items).toHaveLength(3);
});
it('shows error toast and skips pin when response is not a JSON array', async () => {
generateCodeForPromptMock.mockResolvedValue({ code: '{"not":"array"}' });
await useGenerateSampleData(node).generate();
expect(setDataMock).not.toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalled();
});
it('shows error toast and skips pin when array is empty', async () => {
generateCodeForPromptMock.mockResolvedValue({ code: '[]' });
await useGenerateSampleData(node).generate();
expect(setDataMock).not.toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalled();
});
it('shows error toast when API call rejects', async () => {
generateCodeForPromptMock.mockRejectedValue(new Error('boom'));
await useGenerateSampleData(node).generate();
expect(setDataMock).not.toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalled();
});
it('no-ops when node is null', async () => {
const empty = ref(null);
await useGenerateSampleData(empty).generate();
expect(generateCodeForPromptMock).not.toHaveBeenCalled();
});
it('strips markdown code fences before parsing', async () => {
generateCodeForPromptMock.mockResolvedValue({
code: '```json\n[{"id":"a"},{"id":"b"}]\n```',
});
await useGenerateSampleData(node).generate();
expect(setDataMock).toHaveBeenCalledWith(
[{ json: { id: 'a' } }, { json: { id: 'b' } }],
'pin-icon-click',
);
expect(showErrorMock).not.toHaveBeenCalled();
});
it('ignores a second generate() while one is in flight', async () => {
let resolveFirst: (value: { code: string }) => void = () => {};
generateCodeForPromptMock.mockImplementationOnce(
() =>
new Promise<{ code: string }>((resolve) => {
resolveFirst = resolve;
}),
);
const composable = useGenerateSampleData(node);
const first = composable.generate();
const second = composable.generate();
await second;
expect(generateCodeForPromptMock).toHaveBeenCalledTimes(1);
resolveFirst({ code: '[{"x":1}]' });
await first;
expect(generateCodeForPromptMock).toHaveBeenCalledTimes(1);
});
it('sends a payload that includes the node type, parameters, and forNode=transform', async () => {
generateCodeForPromptMock.mockResolvedValue({ code: '[{"ok":true}]' });
await useGenerateSampleData(node).generate();
expect(generateCodeForPromptMock).toHaveBeenCalledTimes(1);
const [, payload] = generateCodeForPromptMock.mock.calls[0];
expect(payload.forNode).toBe('transform');
expect(payload.context.inputSchema.nodeName).toBe('Slack');
expect(payload.question).toContain('n8n-nodes-base.slack');
expect(payload.question).toContain('"resource": "message"');
expect(payload.question).toContain('JSON array');
});
});

View File

@ -0,0 +1,116 @@
import { computed, ref, unref } from 'vue';
import type { MaybeRef } from 'vue';
import { z } from 'zod';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { INodeUi } from '@/Interface';
import { useToast } from '@/app/composables/useToast';
import { usePinnedData } from '@/app/composables/usePinnedData';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { generateCodeForPrompt } from '@/features/ai/assistant/assistant.api';
import type { AskAiRequest } from '@/features/ai/assistant/assistant.types';
const MAX_ITEMS = 3;
const sampleItemsSchema = z.array(z.record(z.string(), z.unknown())).min(1, 'empty').max(50);
function buildPrompt(node: INodeUi): string {
const params = JSON.stringify(node.parameters ?? {}, null, 2);
return [
'You are generating realistic mock OUTPUT data for an n8n node so that downstream nodes can be tested without executing the upstream trigger.',
`Node type: ${node.type} (version ${node.typeVersion}).`,
`Node parameters:\n${params}`,
'',
'Return ONLY a raw JSON array (no prose, no Markdown fences, no explanation) of 2 to 3 items.',
'Each item MUST be a JSON object representing one realistic record this node would output.',
`Use field names a real ${node.type} response would use. Use realistic-looking IDs, timestamps in ISO 8601, and plausible string values. Do NOT include comments.`,
].join('\n');
}
function stripCodeFences(raw: string): string {
const trimmed = raw.trim();
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
return fenceMatch ? fenceMatch[1].trim() : trimmed;
}
export function useGenerateSampleData(node: MaybeRef<INodeUi | null>) {
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const ndvStore = useNDVStore();
const pinnedData = usePinnedData(node);
const isGenerating = ref(false);
const canGenerate = computed(() => {
const target = unref(node);
return !!target && pinnedData.isValidNodeType.value && pinnedData.canPinNode(false);
});
async function generate(): Promise<void> {
const target = unref(node);
if (!target || isGenerating.value) return;
isGenerating.value = true;
try {
const payload: AskAiRequest.RequestPayload = {
question: buildPrompt(target),
context: {
schema: [],
inputSchema: {
nodeName: target.name,
schema: { type: 'object', value: [], path: '' },
},
pushRef: rootStore.pushRef,
ndvPushRef: ndvStore.pushRef,
},
forNode: 'transform',
};
const { code } = await generateCodeForPrompt(rootStore.restApiContext, payload);
let parsed: unknown;
try {
parsed = JSON.parse(stripCodeFences(code));
} catch {
toast.showError(
new Error(i18n.baseText('ndv.pinData.generate.error.invalidResponse')),
i18n.baseText('ndv.pinData.generate.title'),
);
return;
}
const result = sampleItemsSchema.safeParse(parsed);
if (!result.success) {
const key =
result.error.issues[0]?.message === 'empty'
? 'ndv.pinData.generate.error.emptyResponse'
: 'ndv.pinData.generate.error.invalidResponse';
toast.showError(new Error(i18n.baseText(key)), i18n.baseText('ndv.pinData.generate.title'));
return;
}
const items: INodeExecutionData[] = result.data
.slice(0, MAX_ITEMS)
.map((json) => ({ json: json as IDataObject }));
pinnedData.setData(items, 'pin-icon-click');
toast.showMessage({
title: i18n.baseText('ndv.pinData.generate.title'),
message: i18n.baseText('ndv.pinData.generate.success'),
type: 'success',
});
} catch (error) {
toast.showError(
error instanceof Error ? error : new Error(String(error)),
i18n.baseText('ndv.pinData.generate.error.generic'),
);
} finally {
isGenerating.value = false;
}
}
return { generate, isGenerating, canGenerate };
}