Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/src/ui/themes/theme-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ const validCustomTheme: CustomTheme = {

describe('ThemeManager', () => {
beforeEach(() => {
// Reset themeManager state
// Reset themeManager state and inject mocks
themeManager.reinitialize({ fs, homedir: os.homedir });
themeManager.loadCustomThemes({});
themeManager.setActiveTheme(DEFAULT_THEME.name);
themeManager.setTerminalBackground(undefined);
Expand Down
54 changes: 47 additions & 7 deletions packages/cli/src/ui/themes/theme-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ class ThemeManager {
private cachedSemanticColors: SemanticColors | undefined;
private lastCacheKey: string | undefined;

constructor() {
private fs: typeof fs;
private homedir: () => string;

constructor(dependencies?: { fs?: typeof fs; homedir?: () => string }) {
this.fs = dependencies?.fs ?? fs;
this.homedir = dependencies?.homedir ?? homedir;

this.availableThemes = [
AyuDark,
AyuLight,
Expand Down Expand Up @@ -242,10 +248,44 @@ class ThemeManager {
}

/**
* Sets the active theme.
* @param themeName The name of the theme to set as active.
* @returns True if the theme was successfully set, false otherwise.
* Clears all themes loaded from files.
* This is primarily for testing purposes to reset state between tests.
*/
clearFileThemes(): void {
this.fileThemes.clear();
}

/**
* Re-initializes the ThemeManager with new dependencies.
* This is primarily for testing to allow injecting mocks.
*/
reinitialize(dependencies: { fs?: typeof fs; homedir?: () => string }): void {
if (dependencies.fs) {
this.fs = dependencies.fs;
}
if (dependencies.homedir) {
this.homedir = dependencies.homedir;
}
}

/**
* Resets the ThemeManager state to defaults.
* This is for testing purposes to ensure test isolation.
*/
resetForTesting(dependencies?: {
fs?: typeof fs;
homedir?: () => string;
}): void {
if (dependencies) {
this.reinitialize(dependencies);
}
this.settingsThemes.clear();
this.extensionThemes.clear();
this.fileThemes.clear();
this.activeTheme = DEFAULT_THEME;
this.terminalBackground = undefined;
this.clearCache();
}
setActiveTheme(themeName: string | undefined): boolean {
const theme = this.findThemeByName(themeName);
if (!theme) {
Expand Down Expand Up @@ -505,15 +545,15 @@ class ThemeManager {
private loadThemeFromFile(themePath: string): Theme | undefined {
try {
// realpathSync resolves the path and throws if it doesn't exist.
const canonicalPath = fs.realpathSync(path.resolve(themePath));
const canonicalPath = this.fs.realpathSync(path.resolve(themePath));

// 1. Check cache using the canonical path.
if (this.fileThemes.has(canonicalPath)) {
return this.fileThemes.get(canonicalPath);
}

// 2. Perform security check.
const homeDir = path.resolve(homedir());
const homeDir = path.resolve(this.homedir());
if (!canonicalPath.startsWith(homeDir)) {
debugLogger.warn(
`Theme file at "${themePath}" is outside your home directory. ` +
Expand All @@ -523,7 +563,7 @@ class ThemeManager {
}

// 3. Read, parse, and validate the theme file.
const themeContent = fs.readFileSync(canonicalPath, 'utf-8');
const themeContent = this.fs.readFileSync(canonicalPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const customThemeConfig = JSON.parse(themeContent) as CustomTheme;

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { vi, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { coreEvents } from '@google/gemini-cli-core';
import { themeManager } from './src/ui/themes/theme-manager.js';

// Unset CI environment variable so that ink renders dynamically as it does in a real terminal
if (process.env.CI !== undefined) {
Expand All @@ -32,6 +33,9 @@ let consoleErrorSpy: vi.SpyInstance;
let actWarnings: Array<{ message: string; stack: string }> = [];

beforeEach(() => {
// Reset themeManager state to ensure test isolation
themeManager.resetForTesting();

actWarnings = [];
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0];
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export { OAuthUtils } from './mcp/oauth-utils.js';
export * from './telemetry/index.js';
export * from './telemetry/billingEvents.js';
export { logBillingEvent } from './telemetry/loggers.js';
export * from './telemetry/constants.js';
export { sessionId, createSessionId } from './utils/session.js';
export * from './utils/compatibility.js';
export * from './utils/browser.js';
Expand Down
Loading