Merge branch 'main' into fix/allow_scripts_for_any_https_origin

This commit is contained in:
Wendong-Fan 2026-02-12 07:23:15 +00:00 committed by GitHub
commit 698fe337e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 266 additions and 16 deletions

View file

@ -71,7 +71,7 @@ interface FileTreeProps {
isShowSourceCode: boolean;
}
const FileTree: React.FC<FileTreeProps> = ({
export const FileTree: React.FC<FileTreeProps> = ({
node,
level = 0,
selectedFile,
@ -105,29 +105,33 @@ const FileTree: React.FC<FileTreeProps> = ({
onSelectFile(fileInfo);
}
}}
className={`text-primary flex w-full items-center justify-start rounded-xl bg-fill-fill-transparent p-2 text-left text-sm backdrop-blur-lg transition-colors hover:bg-fill-fill-transparent-active ${
className={`text-primary flex w-full items-center justify-start gap-2 rounded-xl bg-fill-fill-transparent p-2 text-left text-sm backdrop-blur-lg transition-colors hover:bg-fill-fill-transparent-active ${
selectedFile?.path === child.path
? 'bg-fill-fill-transparent-active'
: ''
}`}
>
{child.isFolder && (
<span className="flex h-4 w-4 items-center justify-center">
{child.isFolder ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</span>
) : (
<span
className="flex h-4 w-4 flex-shrink-0 items-center justify-center"
aria-hidden
/>
)}
{!child.isFolder && <span className="w-4" />}
{child.isFolder ? (
<FolderIcon className="mr-2 h-5 w-5 flex-shrink-0 text-yellow-600" />
<FolderIcon className="h-5 w-5 flex-shrink-0 text-yellow-600" />
) : child.icon ? (
<child.icon className="mr-2 h-5 w-5 flex-shrink-0" />
<child.icon className="h-5 w-5 flex-shrink-0" />
) : (
<FileText className="mr-2 h-5 w-5 flex-shrink-0" />
<FileText className="h-5 w-5 flex-shrink-0" />
)}
<span

View file

@ -2337,18 +2337,40 @@ const chatStore = (initial?: Partial<ChatStore>) =>
onerror(err) {
console.error('[fetchEventSource] Error:', err);
// Allow automatic retry for connection errors
// TypeError usually means network/connection issues
if (
// Do not retry if the task has already finished (avoids duplicate execution
// after ERR_NETWORK_CHANGED, ERR_INTERNET_DISCONNECTED, sleep/wake - see issue #1212)
const currentStore = getCurrentChatStore();
const lockedId = getCurrentTaskId();
const task = currentStore.tasks[lockedId];
if (task?.status === ChatTaskStatus.FINISHED) {
console.log(
`[fetchEventSource] Task ${lockedId} already finished, stopping retry to avoid duplicate execution`
);
try {
if (activeSSEControllers[newTaskId]) {
delete activeSSEControllers[newTaskId];
}
} catch (cleanupError) {
console.warn(
'Error cleaning up AbortController on finished task:',
cleanupError
);
}
throw err;
}
// Allow automatic retry for connection errors only when task is not finished
const isConnectionError =
err instanceof TypeError ||
err?.message?.includes('Failed to fetch') ||
err?.message?.includes('ECONNREFUSED') ||
err?.message?.includes('NetworkError')
) {
err?.message?.includes('NetworkError') ||
err?.message?.includes('ERR_NETWORK_CHANGED') ||
err?.message?.includes('ERR_INTERNET_DISCONNECTED');
if (isConnectionError) {
console.warn(
'[fetchEventSource] Connection error detected, will retry automatically...'
);
// Don't throw - let fetchEventSource auto-retry
return;
}

View file

@ -0,0 +1,156 @@
// ========= 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileTree } from '../../../../src/components/Folder/index';
describe('FileTree', () => {
const onToggleFolder = vi.fn();
const onSelectFile = vi.fn();
const nodeWithFolderAndFile = {
name: '',
path: '',
children: [
{ name: 'src', path: '/proj/src', isFolder: true, children: [] },
{ name: 'readme.md', path: '/proj/readme.md', isFolder: false },
],
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders folder and file rows', () => {
render(
<FileTree
node={nodeWithFolderAndFile}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
expect(screen.getByRole('button', { name: /src/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /readme\.md/i })
).toBeInTheDocument();
});
it('uses consistent first-column box (h-4 w-4) for folder and file rows for alignment', () => {
const { container } = render(
<FileTree
node={nodeWithFolderAndFile}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(2);
buttons.forEach((btn) => {
const firstCol = btn.querySelector('[class*="h-4"][class*="w-4"]');
expect(firstCol).toBeInTheDocument();
});
});
it('uses gap-2 on row for consistent spacing between chevron, icon, and label', () => {
const { container } = render(
<FileTree
node={nodeWithFolderAndFile}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
const buttons = container.querySelectorAll('button');
buttons.forEach((btn) => {
expect(btn.className).toMatch(/gap-2/);
});
});
it('file row first column has aria-hidden for accessibility', () => {
render(
<FileTree
node={nodeWithFolderAndFile}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
const fileButton = screen.getByRole('button', { name: /readme\.md/i });
const spacer = fileButton.querySelector('[aria-hidden="true"]');
expect(spacer).toBeInTheDocument();
});
it('calls onToggleFolder when folder row is clicked', async () => {
render(
<FileTree
node={nodeWithFolderAndFile}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
await userEvent.click(screen.getByRole('button', { name: /src/i }));
expect(onToggleFolder).toHaveBeenCalledWith('/proj/src');
});
it('calls onSelectFile when file row is clicked', async () => {
render(
<FileTree
node={nodeWithFolderAndFile}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
await userEvent.click(screen.getByRole('button', { name: /readme\.md/i }));
expect(onSelectFile).toHaveBeenCalledWith(
expect.objectContaining({
name: 'readme.md',
path: '/proj/readme.md',
isFolder: false,
})
);
});
it('returns null when node has no children', () => {
const { container } = render(
<FileTree
node={{ name: '', path: '', children: [] }}
selectedFile={null}
expandedFolders={new Set()}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
isShowSourceCode={false}
/>
);
expect(container.firstChild).toBeNull();
});
});

View file

@ -30,11 +30,19 @@ vi.mock('@/api/http', () => ({
fetchPost: vi.fn(),
fetchPut: vi.fn(),
getBaseURL: vi.fn(() => Promise.resolve('http://localhost:8000')),
proxyFetchPost: vi.fn(),
proxyFetchPost: vi.fn(() => Promise.resolve({ id: 'mock-history-id' })),
proxyFetchPut: vi.fn(),
proxyFetchGet: vi.fn(),
proxyFetchGet: vi.fn(() =>
Promise.resolve({
value: '',
api_url: '',
items: [],
warning_code: null,
})
),
uploadFile: vi.fn(),
fetchDelete: vi.fn(),
waitForBackendReady: vi.fn(() => Promise.resolve(true)),
}));
vi.mock('@microsoft/fetch-event-source', () => ({
@ -71,10 +79,22 @@ vi.mock('../../../src/store/authStore', () => ({
workerListData: {},
})),
useWorkerList: vi.fn(() => []),
getWorkerList: vi.fn(() => []),
}));
vi.mock('../../../src/store/projectStore', () => ({
useProjectStore: {
getState: vi.fn(() => ({
activeProjectId: null,
getHistoryId: () => null,
})),
},
}));
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { generateUniqueId } from '../../../src/lib';
import { useChatStore } from '../../../src/store/chatStore';
import { ChatTaskStatus } from '../../../src/types/constants';
// Mock electron IPC
(global as any).ipcRenderer = {
@ -508,4 +528,52 @@ describe('ChatStore - Core Functionality', () => {
expect(result.current.getState().updateCount).toBe(initialCount + 2);
});
});
/**
* Issue #1212: Duplicate task execution after network reconnection / system wake-up.
* When the task is already FINISHED, SSE onerror must not retry (throw to stop retry).
*/
describe('SSE onerror - no retry when task already finished (issue #1212)', () => {
it('should stop retry when task is already FINISHED (avoids duplicate execution)', async () => {
const mockFetchEventSource = vi.mocked(fetchEventSource);
mockFetchEventSource.mockImplementation((_url, opts) => {
// Simulate connection error; when onerror runs, store checks task status
// and throws to stop retry (issue #1212 fix)
try {
opts.onerror?.(new Error('Failed to fetch'));
} catch {
// Expected: onerror throws to stop fetch-event-source from retrying
}
return Promise.resolve();
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { result } = renderHook(() => useChatStore());
let taskId: string;
await act(async () => {
taskId = result.current.getState().create();
result.current.getState().setActiveTaskId(taskId!);
result.current.getState().setStatus(taskId!, ChatTaskStatus.FINISHED);
result.current.getState().addMessages(taskId!, {
id: generateUniqueId(),
role: 'user',
content: 'Test message',
});
result.current.getState().setHasMessages(taskId!, true);
});
await act(async () => {
await result.current.getState().startTask(taskId!);
});
expect(mockFetchEventSource).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('already finished, stopping retry')
);
logSpy.mockRestore();
});
});
});