// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import ArcoVue from '@arco-design/web-vue' const mockFetchPendingWrites = vi.hoisted(() => vi.fn()) const mockFetchPendingWriteReview = vi.hoisted(() => vi.fn()) const mockApprovePendingWrite = vi.hoisted(() => vi.fn()) const mockRejectPendingWrite = vi.hoisted(() => vi.fn()) vi.mock('@/api/hermes/write-gate', () => ({ fetchPendingWrites: mockFetchPendingWrites, fetchPendingWriteReview: mockFetchPendingWriteReview, approvePendingWrite: mockApprovePendingWrite, rejectPendingWrite: mockRejectPendingWrite, })) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), })) vi.mock('@/composables/useMessage', () => ({ useMessage: () => ({ success: vi.fn(), error: vi.fn(), }), })) import PendingWriteApprovals from '@/components/hermes/skills/PendingWriteApprovals.vue' function mountComponent() { return mount(PendingWriteApprovals, { global: { plugins: [ArcoVue], stubs: { MarkdownRenderer: { props: ['content'], template: '
{{ content }}
', }, }, }, }) } /** Buttons are located by their label, never by DOM order. */ function buttonByText(wrapper: any, text: string) { const button = wrapper.findAll('button').find((node: any) => node.text().trim() === text) if (!button) throw new Error(`button not found: ${text}`) return button } /** The pending-write row whose summary matches, so actions never rely on order. */ function itemBySummary(wrapper: any, summary: string) { const item = wrapper.findAll('.write-approval-item') .find((node: any) => node.find('.write-approval-summary').text().trim() === summary) if (!item) throw new Error(`write approval item not found: ${summary}`) return item } describe('PendingWriteApprovals', () => { beforeEach(() => { vi.clearAllMocks() mockFetchPendingWrites.mockResolvedValue({ records: [], counts: { memory: 0, skills: 0 }, supported: true }) mockFetchPendingWriteReview.mockResolvedValue({ subsystem: 'skills', targetLabel: 'SKILL.md', language: 'markdown', current: 'old', proposed: 'new', diff: '-old\n+new', notes: [], }) mockApprovePendingWrite.mockResolvedValue({ output: 'approved' }) mockRejectPendingWrite.mockResolvedValue({ output: 'rejected' }) }) it('shows an unsupported state for older Hermes Agent versions', async () => { mockFetchPendingWrites.mockResolvedValue({ records: [], counts: { memory: 0, skills: 0 }, supported: false }) const wrapper = mountComponent() await flushPromises() expect(wrapper.text()).toContain('skills.writeApprovalUnsupported') expect(wrapper.emitted('count-change')?.[0]).toEqual([0]) }) it('approves and rejects pending write gate records', async () => { mockFetchPendingWrites.mockResolvedValue({ records: [ { id: 'mem123', subsystem: 'memory', action: 'add', summary: 'remember concise answers', origin: 'foreground', created_at: 1765440000, payload: {}, }, { id: 'skill123', subsystem: 'skills', action: 'patch', summary: 'patch demo skill', origin: 'background_review', created_at: 1765440060, payload: {}, }, ], counts: { memory: 1, skills: 1 }, supported: true, }) const wrapper = mountComponent() await flushPromises() expect(wrapper.text()).toContain('remember concise answers') expect(wrapper.text()).toContain('patch demo skill') const memoryRow = itemBySummary(wrapper, 'remember concise answers') const skillsRow = itemBySummary(wrapper, 'patch demo skill') await memoryRow.findAll('button').find(b => b.text() === 'skills.writeApprovalApprove')!.trigger('click') await flushPromises() await skillsRow.findAll('button').find(b => b.text() === 'skills.writeApprovalReject')!.trigger('click') await flushPromises() expect(mockApprovePendingWrite).toHaveBeenCalledWith('memory', 'mem123') expect(mockRejectPendingWrite).toHaveBeenCalledWith('skills', 'skill123') }) it('renders pending write review content as markdown', async () => { mockFetchPendingWrites.mockResolvedValue({ records: [ { id: 'skill123', subsystem: 'skills', action: 'patch', summary: 'patch demo skill', origin: 'foreground', created_at: 1765440060, payload: {}, }, ], counts: { memory: 0, skills: 1 }, supported: true, }) mockFetchPendingWriteReview.mockResolvedValue({ subsystem: 'skills', targetLabel: 'SKILL.md', language: 'markdown', current: 'old', proposed: 'new', diff: '-old\n+new', requestedOldString: 'missing', notes: [{ type: 'patchOldStringMissing', targetLabel: 'SKILL.md' }], }) const wrapper = mountComponent() await flushPromises() await buttonByText(wrapper, 'skills.writeApprovalViewDiff').trigger('click') await flushPromises() expect(mockFetchPendingWriteReview).toHaveBeenCalledWith('skills', 'skill123') expect(wrapper.find('.markdown-renderer-stub').text()).toContain('skills.writeApprovalCurrentFile') expect(wrapper.find('.markdown-renderer-stub').text()).toContain('skills.writeApprovalPatchOldStringMissing') }) })