286 lines
8.5 KiB
TypeScript
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');
|
|
});
|
|
});
|