basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';8import { handleInstall, installCommand } from './install.js';9import yargs from 'yargs';10 11const mockInstallExtension = vi.hoisted(() => vi.fn());12const mockRefreshCache = vi.hoisted(() => vi.fn());13const mockSetExtensionScope = vi.hoisted(() => vi.fn());14const mockEnableExtension = vi.hoisted(() => vi.fn());15const mockDisableExtension = vi.hoisted(() => vi.fn());16const mockParseInstallSource = vi.hoisted(() => vi.fn());17const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());18const mockRequestConsentOrFail = vi.hoisted(() => vi.fn());19const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn());20const mockLoadSettings = vi.hoisted(() => vi.fn());21const mockWriteStdoutLine = vi.hoisted(() => vi.fn());22const mockWriteStderrLine = vi.hoisted(() => vi.fn());23 24vi.mock('@qwen-code/qwen-code-core', () => ({25 ExtensionManager: vi.fn().mockImplementation(() => ({26 installExtension: mockInstallExtension,27 refreshCache: mockRefreshCache,28 setExtensionScope: mockSetExtensionScope,29 enableExtension: mockEnableExtension,30 disableExtension: mockDisableExtension,31 })),32 parseInstallSource: mockParseInstallSource,33}));34 35vi.mock('./consent.js', () => ({36 requestConsentNonInteractive: mockRequestConsentNonInteractive,37 requestConsentOrFail: mockRequestConsentOrFail,38 requestChoicePluginNonInteractive: vi.fn(),39}));40 41vi.mock('../../config/trustedFolders.js', () => ({42 isWorkspaceTrusted: mockIsWorkspaceTrusted,43}));44 45vi.mock('../../config/settings.js', () => ({46 loadSettings: mockLoadSettings,47 SettingScope: {48 User: 'User',49 Workspace: 'Workspace',50 System: 'System',51 SystemDefaults: 'SystemDefaults',52 },53}));54 55vi.mock('../../utils/errors.js', () => ({56 getErrorMessage: vi.fn((error: Error) => error.message),57}));58 59vi.mock('../../utils/stdioHelpers.js', () => ({60 writeStdoutLine: mockWriteStdoutLine,61 writeStderrLine: mockWriteStderrLine,62 clearScreen: vi.fn(),63}));64 65describe('extensions install command', () => {66 it('should fail if no source is provided', () => {67 const validationParser = yargs([])68 .locale('en')69 .command(installCommand)70 .fail(false);71 expect(() => validationParser.parse('install')).toThrow(72 'Not enough non-option arguments: got 0, need at least 1',73 );74 });75});76 77describe('handleInstall', () => {78 beforeEach(() => {79 mockRefreshCache.mockResolvedValue(undefined);80 mockLoadSettings.mockReturnValue({ merged: {} });81 mockIsWorkspaceTrusted.mockReturnValue(true);82 });83 84 afterEach(() => {85 vi.clearAllMocks();86 });87 88 it('should install an extension from a http source', async () => {89 const processSpy = vi90 .spyOn(process, 'exit')91 .mockImplementation(() => undefined as never);92 93 mockParseInstallSource.mockResolvedValue({94 type: 'http',95 url: 'http://google.com',96 });97 mockInstallExtension.mockResolvedValue({ name: 'http-extension' });98 99 await handleInstall({100 source: 'http://google.com',101 });102 103 expect(mockWriteStdoutLine).toHaveBeenCalledWith(104 'Extension "http-extension" installed successfully and enabled.',105 );106 107 processSpy.mockRestore();108 });109 110 it('should install an extension from a https source', async () => {111 const processSpy = vi112 .spyOn(process, 'exit')113 .mockImplementation(() => undefined as never);114 115 mockParseInstallSource.mockResolvedValue({116 type: 'https',117 url: 'https://google.com',118 });119 mockInstallExtension.mockResolvedValue({ name: 'https-extension' });120 121 await handleInstall({122 source: 'https://google.com',123 });124 125 expect(mockWriteStdoutLine).toHaveBeenCalledWith(126 'Extension "https-extension" installed successfully and enabled.',127 );128 129 processSpy.mockRestore();130 });131 132 it('should install an extension from a git source', async () => {133 const processSpy = vi134 .spyOn(process, 'exit')135 .mockImplementation(() => undefined as never);136 137 mockParseInstallSource.mockResolvedValue({138 type: 'git',139 url: 'git@some-url',140 });141 mockInstallExtension.mockResolvedValue({ name: 'git-extension' });142 143 await handleInstall({144 source: 'git@some-url',145 });146 147 expect(mockWriteStdoutLine).toHaveBeenCalledWith(148 'Extension "git-extension" installed successfully and enabled.',149 );150 151 processSpy.mockRestore();152 });153 154 it('throws an error from an unknown source', async () => {155 const processSpy = vi156 .spyOn(process, 'exit')157 .mockImplementation(() => undefined as never);158 159 mockParseInstallSource.mockRejectedValue(160 new Error('Install source not found.'),161 );162 await handleInstall({163 source: 'test://google.com',164 });165 166 expect(mockWriteStderrLine).toHaveBeenCalledWith(167 'Install source not found.',168 );169 expect(processSpy).toHaveBeenCalledWith(1);170 171 processSpy.mockRestore();172 });173 174 it('should install an extension from a sso source', async () => {175 const processSpy = vi176 .spyOn(process, 'exit')177 .mockImplementation(() => undefined as never);178 179 mockParseInstallSource.mockResolvedValue({180 type: 'sso',181 url: 'sso://google.com',182 });183 mockInstallExtension.mockResolvedValue({ name: 'sso-extension' });184 185 await handleInstall({186 source: 'sso://google.com',187 });188 189 expect(mockWriteStdoutLine).toHaveBeenCalledWith(190 'Extension "sso-extension" installed successfully and enabled.',191 );192 193 processSpy.mockRestore();194 });195 196 it('should install an extension from a local path', async () => {197 const processSpy = vi198 .spyOn(process, 'exit')199 .mockImplementation(() => undefined as never);200 201 mockParseInstallSource.mockResolvedValue({202 type: 'local',203 path: '/some/path',204 });205 mockInstallExtension.mockResolvedValue({ name: 'local-extension' });206 207 await handleInstall({208 source: '/some/path',209 });210 211 expect(mockWriteStdoutLine).toHaveBeenCalledWith(212 'Extension "local-extension" installed successfully and enabled.',213 );214 215 processSpy.mockRestore();216 });217 218 it('should install an extension from an archive URL', async () => {219 const processSpy = vi220 .spyOn(process, 'exit')221 .mockImplementation(() => undefined as never);222 223 mockParseInstallSource.mockResolvedValue({224 type: 'archive-url',225 source: 'https://example.com/extension.zip',226 });227 mockInstallExtension.mockResolvedValue({ name: 'archive-extension' });228 229 await handleInstall({230 source: 'https://example.com/extension.zip',231 autoUpdate: true,232 });233 234 expect(mockInstallExtension).toHaveBeenCalledWith(235 expect.objectContaining({236 source: 'https://example.com/extension.zip',237 type: 'archive-url',238 autoUpdate: true,239 }),240 expect.any(Function),241 );242 expect(mockWriteStdoutLine).toHaveBeenCalledWith(243 'Extension "archive-extension" installed successfully and enabled.',244 );245 246 processSpy.mockRestore();247 });248 249 it('should reject --ref for archive URL extensions', async () => {250 const processSpy = vi251 .spyOn(process, 'exit')252 .mockImplementation(() => undefined as never);253 254 mockParseInstallSource.mockResolvedValue({255 type: 'archive-url',256 source: 'https://example.com/extension.zip',257 });258 259 await handleInstall({260 source: 'https://example.com/extension.zip',261 ref: 'v1.0.0',262 });263 264 expect(mockWriteStderrLine).toHaveBeenCalledWith(265 '--ref is not applicable for archive URL extensions.',266 );267 expect(mockInstallExtension).not.toHaveBeenCalled();268 expect(processSpy).toHaveBeenCalledWith(1);269 270 processSpy.mockRestore();271 });272 273 it('should throw an error if install extension fails', async () => {274 const processSpy = vi275 .spyOn(process, 'exit')276 .mockImplementation(() => undefined as never);277 278 mockParseInstallSource.mockResolvedValue({279 type: 'git',280 url: 'git@some-url',281 });282 mockInstallExtension.mockRejectedValue(283 new Error('Install extension failed'),284 );285 286 await handleInstall({ source: 'git@some-url' });287 288 expect(mockWriteStderrLine).toHaveBeenCalledWith(289 'Install extension failed',290 );291 expect(processSpy).toHaveBeenCalledWith(1);292 293 processSpy.mockRestore();294 });295 296 it('should re-scope enablement to the workspace for a project-scope install', async () => {297 mockParseInstallSource.mockResolvedValue({298 type: 'git',299 url: 'git@some-url',300 });301 mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });302 303 await handleInstall({ source: 'git@some-url', scope: 'project' });304 305 expect(mockSetExtensionScope).toHaveBeenCalledWith(306 'scoped-extension',307 'project',308 );309 expect(mockDisableExtension).toHaveBeenCalledWith(310 'scoped-extension',311 'User',312 );313 expect(mockEnableExtension).toHaveBeenCalledWith(314 'scoped-extension',315 'Workspace',316 );317 expect(mockWriteStdoutLine).toHaveBeenCalledWith(318 'Extension "scoped-extension" installed successfully and enabled for the current workspace.',319 );320 });321 322 it('rolls back the User-scope disable when the Workspace enable fails', async () => {323 const processSpy = vi324 .spyOn(process, 'exit')325 .mockImplementation(() => undefined as never);326 mockParseInstallSource.mockResolvedValue({327 type: 'git',328 url: 'git@some-url',329 });330 mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });331 // Workspace enable (first call) fails; the rollback User enable succeeds.332 mockEnableExtension.mockRejectedValueOnce(333 new Error('workspace enable failed'),334 );335 mockEnableExtension.mockResolvedValueOnce(undefined);336 337 await handleInstall({ source: 'git@some-url', scope: 'project' });338 339 expect(mockDisableExtension).toHaveBeenCalledWith(340 'scoped-extension',341 'User',342 );343 // Both the failed Workspace enable and the rollback User enable were attempted.344 expect(mockEnableExtension).toHaveBeenNthCalledWith(345 1,346 'scoped-extension',347 'Workspace',348 );349 expect(mockEnableExtension).toHaveBeenNthCalledWith(350 2,351 'scoped-extension',352 'User',353 );354 // The original failure is surfaced and the command exits non-zero.355 expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed');356 expect(processSpy).toHaveBeenCalledWith(1);357 processSpy.mockRestore();358 });359 360 it('surfaces a rollback failure when the recovery enable also fails', async () => {361 const processSpy = vi362 .spyOn(process, 'exit')363 .mockImplementation(() => undefined as never);364 mockParseInstallSource.mockResolvedValue({365 type: 'git',366 url: 'git@some-url',367 });368 mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });369 // Both the Workspace enable and the rollback User enable fail.370 mockEnableExtension.mockRejectedValueOnce(371 new Error('workspace enable failed'),372 );373 mockEnableExtension.mockRejectedValueOnce(new Error('rollback failed'));374 375 await handleInstall({ source: 'git@some-url', scope: 'project' });376 377 // A warning naming the failed rollback, plus the original error, are shown.378 expect(mockWriteStderrLine).toHaveBeenCalledWith(379 expect.stringContaining('failed to roll back the scope change'),380 );381 expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed');382 expect(processSpy).toHaveBeenCalledWith(1);383 processSpy.mockRestore();384 });385 386 it('should accept workspace as an alias of project scope', async () => {387 mockParseInstallSource.mockResolvedValue({388 type: 'git',389 url: 'git@some-url',390 });391 mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });392 393 await handleInstall({ source: 'git@some-url', scope: 'workspace' });394 395 expect(mockSetExtensionScope).toHaveBeenCalledWith(396 'scoped-extension',397 'project',398 );399 expect(mockEnableExtension).toHaveBeenCalledWith(400 'scoped-extension',401 'Workspace',402 );403 });404 405 it('should record user scope without re-scoping enablement', async () => {406 mockParseInstallSource.mockResolvedValue({407 type: 'git',408 url: 'git@some-url',409 });410 mockInstallExtension.mockResolvedValue({ name: 'user-extension' });411 412 await handleInstall({ source: 'git@some-url', scope: 'user' });413 414 expect(mockSetExtensionScope).toHaveBeenCalledWith(415 'user-extension',416 'user',417 );418 expect(mockDisableExtension).not.toHaveBeenCalled();419 expect(mockEnableExtension).not.toHaveBeenCalled();420 expect(mockWriteStdoutLine).toHaveBeenCalledWith(421 'Extension "user-extension" installed successfully and enabled.',422 );423 });424 425 it('should print archive validation errors from the extension manager', async () => {426 const processSpy = vi427 .spyOn(process, 'exit')428 .mockImplementation(() => undefined as never);429 430 mockParseInstallSource.mockResolvedValue({431 type: 'git',432 source: 'owner/repo',433 });434 mockInstallExtension.mockRejectedValue(435 new Error(436 'Extension archive is missing a supported extension manifest. Expected qwen-extension.json, gemini-extension.json, .claude-plugin/marketplace.json, or .claude-plugin/plugin.json at the archive root, or inside a single top-level extension directory.',437 ),438 );439 440 await handleInstall({ source: 'owner/repo' });441 442 expect(mockWriteStderrLine).toHaveBeenCalledWith(443 'Extension archive is missing a supported extension manifest. Expected qwen-extension.json, gemini-extension.json, .claude-plugin/marketplace.json, or .claude-plugin/plugin.json at the archive root, or inside a single top-level extension directory.',444 );445 expect(processSpy).toHaveBeenCalledWith(1);446 447 processSpy.mockRestore();448 });449});450 