mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-29 02:41:44 +00:00
534 lines
15 KiB
TypeScript
534 lines
15 KiB
TypeScript
// ========= 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([]);
|
||
});
|
||
});
|