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
242 changes: 242 additions & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,248 @@ describe('gemini.tsx main function', () => {
);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
});

it('should print "No extensions installed." and exit when --list-extensions is set and no extensions exist', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const cleanupModule = await import('./utils/cleanup.js');
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(relaunchAppInChildProcess).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as never);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => true,
getExtensions: () => [],
getApprovalMode: () => 'suggest',
getMcpServers: () => ({}),
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config);

try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
}

expect(consoleLogSpy).toHaveBeenCalledWith('No extensions installed.');
expect(processExitSpy).toHaveBeenCalledWith(0);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
// Verify config.initialize() is called before getExtensions() — extensions are loaded during initialize
const configMock = (await vi.mocked(loadCliConfig).mock.results[0]!
.value) as unknown as { initialize: ReturnType<typeof vi.fn> };
expect(configMock.initialize).toHaveBeenCalledTimes(1);

consoleLogSpy.mockRestore();
processExitSpy.mockRestore();
});

it('should list extensions with [disabled] suffix when --list-extensions is set', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const cleanupModule = await import('./utils/cleanup.js');
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(relaunchAppInChildProcess).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as never);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => true,
getExtensions: () => [
{ name: 'my-ext', version: '1.0.0', isActive: true },
{ name: 'old-ext', version: '0.5.2', isActive: false },
{ name: 'esc-ext', version: '2.0\x1b[31m.0', isActive: true },
],
getApprovalMode: () => 'suggest',
getMcpServers: () => ({}),
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config);

try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
}

expect(consoleLogSpy).toHaveBeenCalledWith('Installed extensions:');
expect(consoleLogSpy).toHaveBeenCalledWith('- my-ext (v1.0.0)');
expect(consoleLogSpy).toHaveBeenCalledWith('- old-ext (v0.5.2) [disabled]');
// Verify non-printable characters are stripped from version output
expect(consoleLogSpy).toHaveBeenCalledWith('- esc-ext (v2.0[31m.0)');
expect(processExitSpy).toHaveBeenCalledWith(0);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
// Verify config.initialize() is called before getExtensions() — extensions are loaded during initialize
const configMock2 = (await vi.mocked(loadCliConfig).mock.results[0]!
.value) as unknown as { initialize: ReturnType<typeof vi.fn> };
expect(configMock2.initialize).toHaveBeenCalledTimes(1);

consoleLogSpy.mockRestore();
processExitSpy.mockRestore();
});

it('should exit with code 1 and print error when config.initialize() fails during --list-extensions', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const cleanupModule = await import('./utils/cleanup.js');
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const stderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);

vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(relaunchAppInChildProcess).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as never);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => true,
getExtensions: () => [],
getApprovalMode: () => 'suggest',
getMcpServers: () => ({}),
initialize: vi.fn().mockRejectedValue(new Error('config load failed')),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config);

try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
}

expect(stderrWriteSpy).toHaveBeenCalledWith(
'Error: failed to load extensions: config load failed\n',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
const configMock = (await vi.mocked(loadCliConfig).mock.results[0]!
.value) as unknown as { initialize: ReturnType<typeof vi.fn> };
expect(configMock.initialize).toHaveBeenCalledTimes(1);

stderrWriteSpy.mockRestore();
processExitSpy.mockRestore();
});
});

describe('gemini.tsx main function kitty protocol', () => {
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,9 @@ export async function main() {
const authType = modelsConfig.getCurrentAuthType();
const resolvedBaseUrl = modelsConfig.getGenerationConfig().baseUrl;
const proxy = config.getProxy();
preconnectApi(authType, { resolvedBaseUrl, proxy });
if (!config.getListExtensions()) {
preconnectApi(authType, { resolvedBaseUrl, proxy });
}
} catch (error) {
// If we can't get authType, skip preconnect - it's optional optimization
debugLogger.debug(
Expand Down Expand Up @@ -963,6 +965,45 @@ export async function main() {
// Render UI, passing necessary config values. Check that there is no command line question.
profileCheckpoint('before_render');

if (config.getListExtensions()) {
// Always initialize config to populate extensionCache via refreshCache().
// Without this, getExtensions() returns [] because extensionCache is null.
try {
await config.initialize();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: failed to load extensions: ${msg}\n`);
await runExitCleanup();
process.exit(1);
}
const extensions = config.getExtensions();
if (extensions.length === 0) {
// eslint-disable-next-line no-console -- CLI flag output
console.log('No extensions installed.');
} else {
// eslint-disable-next-line no-console -- CLI flag output
console.log('Installed extensions:');
for (const extension of extensions) {
const safeVersion = extension.version.replace(
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safety
/[\x00-\x1f\x7f-\x9f]/g,
'',
);
const safeName = extension.name.replace(
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safety
/[\x00-\x1f\x7f-\x9f]/g,
'',
);
// eslint-disable-next-line no-console -- CLI flag output
console.log(
`- ${safeName} (v${safeVersion})${extension.isActive ? '' : ' [disabled]'}`,
);
}
}
await runExitCleanup();
process.exit(0);
}

if (config.isInteractive()) {
// --json-schema is a headless-only contract: the synthetic
// structured_output tool only terminates the run inside
Expand Down Expand Up @@ -1047,6 +1088,7 @@ export async function main() {
profileCheckpoint('config_initialize_start');
await config.initialize();
profileCheckpoint('config_initialize_end');

// Non-interactive paths feed a prompt to the model immediately after
// init. Under PR-A's progressive MCP availability,
// `config.initialize()` returns BEFORE MCP servers settle, so
Expand Down
Loading