mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge d0ca419185 into ec074519d9
This commit is contained in:
commit
c43ee0b13d
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ exports[`InputPanel > should render 1`] = `
|
|||
</div>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="editModeActions"
|
||||
data-v-5b5900d0=""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user