// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { collectChangedFilePaths } from '@/components/Session/PreviewPanel/tabs/review/reviewSources'; import { useReviewChanges } from '@/components/Session/PreviewPanel/tabs/review/useReviewChanges'; import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockFetchOverlays, mockFetchGitChanges, mockFetchGitChangeContent, mockFetchRunGitChanges, mockFetchRunGitChangeContent, mockReviewListBackups, mockHost, mockProjectRuntime, } = vi.hoisted(() => { const reviewListBackups = vi.fn(); return { mockFetchOverlays: vi.fn(), mockFetchGitChanges: vi.fn(), mockFetchGitChangeContent: vi.fn(), mockFetchRunGitChanges: vi.fn(), mockFetchRunGitChangeContent: vi.fn(), mockReviewListBackups: reviewListBackups, mockHost: { electronAPI: { readFile: vi.fn(), reviewListBackups, }, }, mockProjectRuntime: { getProjectById: () => ({ id: 'project-1', spaceId: 'space-1', workdirMode: 'copy', metadata: { serverSynced: true }, }), getAllChatStores: () => [], }, }; }); vi.mock('@/host', () => ({ useHost: () => mockHost, })); vi.mock('@/service/spaceApi', () => ({ proxyFetchSpaceProjectOverlays: mockFetchOverlays, })); vi.mock('@/service/workspaceGitApi', () => ({ fetchProjectGitChanges: mockFetchGitChanges, fetchProjectGitChangeContent: mockFetchGitChangeContent, fetchRunGitChanges: mockFetchRunGitChanges, fetchRunGitChangeContent: mockFetchRunGitChangeContent, })); vi.mock('@/store/authStore', () => ({ useAuthStore: ( selector: (state: { email: string; user_id: number }) => unknown ) => selector({ email: 'user@example.com', user_id: 42 }), })); vi.mock('@/store/pageTabStore', () => ({ usePageTabStore: (selector: (state: unknown) => unknown) => selector({ sessionPreviewProjectId: 'project-1' }), })); vi.mock('@/store/projectRuntimeStore', () => ({ useProjectRuntimeStore: () => mockProjectRuntime, })); vi.mock('@/store/spaceStore', () => { const state = { activeSpaceId: 'space-1', getProjectMeta: () => ({ id: 'project-1', spaceId: 'space-1', workdirMode: 'copy', metadata: { serverSynced: true }, }), spaces: { 'space-1': { id: 'space-1', sourceType: 'folder', rootPath: '/workspace', }, }, }; return { useSpaceStore: (selector: (store: typeof state) => unknown) => selector(state), }; }); describe('useReviewChanges', () => { beforeEach(() => { localStorage.removeItem('eigent-review-fixture'); mockFetchOverlays.mockReset(); mockFetchGitChanges.mockReset(); mockFetchGitChangeContent.mockReset(); mockFetchRunGitChanges.mockReset(); mockFetchRunGitChangeContent.mockReset(); const missingGitState = Object.assign(new Error('Git state not found'), { status: 404, }); mockFetchGitChanges.mockRejectedValue(missingGitState); mockReviewListBackups.mockReset(); mockReviewListBackups.mockResolvedValue([ { path: '/scratch/src/example.ts', exists: true, size: 120, backups: [ { path: '/scratch/src/example.ts.20260722_120000.bak', size: 90 }, ], }, ]); }); it('limits direct-write recovery paths to the selected older Run', () => { const file = (name: string): FileInfo => ({ name, type: 'txt', path: `/workspace/${name}`, relativePath: name, }); expect( collectChangedFilePaths( [ { tasks: { 'run-1': { messages: [{ fileList: [file('run-one.txt')] }] }, 'run-2': { messages: [{ fileList: [file('run-two.txt')] }] }, }, }, ], 'run-1' ) ).toEqual(['/workspace/run-one.txt']); }); it('uses Git as the primary source and lazily reads visible content', async () => { mockFetchGitChanges.mockResolvedValue({ repository_id: 'repo-1', project_id: 'project-1', base_commit: 'a'.repeat(40), target_commit: 'b'.repeat(40), files: [ { path: 'src/example.ts', status: 'modified', before_size: 8, after_size: 9, binary: false, added_lines: 2, removed_lines: 1, }, ], totals: { added: 2, removed: 1 }, truncated: false, }); mockFetchGitChangeContent.mockResolvedValue({ path: 'src/example.ts', base_commit: 'a'.repeat(40), target_commit: 'b'.repeat(40), before: { content: 'before\n', size: 8, binary: false, too_large: false, }, after: { content: 'after\n', size: 7, binary: false, too_large: false, }, }); const { result } = renderHook(() => useReviewChanges()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(mockFetchOverlays).not.toHaveBeenCalled(); expect(result.current.totals).toEqual({ added: 2, removed: 1 }); expect(result.current.files[0]).toMatchObject({ id: 'git:src/example.ts', path: 'src/example.ts', status: 'modified', binary: false, tooLarge: false, }); await expect(result.current.files[0].loadContent?.()).resolves.toEqual({ original: 'before\n', modified: 'after\n', }); expect(mockFetchGitChangeContent).toHaveBeenCalledWith( 'project-1', 'space-1', { email: 'user@example.com', userId: 42 }, { path: 'src/example.ts', baseCommit: 'a'.repeat(40), targetCommit: 'b'.repeat(40), } ); }); it('uses a finalized Run Git range without falling back to Project data', async () => { mockFetchRunGitChanges.mockResolvedValue({ repository_id: 'repo-1', project_id: 'project-1', run_id: 'run-1', base_commit: 'c'.repeat(40), target_commit: 'd'.repeat(40), files: [ { path: 'src/run-only.ts', status: 'added', before_size: null, after_size: 8, binary: false, added_lines: 1, removed_lines: 0, }, ], totals: { added: 1, removed: 0 }, truncated: false, }); mockFetchRunGitChangeContent.mockResolvedValue({ run_id: 'run-1', project_id: 'project-1', path: 'src/run-only.ts', base_commit: 'c'.repeat(40), target_commit: 'd'.repeat(40), before: { content: null, size: null, binary: false, too_large: false, }, after: { content: 'created\n', size: 8, binary: false, too_large: false, }, }); const { result } = renderHook(() => useReviewChanges({ scope: 'run', runId: 'run-1', focusPath: 'src/run-only.ts', focusRequestId: 1, }) ); await waitFor(() => expect(result.current.loading).toBe(false)); expect(mockFetchRunGitChanges).toHaveBeenCalledWith('run-1', 'space-1', { email: 'user@example.com', userId: 42, }); expect(mockFetchGitChanges).not.toHaveBeenCalled(); expect(mockFetchOverlays).not.toHaveBeenCalled(); expect(result.current.files[0]).toMatchObject({ id: 'run-git:run-1:src/run-only.ts', path: 'src/run-only.ts', status: 'added', }); await expect(result.current.files[0].loadContent?.()).resolves.toEqual({ original: '', modified: 'created\n', }); expect(mockFetchRunGitChangeContent).toHaveBeenCalledWith( 'run-1', 'space-1', { email: 'user@example.com', userId: 42 }, { path: 'src/run-only.ts', baseCommit: 'c'.repeat(40), targetCommit: 'd'.repeat(40), } ); }); it("recovers an older Run from only that Run's retained overlays", async () => { mockFetchRunGitChanges.mockRejectedValue( Object.assign(new Error('Run Git state not found'), { status: 404 }) ); mockReviewListBackups.mockResolvedValue([ { path: '/scratch/src/run-one.ts', exists: true, size: 120, backups: [ { path: '/scratch/src/run-one.ts.20260722_120000.bak', size: 90 }, ], }, ]); mockFetchOverlays.mockResolvedValue({ space_id: 'space-1', project_id: 'project-1', overlays: [ { id: 7, space_id: 'space-1', project_id: 'project-1', run_id: 'run-1', path: 'src/run-one.ts', status: 'modified', metadata: { source_path: '/scratch/src/run-one.ts' }, }, { id: 8, space_id: 'space-1', project_id: 'project-1', run_id: 'run-2', path: 'src/run-two.ts', status: 'modified', metadata: { source_path: '/scratch/src/run-two.ts' }, }, ], }); const { result } = renderHook(() => useReviewChanges({ scope: 'run', runId: 'run-1', focusRequestId: 0, }) ); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.error).toBeNull(); expect(result.current.files).toHaveLength(1); expect(result.current.files[0]).toMatchObject({ id: 'overlay:run-1:src/run-one.ts', path: 'src/run-one.ts', }); }); it('uses authoritative overlays and removes applied entries on refresh', async () => { mockFetchOverlays .mockResolvedValueOnce({ space_id: 'space-1', project_id: 'project-1', overlays: [ { id: 7, space_id: 'space-1', project_id: 'project-1', run_id: 'run-1', path: 'src/example.ts', status: 'modified', metadata: { source_path: '/scratch/src/example.ts' }, }, ], }) .mockResolvedValueOnce({ space_id: 'space-1', project_id: 'project-1', overlays: [], }); const { result } = renderHook(() => useReviewChanges()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(mockFetchOverlays).toHaveBeenCalledWith('space-1', 'project-1'); expect(result.current.files).toEqual([ { id: 'overlay:run-1:src/example.ts', path: 'src/example.ts', status: 'modified', absPath: '/scratch/src/example.ts', bakPath: '/scratch/src/example.ts.20260722_120000.bak', beforeUnavailable: false, tooLarge: false, }, ]); expect(result.current.error).toBeNull(); act(() => result.current.refresh()); await waitFor(() => { expect(mockFetchOverlays).toHaveBeenCalledTimes(2); expect(result.current.loading).toBe(false); }); expect(result.current.files).toEqual([]); }); it('flags a modified overlay with no surviving backup', async () => { mockReviewListBackups.mockResolvedValue([ { path: '/scratch/src/example.ts', exists: true, size: 120, backups: [] }, ]); mockFetchOverlays.mockResolvedValue({ space_id: 'space-1', project_id: 'project-1', overlays: [ { id: 7, space_id: 'space-1', project_id: 'project-1', run_id: 'run-1', path: 'src/example.ts', status: 'modified', metadata: { source_path: '/scratch/src/example.ts' }, }, ], }); const { result } = renderHook(() => useReviewChanges()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.files[0]).toMatchObject({ status: 'modified', bakPath: null, beforeUnavailable: true, tooLarge: false, }); }); it('marks a file too large when either side exceeds the diff limit', async () => { mockReviewListBackups.mockResolvedValue([ { path: '/scratch/src/example.ts', exists: true, size: 5_000_000, backups: [ { path: '/scratch/src/example.ts.20260722_120000.bak', size: 90 }, ], }, ]); mockFetchOverlays.mockResolvedValue({ space_id: 'space-1', project_id: 'project-1', overlays: [ { id: 7, space_id: 'space-1', project_id: 'project-1', run_id: 'run-1', path: 'src/example.ts', status: 'modified', metadata: { source_path: '/scratch/src/example.ts' }, }, ], }); const { result } = renderHook(() => useReviewChanges()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.files[0]).toMatchObject({ tooLarge: true }); }); it('totals added and removed lines across every changed file', async () => { const before = 'a\nb\nc\n'; const afterOne = 'a\nB\nc\nd\n'; // +2 −1 const afterTwo = 'a\nb\nc\nd\ne\n'; // +2 −0 const encode = (text: string) => new TextEncoder().encode(text); mockHost.electronAPI.readFile.mockImplementation((path: string) => { const body = path.endsWith('.bak') ? before : path.endsWith('one.ts') ? afterOne : afterTwo; return Promise.resolve({ success: true, data: encode(body) }); }); mockReviewListBackups.mockResolvedValue([ { path: '/scratch/src/one.ts', exists: true, size: 120, backups: [ { path: '/scratch/src/one.ts.20260722_120000.bak', size: 90 }, ], }, { path: '/scratch/src/two.ts', exists: true, size: 120, backups: [ { path: '/scratch/src/two.ts.20260722_120000.bak', size: 90 }, ], }, ]); mockFetchOverlays.mockResolvedValue({ space_id: 'space-1', project_id: 'project-1', overlays: ['one', 'two'].map((name, index) => ({ id: index, space_id: 'space-1', project_id: 'project-1', run_id: 'run-1', path: `src/${name}.ts`, status: 'modified', metadata: { source_path: `/scratch/src/${name}.ts` }, })), }); const { result } = renderHook(() => useReviewChanges()); // Totals start at zero for the empty initial file list, so wait for the // count that follows the scan rather than for the first non-null value. await waitFor(() => expect(result.current.totals).toEqual({ added: 4, removed: 1 }) ); }); it('reports a failed scan instead of an empty change set', async () => { mockFetchOverlays.mockRejectedValue(new Error('overlay service down')); const { result } = renderHook(() => useReviewChanges()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.error).toBe('overlay service down'); expect(result.current.files).toEqual([]); }); });