feat(report-bug): add diagnostics export and email report flow

Replace the top-bar support dropdown with a report bug dialog that packages diagnostics, prepares bug metadata, and opens a prefilled support email while keeping cancel as a no-op. This also updates docs/locales and adds a regression test for canceled saves.

Made-with: Cursor
This commit is contained in:
Douglas 2026-04-23 14:10:32 +01:00
parent 92dbb06b69
commit 175e62a72e
12 changed files with 630 additions and 92 deletions

View file

@ -10,23 +10,23 @@ width="100%"
height="auto"
/>
### Step 1: Access the Bug Report Feature
### Step 1: Open Report a bug
1. Locate the **Bug Report** button in the top section of the desktop interface, positioned to the right of your project name
1. Click the **Bug Report** button to initiate the reporting process
1. In the top bar, click the **Support** (help) icon
1. Choose **Report a bug**
### Step 2: Download Log Files
### Step 2: Describe the issue and save diagnostics
- Upon clicking the Bug Report button, log files will be automatically downloaded to your device
- These log files contain technical information that helps our development team diagnose issues more effectively
1. Enter what went wrong. Optionally add **steps to reproduce**
1. Click **Save diagnostics and open email**
1. Choose where to save the **diagnostics ZIP** (it includes app logs and a `bug_report.txt` file)
### Step 3: Complete the Bug Report Form
Your default email app opens addressed to **info@eigent.ai** with a pre-filled message.
- A bug report web form will automatically open in your default browser
- Please provide the following information:
- **Bug Description**: Write a clear description of the issue you encountered
- **Screenshot Upload**: Attach relevant screenshots that demonstrate the problem
- **Log File Upload**: Upload the log files that were downloaded in Step 2
### Step 3: Send the email
1. **Attach the ZIP** you just saved to the message (the mail app cannot add this automatically)
1. Add screenshots or other files if they help, then send
### Step 4: Join Our Community for Real-time Support
@ -47,9 +47,11 @@ Developers and technical users are welcome to report issues directly through our
- **Repository URL**: https://github.com/eigent-ai/eigent
- Submit detailed issues with reproduction steps
**Optional:** In the same **Report a bug** dialog, use **Download logs** to save a single log file (without the full diagnostics ZIP).
## Important Notes
- Always include log files when reporting bugs for faster resolution
- Always include the diagnostics ZIP (or log export) when reporting bugs for faster resolution
- Provide as much detail as possible in your bug description
- Screenshots help our team understand visual issues more quickly
- Our community channels provide the fastest response times for urgent issues

View file

@ -59,7 +59,7 @@ import {
removeEnvKey,
updateEnvBlock,
} from './utils/envUtil';
import { zipFolder } from './utils/log';
import { createDiagnosticsZip, zipFolder } from './utils/log';
import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig';
import {
checkVenvExistsForPreCheck,
@ -1111,6 +1111,100 @@ function registerIpcHandlers() {
}
});
ipcMain.handle('get-diagnostics-info', async () => {
return {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
};
});
ipcMain.handle(
'export-diagnostics-zip',
async (
_event,
payload: { description: string; steps?: string } | undefined
) => {
try {
const description =
typeof payload?.description === 'string'
? payload.description.trim()
: '';
if (!description) {
return { success: false, error: 'Description is required' };
}
const steps =
typeof payload?.steps === 'string' ? payload.steps.trim() : '';
const logFiles: { src: string; destName: string }[] = [];
if (fs.existsSync(logPath)) {
logFiles.push({ src: logPath, destName: 'electron-main.log' });
}
const backupResolved = getBackupLogPath();
if (
fs.existsSync(backupResolved) &&
path.resolve(backupResolved) !== path.resolve(logPath)
) {
logFiles.push({
src: backupResolved,
destName: 'electron-userdata-logs.log',
});
}
if (logFiles.length === 0) {
return { success: false, error: 'no log file' };
}
const appVersion = app.getVersion();
const platform = process.platform;
const arch = process.arch;
const bugReportText = [
'Eigent bug report',
'=================',
'',
`App version: ${appVersion}`,
`OS: ${platform} (${arch})`,
'',
'Description',
'-----------',
description,
'',
...(steps
? ['Steps to reproduce', '-------------------', steps, '']
: []),
].join('\n');
const defaultFileName = `eigent-diagnostics-${appVersion}-${Date.now()}.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save diagnostics',
defaultPath: defaultFileName,
filters: [{ name: 'ZIP archive', extensions: ['zip'] }],
});
if (canceled || !filePath) {
return { success: false, error: '' };
}
await createDiagnosticsZip(filePath, bugReportText, logFiles);
return { success: true, savedPath: filePath };
} catch (error: any) {
log.error('export-diagnostics-zip failed:', error);
return { success: false, error: error.message };
}
}
);
ipcMain.handle('open-mailto', async (_event, url: string) => {
try {
if (typeof url !== 'string' || !url.startsWith('mailto:')) {
return { success: false, error: 'Invalid mailto URL' };
}
await shell.openExternal(url);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
});
ipcMain.handle(
'upload-log',
async (

View file

@ -12,7 +12,11 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
// @ts-ignore
import archiver from 'archiver';
import log from 'electron-log';
@ -37,3 +41,42 @@ export function zipFolder(
archive.finalize();
});
}
export type DiagnosticsLogFile = { src: string; destName: string };
/**
* Stages log files and bug_report.txt into a temp directory, zips to outputZipPath, then removes the staging dir.
*/
export async function createDiagnosticsZip(
outputZipPath: string,
bugReportText: string,
logFiles: DiagnosticsLogFile[]
): Promise<void> {
if (logFiles.length === 0) {
throw new Error('no log files to include');
}
const id = randomBytes(8).toString('hex');
const staging = path.join(os.tmpdir(), `eigent-diagnostics-${id}`);
await fsp.mkdir(staging, { recursive: true });
try {
for (const f of logFiles) {
if (!fs.existsSync(f.src)) {
log.warn(`[diagnostics] skip missing log: ${f.src}`);
continue;
}
await fsp.copyFile(f.src, path.join(staging, f.destName));
}
await fsp.writeFile(
path.join(staging, 'bug_report.txt'),
bugReportText,
'utf-8'
);
const entries = await fsp.readdir(staging);
if (entries.length === 0) {
throw new Error('no log files to include');
}
await zipFolder(staging, outputZipPath);
} finally {
await fsp.rm(staging, { recursive: true, force: true });
}
}

View file

@ -76,6 +76,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
webviewDestroy: (webviewId: string) =>
ipcRenderer.invoke('webview-destroy', webviewId),
exportLog: () => ipcRenderer.invoke('export-log'),
getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'),
exportDiagnosticsZip: (payload: { description: string; steps?: string }) =>
ipcRenderer.invoke('export-diagnostics-zip', payload),
openMailto: (url: string) => ipcRenderer.invoke('open-mailto', url),
uploadLog: (email: string, taskId: string, baseUrl: string, token: string) =>
ipcRenderer.invoke('upload-log', email, taskId, baseUrl, token),
// mcp

View file

@ -9,3 +9,7 @@ src/components/ui/WordCarousel/WordCarousel.tsx
src/components/Terminal/index.tsx
#
# Install progress bar (src/components/ui/progress-install.tsx) and similar: use CSS vars only — no entry needed.
#
# Electron shell surfaces use fixed native colors in preload HTML and BrowserWindow options.
electron/main/index.ts
electron/preload/index.ts

View file

@ -0,0 +1,293 @@
// ========= 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 { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { useHost } from '@/host';
import { Download } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
const INFO_EMAIL = 'info@eigent.ai';
function buildMailtoUrl(
subject: string,
body: string
): { url: string; truncated: boolean } {
const maxLen = 1800;
const tail = '\n\n[…]';
let b = body;
let truncated = false;
if (b.length > maxLen) {
b = b.slice(0, maxLen - tail.length) + tail;
truncated = true;
}
const url = `mailto:${INFO_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(b)}`;
return { url, truncated };
}
/** Matches `getDiagnosticsInfo` in preload / `ElectronAPI` */
type DiagnosticsInfo = {
version: string;
platform: string;
arch: string;
};
interface ReportBugDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function ReportBugDialog({
open,
onOpenChange,
}: ReportBugDialogProps) {
const host = useHost();
const { t } = useTranslation();
const [description, setDescription] = useState('');
const [steps, setSteps] = useState('');
const [meta, setMeta] = useState<DiagnosticsInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [downloadingLog, setDownloadingLog] = useState(false);
const handleDownloadLog = useCallback(async () => {
const exportLog = host?.electronAPI?.exportLog;
if (!exportLog) {
toast.error(t('layout.general-error'));
return;
}
setDownloadingLog(true);
try {
const response = await exportLog();
if (!response?.success) {
if (response?.error) {
toast.error(response.error);
}
return;
}
if (response.savedPath) {
toast.success(`${t('layout.log-saved')} ${response.savedPath}`);
return;
}
if (response.data === 'log file is empty') {
toast.info(t('layout.log-file-empty'));
}
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('layout.general-error'));
} finally {
setDownloadingLog(false);
}
}, [host?.electronAPI, t]);
useEffect(() => {
if (!open) {
return;
}
const api = host?.electronAPI;
if (!api?.getDiagnosticsInfo) {
return;
}
void api
.getDiagnosticsInfo()
.then((info: DiagnosticsInfo) => {
if (info?.version) {
setMeta({
version: info.version,
platform: info.platform,
arch: info.arch,
});
}
})
.catch(() => {
setMeta(null);
});
}, [open, host?.electronAPI]);
useEffect(() => {
if (!open) {
setDescription('');
setSteps('');
}
}, [open]);
const onSubmit = useCallback(async () => {
const api = host?.electronAPI;
if (!api?.exportDiagnosticsZip || !api?.openMailto) {
toast.error(t('layout.general-error'));
return;
}
const trimmed = description.trim();
if (!trimmed) {
toast.error(t('layout.report-bug-description-required'));
return;
}
setSubmitting(true);
try {
const response = await api.exportDiagnosticsZip({
description: trimmed,
steps: steps.trim() || undefined,
});
if (!response?.success) {
if (response?.error === '') {
return;
}
if (response?.error) {
toast.error(response.error);
} else {
toast.error(t('layout.general-error'));
}
return;
}
if (!response.savedPath) {
return;
}
const subject = t('layout.report-bug-mail-subject');
const v = meta?.version ?? '—';
const p = meta?.platform ?? '—';
const a = meta?.arch ?? '—';
const body = [
t('layout.report-bug-mail-body-intro'),
'',
t('layout.report-bug-mail-body-path', { path: response.savedPath }),
'',
'—',
t('layout.report-bug-mail-body-meta', { version: v, os: p, arch: a }),
'',
t('layout.report-bug-mail-body-desc'),
trimmed,
'',
...(steps.trim()
? [t('layout.report-bug-mail-body-steps'), steps.trim(), '']
: []),
].join('\n');
const { url, truncated } = buildMailtoUrl(subject, body);
if (truncated) {
toast.info(t('layout.report-bug-mail-body-truncated'));
}
const mail = await api.openMailto(url);
if (!mail?.success) {
if (mail?.error) {
toast.error(mail.error);
}
return;
}
onOpenChange(false);
toast.success(t('layout.report-bug-diagnostics-saved'));
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('layout.general-error'));
} finally {
setSubmitting(false);
}
}, [host?.electronAPI, description, steps, meta, onOpenChange, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="md"
className="gap-0 !rounded-xl border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[560px] border"
>
<div className="bg-ds-bg-neutral-strong-default rounded-t-xl pl-md pr-12 pt-md pb-2 w-full text-left">
<DialogTitle className="m-0 text-body-md font-bold text-ds-text-neutral-default-default block w-full text-left">
{t('layout.report-bug-dialog-title')}
</DialogTitle>
</div>
<div className="gap-md bg-ds-bg-neutral-strong-default px-md pt-2 pb-md flex max-h-[min(70vh,520px)] flex-col text-left">
{meta && (
<p className="text-body-sm text-ds-text-neutral-subtle-default m-0">
{t('layout.report-bug-meta', {
version: meta.version,
os: meta.platform,
arch: meta.arch,
})}
</p>
)}
<p className="text-body-sm text-ds-text-neutral-subtle-default m-0">
{t('layout.report-bug-footer-hint')}
</p>
<label
className="text-body-sm font-medium text-ds-text-neutral-default-default"
htmlFor="report-bug-description"
>
{t('layout.report-bug-field-description')}
</label>
<Textarea
id="report-bug-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('layout.report-bug-field-description-placeholder')}
className="min-h-[88px] resize-y"
disabled={submitting}
/>
<label
className="text-body-sm font-medium text-ds-text-neutral-default-default"
htmlFor="report-bug-steps"
>
{t('layout.report-bug-field-steps')}
</label>
<Textarea
id="report-bug-steps"
value={steps}
onChange={(e) => setSteps(e.target.value)}
placeholder={t('layout.report-bug-field-steps-placeholder')}
className="min-h-[72px] resize-y"
disabled={submitting}
/>
<div className="pt-1">
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="text"
onClick={() => void handleDownloadLog()}
disabled={submitting || downloadingLog}
>
<Download className="h-4 w-4 shrink-0" aria-hidden />
{t('layout.download-logs')}
</Button>
</div>
</div>
<DialogFooter className="!rounded-b-xl p-md gap-sm sm:!flex-col flex !flex-col !border-0 !border-t-0 bg-transparent shadow-none">
<div className="gap-sm flex w-full flex-row justify-end">
<Button
variant="ghost"
size="md"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('layout.cancel')}
</Button>
<Button
size="md"
variant="primary"
onClick={() => void onSubmit()}
disabled={submitting || !description.trim()}
>
{t('layout.report-bug-submit')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -19,14 +19,9 @@ import eigentAppIconBlack from '@/assets/logo/icon_black.svg';
import eigentAppIconWhite from '@/assets/logo/icon_white.svg';
import logoBlack from '@/assets/logo/logo_black.png';
import logoWhite from '@/assets/logo/logo_white.png';
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
import NotificationPanel from '@/components/Notification';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TooltipSimple } from '@/components/ui/tooltip';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useHost } from '@/host';
@ -60,8 +55,6 @@ import {
} from 'react-router-dom';
import { toast } from 'sonner';
const SUPPORT_EMAIL = 'support@eigent.ai';
/** Tracks linear in-app history so back/forward buttons can enable/disable like a browser. */
function useStackNavigationBounds() {
const location = useLocation();
@ -127,6 +120,7 @@ function HeaderWin() {
const navigate = useNavigate();
const location = useLocation();
const { canGoBack, canGoForward } = useStackNavigationBounds();
const [reportBugOpen, setReportBugOpen] = useState(false);
//Get Chatstore for the active project's task
const { chatStore } = useChatStoreAdapter();
const { chatPanelPosition, setChatPanelPosition } = usePageTabStore();
@ -184,41 +178,6 @@ function HeaderWin() {
share(taskId);
};
const handleDownloadLog = async () => {
try {
const exportLog = host?.electronAPI?.exportLog;
if (!exportLog) {
toast.error(t('layout.general-error'));
return;
}
const response = await exportLog();
if (!response?.success) {
if (response?.error) {
toast.error(response.error);
}
return;
}
if (response.savedPath) {
toast.success(`${t('layout.log-saved')} ${response.savedPath}`);
return;
}
if (response.data === 'log file is empty') {
toast.info(t('layout.log-file-empty'));
}
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('layout.general-error'));
}
};
const handleCopySupportEmail = async () => {
try {
await navigator.clipboard.writeText(SUPPORT_EMAIL);
toast.success(t('layout.email-copied'));
} catch {
toast.error(t('layout.copy-failed'));
}
};
if (!chatStore) {
return <div>Loading...</div>;
}
@ -403,42 +362,22 @@ function HeaderWin() {
<Bell className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<DropdownMenu>
<TooltipSimple
content={t('layout.support')}
side="bottom"
align="end"
<TooltipSimple
content={t('layout.support')}
side="bottom"
align="end"
>
<Button
type="button"
variant="ghost"
size="icon"
className="no-drag rounded-full"
aria-label={t('layout.support')}
onClick={() => setReportBugOpen(true)}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="no-drag rounded-full"
aria-label={t('layout.support')}
aria-haspopup="menu"
>
<CircleHelp className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
</TooltipSimple>
<DropdownMenuContent side="bottom" align="end" sideOffset={6}>
<DropdownMenuItem
onSelect={() => {
void handleDownloadLog();
}}
>
{t('layout.download-logs')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void handleCopySupportEmail();
}}
>
{t('layout.copy-email')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CircleHelp className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.refer-friends')}
side="bottom"
@ -506,6 +445,7 @@ function HeaderWin() {
open={notificationPanelOpen}
onOpenChange={setNotificationPanelOpen}
/>
<ReportBugDialog open={reportBugOpen} onOpenChange={setReportBugOpen} />
</div>
);
}

View file

@ -1,5 +1,22 @@
{
"report-bug": "Report a bug",
"report-bug-dialog-title": "Report a bug",
"report-bug-footer-hint": "We will save a ZIP of app logs, then open your email app. Attach the saved ZIP to the message before sending.",
"report-bug-field-description": "What went wrong?",
"report-bug-field-description-placeholder": "Describe the problem…",
"report-bug-field-steps": "Steps to reproduce (optional)",
"report-bug-field-steps-placeholder": "1. … 2. …",
"report-bug-meta": "Version {{version}} · {{os}} ({{arch}})",
"report-bug-description-required": "Please describe what went wrong.",
"report-bug-submit": "Save diagnostics and open email",
"report-bug-diagnostics-saved": "Diagnostics saved. Attach the ZIP in your email, then send.",
"report-bug-mail-subject": "[Eigent] Bug report",
"report-bug-mail-body-intro": "Please attach the diagnostics ZIP file you just saved to this message.",
"report-bug-mail-body-path": "ZIP saved to: {{path}}",
"report-bug-mail-body-meta": "App version: {{version}} | OS: {{os}} ({{arch}})",
"report-bug-mail-body-desc": "Description:",
"report-bug-mail-body-steps": "Steps to reproduce:",
"report-bug-mail-body-truncated": "The email body was shortened because of length limits. See bug_report.txt inside the ZIP for the full text.",
"refer-friends": "Refer friends",
"retry": "Retry",
"please-enter-email-address": "Please enter email address",

View file

@ -1,5 +1,22 @@
{
"report-bug": "报告 bug",
"report-bug-dialog-title": "报告 bug",
"report-bug-footer-hint": "我们会将应用日志打包为 ZIP 并打开您的邮件客户端。发送前请将该 ZIP 作为附件。",
"report-bug-field-description": "出现了什么问题?",
"report-bug-field-description-placeholder": "请描述问题…",
"report-bug-field-steps": "复现步骤(可选)",
"report-bug-field-steps-placeholder": "1. … 2. …",
"report-bug-meta": "版本 {{version}} · {{os}} ({{arch}})",
"report-bug-description-required": "请简要描述问题。",
"report-bug-submit": "保存诊断并打开邮件",
"report-bug-diagnostics-saved": "诊断已保存。请在邮件中附上 ZIP 后再发送。",
"report-bug-mail-subject": "[Eigent] 问题反馈",
"report-bug-mail-body-intro": "请附上您刚保存的诊断 ZIP 文件。",
"report-bug-mail-body-path": "ZIP 保存位置:{{path}}",
"report-bug-mail-body-meta": "应用版本:{{version}} | 系统:{{os}} ({{arch}})",
"report-bug-mail-body-desc": "问题描述:",
"report-bug-mail-body-steps": "复现步骤:",
"report-bug-mail-body-truncated": "因长度限制,邮件正文已截断。完整内容见 ZIP 内的 bug_report.txt。",
"refer-friends": "推荐朋友",
"retry": "重试",
"please-enter-email-address": "请输入邮箱地址",

View file

@ -64,6 +64,23 @@ interface ElectronAPI {
getShowWebview: () => Promise<any>;
webviewDestroy: (webviewId: string) => Promise<any>;
exportLog: () => Promise<any>;
getDiagnosticsInfo: () => Promise<{
version: string;
platform: string;
arch: string;
}>;
exportDiagnosticsZip: (payload: {
description: string;
steps?: string;
}) => Promise<{
success: boolean;
savedPath?: string;
error?: string;
}>;
openMailto: (url: string) => Promise<{
success: boolean;
error?: string;
}>;
mcpInstall: (name: string, mcp: any) => Promise<any>;
mcpRemove: (name: string) => Promise<any>;
mcpUpdate: (name: string, mcp: any) => Promise<any>;

View file

@ -39,6 +39,9 @@ export interface MockedElectronAPI {
checkAndInstallDepsOnUpdate: ReturnType<typeof vi.fn>;
getInstallationStatus: ReturnType<typeof vi.fn>;
exportLog: ReturnType<typeof vi.fn>;
getDiagnosticsInfo: ReturnType<typeof vi.fn>;
exportDiagnosticsZip: ReturnType<typeof vi.fn>;
openMailto: ReturnType<typeof vi.fn>;
onInstallDependenciesStart: ReturnType<typeof vi.fn>;
onInstallDependenciesLog: ReturnType<typeof vi.fn>;
onInstallDependenciesComplete: ReturnType<typeof vi.fn>;
@ -199,6 +202,25 @@ export function createElectronAPIMock(): MockedElectronAPI {
};
}),
getDiagnosticsInfo: vi.fn().mockImplementation(async () => {
return {
version: '1.0.0',
platform: 'darwin',
arch: 'arm64',
};
}),
exportDiagnosticsZip: vi.fn().mockImplementation(async () => {
return {
success: true,
savedPath: '/mock/diagnostics.zip',
};
}),
openMailto: vi.fn().mockImplementation(async () => {
return { success: true };
}),
// Event listeners
onInstallDependenciesStart: vi
.fn()
@ -410,6 +432,9 @@ export function createElectronAPIMock(): MockedElectronAPI {
electronAPI.checkAndInstallDepsOnUpdate.mockClear();
electronAPI.getInstallationStatus.mockClear();
electronAPI.exportLog.mockClear();
electronAPI.getDiagnosticsInfo.mockClear();
electronAPI.exportDiagnosticsZip.mockClear();
electronAPI.openMailto.mockClear();
electronAPI.onInstallDependenciesStart.mockClear();
electronAPI.onInstallDependenciesLog.mockClear();
electronAPI.onInstallDependenciesComplete.mockClear();

View file

@ -0,0 +1,82 @@
// ========= 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, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
import { useHost } from '@/host';
import { toast } from 'sonner';
vi.mock('@/host', () => ({
useHost: vi.fn(),
}));
vi.mock('sonner', () => ({
toast: {
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
describe('ReportBugDialog', () => {
const mockUseHost = vi.mocked(useHost);
const mockToast = vi.mocked(toast);
const mockElectronAPI = {
exportLog: vi.fn().mockResolvedValue({ success: true }),
getDiagnosticsInfo: vi.fn().mockResolvedValue({
version: '1.0.0',
platform: 'darwin',
arch: 'arm64',
}),
exportDiagnosticsZip: vi.fn(),
openMailto: vi.fn().mockResolvedValue({ success: true }),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseHost.mockReturnValue({ electronAPI: mockElectronAPI } as any);
});
it('silently stops when diagnostics save is canceled', async () => {
mockElectronAPI.exportDiagnosticsZip.mockResolvedValueOnce({
success: false,
error: '',
});
render(<ReportBugDialog open onOpenChange={vi.fn()} />);
await userEvent.type(
screen.getByLabelText('layout.report-bug-field-description'),
'A short repro description'
);
await userEvent.click(
screen.getByRole('button', { name: 'layout.report-bug-submit' })
);
await waitFor(() => {
expect(mockElectronAPI.exportDiagnosticsZip).toHaveBeenCalledWith({
description: 'A short repro description',
steps: undefined,
});
});
expect(mockElectronAPI.openMailto).not.toHaveBeenCalled();
expect(mockToast.error).not.toHaveBeenCalled();
expect(mockToast.success).not.toHaveBeenCalled();
});
});