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
318 changes: 318 additions & 0 deletions packages/cli/src/config/extension-manager-hydration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';

const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));

vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: mockHomedir,
};
});

// Mock @google/gemini-cli-core
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: mockHomedir,
// Use actual implementations for loading skills and agents to test hydration
loadAgentsFromDirectory: actual.loadAgentsFromDirectory,
loadSkillsFromDir: actual.loadSkillsFromDir,
};
});

describe('ExtensionManager hydration', () => {
let extensionManager: ExtensionManager;
let tempDir: string;
let extensionsDir: string;

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(coreEvents, 'emitFeedback');
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});

tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
mockHomedir.mockReturnValue(tempDir);

// Create the extensions directory that ExtensionManager expects
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(extensionsDir, { recursive: true });

extensionManager = new ExtensionManager({
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
});
});

afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});

it('should hydrate skill body with extension settings', async () => {
const sourceDir = path.join(tempDir, 'source-ext-skill');
const extensionName = 'skill-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'API Key',
envVar: 'MY_API_KEY',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);

// Create skill with variable
const skillsDir = path.join(extensionPath, 'skills');
const skillSubdir = path.join(skillsDir, 'my-skill');
fs.mkdirSync(skillSubdir, { recursive: true });
fs.writeFileSync(
path.join(skillSubdir, 'SKILL.md'),
`---
name: my-skill
description: test
---
Use key: \${MY_API_KEY}
`,
);

await extensionManager.loadExtensions();

extensionManager.setRequestSetting(async (setting) => {
if (setting.envVar === 'MY_API_KEY') return 'secret-123';
return '';
});

const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});

expect(extension.skills).toHaveLength(1);
expect(extension.skills![0].body).toContain('Use key: secret-123');
});

it('should hydrate agent system prompt with extension settings', async () => {
const sourceDir = path.join(tempDir, 'source-ext-agent');
const extensionName = 'agent-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Model Name',
description: 'Model',
envVar: 'MODEL_NAME',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);

// Create agent with variable
const agentsDir = path.join(extensionPath, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(
path.join(agentsDir, 'my-agent.md'),
`---
name: my-agent
description: test
---
System using model: \${MODEL_NAME}
`,
);

await extensionManager.loadExtensions();

extensionManager.setRequestSetting(async (setting) => {
if (setting.envVar === 'MODEL_NAME') return 'gemini-pro';
return '';
});

const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});

expect(extension.agents).toHaveLength(1);
const agent = extension.agents![0];
if (agent.kind === 'local') {
expect(agent.promptConfig.systemPrompt).toContain(
'System using model: gemini-pro',
);
} else {
throw new Error('Expected local agent');
}
});

it('should hydrate hooks with extension settings', async () => {
const sourceDir = path.join(tempDir, 'source-ext-hooks');
const extensionName = 'hooks-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Hook Command',
description: 'Cmd',
envVar: 'HOOK_CMD',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);

const hooksDir = path.join(extensionPath, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({
hooks: {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: 'echo $HOOK_CMD',
},
],
},
],
},
}),
);

// Enable hooks in settings
extensionManager = new ExtensionManager({
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
tools: { enableHooks: true },
hooksConfig: { enabled: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
});

await extensionManager.loadExtensions();

extensionManager.setRequestSetting(async (setting) => {
if (setting.envVar === 'HOOK_CMD') return 'hello-world';
return '';
});

const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});

expect(extension.hooks).toBeDefined();
expect(extension.hooks?.BeforeTool).toHaveLength(1);
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
'hello-world',
);
});

it('should pick up new settings after restartExtension', async () => {
const sourceDir = path.join(tempDir, 'source-ext-restart');
const extensionName = 'restart-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Value',
description: 'Val',
envVar: 'MY_VALUE',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);

const skillsDir = path.join(extensionPath, 'skills');
const skillSubdir = path.join(skillsDir, 'my-skill');
fs.mkdirSync(skillSubdir, { recursive: true });
fs.writeFileSync(
path.join(skillSubdir, 'SKILL.md'),
'---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}',
);

await extensionManager.loadExtensions();

// Initial setting
extensionManager.setRequestSetting(async () => 'first');
const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});
expect(extension.skills![0].body).toContain('Value is: first');

const { updateSetting, ExtensionSettingScope } = await import(
'./extensions/extensionSettings.js'
);
const extensionConfig =
await extensionManager.loadExtensionConfig(extensionPath);

const mockRequestSetting = vi.fn().mockResolvedValue('second');
await updateSetting(
extensionConfig,
extension.id,
'MY_VALUE',
mockRequestSetting,
ExtensionSettingScope.USER,
);

await extensionManager.restartExtension(extension);

const reloadedExtension = extensionManager
.getExtensions()
.find((e) => e.name === extensionName)!;
expect(reloadedExtension.skills![0].body).toContain('Value is: second');
});
});
Loading
Loading