From 1b2249fb8fc9e493ec8aae5eda87ef06e99e90ed Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Mon, 25 Aug 2025 17:10:36 -0400 Subject: [PATCH] feat(ide): Enable Firebase Studio install now that FS has updated VsCode (#7027) --- packages/core/src/ide/ide-installer.test.ts | 138 ++++++++++++++++---- packages/core/src/ide/ide-installer.ts | 43 +++--- 2 files changed, 142 insertions(+), 39 deletions(-) diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index 84000a79..4b1f2bc1 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -5,10 +5,11 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { getIdeInstaller, IdeInstaller } from './ide-installer.js'; +import { getIdeInstaller } from './ide-installer.js'; import * as child_process from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; +import * as path from 'node:path'; import { DetectedIde } from './detect-ide.js'; vi.mock('child_process'); @@ -16,8 +17,10 @@ vi.mock('fs'); vi.mock('os'); describe('ide-installer', () => { + const HOME_DIR = '/home/user'; + beforeEach(() => { - vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); + vi.spyOn(os, 'homedir').mockReturnValue(HOME_DIR); }); afterEach(() => { @@ -25,36 +28,123 @@ describe('ide-installer', () => { }); describe('getIdeInstaller', () => { - it('should return a VsCodeInstaller for "vscode"', () => { - const installer = getIdeInstaller(DetectedIde.VSCode); - expect(installer).not.toBeNull(); - // A more specific check might be needed if we export the class - expect(installer).toBeInstanceOf(Object); - }); + it.each([{ ide: DetectedIde.VSCode }, { ide: DetectedIde.FirebaseStudio }])( + 'returns a VsCodeInstaller for "$ide"', + ({ ide }) => { + const installer = getIdeInstaller(ide); + + expect(installer).not.toBeNull(); + expect(installer?.install).toEqual(expect.any(Function)); + }, + ); }); describe('VsCodeInstaller', () => { - let installer: IdeInstaller; + function setup({ + ide = DetectedIde.VSCode, + existsResult = false, + execSync = () => '', + platform = 'linux' as NodeJS.Platform, + } = {}) { + vi.spyOn(child_process, 'execSync').mockImplementation(execSync); + vi.spyOn(fs, 'existsSync').mockReturnValue(existsResult); + const installer = getIdeInstaller(ide, platform)!; - beforeEach(() => { - // We get a new installer for each test to reset the find command logic - installer = getIdeInstaller(DetectedIde.VSCode)!; - vi.spyOn(child_process, 'execSync').mockImplementation(() => ''); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - }); + return { installer }; + } describe('install', () => { - it('should return a failure message if VS Code is not installed', async () => { - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error('Command not found'); + it.each([ + { + platform: 'win32' as NodeJS.Platform, + expectedLookupPaths: [ + path.join('C:\\Program Files', 'Microsoft VS Code/bin/code.cmd'), + path.join( + HOME_DIR, + '/AppData/Local/Programs/Microsoft VS Code/bin/code.cmd', + ), + ], + }, + { + platform: 'darwin' as NodeJS.Platform, + expectedLookupPaths: [ + '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code', + path.join(HOME_DIR, 'Library/Application Support/Code/bin/code'), + ], + }, + { + platform: 'linux' as NodeJS.Platform, + expectedLookupPaths: ['/usr/share/code/bin/code'], + }, + ])( + 'identifies the path to code cli on platform: $platform', + async ({ platform, expectedLookupPaths }) => { + const { installer } = setup({ + platform, + execSync: () => { + throw new Error('Command not found'); // `code` is not in PATH + }, + }); + await installer.install(); + for (const [idx, path] of expectedLookupPaths.entries()) { + expect(fs.existsSync).toHaveBeenNthCalledWith(idx + 1, path); + } + }, + ); + + it('installs the extension using code cli', async () => { + const { installer } = setup({ + platform: 'linux', }); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - // Re-create the installer so it re-runs findVsCodeCommand - installer = getIdeInstaller(DetectedIde.VSCode)!; - const result = await installer.install(); - expect(result.success).toBe(false); - expect(result.message).toContain('VS Code CLI not found'); + await installer.install(); + expect(child_process.execSync).toHaveBeenCalledWith( + '"code" --install-extension google.gemini-cli-vscode-ide-companion --force', + { stdio: 'pipe' }, + ); }); + + it.each([ + { + ide: DetectedIde.VSCode, + expectedMessage: + 'VS Code companion extension was installed successfully', + }, + { + ide: DetectedIde.FirebaseStudio, + expectedMessage: + 'Firebase Studio companion extension was installed successfully', + }, + ])( + 'returns that the cli was installed successfully', + async ({ ide, expectedMessage }) => { + const { installer } = setup({ ide }); + const result = await installer.install(); + expect(result.success).toBe(true); + expect(result.message).toContain(expectedMessage); + }, + ); + + it.each([ + { ide: DetectedIde.VSCode, expectedErr: 'VS Code CLI not found' }, + { + ide: DetectedIde.FirebaseStudio, + expectedErr: 'Firebase Studio CLI not found', + }, + ])( + 'should return a failure message if $ide is not installed', + async ({ ide, expectedErr }) => { + const { installer } = setup({ + ide, + execSync: () => { + throw new Error('Command not found'); + }, + existsResult: false, + }); + const result = await installer.install(); + expect(result.success).toBe(false); + expect(result.message).toContain(expectedErr); + }, + ); }); }); }); diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts index 784b89ca..e82ac69c 100644 --- a/packages/core/src/ide/ide-installer.ts +++ b/packages/core/src/ide/ide-installer.ts @@ -9,10 +9,12 @@ import * as process from 'node:process'; import * as path from 'node:path'; import * as fs from 'node:fs'; import * as os from 'node:os'; -import { DetectedIde } from './detect-ide.js'; +import { DetectedIde, getIdeInfo, IdeInfo } from './detect-ide.js'; import { GEMINI_CLI_COMPANION_EXTENSION_NAME } from './constants.js'; -const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code'; +function getVsCodeCommand(platform: NodeJS.Platform = process.platform) { + return platform === 'win32' ? 'code.cmd' : 'code'; +} export interface IdeInstaller { install(): Promise; @@ -23,12 +25,15 @@ export interface InstallResult { message: string; } -async function findVsCodeCommand(): Promise { +async function findVsCodeCommand( + platform: NodeJS.Platform = process.platform, +): Promise { // 1. Check PATH first. + const vscodeCommand = getVsCodeCommand(platform); try { - if (process.platform === 'win32') { + if (platform === 'win32') { const result = child_process - .execSync(`where.exe ${VSCODE_COMMAND}`) + .execSync(`where.exe ${vscodeCommand}`) .toString() .trim(); // `where.exe` can return multiple paths. Return the first one. @@ -37,10 +42,10 @@ async function findVsCodeCommand(): Promise { return firstPath; } } else { - child_process.execSync(`command -v ${VSCODE_COMMAND}`, { + child_process.execSync(`command -v ${vscodeCommand}`, { stdio: 'ignore', }); - return VSCODE_COMMAND; + return vscodeCommand; } } catch { // Not in PATH, continue to check common locations. @@ -48,7 +53,6 @@ async function findVsCodeCommand(): Promise { // 2. Check common installation locations. const locations: string[] = []; - const platform = process.platform; const homeDir = os.homedir(); if (platform === 'darwin') { @@ -96,9 +100,14 @@ async function findVsCodeCommand(): Promise { class VsCodeInstaller implements IdeInstaller { private vsCodeCommand: Promise; + private readonly ideInfo: IdeInfo; - constructor() { - this.vsCodeCommand = findVsCodeCommand(); + constructor( + readonly ide: DetectedIde, + readonly platform = process.platform, + ) { + this.vsCodeCommand = findVsCodeCommand(platform); + this.ideInfo = getIdeInfo(ide); } async install(): Promise { @@ -106,7 +115,7 @@ class VsCodeInstaller implements IdeInstaller { if (!commandPath) { return { success: false, - message: `VS Code CLI not found. Please ensure 'code' is in your system's PATH. For help, see https://code.visualstudio.com/docs/configure/command-line#_code-is-not-recognized-as-an-internal-or-external-command. You can also install the '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' extension manually from the VS Code marketplace.`, + message: `${this.ideInfo.displayName} CLI not found. Please ensure 'code' is in your system's PATH. For help, see https://code.visualstudio.com/docs/configure/command-line#_code-is-not-recognized-as-an-internal-or-external-command. You can also install the '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' extension manually from the VS Code marketplace.`, }; } @@ -115,21 +124,25 @@ class VsCodeInstaller implements IdeInstaller { child_process.execSync(command, { stdio: 'pipe' }); return { success: true, - message: 'VS Code companion extension was installed successfully.', + message: `${this.ideInfo.displayName} companion extension was installed successfully.`, }; } catch (_error) { return { success: false, - message: `Failed to install VS Code companion extension. Please try installing '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' manually from the VS Code extension marketplace.`, + message: `Failed to install ${this.ideInfo.displayName} companion extension. Please try installing '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' manually from the ${this.ideInfo.displayName} extension marketplace.`, }; } } } -export function getIdeInstaller(ide: DetectedIde): IdeInstaller | null { +export function getIdeInstaller( + ide: DetectedIde, + platform = process.platform, +): IdeInstaller | null { switch (ide) { case DetectedIde.VSCode: - return new VsCodeInstaller(); + case DetectedIde.FirebaseStudio: + return new VsCodeInstaller(ide, platform); default: return null; }