From ede265d46fce8cbde0e744344fb693764269f29d Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Fri, 28 Aug 2026 07:16:50 +0000 Subject: [PATCH] fix(ai-builder): Add case for agent artifacts in AIA preview tab bar (#37141) --- .../__tests__/InstanceAiPreviewTabBar.test.ts | 105 +++++++++++++++++- .../components/InstanceAiPreviewTabBar.vue | 3 + 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts index a17bd591503..d09206c7c14 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPreviewTabBar.test.ts @@ -1,18 +1,21 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { defineComponent, h } from 'vue'; -import { fireEvent } from '@testing-library/vue'; +import { fireEvent, waitFor } from '@testing-library/vue'; import { TabsRoot } from 'reka-ui'; import { readFileSync } from 'node:fs'; import { createComponentRenderer } from '@/__tests__/render'; import InstanceAiPreviewTabBar from '../components/InstanceAiPreviewTabBar.vue'; import type { ArtifactTab } from '../useCanvasPreview'; +const mockCopy = vi.fn(); +const mockShowMessage = vi.fn(); + vi.mock('@n8n/composables/useClipboard', () => ({ - useClipboard: () => ({ copy: vi.fn() }), + useClipboard: () => ({ copy: mockCopy }), })); vi.mock('@n8n/composables/useToast', () => ({ - useToast: () => ({ showMessage: vi.fn() }), + useToast: () => ({ showMessage: mockShowMessage }), })); const workflowTab: ArtifactTab = { @@ -30,6 +33,21 @@ const dataTableTab: ArtifactTab = { projectId: 'proj-1', }; +const agentTab: ArtifactTab = { + id: 'agent-1', + type: 'agent', + name: 'SEO Auditor', + icon: 'robot', + projectId: 'proj-1', +}; + +const agentTabWithoutProject: ArtifactTab = { + id: 'agent-2', + type: 'agent', + name: 'Standalone Agent', + icon: 'robot', +}; + // TabsList/Trigger rely on reka-ui's Tabs context, so the harness wraps the // bar in a TabsRoot. We also forward `activeTabId` through to the component // so the scroll-into-view watcher is actually exercised. @@ -60,14 +78,44 @@ const Wrapper = defineComponent({ const renderComponent = createComponentRenderer(Wrapper); +async function openAgentTabContextMenu(container: Element, tabId = 'agent-1') { + const agentTabTrigger = container.querySelector(`[data-tab-id="${tabId}"]`); + expect(agentTabTrigger).not.toBeNull(); + await fireEvent.contextMenu(agentTabTrigger!); +} + +async function selectContextMenuItem(label: string) { + let menuItem: HTMLElement | null = null; + await waitFor(() => { + menuItem = + [...document.body.querySelectorAll('[role="menuitem"]')].find((item) => + item.textContent?.includes(label), + ) ?? null; + expect(menuItem).not.toBeNull(); + }); + await fireEvent.click(menuItem!); +} + describe('InstanceAiPreviewTabBar', () => { + beforeEach(() => { + mockCopy.mockReset(); + mockShowMessage.mockReset(); + mockCopy.mockResolvedValue(undefined); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('renders a trigger with data-tab-id for each tab', () => { const { container } = renderComponent({ - props: { tabs: [workflowTab, dataTableTab], activeTabId: 'wf-1' }, + props: { tabs: [workflowTab, dataTableTab, agentTab], activeTabId: 'wf-1' }, }); expect(container.querySelector('[data-tab-id="wf-1"]')).not.toBeNull(); expect(container.querySelector('[data-tab-id="dt-1"]')).not.toBeNull(); + expect(container.querySelector('[data-tab-id="agent-1"]')).not.toBeNull(); }); it('renders tab labels from props', () => { @@ -157,4 +205,51 @@ describe('InstanceAiPreviewTabBar', () => { expect(source).not.toContain('--left--fade'); }); + + describe('agent artifact context menu', () => { + it('opens the agent in the editor from the context menu', async () => { + const { container } = renderComponent({ + props: { tabs: [agentTab], activeTabId: 'agent-1' }, + }); + + await openAgentTabContextMenu(container); + await selectContextMenuItem('Open in editor'); + + expect(window.open).toHaveBeenCalledWith( + '/projects/proj-1/agents/agent-1', + '_blank', + 'noopener', + ); + }); + + it('copies the agent link from the context menu', async () => { + const { container } = renderComponent({ + props: { tabs: [agentTab], activeTabId: 'agent-1' }, + }); + + await openAgentTabContextMenu(container); + await selectContextMenuItem('Copy link'); + + await waitFor(() => { + expect(mockCopy).toHaveBeenCalledWith( + `${window.location.origin}/projects/proj-1/agents/agent-1`, + ); + }); + expect(mockShowMessage).toHaveBeenCalledWith({ + title: 'Copied to clipboard', + type: 'success', + }); + }); + + it('falls back to the agents home route when the agent has no project', async () => { + const { container } = renderComponent({ + props: { tabs: [agentTabWithoutProject], activeTabId: 'agent-2' }, + }); + + await openAgentTabContextMenu(container, 'agent-2'); + await selectContextMenuItem('Open in editor'); + + expect(window.open).toHaveBeenCalledWith('/home/agents', '_blank', 'noopener'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue index 9cf8f2880c3..c510c1cfe13 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPreviewTabBar.vue @@ -103,6 +103,9 @@ function tabHref(tab: ArtifactTab): string | undefined { if (tab.type === 'data-table') { return tab.projectId ? `/projects/${tab.projectId}/datatables/${tab.id}` : '/home/datatables'; } + if (tab.type === 'agent') { + return tab.projectId ? `/projects/${tab.projectId}/agents/${tab.id}` : '/home/agents'; + } return undefined; }