Skip to content
1 change: 1 addition & 0 deletions apps/frontend/src/__mocks__/sentry-electron-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './sentry-electron-shared';
1 change: 1 addition & 0 deletions apps/frontend/src/__mocks__/sentry-electron-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './sentry-electron-shared';
26 changes: 26 additions & 0 deletions apps/frontend/src/__mocks__/sentry-electron-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type SentryErrorEvent = Record<string, unknown>;

export type SentryScope = {
setContext: (key: string, value: Record<string, unknown>) => void;
};

export type SentryInitOptions = {
beforeSend?: (event: SentryErrorEvent) => SentryErrorEvent | null;
tracesSampleRate?: number;
profilesSampleRate?: number;
dsn?: string;
environment?: string;
release?: string;
debug?: boolean;
enabled?: boolean;
};

export function init(_options: SentryInitOptions): void {}

export function captureException(_error: Error): void {}

export function withScope(callback: (scope: SentryScope) => void): void {
callback({
setContext: () => {}
});
}
8 changes: 8 additions & 0 deletions apps/frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ Object.defineProperty(global, 'localStorage', {
value: localStorageMock
});

// Mock scrollIntoView for Radix Select in jsdom
if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
writable: true
});
}

// Test data directory for isolated file operations
export const TEST_DATA_DIR = '/tmp/auto-claude-ui-tests';

Expand Down
99 changes: 99 additions & 0 deletions apps/frontend/src/main/__tests__/insights-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* @vitest-environment node
*/
import path from 'path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InsightsConfig } from '../insights/config';

vi.mock('electron', () => ({
app: {
getAppPath: () => '/app',
getPath: () => '/tmp',
isPackaged: false
}
}));

vi.mock('../rate-limit-detector', () => ({
getProfileEnv: () => ({ CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' })
}));

const mockGetApiProfileEnv = vi.fn();
vi.mock('../services/profile', () => ({
getAPIProfileEnv: (...args: unknown[]) => mockGetApiProfileEnv(...args)
}));

const mockGetPythonEnv = vi.fn();
vi.mock('../python-env-manager', () => ({
pythonEnvManager: {
getPythonEnv: () => mockGetPythonEnv()
}
}));

describe('InsightsConfig', () => {
const originalEnv = { ...process.env };

beforeEach(() => {
process.env = { ...originalEnv, TEST_ENV: 'ok' };
mockGetApiProfileEnv.mockResolvedValue({
ANTHROPIC_BASE_URL: 'https://api.z.ai',
ANTHROPIC_AUTH_TOKEN: 'key'
});
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
});

afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
vi.restoreAllMocks();
});

it('should build process env with python and profile settings', async () => {
const config = new InsightsConfig();
vi.spyOn(config, 'loadAutoBuildEnv').mockReturnValue({ CUSTOM_ENV: '1' });
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');

const env = await config.getProcessEnv();

expect(env.TEST_ENV).toBe('ok');
expect(env.CUSTOM_ENV).toBe('1');
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('key');
expect(env.PYTHONPATH).toBe(['/site-packages', '/backend'].join(path.delimiter));
});

it('should clear ANTHROPIC env vars in OAuth mode when no API profile is set', async () => {
const config = new InsightsConfig();
mockGetApiProfileEnv.mockResolvedValue({});
process.env = {
...originalEnv,
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_BASE_URL: 'https://stale.example'
};

const env = await config.getProcessEnv();

expect(env.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(env.ANTHROPIC_BASE_URL).toBe('');
});

it('should set PYTHONPATH only to auto-build path when python env has none', async () => {
const config = new InsightsConfig();
mockGetPythonEnv.mockReturnValue({});
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');

const env = await config.getProcessEnv();

expect(env.PYTHONPATH).toBe('/backend');
});

it('should keep PYTHONPATH from python env when auto-build path is missing', async () => {
const config = new InsightsConfig();
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue(null);

const env = await config.getProcessEnv();

expect(env.PYTHONPATH).toBe('/site-packages');
});
});
40 changes: 35 additions & 5 deletions apps/frontend/src/main/insights/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import path from 'path';
import { existsSync, readFileSync } from 'fs';
import { app } from 'electron';
import { getProfileEnv } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { getOAuthModeClearVars } from '../agent/env-utils';
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
import { getValidatedPythonPath } from '../python-detector';
import { getConfiguredPythonPath, pythonEnvManager } from '../python-env-manager';
import { getAugmentedEnv } from '../env-utils';
import { getEffectiveSourcePath } from '../updater/path-resolver';

Expand Down Expand Up @@ -105,23 +107,51 @@ export class InsightsConfig {
* Get complete environment for process execution
* Includes system env, auto-claude env, and active Claude profile
*/
getProcessEnv(): Record<string, string> {
async getProcessEnv(): Promise<Record<string, string>> {
const autoBuildEnv = this.loadAutoBuildEnv();
const profileEnv = getProfileEnv();
// Get Python environment (PYTHONPATH for bundled packages like python-dotenv)
const apiProfileEnv = await getAPIProfileEnv();
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
const pythonEnv = pythonEnvManager.getPythonEnv();
const autoBuildSource = this.getAutoBuildSourcePath();
const pythonPathParts = (pythonEnv.PYTHONPATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));

if (autoBuildSource) {
const normalizedAutoBuildSource = path.resolve(autoBuildSource);
const autoBuildComparator = process.platform === 'win32'
? normalizedAutoBuildSource.toLowerCase()
: normalizedAutoBuildSource;
const hasAutoBuildSource = pythonPathParts.some((entry) => {
const candidate = process.platform === 'win32' ? entry.toLowerCase() : entry;
return candidate === autoBuildComparator;
});

if (!hasAutoBuildSource) {
pythonPathParts.push(normalizedAutoBuildSource);
}
}

const combinedPythonPath = pythonPathParts.join(path.delimiter);

// Use getAugmentedEnv() to ensure common tool paths (claude, dotnet, etc.)
// are available even when app is launched from Finder/Dock
// are available even when app is launched from Finder/Dock.
const augmentedEnv = getAugmentedEnv();

return {
...augmentedEnv,
...pythonEnv, // Include PYTHONPATH for bundled site-packages
...autoBuildEnv,
...oauthModeClearVars,
...profileEnv,
...apiProfileEnv,
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
PYTHONUTF8: '1',
...(combinedPythonPath ? { PYTHONPATH: combinedPythonPath } : {})
};
}
}
2 changes: 1 addition & 1 deletion apps/frontend/src/main/insights/insights-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class InsightsExecutor extends EventEmitter {
} as InsightsChatStatus);

// Get process environment
const processEnv = this.config.getProcessEnv();
const processEnv = await this.config.getProcessEnv();

// Write conversation history to temp file to avoid Windows command-line length limit
const historyFile = path.join(
Expand Down
Loading
Loading