gemini-cli / packages /cli /src /commands /extensions /validate.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
84aa3bf verified
Raw History Blame Contribute Delete
6.23 kB
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { handleValidate, validateCommand } from './validate.js';
import yargs from 'yargs';
import { createExtension } from '../../test-utils/createExtension.js';
import path from 'node:path';
import * as os from 'node:os';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions validate command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([])
.locale('en')
.command(validateCommand)
.fail(false);
expect(() => validationParser.parse('validate')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
});
describe('handleValidate', () => {
let debugLoggerLogSpy: MockInstance;
let debugLoggerWarnSpy: MockInstance;
let debugLoggerErrorSpy: MockInstance;
let processSpy: MockInstance;
let tempHomeDir: string;
let tempWorkspaceDir: string;
beforeEach(() => {
debugLoggerLogSpy = vi.spyOn(debugLogger, 'log');
debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn');
debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home'));
tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace'));
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
});
it('should validate an extension from a local dir', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'local-ext-name',
version: '1.0.0',
});
await handleValidate({
path: 'local-ext-name',
});
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Extension local-ext-name has been successfully validated.',
);
});
it('should throw an error if the extension name is invalid', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'INVALID_NAME',
version: '1.0.0',
});
await handleValidate({
path: 'INVALID_NAME',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should warn if version is not formatted with semver', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'valid-name',
version: '1',
});
await handleValidate({
path: 'valid-name',
});
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Version '1' does not appear to be standard semver (e.g., 1.0.0).",
),
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Extension valid-name has been successfully validated.',
);
});
it('should throw an error if context files are missing', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'valid-name',
version: '1.0.0',
contextFileName: 'contextFile.md',
});
fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md'));
await handleValidate({
path: 'valid-name',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'The following context files referenced in gemini-extension.json are missing: contextFile.md',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should throw an error if context file uses relative parent directory navigation', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'parent-nav-ext',
version: '1.0.0',
contextFileName: '../secret.txt',
});
await handleValidate({
path: 'parent-nav-ext',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Invalid context file path: "../secret.txt". Relative parent directory references ("..") are not allowed.',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should throw an error if context file uses URL-encoded relative parent directory navigation', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'encoded-nav-ext',
version: '1.0.0',
contextFileName: '%2e%2e/secret.txt',
});
await handleValidate({
path: 'encoded-nav-ext',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Invalid context file path: "%2e%2e/secret.txt". Relative parent directory references ("..") are not allowed.',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should throw an error if context file uses absolute path', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'abs-path-ext',
version: '1.0.0',
contextFileName: '/etc/hosts',
});
await handleValidate({
path: 'abs-path-ext',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Invalid context file path: "/etc/hosts". Absolute paths are not allowed.',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should successfully validate an extension with context file containing percent characters', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'percent-name-ext',
version: '1.0.0',
contextFileName: '100%_complete.md',
});
await handleValidate({
path: 'percent-name-ext',
});
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Extension percent-name-ext has been successfully validated.',
);
});
});