397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 AionUi (aionui.com)
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*
|
|
* Unit tests for the pure helpers exported from `packages/desktop/src/sentry.ts`:
|
|
* `selectRecentLogFiles`, `packAndCap`. Electron and Sentry are mocked so this
|
|
* suite runs under the `node` Vitest project.
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { gunzipSync } from 'node:zlib';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getVersion: () => '0.0.0-test', getPath: () => '/tmp', isPackaged: false },
|
|
}));
|
|
|
|
let sentryInitOptions: { beforeSend?: (event: unknown) => unknown } | undefined;
|
|
const scopeSetContext = vi.fn();
|
|
const scopeSetExtra = vi.fn();
|
|
const scopeSetTag = vi.fn();
|
|
|
|
vi.mock('@sentry/electron/main', () => ({
|
|
init: vi.fn((options: { beforeSend?: (event: unknown) => unknown }) => {
|
|
sentryInitOptions = options;
|
|
}),
|
|
setTag: vi.fn(),
|
|
setUser: vi.fn(),
|
|
withScope: vi.fn((callback: (scope: unknown) => void) => {
|
|
callback({
|
|
setTag: scopeSetTag,
|
|
setExtra: scopeSetExtra,
|
|
setContext: scopeSetContext,
|
|
});
|
|
}),
|
|
captureException: vi.fn(),
|
|
captureEvent: vi.fn(),
|
|
flush: vi.fn(async () => true),
|
|
}));
|
|
|
|
vi.mock('@/process/utils/analyticsId', () => ({
|
|
getOrCreateAnalyticsId: () => 'test-device-id',
|
|
}));
|
|
|
|
const autoUpdateDiagnosticsMock = vi.hoisted(() => ({
|
|
readAutoUpdateDiagnostics: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/process/services/autoUpdateDiagnostics', () => ({
|
|
readAutoUpdateDiagnostics: autoUpdateDiagnosticsMock.readAutoUpdateDiagnostics,
|
|
}));
|
|
|
|
import * as Sentry from '@sentry/electron/main';
|
|
import { listLogFilesSync, selectRecentLogFiles, packAndCap, captureBackendStartupFailure, initSentry } from '@/sentry';
|
|
|
|
describe('selectRecentLogFiles', () => {
|
|
it('returns every file from the N most recent non-empty days', () => {
|
|
const files = [
|
|
{ path: '/a/2026-05-22.log', mtime: Date.UTC(2026, 4, 22, 10), size: 100 },
|
|
{ path: '/a/2026-05-22.aioncore.log', mtime: Date.UTC(2026, 4, 22, 11), size: 200 },
|
|
{ path: '/a/2026-05-21.log', mtime: Date.UTC(2026, 4, 21, 10), size: 50 },
|
|
{ path: '/a/2026-05-20.log', mtime: Date.UTC(2026, 4, 20, 10), size: 0 },
|
|
{ path: '/a/2026-05-19.log', mtime: Date.UTC(2026, 4, 19, 10), size: 80 },
|
|
];
|
|
const picked = selectRecentLogFiles(files, 2);
|
|
const days = new Set(picked.map((f) => /\d{4}-\d{2}-\d{2}/.exec(f.path)![0]));
|
|
expect(days).toEqual(new Set(['2026-05-22', '2026-05-21']));
|
|
expect(picked).toHaveLength(3);
|
|
});
|
|
|
|
it('skips empty files', () => {
|
|
const files = [{ path: '/a/x.log', mtime: 1, size: 0 }];
|
|
expect(selectRecentLogFiles(files, 7)).toEqual([]);
|
|
});
|
|
|
|
it('returns fewer days when the input has fewer than N distinct days', () => {
|
|
const files = [
|
|
{ path: '/a/2026-05-22.log', mtime: Date.UTC(2026, 4, 22, 10), size: 1 },
|
|
{ path: '/a/2026-05-22b.log', mtime: Date.UTC(2026, 4, 22, 11), size: 1 },
|
|
];
|
|
const picked = selectRecentLogFiles(files, 7);
|
|
expect(picked).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('listLogFilesSync', () => {
|
|
it('finds log files under dated year/month/day directories', () => {
|
|
const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-sentry-logs-'));
|
|
try {
|
|
const datedDir = path.join(logsDir, '2026', '07', '02');
|
|
mkdirSync(datedDir, { recursive: true });
|
|
writeFileSync(path.join(datedDir, '2026-07-02.aioncore.log'), 'backend\n');
|
|
writeFileSync(path.join(datedDir, '2026-07-02.aionrs.log'), 'aionrs\n');
|
|
writeFileSync(path.join(logsDir, '2026-07-02.log'), 'frontend\n');
|
|
|
|
const files = listLogFilesSync(logsDir);
|
|
const relative = files.map((file) => path.relative(logsDir, file.path).split(path.sep).join('/')).toSorted();
|
|
|
|
expect(relative).toEqual([
|
|
'2026-07-02.log',
|
|
'2026/07/02/2026-07-02.aioncore.log',
|
|
'2026/07/02/2026-07-02.aionrs.log',
|
|
]);
|
|
} finally {
|
|
rmSync(logsDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('packAndCap', () => {
|
|
it('returns a gzip buffer well under cap with truncated=false', () => {
|
|
const segments = [{ name: 'tiny.log', mtime: 0, content: 'hello world\n' }];
|
|
const out = packAndCap(segments, 1024);
|
|
expect(out.gzipped.length).toBeLessThan(1024);
|
|
expect(out.truncated).toBe(false);
|
|
const decompressed = gunzipSync(out.gzipped).toString('utf8');
|
|
expect(decompressed).toContain('hello world');
|
|
expect(decompressed).toContain('tiny.log');
|
|
});
|
|
|
|
it('truncates from the head and preserves the tail when over cap', () => {
|
|
// Highly-incompressible payload so gzip can't shrink past the cap on its own:
|
|
// base64-encoded random bytes are near maximum entropy.
|
|
const big = randomBytes(2_000_000).toString('base64');
|
|
const segments = [{ name: 'big.log', mtime: 0, content: big + '\nMARKER_TAIL\n' }];
|
|
const out = packAndCap(segments, 50_000);
|
|
expect(out.gzipped.length).toBeLessThanOrEqual(50_000);
|
|
expect(out.truncated).toBe(true);
|
|
const decompressed = gunzipSync(out.gzipped).toString('utf8');
|
|
expect(decompressed).toContain('MARKER_TAIL');
|
|
});
|
|
});
|
|
|
|
describe('captureBackendStartupFailure', () => {
|
|
it('captures and flushes a dedicated backend startup failure with diagnostics', async () => {
|
|
autoUpdateDiagnosticsMock.readAutoUpdateDiagnostics.mockReturnValue(undefined);
|
|
const error = new Error('aioncore failed to start within timeout') as Error & {
|
|
details?: Record<string, unknown>;
|
|
};
|
|
error.details = {
|
|
stage: 'health_timeout',
|
|
binaryPath: '/abs/path/aioncore',
|
|
port: 33334,
|
|
stderrTail: 'database is locked',
|
|
};
|
|
|
|
await captureBackendStartupFailure(error);
|
|
|
|
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
|
expect(Sentry.flush).toHaveBeenCalledWith(2000);
|
|
expect(Sentry.withScope).toHaveBeenCalledOnce();
|
|
expect(scopeSetContext).toHaveBeenCalledWith(
|
|
'aioncore_install_diagnostics',
|
|
expect.objectContaining({
|
|
appVersion: '0.0.0-test',
|
|
isPackaged: false,
|
|
platform: process.platform,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('sets flattened incomplete-installation tags for update-related missing directory resources', async () => {
|
|
scopeSetTag.mockClear();
|
|
scopeSetContext.mockClear();
|
|
autoUpdateDiagnosticsMock.readAutoUpdateDiagnostics.mockReturnValue({
|
|
currentAppVersion: '2.1.8',
|
|
events: [],
|
|
lastEvent: {
|
|
at: '2026-06-01T22:41:03.273Z',
|
|
status: 'quit-and-install',
|
|
},
|
|
lastQuitAndInstallAt: '2026-06-01T22:41:03.273Z',
|
|
});
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-06-01T22:41:49.273Z'));
|
|
|
|
try {
|
|
const error = new Error('aioncore startup failed while resolving backend binary') as Error & {
|
|
details?: Record<string, unknown>;
|
|
};
|
|
error.details = {
|
|
stage: 'resolve_binary',
|
|
isPackaged: true,
|
|
runtimeKey: 'win32-x64',
|
|
binaryName: 'aioncore.exe',
|
|
resourcesPath: 'C:\\Users\\alice\\AppData\\Local\\Programs\\AionUi\\resources',
|
|
bundledDirExists: false,
|
|
runtimeDirExists: false,
|
|
resourcesDirEntries: [
|
|
'app-update.yml',
|
|
'app.asar',
|
|
'app.asar.unpacked/',
|
|
'app.png',
|
|
'elevate.exe',
|
|
'manifest.webmanifest',
|
|
'sw.js',
|
|
],
|
|
};
|
|
|
|
await captureBackendStartupFailure(error);
|
|
|
|
expect(scopeSetTag).toHaveBeenCalledWith(
|
|
'aionui.backend_startup.incomplete_installation_kind',
|
|
'missing_directory_resources'
|
|
);
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.missing_bundled_dir', 'true');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.missing_runtime_dir', 'true');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.missing_binary', 'true');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.missing_hub_dir', 'true');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.last_update_status', 'quit-and-install');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.seconds_since_quit_and_install', '46');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.install_path_kind', 'user_local_programs');
|
|
expect(scopeSetContext).toHaveBeenCalledWith(
|
|
'aioncore_startup_classification',
|
|
expect.objectContaining({
|
|
incompleteInstallationKind: 'missing_directory_resources',
|
|
missingBundledAioncoreDir: true,
|
|
missingRuntimeDir: true,
|
|
missingBackendBinary: true,
|
|
})
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
autoUpdateDiagnosticsMock.readAutoUpdateDiagnostics.mockReturnValue(undefined);
|
|
}
|
|
});
|
|
|
|
it('sets bucketed health polling tags for backend startup timeouts', async () => {
|
|
scopeSetTag.mockClear();
|
|
autoUpdateDiagnosticsMock.readAutoUpdateDiagnostics.mockReturnValue(undefined);
|
|
const error = new Error('aioncore failed to start within timeout') as Error & {
|
|
details?: Record<string, unknown>;
|
|
};
|
|
error.details = {
|
|
stage: 'health_timeout',
|
|
binaryPath: '/abs/path/aioncore',
|
|
port: 33334,
|
|
healthCheckAttempts: 1,
|
|
healthCheckExpectedAttempts: 150,
|
|
healthCheckAttemptDeficit: 149,
|
|
healthCheckPollingDelayed: true,
|
|
healthCheckTimeoutOverrunMs: 515_417,
|
|
healthCheckMaxAttemptGapMs: 0,
|
|
};
|
|
|
|
await captureBackendStartupFailure(error);
|
|
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.health_polling_delayed', 'true');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.health_attempts_bucket', '1');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.health_attempt_deficit_bucket', '76-150');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.health_timeout_overrun_bucket', 'over_60s');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.health_max_attempt_gap_bucket', '0ms');
|
|
});
|
|
|
|
it('sets backend data migration reason and boundary tags', async () => {
|
|
scopeSetTag.mockClear();
|
|
const error = new Error('aioncore exited before health check passed') as Error & {
|
|
details?: Record<string, unknown>;
|
|
};
|
|
error.details = {
|
|
stage: 'early_exit',
|
|
backendBoundaryCode: 'BOOTSTRAP_DATA_INIT_FAILED',
|
|
backendBoundaryStage: 'database.migration',
|
|
stderrTail:
|
|
'BOOTSTRAP_DATA_INIT_FAILED stage=database.migration databasePath=/db/aionui-backend.db: failed to initialize application data',
|
|
};
|
|
|
|
await captureBackendStartupFailure(error);
|
|
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.reason', 'backend_data_migration_failed');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.boundary_code', 'BOOTSTRAP_DATA_INIT_FAILED');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.boundary_stage', 'database.migration');
|
|
});
|
|
|
|
it('sets local data repair reason and issue-kind tags', async () => {
|
|
scopeSetTag.mockClear();
|
|
const error = new Error('aioncore exited before health check passed') as Error & {
|
|
details?: Record<string, unknown>;
|
|
};
|
|
error.details = {
|
|
stage: 'early_exit',
|
|
backendBoundaryCode: 'BOOTSTRAP_SERVICE_INIT_FAILED',
|
|
backendBoundaryStage: 'services.init',
|
|
stderrTail:
|
|
'Failed to hydrate agent registry: Internal error: load agent_metadata: Database query failed: error occurred while decoding column "config_options": invalid utf-8 sequence of 1 bytes from index 793',
|
|
};
|
|
|
|
await captureBackendStartupFailure(error);
|
|
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.reason', 'backend_local_data_repair_failed');
|
|
expect(scopeSetTag).toHaveBeenCalledWith(
|
|
'aionui.backend_startup.local_data_issue_kind',
|
|
'agent_metadata_invalid_utf8'
|
|
);
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.boundary_code', 'BOOTSTRAP_SERVICE_INIT_FAILED');
|
|
expect(scopeSetTag).toHaveBeenCalledWith('aionui.backend_startup.boundary_stage', 'services.init');
|
|
});
|
|
});
|
|
|
|
describe('initSentry beforeSend', () => {
|
|
it('drops native GPU unusable crashes reported only through crashpad context', () => {
|
|
initSentry();
|
|
|
|
const event = {
|
|
contexts: {
|
|
electron: {
|
|
'crashpad.LOG_FATAL': "gpu_data_manager_impl_private.cc:415: GPU process isn't usable. Goodbye.\n",
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(sentryInitOptions?.beforeSend?.(event)).toBeNull();
|
|
});
|
|
|
|
it('keeps native shutdown fatal crashes while filtering GPU crashpad noise', () => {
|
|
initSentry();
|
|
|
|
const event = {
|
|
contexts: {
|
|
electron: {
|
|
'crashpad.LOG_FATAL': 'electron_browser_main_parts.cc:501: Failed to shutdown.\n',
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(sentryInitOptions?.beforeSend?.(event)).toBe(event);
|
|
});
|
|
|
|
it('drops backend-port secondary errors after backend startup already failed', () => {
|
|
initSentry();
|
|
(globalThis as { __backendStartupFailed?: boolean }).__backendStartupFailed = true;
|
|
|
|
const event = {
|
|
exception: {
|
|
values: [
|
|
{
|
|
value: '[WebUI] Cannot start: aioncore is not running (globalThis.__backendPort unset)',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(sentryInitOptions?.beforeSend?.(event)).toBeNull();
|
|
|
|
delete (globalThis as { __backendStartupFailed?: boolean }).__backendStartupFailed;
|
|
});
|
|
|
|
it('keeps the primary backend startup failure even when its details contain secondary text', () => {
|
|
initSentry();
|
|
(globalThis as { __backendStartupFailed?: boolean }).__backendStartupFailed = true;
|
|
|
|
const event = {
|
|
tags: {
|
|
'aionui.failure': 'backend_startup',
|
|
},
|
|
exception: {
|
|
values: [
|
|
{
|
|
value: 'BackendStartupError: connect ECONNREFUSED 127.0.0.1:33334',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(sentryInitOptions?.beforeSend?.(event)).toBe(event);
|
|
|
|
delete (globalThis as { __backendStartupFailed?: boolean }).__backendStartupFailed;
|
|
});
|
|
|
|
it('keeps user feedback reports even when diagnostics contain backend secondary text', () => {
|
|
initSentry();
|
|
(globalThis as { __backendStartupFailed?: boolean }).__backendStartupFailed = true;
|
|
|
|
const event = {
|
|
tags: {
|
|
type: 'user-feedback',
|
|
'aionui.installation_integrity.user_report': 'true',
|
|
},
|
|
extra: {
|
|
installation_integrity: {
|
|
backendStartupFailure: {
|
|
message: 'BackendStartupError: connect ECONNREFUSED 127.0.0.1:33334',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(sentryInitOptions?.beforeSend?.(event)).toBe(event);
|
|
|
|
delete (globalThis as { __backendStartupFailed?: boolean }).__backendStartupFailed;
|
|
});
|
|
});
|