diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/extensions-install.test.ts new file mode 100644 index 0000000000..3a94167706 --- /dev/null +++ b/integration-tests/extensions-install.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, test } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const extension = `{ + "name": "test-extension", + "version": "0.0.1" +}`; + +const extensionUpdate = `{ + "name": "test-extension", + "version": "0.0.2" +}`; + +test('installs a local extension, verifies a command, and updates it', async () => { + const rig = new TestRig(); + rig.setup('extension install test'); + const testServerPath = join(rig.testDir!, 'gemini-extension.json'); + writeFileSync(testServerPath, extension); + try { + await rig.runCommand(['extensions', 'uninstall', 'test-extension']); + } catch { + /* empty */ + } + + const result = await rig.runCommand( + ['extensions', 'install', `--path=${rig.testDir!}`], + { stdin: 'y\n' }, + ); + expect(result).toContain('test-extension'); + + const listResult = await rig.runCommand(['extensions', 'list']); + expect(listResult).toContain('test-extension'); + writeFileSync(testServerPath, extensionUpdate); + const updateResult = await rig.runCommand([ + 'extensions', + 'update', + `test-extension`, + ]); + expect(updateResult).toContain('0.0.2'); + + await rig.runCommand(['extensions', 'uninstall', 'test-extension']); + + await rig.cleanup(); +}); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 63362fc257..149aadca20 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -309,6 +309,57 @@ export class TestRig { return promise; } + runCommand( + args: string[], + options: { stdin?: string } = {}, + ): Promise { + const commandArgs = [this.bundlePath, ...args]; + + const child = spawn('node', commandArgs, { + cwd: this.testDir!, + stdio: 'pipe', + }); + + let stdout = ''; + let stderr = ''; + + if (options.stdin) { + child.stdin!.write(options.stdin); + child.stdin!.end(); + } + + child.stdout!.on('data', (data: Buffer) => { + stdout += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stdout.write(data); + } + }); + + child.stderr!.on('data', (data: Buffer) => { + stderr += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stderr.write(data); + } + }); + + const promise = new Promise((resolve, reject) => { + child.on('close', (code: number) => { + if (code === 0) { + this._lastRunStdout = stdout; + let result = stdout; + if (stderr) { + result += `\n\nStdErr:\n${stderr}`; + } + resolve(result); + } else { + reject(new Error(`Process exited with code ${code}:\n${stderr}`)); + } + }); + }); + + return promise; + } + readFile(fileName: string) { const filePath = join(this.testDir!, fileName); const content = readFileSync(filePath, 'utf-8');