diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 36a14b87ef8..8b6142a85f0 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -60,6 +60,10 @@ export class PublishAgentDto extends Z.class({ versionId: z.string().min(1).optional(), }) {} +export class RevertAgentToVersionDto extends Z.class({ + versionId: z.string().min(1), +}) {} + export class CreateSlackAgentAppDto extends Z.class({ appConfigurationToken: z.string().min(1), }) {} diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 42c36003be4..347a99f0eb2 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -121,6 +121,15 @@ export interface AgentVersionDto { author: string; } +export interface AgentVersionListItemDto { + versionId: string; + agentId: string; + createdAt: string; + updatedAt: string; + author: string; + isActive: boolean; +} + export interface AgentPersistedMessageContentPart { type: 'text' | 'reasoning' | 'tool-call' | (string & {}); text?: string; diff --git a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts index 04760a030ca..52c29839124 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts @@ -110,13 +110,71 @@ describe('AgentsController route access scopes', () => { ['updateSkill', 'agent:update'], ['deleteSkill', 'agent:update'], ['revertToPublished', 'agent:update'], + ['revertToVersion', 'agent:update'], ['createSlackApp', 'agent:update'], ['getSlackAppManifest', 'agent:read'], + ['listVersions', 'agent:read'], ])('%s uses %s', (handlerName, scope) => { expect(metadata.routes.get(handlerName)?.accessScope?.scope).toBe(scope); }); }); +describe('AgentsController publish history', () => { + it('lists publish history with pagination forwarded from the query', async () => { + const { controller, agentsService } = makeController(); + agentsService.listPublishHistory.mockResolvedValue([ + { + versionId: 'v2', + agentId: 'agent-1', + createdAt: '2026-01-02T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + author: 'Ada Lovelace', + isActive: true, + }, + ]); + + const result = await controller.listVersions( + { params: { projectId: 'project-1', agentId: 'agent-1' } } as never, + undefined as never, + 'agent-1', + { take: 5, skip: 10 } as never, + ); + + expect(agentsService.listPublishHistory).toHaveBeenCalledWith('agent-1', 'project-1', 5, 10); + expect(result).toHaveLength(1); + expect(result[0].isActive).toBe(true); + }); +}); + +describe('AgentsController revert to version', () => { + it('forwards the parsed versionId to the service and returns the agent with runnable state', async () => { + const { controller, agentsService } = makeController(); + agentsService.revertToVersion.mockResolvedValue({ + id: 'agent-1', + projectId: 'project-1', + } as never); + agentsService.validateAgentIsRunnable.mockResolvedValue({ missing: [] }); + + const result = await controller.revertToVersion( + { + params: { projectId: 'project-1' }, + user: { id: 'user-1' }, + } as never, + undefined as never, + 'agent-1', + { versionId: 'v1' } as never, + ); + + expect(agentsService.revertToVersion).toHaveBeenCalledWith('agent-1', 'project-1', 'v1'); + expect(result).toEqual( + expect.objectContaining({ + id: 'agent-1', + isRunnable: true, + }), + ); + }); +}); + describe('AgentsController integration credentials', () => { it('rejects credentials that are not usable in the agent project', async () => { const credentialsService = mock(); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 944a3df62bc..15532f75758 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -718,8 +718,8 @@ describe('AgentsService', () => { }); describe('with explicit versionId', () => { - it('flips activeVersionId to an existing history row without creating a new one', async () => { - const agent = makeAgent({ versionId: 'v2' }); + it('flips activeVersionId to an existing history row and bumps draft versionId', async () => { + const agent = makeAgent({ versionId: 'v2', activeVersionId: 'v2' }); const existingHistory = makeAgentHistory({ versionId: 'v1' }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(existingHistory); @@ -734,9 +734,30 @@ describe('AgentsService', () => { expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBe('v1'); expect(agent.activeVersion).toBe(existingHistory); + // Draft versionId was v2 (a history-row PK). Bumping to a fresh + // UUID lets the next regular publish snapshot cleanly. + expect(agent.versionId).not.toBe('v2'); + expect(agent.versionId).not.toBe('v1'); + expect(agent.versionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); expect(mockTrx.save).toHaveBeenCalledWith(agent); }); + it('is a no-op when the requested version is already active', async () => { + const agent = makeAgent({ versionId: 'v1', activeVersionId: 'v1' }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.publishAgent(agentId, projectId, testUser, 'v1'); + + expect(agentHistoryRepository.findByVersionAndAgentId).not.toHaveBeenCalled(); + expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled(); + expect(mockTrx.save).not.toHaveBeenCalled(); + expect(agent.versionId).toBe('v1'); + expect(agent.activeVersionId).toBe('v1'); + expect(result).toBe(agent); + }); + it('throws NotFoundError when the versionId does not belong to the agent', async () => { agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ versionId: 'v2' })); agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(null); @@ -1461,6 +1482,222 @@ describe('AgentsService', () => { }); }); + describe('revertToVersion', () => { + let mockTrx: { save: jest.Mock }; + let mockTransaction: jest.Mock; + + beforeEach(() => { + mockTrx = { save: jest.fn() }; + mockTransaction = jest.fn( + async (cb: (trx: typeof mockTrx) => Promise) => await cb(mockTrx), + ); + Object.defineProperty(agentRepository, 'manager', { + value: { transaction: mockTransaction }, + configurable: true, + }); + }); + + it('throws NotFoundError when the agent does not exist', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(service.revertToVersion(agentId, projectId, 'v1')).rejects.toThrow( + NotFoundError, + ); + expect(agentHistoryRepository.findByVersionAndAgentId).not.toHaveBeenCalled(); + }); + + it('throws NotFoundError when the version does not exist for the agent', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(null); + + await expect(service.revertToVersion(agentId, projectId, 'foreign-version')).rejects.toThrow( + NotFoundError, + ); + expect(agentHistoryRepository.findByVersionAndAgentId).toHaveBeenCalledWith( + 'foreign-version', + agentId, + mockTrx, + ); + expect(mockTrx.save).not.toHaveBeenCalled(); + }); + + it('restores the draft fields from the targeted snapshot and leaves activeVersionId untouched', async () => { + const snapshotSchema: AgentJsonConfig = { + name: 'Old Agent', + description: 'Old description', + model: 'anthropic/claude-sonnet-4-5', + credential: 'cred-old', + instructions: 'Old instructions', + tools: [{ type: 'custom', id: 'old_tool' }], + skills: [{ type: 'skill', id: 'old_skill' }], + }; + const snapshotTools = { + old_tool: { + code: 'return "old";', + descriptor: { name: 'old_tool' }, + }, + } as unknown as Agent['tools']; + const snapshotSkills = { + old_skill: { + name: 'Old skill', + description: 'Old skill description', + instructions: 'Old skill instructions', + }, + }; + const targetVersion = makeAgentHistory({ + versionId: 'v1', + schema: snapshotSchema, + tools: snapshotTools, + skills: snapshotSkills, + }); + const activeVersion = makeAgentHistory({ versionId: 'v2' }); + const agent = makeAgent({ + name: 'Current Agent', + description: 'Current description', + versionId: 'v3', + schema: { + name: 'Current Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Current instructions', + }, + tools: {}, + skills: {}, + activeVersionId: 'v2', + activeVersion, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(targetVersion); + + const result = await service.revertToVersion(agentId, projectId, 'v1'); + + expect(agent.schema).toEqual(snapshotSchema); + expect(agent.schema).not.toBe(snapshotSchema); + expect(agent.tools).toEqual(snapshotTools); + expect(agent.skills).toEqual(snapshotSkills); + // Fresh UUID so the next publish snapshot doesn't collide with the + // targeted history row's PK and doesn't fast-path past the revert. + expect(agent.versionId).not.toBe('v1'); + expect(agent.versionId).not.toBe('v3'); + expect(agent.versionId).not.toBe(agent.activeVersionId); + expect(agent.versionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(agent.name).toBe('Old Agent'); + expect(agent.description).toBe('Old description'); + expect(agent.activeVersionId).toBe('v2'); + expect(agent.activeVersion).toBe(activeVersion); + expect(mockTrx.save).toHaveBeenCalledWith(agent); + expect(result).toBe(agent); + }); + + it('reverts successfully when the agent is currently unpublished', async () => { + const snapshotSchema: AgentJsonConfig = { + name: 'Restored Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Restored instructions', + }; + const targetVersion = makeAgentHistory({ + versionId: 'v1', + schema: snapshotSchema, + tools: {}, + skills: {}, + }); + const agent = makeAgent({ activeVersionId: null, activeVersion: null }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(targetVersion); + + await service.revertToVersion(agentId, projectId, 'v1'); + + expect(agent.activeVersionId).toBeNull(); + expect(agent.versionId).not.toBe('v1'); + expect(agent.versionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(agent.name).toBe('Restored Agent'); + expect(mockTrx.save).toHaveBeenCalledWith(agent); + }); + }); + + describe('listPublishHistory', () => { + it('throws NotFoundError when the agent does not exist in the project', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(service.listPublishHistory(agentId, projectId, 20, 0)).rejects.toThrow( + NotFoundError, + ); + expect(agentHistoryRepository.findByAgentId).not.toHaveBeenCalled(); + }); + + it('forwards take and skip to the repository', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ activeVersionId: null })); + agentHistoryRepository.findByAgentId.mockResolvedValue([]); + + await service.listPublishHistory(agentId, projectId, 5, 10); + + expect(agentHistoryRepository.findByAgentId).toHaveBeenCalledWith(agentId, 5, 10); + }); + + it('maps history rows to DTOs and marks the active version', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + const updatedAt = new Date('2026-01-02T00:00:00.000Z'); + const activeRow = { + versionId: 'v2', + agentId, + createdAt, + updatedAt, + author: 'Ada Lovelace', + } as unknown as AgentHistory; + const inactiveRow = { + versionId: 'v1', + agentId, + createdAt, + updatedAt, + author: 'Unknown', + } as unknown as AgentHistory; + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ activeVersionId: 'v2' })); + agentHistoryRepository.findByAgentId.mockResolvedValue([activeRow, inactiveRow]); + + const result = await service.listPublishHistory(agentId, projectId, 20, 0); + + expect(result).toEqual([ + { + versionId: 'v2', + agentId, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + author: 'Ada Lovelace', + isActive: true, + }, + { + versionId: 'v1', + agentId, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + author: 'Unknown', + isActive: false, + }, + ]); + }); + + it('marks every row as inactive when the agent is unpublished', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ activeVersionId: null })); + agentHistoryRepository.findByAgentId.mockResolvedValue([ + { + versionId: 'v1', + agentId, + createdAt, + updatedAt: createdAt, + author: 'Ada Lovelace', + } as unknown as AgentHistory, + ]); + + const result = await service.listPublishHistory(agentId, projectId, 20, 0); + + expect(result[0].isActive).toBe(false); + }); + }); + describe('getConversationHistory', () => { it('returns the user-visible transcript from execution history', async () => { agentExecutionService.getThreadDetail.mockResolvedValue({ diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index 31932aaf32e..9d34d8ecc74 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -9,6 +9,7 @@ import { type AgentScheduleConfig, type AgentSkill, type AgentSseEvent, + type AgentVersionListItemDto, type ChatIntegrationDescriptor, CreateSlackAgentAppDto, type CreateSlackAgentAppResponse, @@ -16,12 +17,14 @@ import { CreateAgentDto, CreateAgentSkillDto, isAgentCredentialIntegration, + PaginationDto, UpdateAgentConfigDto, UpdateAgentDto, UpdateAgentScheduleDto, UpdateAgentSkillDto, AgentDisconnectIntegrationDto, PublishAgentDto, + RevertAgentToVersionDto, } from '@n8n/api-types'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { @@ -33,6 +36,7 @@ import { Post, ProjectScope, Put, + Query, RestController, } from '@n8n/decorators'; import { randomUUID } from 'crypto'; @@ -128,19 +132,21 @@ export class AgentsController { agent: Agent, projectId: string, user: User, - ): Promise { + ): Promise { const credentialProvider = new AgentsCredentialProvider( this.credentialsService, projectId, user, ); - const { missing } = await this.agentsService.validateAgentIsRunnable( - agent.id, - projectId, - credentialProvider, - ); + const [{ missing }, hasPublishHistory] = await Promise.all([ + this.agentsService.validateAgentIsRunnable(agent.id, projectId, credentialProvider), + this.agentsService.hasPublishHistory(agent.id), + ]); - return Object.assign(agent, { isRunnable: missing.length === 0 }); + return Object.assign(agent, { + isRunnable: missing.length === 0, + hasPublishHistory, + }); } @Post('/') @@ -436,6 +442,38 @@ export class AgentsController { return await this.withRunnableState(agent, req.params.projectId, req.user); } + @Post('/:agentId/revert-to-version') + @ProjectScope('agent:update') + async revertToVersion( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: RevertAgentToVersionDto, + ) { + const agent = await this.agentsService.revertToVersion( + agentId, + req.params.projectId, + payload.versionId, + ); + return await this.withRunnableState(agent, req.params.projectId, req.user); + } + + @Get('/:agentId/versions') + @ProjectScope('agent:read') + async listVersions( + req: AuthenticatedRequest<{ projectId: string; agentId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Query query: PaginationDto, + ): Promise { + return await this.agentsService.listPublishHistory( + agentId, + req.params.projectId, + query.take, + query.skip, + ); + } + @Post('/:agentId/chat', { usesTemplates: true }) @ProjectScope('agent:execute') async chat( diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 3cf4cb2420c..57b24c2760e 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -24,6 +24,7 @@ import { type AgentJsonToolConfig, type AgentSkill, type AgentSkillMutationResponse, + type AgentVersionListItemDto, type ChatIntegrationDescriptor, AgentPersistedMessageDto, } from '@n8n/api-types'; @@ -520,6 +521,12 @@ export class AgentsService { return agent; } + // Idempotent fast-path for re-publishing the already-active version — + // no pointer to flip, no need to disturb the draft's versionId. + if (versionId !== undefined && versionId === agent.activeVersionId) { + return agent; + } + await this.agentRepository.manager.transaction(async (trx) => { if (versionId) { const existing = await this.agentHistoryRepository.findByVersionAndAgentId( @@ -532,6 +539,11 @@ export class AgentsService { } agent.activeVersionId = existing.versionId; agent.activeVersion = existing; + // The previously-active versionId may already own a history row + // (e.g. the draft was in sync with the old active version). Bump + // to a fresh UUID so the next regular publish writes a new row + // instead of colliding on that PK. + agent.versionId = uuid(); } else { // Snapshot the current draft. agent.versionId is the snapshot's PK, // so make sure it's set before inserting. @@ -658,6 +670,82 @@ export class AgentsService { return agent; } + async revertToVersion(agentId: string, projectId: string, versionId: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + await this.agentRepository.manager.transaction(async (trx) => { + const target = await this.agentHistoryRepository.findByVersionAndAgentId( + versionId, + agentId, + trx, + ); + if (!target) { + throw new NotFoundError(`Version "${versionId}" not found`); + } + + agent.schema = target.schema ? deepCopy(target.schema) : null; + agent.tools = deepCopy(target.tools ?? {}); + agent.skills = deepCopy(target.skills ?? {}); + // Fresh UUID so a follow-up publish writes a new history row. + // Re-using target.versionId would collide on the snapshot insert + // (target already owns that PK), and leaving the previous + // versionId in place could equal activeVersionId — that hits the + // idempotent fast-path in publishAgent and silently discards the + // revert. + agent.versionId = uuid(); + + if (agent.schema) { + agent.name = agent.schema.name; + agent.description = agent.schema.description ?? null; + } + + await trx.save(agent); + }); + + this.clearRuntimes(agentId); + + this.logger.debug('Reverted SDK agent to a specific version', { + agentId, + projectId, + versionId, + }); + return agent; + } + + /** + * Cheap existence check used by the editor to gate the version-history + * panel button. Survives unpublish, unlike `agent.activeVersionId`. + */ + async hasPublishHistory(agentId: string): Promise { + return await this.agentHistoryRepository.existsForAgent(agentId); + } + + async listPublishHistory( + agentId: string, + projectId: string, + take: number, + skip: number, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + const versions = await this.agentHistoryRepository.findByAgentId(agentId, take, skip); + + return versions.map((v) => ({ + versionId: v.versionId, + agentId: v.agentId, + createdAt: v.createdAt.toISOString(), + updatedAt: v.updatedAt.toISOString(), + author: v.author, + isActive: v.versionId === agent.activeVersionId, + })); + } + async delete(agentId: string, projectId: string): Promise { const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); diff --git a/packages/cli/src/modules/agents/repositories/agent-history.repository.ts b/packages/cli/src/modules/agents/repositories/agent-history.repository.ts index 0a861409311..f3bca84a968 100644 --- a/packages/cli/src/modules/agents/repositories/agent-history.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-history.repository.ts @@ -77,4 +77,33 @@ export class AgentHistoryRepository extends Repository { const repo = trx?.getRepository(AgentHistory) ?? this; return await repo.findOneBy({ versionId, agentId }); } + + /** + * List an agent's publish history, newest first. `schema`/`tools`/`skills` + * are intentionally omitted — the list view only needs metadata. + */ + async findByAgentId(agentId: string, take: number, skip: number): Promise { + return await this.find({ + where: { agentId }, + take, + skip, + order: { createdAt: 'DESC' }, + select: { + versionId: true, + agentId: true, + createdAt: true, + updatedAt: true, + author: true, + }, + }); + } + + /** + * Whether any history row exists for an agent. Backs the "has the agent + * ever been published" signal — unlike `agent.activeVersionId !== null`, + * this stays true after an unpublish (which preserves the rows). + */ + async existsForAgent(agentId: string): Promise { + return await this.existsBy({ agentId }); + } } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 51f06441f07..ef69c074df4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6132,6 +6132,24 @@ "agents.revertToPublished.modal.title": "Revert changes?", "agents.revertToPublished.modal.description": "This will permanently remove all changes made since the latest published version.", "agents.revertToPublished.modal.button.revert": "Revert changes", + "agents.versionHistory.title": "Publish history", + "agents.versionHistory.button.tooltip": "Version history", + "agents.versionHistory.button.tooltip.empty": "This agent currently has no history to view. Once you've published it for the first time, you'll be able to view previous versions", + "agents.versionHistory.button.ariaLabel": "Open version history", + "agents.versionHistory.close": "Close", + "agents.versionHistory.empty": "No versions yet. Publish your agent to start a history.", + "agents.versionHistory.error.load": "Could not load version history", + "agents.versionHistory.item.createdAt": "{date} at {time}", + "agents.versionHistory.item.publishedBadge": "Currently published", + "agents.versionHistory.item.actions.revert": "Revert to this version", + "agents.versionHistory.item.actions.publish": "Publish this version", + "agents.versionHistory.revert.modal.title": "Revert to this version?", + "agents.versionHistory.revert.modal.description": "This will replace your current draft with the contents of this version. Unsaved changes will be lost.", + "agents.versionHistory.revert.modal.button.revert": "Revert draft", + "agents.versionHistory.revert.toast.title": "Reverted to version", + "agents.versionHistory.revert.error": "Could not revert to the selected version", + "agents.versionHistory.publish.toast.title": "Version published", + "agents.versionHistory.publish.error": "Could not publish the selected version", "agents.search.placeholder": "Search agents...", "agents.tools.title": "Tools", "agents.tools.add": "Add tool", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts new file mode 100644 index 00000000000..66c496954b5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { ref } from 'vue'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), +})); + +const versionHistoryMock = { + items: ref([]), + isLoading: ref(false), + isInitialLoad: ref(false), + hasMore: ref(false), + refresh: vi.fn().mockResolvedValue(undefined), + fetchMore: vi.fn(), + revertToVersion: vi.fn(), + publishVersion: vi.fn(), +}; + +vi.mock('../composables/useAgentVersionHistory', () => ({ + useAgentVersionHistory: () => versionHistoryMock, +})); + +const agentPermissionsMock = { + canCreate: ref(true), + canUpdate: ref(true), + canDelete: ref(true), + canPublish: ref(true), + canUnpublish: ref(true), +}; + +vi.mock('../composables/useAgentPermissions', () => ({ + useAgentPermissions: () => agentPermissionsMock, +})); + +const STUBS = { + N8nHeading: { template: '
' }, + N8nIconButton: { + template: '