diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 766587e622f..ce377d8e158 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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?", diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap b/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap index 1f25c27c7ce..87da2bce51d 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap @@ -210,6 +210,7 @@ exports[`InputPanel > should render 1`] = ` +
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' })" /> + + { + 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/); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataGenerateSampleButton.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataGenerateSampleButton.vue new file mode 100644 index 00000000000..9eac83795a0 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataGenerateSampleButton.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/composables/useGenerateSampleData.test.ts b/packages/frontend/editor-ui/src/features/ndv/runData/composables/useGenerateSampleData.test.ts new file mode 100644 index 00000000000..c5937dd3676 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/composables/useGenerateSampleData.test.ts @@ -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'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/composables/useGenerateSampleData.ts b/packages/frontend/editor-ui/src/features/ndv/runData/composables/useGenerateSampleData.ts new file mode 100644 index 00000000000..9a0e0527550 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/composables/useGenerateSampleData.ts @@ -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) { + 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 { + 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 }; +}