Files
iofficeai--aionui/tests/unit/feedback/submitFeedbackReport.test.ts
2026-07-13 13:20:22 +08:00

286 lines
8.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { submitFeedbackReport } from '@/renderer/services/feedback/submitFeedbackReport';
const sentryMocks = vi.hoisted(() => {
const setTag = vi.fn();
const flush = vi.fn(async () => true);
return {
captureEvent: vi.fn(() => 'event-id'),
flush,
getClient: vi.fn(() => ({ flush })),
setTag,
withScope: vi.fn((callback: (scope: { setTag: typeof setTag }) => void) => {
callback({ setTag });
}),
};
});
vi.mock('@sentry/electron/renderer', () => sentryMocks);
describe('submitFeedbackReport', () => {
beforeEach(() => {
sentryMocks.captureEvent.mockClear();
sentryMocks.captureEvent.mockReturnValue('event-id');
sentryMocks.flush.mockClear();
sentryMocks.flush.mockResolvedValue(true);
sentryMocks.getClient.mockClear();
sentryMocks.getClient.mockReturnValue({ flush: sentryMocks.flush });
sentryMocks.setTag.mockClear();
sentryMocks.withScope.mockClear();
vi.stubGlobal('window', { electronAPI: undefined });
});
it('submits a user-feedback event with tags, extra context, logs, and attachments', async () => {
const collectFeedbackLogs = vi.fn().mockResolvedValue({
filename: 'aionui-logs.log.gz',
data: [1, 2, 3],
});
const logFeedbackEvent = vi.fn();
vi.stubGlobal('window', {
electronAPI: {
collectFeedbackLogs,
emit: vi.fn(),
logFeedbackEvent,
on: vi.fn(),
},
});
await submitFeedbackReport({
attachments: [
{
filename: 'screenshot.png',
data: new Uint8Array([4, 5, 6]),
contentType: 'image/png',
},
],
collectLogs: true,
description: ' AionCore cannot start ',
extra: {
installation_integrity: {
source: 'backend_startup_failure',
},
},
module: 'installation-integrity',
moduleLabel: 'AionUi installation is incomplete',
tags: {
'aionui.installation_integrity.report_source': 'backend_startup_failure',
},
});
expect(collectFeedbackLogs).toHaveBeenCalledOnce();
expect(sentryMocks.setTag).toHaveBeenCalledWith('type', 'user-feedback');
expect(sentryMocks.setTag).toHaveBeenCalledWith('module', 'installation-integrity');
expect(sentryMocks.setTag).toHaveBeenCalledWith(
'aionui.installation_integrity.report_source',
'backend_startup_failure'
);
expect(sentryMocks.captureEvent).toHaveBeenCalledWith(
{
level: 'info',
message: 'AionUi installation is incomplete: AionCore cannot start',
extra: {
description: 'AionCore cannot start',
installation_integrity: {
source: 'backend_startup_failure',
},
},
},
{
attachments: [
{
filename: 'aionui-logs.log.gz',
data: new Uint8Array([1, 2, 3]),
contentType: 'application/gzip',
},
{
filename: 'screenshot.png',
data: new Uint8Array([4, 5, 6]),
contentType: 'image/png',
},
],
}
);
expect(sentryMocks.flush).not.toHaveBeenCalled();
expect(logFeedbackEvent).toHaveBeenCalledOnce();
expect(logFeedbackEvent).toHaveBeenCalledWith(
expect.objectContaining({
level: 'info',
message: 'submitted',
})
);
});
it('continues without logs when log collection is unavailable', async () => {
await submitFeedbackReport({
collectLogs: true,
description: 'No logs available',
module: 'installation-integrity',
moduleLabel: 'AionUi installation is incomplete',
});
expect(sentryMocks.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
extra: {
description: 'No logs available',
},
}),
{ attachments: [] }
);
});
it('attaches db diagnostics when collection succeeds', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
success: true,
data: {
schema_version: 'feedback-diagnostics/v1',
profiles: [{ name: 'conversation-session', mode: 'detail', data: { conversation: { id: 'conv-1' } } }],
privacy: { raw_content_included: false, api_keys_included: false },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
electronAPI: {
emit: vi.fn(),
on: vi.fn(),
},
});
await submitFeedbackReport({
collectDbDiagnostics: {
routeAtOpen: '#/conversation/conv-1',
routeAtSubmit: '#/conversation/conv-1',
selectedModule: 'conversation-session',
explicitContext: {
conversationId: 'conv-1',
},
},
collectLogs: false,
description: 'Conversation stuck',
module: 'conversation-session',
moduleLabel: 'Conversation & Sessions',
});
expect(fetchMock).toHaveBeenCalledOnce();
const [path, options] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(path).toContain('/api/system/diagnostics/feedback-report?');
expect(path).toContain('route_at_open=%23%2Fconversation%2Fconv-1');
expect(path).toContain('route_at_submit=%23%2Fconversation%2Fconv-1');
expect(path).toContain('selected_module=conversation-session');
expect(path).toContain('conversation_id=conv-1');
expect(options.method).toBe('GET');
expect(sentryMocks.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
extra: {
description: 'Conversation stuck',
},
}),
{
attachments: [
expect.objectContaining({
filename: expect.stringMatching(/^db-diagnostics\.json(?:\.gz)?$/),
data: expect.any(Uint8Array),
contentType: expect.stringMatching(/^application\/(?:gzip|json)$/),
}),
],
}
);
});
it('continues without db diagnostics when collection fails', async () => {
const fetchMock = vi.fn(async () => {
throw new Error('db locked');
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
electronAPI: {
emit: vi.fn(),
on: vi.fn(),
},
});
await submitFeedbackReport({
collectDbDiagnostics: {
routeAtOpen: '#/conversation/conv-1',
selectedModule: 'conversation-session',
},
collectLogs: false,
description: 'Conversation stuck',
module: 'conversation-session',
moduleLabel: 'Conversation & Sessions',
});
expect(fetchMock).toHaveBeenCalledOnce();
expect(sentryMocks.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
extra: {
description: 'Conversation stuck',
},
}),
{ attachments: [] }
);
});
it('flushes when requested', async () => {
await submitFeedbackReport({
collectLogs: false,
description: 'Flush me',
flushTimeoutMs: 2000,
module: 'installation-integrity',
moduleLabel: 'AionUi installation is incomplete',
});
expect(sentryMocks.captureEvent).toHaveBeenCalledOnce();
expect(sentryMocks.getClient).toHaveBeenCalledOnce();
expect(sentryMocks.flush).toHaveBeenCalledWith(2000);
});
it('rejects when requested flush does not complete', async () => {
sentryMocks.flush.mockResolvedValue(false);
const logFeedbackEvent = vi.fn();
vi.stubGlobal('window', {
electronAPI: {
logFeedbackEvent,
},
});
await expect(
submitFeedbackReport({
collectLogs: false,
description: 'Flush me',
flushTimeoutMs: 2000,
module: 'installation-integrity',
moduleLabel: 'AionUi installation is incomplete',
})
).rejects.toThrow('Failed to flush feedback report (event-id)');
expect(logFeedbackEvent).toHaveBeenCalledOnce();
expect(logFeedbackEvent).toHaveBeenCalledWith(
expect.objectContaining({
level: 'error',
message: 'failed',
})
);
});
it('rejects when requested flush has no initialized Sentry client', async () => {
sentryMocks.getClient.mockReturnValue(undefined);
await expect(
submitFeedbackReport({
collectLogs: false,
description: 'Flush me',
flushTimeoutMs: 2000,
module: 'installation-integrity',
moduleLabel: 'AionUi installation is incomplete',
})
).rejects.toThrow('Sentry is not initialized');
});
});