eigent/test/unit/components/Session/useReviewChanges.test.tsx

534 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ========= 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([]);
});
});