mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(paths): Add cross-platform path normalization (#18939)
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
|||||||
type Mock,
|
type Mock,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { awaitConfirmation, resolveConfirmation } from './confirmation.js';
|
import { resolveConfirmation } from './confirmation.js';
|
||||||
import {
|
import {
|
||||||
MessageBusType,
|
MessageBusType,
|
||||||
type ToolConfirmationResponse,
|
type ToolConfirmationResponse,
|
||||||
@@ -31,7 +31,7 @@ import type { ToolModificationHandler } from './tool-modifier.js';
|
|||||||
import type { ValidatingToolCall, WaitingToolCall } from './types.js';
|
import type { ValidatingToolCall, WaitingToolCall } from './types.js';
|
||||||
import { ROOT_SCHEDULER_ID } from './types.js';
|
import { ROOT_SCHEDULER_ID } from './types.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type { EditorType } from '../utils/editor.js';
|
import { type EditorType } from '../utils/editor.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
// Mock Dependencies
|
// Mock Dependencies
|
||||||
@@ -39,10 +39,19 @@ vi.mock('node:crypto', () => ({
|
|||||||
randomUUID: vi.fn(),
|
randomUUID: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/editor.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../utils/editor.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveEditorAsync: () => Promise.resolve('vim'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('confirmation.ts', () => {
|
describe('confirmation.ts', () => {
|
||||||
let mockMessageBus: MessageBus;
|
let mockMessageBus: MessageBus;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('SANDBOX', '');
|
||||||
mockMessageBus = new EventEmitter() as unknown as MessageBus;
|
mockMessageBus = new EventEmitter() as unknown as MessageBus;
|
||||||
mockMessageBus.publish = vi.fn().mockResolvedValue(undefined);
|
mockMessageBus.publish = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.spyOn(mockMessageBus, 'on');
|
vi.spyOn(mockMessageBus, 'on');
|
||||||
@@ -53,6 +62,7 @@ describe('confirmation.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,43 +85,6 @@ describe('confirmation.ts', () => {
|
|||||||
mockMessageBus.on('newListener', handler);
|
mockMessageBus.on('newListener', handler);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('awaitConfirmation', () => {
|
|
||||||
it('should resolve when confirmed response matches correlationId', async () => {
|
|
||||||
const correlationId = 'test-correlation-id';
|
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
const promise = awaitConfirmation(
|
|
||||||
mockMessageBus,
|
|
||||||
correlationId,
|
|
||||||
abortController.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
emitResponse({
|
|
||||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
|
||||||
correlationId,
|
|
||||||
confirmed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await promise;
|
|
||||||
expect(result).toEqual({
|
|
||||||
outcome: ToolConfirmationOutcome.ProceedOnce,
|
|
||||||
payload: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when abort signal is triggered', async () => {
|
|
||||||
const correlationId = 'abort-id';
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const promise = awaitConfirmation(
|
|
||||||
mockMessageBus,
|
|
||||||
correlationId,
|
|
||||||
abortController.signal,
|
|
||||||
);
|
|
||||||
abortController.abort();
|
|
||||||
await expect(promise).rejects.toThrow('Operation cancelled');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveConfirmation', () => {
|
describe('resolveConfirmation', () => {
|
||||||
let mockState: Mocked<SchedulerStateManager>;
|
let mockState: Mocked<SchedulerStateManager>;
|
||||||
let mockModifier: Mocked<ToolModificationHandler>;
|
let mockModifier: Mocked<ToolModificationHandler>;
|
||||||
@@ -286,8 +259,13 @@ describe('confirmation.ts', () => {
|
|||||||
};
|
};
|
||||||
invocationMock.shouldConfirmExecute.mockResolvedValue(details);
|
invocationMock.shouldConfirmExecute.mockResolvedValue(details);
|
||||||
|
|
||||||
// 1. User says Modify
|
// Set up modifier mock before starting the flow
|
||||||
// 2. User says Proceed
|
mockModifier.handleModifyWithEditor.mockResolvedValue({
|
||||||
|
updatedParams: { foo: 'bar' },
|
||||||
|
});
|
||||||
|
toolMock.build.mockReturnValue({} as unknown as AnyToolInvocation);
|
||||||
|
|
||||||
|
// Start the confirmation flow
|
||||||
const listenerPromise1 = waitForListener(
|
const listenerPromise1 = waitForListener(
|
||||||
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
);
|
);
|
||||||
@@ -302,7 +280,12 @@ describe('confirmation.ts', () => {
|
|||||||
|
|
||||||
await listenerPromise1;
|
await listenerPromise1;
|
||||||
|
|
||||||
// First response: Modify
|
// Prepare to detect when the loop re-subscribes after modification
|
||||||
|
const listenerPromise2 = waitForListener(
|
||||||
|
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First response: User chooses to modify with editor
|
||||||
emitResponse({
|
emitResponse({
|
||||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
correlationId: '123e4567-e89b-12d3-a456-426614174000',
|
correlationId: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
@@ -310,22 +293,12 @@ describe('confirmation.ts', () => {
|
|||||||
outcome: ToolConfirmationOutcome.ModifyWithEditor,
|
outcome: ToolConfirmationOutcome.ModifyWithEditor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the modifier action
|
// Wait for the loop to process the modification and re-subscribe
|
||||||
mockModifier.handleModifyWithEditor.mockResolvedValue({
|
|
||||||
updatedParams: { foo: 'bar' },
|
|
||||||
});
|
|
||||||
toolMock.build.mockReturnValue({} as unknown as AnyToolInvocation);
|
|
||||||
|
|
||||||
// Wait for loop to cycle and re-subscribe
|
|
||||||
const listenerPromise2 = waitForListener(
|
|
||||||
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
|
||||||
);
|
|
||||||
await listenerPromise2;
|
await listenerPromise2;
|
||||||
|
|
||||||
// Expect state update
|
|
||||||
expect(mockState.updateArgs).toHaveBeenCalled();
|
expect(mockState.updateArgs).toHaveBeenCalled();
|
||||||
|
|
||||||
// Second response: Proceed
|
// Second response: User approves the modified params
|
||||||
emitResponse({
|
emitResponse({
|
||||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||||
correlationId: '123e4567-e89b-12d3-a456-426614174000',
|
correlationId: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
unescapePath,
|
unescapePath,
|
||||||
isSubpath,
|
isSubpath,
|
||||||
shortenPath,
|
shortenPath,
|
||||||
|
normalizePath,
|
||||||
resolveToRealPath,
|
resolveToRealPath,
|
||||||
} from './paths.js';
|
} from './paths.js';
|
||||||
|
|
||||||
@@ -521,3 +522,46 @@ describe('resolveToRealPath', () => {
|
|||||||
expect(resolveToRealPath(input)).toBe(expected);
|
expect(resolveToRealPath(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('normalizePath', () => {
|
||||||
|
it('should resolve a relative path to an absolute path', () => {
|
||||||
|
const result = normalizePath('some/relative/path');
|
||||||
|
expect(result).toMatch(/^\/|^[a-z]:\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert all backslashes to forward slashes', () => {
|
||||||
|
const result = normalizePath(path.resolve('some', 'path'));
|
||||||
|
expect(result).not.toContain('\\');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(process.platform !== 'win32')('on Windows', () => {
|
||||||
|
it('should lowercase the entire path', () => {
|
||||||
|
const result = normalizePath('C:\\Users\\TEST');
|
||||||
|
expect(result).toBe(result.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize drive letters to lowercase', () => {
|
||||||
|
const result = normalizePath('C:\\');
|
||||||
|
expect(result).toMatch(/^c:\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed separators', () => {
|
||||||
|
const result = normalizePath('C:/Users\\Test/file.txt');
|
||||||
|
expect(result).not.toContain('\\');
|
||||||
|
expect(result).toMatch(/^c:\/users\/test\/file\.txt$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(process.platform === 'win32')('on POSIX', () => {
|
||||||
|
it('should preserve case', () => {
|
||||||
|
const result = normalizePath('/usr/Local/Bin');
|
||||||
|
expect(result).toContain('Local');
|
||||||
|
expect(result).toContain('Bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use forward slashes', () => {
|
||||||
|
const result = normalizePath('/usr/local/bin');
|
||||||
|
expect(result).toBe('/usr/local/bin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -319,13 +319,15 @@ export function getProjectHash(projectRoot: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a path for reliable comparison.
|
* Normalizes a path for reliable comparison across platforms.
|
||||||
* - Resolves to an absolute path.
|
* - Resolves to an absolute path.
|
||||||
|
* - Converts all path separators to forward slashes.
|
||||||
* - On Windows, converts to lowercase for case-insensitivity.
|
* - On Windows, converts to lowercase for case-insensitivity.
|
||||||
*/
|
*/
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
const resolved = path.resolve(p);
|
const resolved = path.resolve(p);
|
||||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
const normalized = resolved.replace(/\\/g, '/');
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user