diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index da44fc9e121..aafc59fd714 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -30,12 +30,14 @@ const { fetchAllCredentialsMock, fetchCredentialTypesMock, setCredentialsMock, + fetchAllVariablesMock, agentPermissionsMock, } = vi.hoisted(() => ({ fetchAllCredentialsForWorkflowMock: vi.fn().mockResolvedValue(undefined), fetchAllCredentialsMock: vi.fn().mockResolvedValue(undefined), fetchCredentialTypesMock: vi.fn().mockResolvedValue(undefined), setCredentialsMock: vi.fn(), + fetchAllVariablesMock: vi.fn().mockResolvedValue(undefined), agentPermissionsMock: { canCreate: { value: true }, canUpdate: { value: true }, @@ -85,6 +87,14 @@ vi.mock('@/features/credentials/credentials.store', () => ({ }), })); +vi.mock('@/features/settings/environments.ee/environments.store', () => ({ + default: () => ({ fetchAllVariables: fetchAllVariablesMock }), +})); + +vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({ + default: { template: '
' }, +})); + vi.mock('@n8n/composables/useTelemetry', () => ({ useTelemetry: () => ({ track: vi.fn() }), })); @@ -329,7 +339,10 @@ async function renderView({ setActivePinia(pinia); const { useSettingsStore } = await import('@/app/stores/settings.store'); const settingsStore = useSettingsStore(); - settingsStore.settings = { activeModules: knowledgeBaseEnabled ? ['agents'] : [] } as never; + settingsStore.settings = { + activeModules: knowledgeBaseEnabled ? ['agents'] : [], + enterprise: { variables: true }, + } as never; settingsStore.moduleSettings = { agents: { modules: [], @@ -532,6 +545,7 @@ function resetViewMocks() { uploadAgentFilesMock.mockReset(); uploadAgentFilesMock.mockResolvedValue([]); showErrorMock.mockReset(); + fetchAllVariablesMock.mockClear(); fetchConfigMock.mockClear(); builderTelemetryMock.fetchInitialTriggersBaseline.mockResolvedValue(null); favoritesStoreMock.isFavorite.mockReturnValue(false); @@ -545,7 +559,7 @@ describe('AgentBuilderView — preview routing', () => { // 5s test timeout; warm the module once so each case measures mount behavior. beforeAll(async () => { await import('../views/AgentBuilderView.vue'); - }, 30_000); + }, 60_000); beforeEach(() => { resetViewMocks(); @@ -578,12 +592,13 @@ describe('AgentBuilderView — preview routing', () => { expect(wrapper.find('[data-testid="agent-chat-mode-toggle"]').exists()).toBe(false); }); - it('loads credentials through the workflow-scoped credentials endpoint for the agent project', async () => { + it('loads credentials and variables for the agent project', async () => { await renderView(); expect(setCredentialsMock).toHaveBeenCalledWith([]); expect(fetchAllCredentialsForWorkflowMock).toHaveBeenCalledWith({ projectId: 'p1' }); expect(fetchAllCredentialsMock).not.toHaveBeenCalled(); + expect(fetchAllVariablesMock).toHaveBeenCalledOnce(); }); it('persists a generated personalisation gradient when an existing agent is missing one', async () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentExpressionEditors.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentExpressionEditors.test.ts new file mode 100644 index 00000000000..4b36db47150 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentExpressionEditors.test.ts @@ -0,0 +1,120 @@ +import { createTestingPinia } from '@pinia/testing'; +import { mount } from '@vue/test-utils'; +import { defineComponent, nextTick } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; + +import AgentExpressionAwareMarkdownEditor from '../components/AgentExpressionAwareMarkdownEditor.vue'; +import AgentExpressionInput from '../components/AgentExpressionInput.vue'; + +const { completionStatusMock } = vi.hoisted(() => ({ completionStatusMock: vi.fn() })); + +vi.mock('@codemirror/autocomplete', async (importOriginal) => ({ + ...(await importOriginal()), + completionStatus: completionStatusMock, +})); + +const expressionFocus = vi.fn(); + +const ExpressionInputStub = defineComponent({ + name: 'AgentExpressionInput', + props: ['modelValue', 'readonly', 'containerClass'], + setup(_, { expose }) { + expose({ focus: expressionFocus }); + return {}; + }, + template: '
', +}); + +const MarkdownEditorStub = defineComponent({ + name: 'MarkdownEditor', + props: ['modelValue'], + emits: ['update:modelValue'], + template: '
', +}); + +const InlineExpressionEditorInputStub = defineComponent({ + name: 'InlineExpressionEditorInput', + props: ['modelValue', 'additionalData'], + emits: ['update:modelValue'], + setup(_, { expose }) { + expose({ + editor: { state: { doc: { toString: () => '{{ $vars.latest }}' } } }, + focus: vi.fn(), + }); + }, + template: '
', +}); + +function mountAwareEditor( + modelValue: string, + props: { readonly?: boolean; containerClass?: string } = {}, +) { + return mount(AgentExpressionAwareMarkdownEditor, { + props: { modelValue, ...props }, + global: { + stubs: { + AgentExpressionInput: ExpressionInputStub, + N8nMarkdownEditor: MarkdownEditorStub, + MarkdownEditor: MarkdownEditorStub, + }, + }, + }); +} + +describe('AgentExpressionAwareMarkdownEditor', () => { + it('keeps ordinary instructions in the Markdown editor and forwards updates', () => { + const wrapper = mountAwareEditor('# Role\nHelp users.'); + const markdown = wrapper.findComponent(MarkdownEditorStub); + + expect(markdown.props('modelValue')).toBe('# Role\nHelp users.'); + markdown.vm.$emit('update:modelValue', '# Role\nBe concise.'); + + expect(wrapper.emitted('update:modelValue')).toEqual([['# Role\nBe concise.']]); + }); + + it('switches at a leading equals sign and preserves focus', async () => { + expressionFocus.mockClear(); + const wrapper = mountAwareEditor('', { readonly: true, containerClass: 'fill-height' }); + + wrapper.findComponent(MarkdownEditorStub).vm.$emit('update:modelValue', '='); + await nextTick(); + + expect(wrapper.findComponent(ExpressionInputStub).props()).toMatchObject({ + modelValue: '=', + readonly: true, + containerClass: 'fill-height', + }); + expect(expressionFocus).toHaveBeenCalledOnce(); + }); +}); + +describe('AgentExpressionInput', () => { + it('adapts raw values and submits the current document after completion closes', async () => { + completionStatusMock.mockReturnValue(null); + const wrapper = mount(AgentExpressionInput, { + props: { modelValue: '={{ $vars.team }}', submitOnEnter: true }, + global: { + plugins: [createTestingPinia()], + stubs: { InlineExpressionEditorInput: InlineExpressionEditorInputStub }, + }, + }); + const inline = wrapper.findComponent(InlineExpressionEditorInputStub); + + expect(inline.props('modelValue')).toBe('{{ $vars.team }}'); + expect(inline.props('additionalData')).toHaveProperty('$vars'); + + inline.vm.$emit('update:modelValue', { + value: '={{ $vars.department }}', + segments: [], + }); + + expect(wrapper.emitted('update:modelValue')).toEqual([['={{ $vars.department }}']]); + + await inline.trigger('keydown', { key: 'Enter' }); + expect(wrapper.emitted('submit')).toEqual([['={{ $vars.latest }}']]); + + completionStatusMock.mockReturnValue('active'); + await inline.trigger('keydown', { key: 'Enter' }); + expect(wrapper.emitted('submit')).toHaveLength(1); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts index bcb22875357..42b416f6098 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts @@ -57,16 +57,18 @@ vi.mock('@n8n/i18n', () => ({ })); vi.mock('@n8n/design-system', () => ({ - N8nMarkdownEditor: { - name: 'N8nMarkdownEditor', - props: ['modelValue', 'variant', 'showToolbar', 'placeholder', 'readonly', 'maxHeight'], - emits: ['update:modelValue'], - template: - '
{{ modelValue }} {{ placeholder }}
', - }, N8nText: { template: '', props: ['tag', 'bold', 'size', 'color'] }, })); +vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({ + default: { + name: 'AgentExpressionAwareMarkdownEditor', + props: ['modelValue', 'variant', 'showToolbar', 'maxHeight', 'disabled'], + emits: ['update:modelValue'], + template: '
{{ modelValue }}
', + }, +})); + vi.mock('@/app/composables/useToast', () => ({ useToast: () => ({ showError: vi.fn() }), })); @@ -182,7 +184,7 @@ describe('AgentInfoPanel', () => { it('renders instructions as a contained markdown editor', () => { const wrapper = mountPanel(); - const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' }); + const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }); expect(editor.props()).toMatchObject({ modelValue: '# Role\nHelp users.', variant: 'contained', @@ -198,7 +200,7 @@ describe('AgentInfoPanel', () => { it('can show the markdown toolbar above instructions', () => { const wrapper = mountPanel('# Role\nHelp users.', { showInstructionsToolbar: true }); - const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' }); + const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }); expect(editor.props()).toMatchObject({ showToolbar: 'always', variant: 'contained', @@ -208,7 +210,7 @@ describe('AgentInfoPanel', () => { it('does not pass placeholder text to the instructions editor', () => { const wrapper = mountPanel(''); - const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' }); + const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }); expect(editor.props('modelValue')).toBe(''); expect(editor.props('placeholder')).toBeUndefined(); expect(wrapper.text()).not.toContain('Enter instructions here'); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts index eac16d3c1ac..ba161a82de3 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts @@ -9,6 +9,15 @@ import { import AgentSkillViewer from '../components/AgentSkillViewer.vue'; +vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({ + default: { + name: 'AgentExpressionAwareMarkdownEditor', + props: ['modelValue', 'readonly', 'path', 'showToolbar'], + emits: ['update:modelValue'], + template: '
', + }, +})); + vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string, options?: { interpolate?: Record }) => { @@ -44,6 +53,37 @@ const N8nFormInputStub = defineComponent({ }); describe('AgentSkillViewer', () => { + it('uses expression-aware editing only for the SKILL.md instructions', () => { + const skill = { + name: 'Research', + description: 'Use for research', + instructions: 'Main body', + references: [{ path: 'references/guide.md', content: '# Guide' }], + }; + const instructions = mountViewer({ skill, disabled: true }); + const editor = instructions.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }); + expect(editor.props()).toMatchObject({ + readonly: true, + path: 'agent.skill.instructions', + showToolbar: 'always', + }); + editor.vm.$emit('update:modelValue', '={{ $vars.skill }}'); + + expect(instructions.emitted('update:skill')?.at(-1)).toEqual([ + { instructions: '={{ $vars.skill }}' }, + ]); + + const reference = mountViewer({ + skill, + selectedPath: 'references/guide.md', + }); + + expect(reference.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }).exists()).toBe( + false, + ); + expect(reference.find('textarea').element.value).toBe('# Guide'); + }); + it('imports SKILL.md frontmatter and instructions', async () => { const wrapper = mount(AgentSkillViewer, { props: { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTelegramAccessSettingsForm.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTelegramAccessSettingsForm.test.ts new file mode 100644 index 00000000000..2e13a5f7228 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTelegramAccessSettingsForm.test.ts @@ -0,0 +1,97 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import AgentTelegramAccessSettingsForm from '../components/AgentTelegramAccessSettingsForm.vue'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), + i18n: { baseText: (key: string) => key }, +})); + +const selectStub = { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: ` + + `, +}; + +const expressionInputStub = { + name: 'AgentExpressionInput', + props: ['modelValue', 'disabled', 'rows', 'embedded', 'submitOnEnter'], + emits: ['update:modelValue', 'blur', 'submit'], + methods: { focus: vi.fn() }, + template: '