Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion packages/cli/src/ui/contexts/KeypressContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import type { Mock } from 'vitest';
import { vi, afterAll, beforeAll } from 'vitest';
import {
vi,
afterAll,
beforeAll,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import type { Key } from './KeypressContext.js';
import {
KeypressProvider,
Expand Down Expand Up @@ -399,6 +408,60 @@ describe('KeypressContext', () => {
);
});

describe('Windows Terminal Backspace handling', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('should NOT treat \\b as ctrl when WT_SESSION is NOT present', async () => {
vi.stubEnv('WT_SESSION', '');
const { keyHandler } = setupKeypressTest();

act(() => {
stdin.write('\b');
});

expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: false,
}),
);
});

it('should treat \\b as ctrl when WT_SESSION IS present', async () => {
vi.stubEnv('WT_SESSION', 'some-id');
const { keyHandler } = setupKeypressTest();

act(() => {
stdin.write('\b');
});

expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: true,
}),
);
});

it('should treat \\x7f as regular backspace regardless of WT_SESSION', async () => {
vi.stubEnv('WT_SESSION', 'some-id');
const { keyHandler } = setupKeypressTest();

act(() => {
stdin.write('\x7f');
});

expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
ctrl: false,
}),
);
});
});

describe('paste mode', () => {
it.each([
{
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/ui/contexts/KeypressContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,17 @@ function* emitKeys(
// tab
name = 'tab';
alt = escaped;
} else if (ch === '\b' || ch === '\x7f') {
// backspace or ctrl+h
} else if (ch === '\b') {
// ctrl+h / ctrl+backspace (windows terminals send \x08 for ctrl+backspace)
name = 'backspace';
// In Windows Terminal, \b can be sent for Ctrl+Backspace.
// We scope this behavior to avoid breaking other terminals where \b is a plain backspace.
if (typeof process !== 'undefined' && !!process.env?.['WT_SESSION']) {
ctrl = true;
}
alt = escaped;
} else if (ch === '\x7f') {
// backspace
name = 'backspace';
alt = escaped;
} else if (ch === ESC) {
Expand Down
202 changes: 158 additions & 44 deletions packages/core/src/utils/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ vi.mock('child_process', () => ({
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
}));

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
}));

import { existsSync } from 'node:fs';

const originalPlatform = process.platform;

describe('editor utils', () => {
Expand All @@ -45,6 +51,10 @@ describe('editor utils', () => {
value: originalPlatform,
writable: true,
});
vi.stubEnv('LOCALAPPDATA', 'C:\\LOCALAPPDATA');
vi.stubEnv('ProgramFiles', 'C:\\Program Files');
vi.stubEnv('APPDATA', 'C:\\APPDATA');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
});

afterEach(() => {
Expand All @@ -62,26 +72,60 @@ describe('editor utils', () => {
commands: string[];
win32Commands: string[];
}> = [
{ editor: 'vscode', commands: ['code'], win32Commands: ['code.cmd'] },
{
editor: 'vscode',
commands: ['code'],
win32Commands: [
'code.cmd',
'code',
'C:\\LOCALAPPDATA\\Programs\\Microsoft VS Code\\bin\\code.cmd',
'C:\\Program Files\\Microsoft VS Code\\bin\\code.cmd',
],
},
{
editor: 'vscodium',
commands: ['codium'],
win32Commands: ['codium.cmd'],
win32Commands: [
'codium.cmd',
'codium',
'C:\\LOCALAPPDATA\\Programs\\VSCodium\\bin\\codium.cmd',
'C:\\Program Files\\VSCodium\\bin\\codium.cmd',
],
},
{
editor: 'windsurf',
commands: ['windsurf'],
win32Commands: ['windsurf'],
},
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
{
editor: 'cursor',
commands: ['cursor'],
win32Commands: [
'cursor',
'C:\\LOCALAPPDATA\\Programs\\cursor\\resources\\app\\bin\\cursor.cmd',
'C:\\Program Files\\cursor\\resources\\app\\bin\\cursor.cmd',
],
},
{ editor: 'vim', commands: ['vim'], win32Commands: ['vim'] },
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
{
editor: 'emacs',
commands: ['emacs'],
win32Commands: ['emacs.exe', 'emacs'],
},
{
editor: 'antigravity',
commands: ['agy', 'antigravity'],
win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'],
commands: ['gemini', 'agy', 'antigravity'],
win32Commands: [
'gemini.cmd',
'gemini',
'agy.cmd',
'agy',
'antigravity.cmd',
'antigravity',
'C:\\APPDATA\\npm\\agy.cmd',
],
},
{ editor: 'hx', commands: ['hx'], win32Commands: ['hx'] },
];
Expand Down Expand Up @@ -125,30 +169,55 @@ describe('editor utils', () => {
// Windows tests
it(`should return true if first command "${win32Commands[0]}" exists on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock).mockReturnValue(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`),
);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(
`where.exe ${win32Commands[0]}`,
{
stdio: 'ignore',
},
);
if (
win32Commands[0].includes('\\') ||
win32Commands[0].includes('/')
) {
(existsSync as Mock).mockReturnValue(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(existsSync).toHaveBeenCalledWith(win32Commands[0]);
} else {
(execSync as Mock).mockReturnValue(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`),
);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(
`where.exe ${win32Commands[0]}`,
{
stdio: 'ignore',
},
);
}
});

if (win32Commands.length > 1) {
it(`should return true if first command doesn't exist but second command "${win32Commands[1]}" exists on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock)
.mockImplementationOnce(() => {
throw new Error(); // first command not found
})
.mockReturnValueOnce(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`),
); // second command found

const mockCommand = (cmd: string, exists: boolean) => {
if (cmd.includes('\\') || cmd.includes('/')) {
if (exists) {
(existsSync as Mock).mockReturnValueOnce(true);
} else {
(existsSync as Mock).mockReturnValueOnce(false);
}
} else {
if (exists) {
(execSync as Mock).mockReturnValueOnce(
Buffer.from(`C:\\Program Files\\...\\${cmd}`),
);
} else {
(execSync as Mock).mockImplementationOnce(() => {
throw new Error();
});
}
}
};

mockCommand(win32Commands[0], false);
mockCommand(win32Commands[1], true);

expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledTimes(2);
});
}

Expand All @@ -157,8 +226,9 @@ describe('editor utils', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
(existsSync as Mock).mockReturnValue(false);

expect(hasValidEditorCommand(editor)).toBe(false);
expect(execSync).toHaveBeenCalledTimes(win32Commands.length);
});
});
}
Expand All @@ -170,23 +240,53 @@ describe('editor utils', () => {
commands: string[];
win32Commands: string[];
}> = [
{ editor: 'vscode', commands: ['code'], win32Commands: ['code.cmd'] },
{
editor: 'vscode',
commands: ['code'],
win32Commands: [
'code.cmd',
'code',
'C:\\LOCALAPPDATA\\Programs\\Microsoft VS Code\\bin\\code.cmd',
'C:\\Program Files\\Microsoft VS Code\\bin\\code.cmd',
],
},
{
editor: 'vscodium',
commands: ['codium'],
win32Commands: ['codium.cmd'],
win32Commands: [
'codium.cmd',
'codium',
'C:\\LOCALAPPDATA\\Programs\\VSCodium\\bin\\codium.cmd',
'C:\\Program Files\\VSCodium\\bin\\codium.cmd',
],
},
{
editor: 'windsurf',
commands: ['windsurf'],
win32Commands: ['windsurf'],
},
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
{
editor: 'cursor',
commands: ['cursor'],
win32Commands: [
'cursor',
'C:\\LOCALAPPDATA\\Programs\\cursor\\resources\\app\\bin\\cursor.cmd',
'C:\\Program Files\\cursor\\resources\\app\\bin\\cursor.cmd',
],
},
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{
editor: 'antigravity',
commands: ['agy', 'antigravity'],
win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'],
commands: ['gemini', 'agy', 'antigravity'],
win32Commands: [
'gemini.cmd',
'gemini',
'agy.cmd',
'agy',
'antigravity.cmd',
'antigravity',
'C:\\APPDATA\\npm\\agy.cmd',
],
},
];
Comment on lines 242 to 291

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The editor configurations in this array are largely duplicated from the testCases array defined earlier in this file (lines 70-131). This duplication makes the tests harder to maintain, as changes to editor configurations must be updated in multiple places, increasing the risk of inconsistencies. To improve maintainability, I suggest refactoring to define the editor test configurations in a single, shared data structure that can be reused across both the hasValidEditorCommand and getDiffCommand test suites. You could then filter this structure as needed for each suite.

References
  1. While the rule suggests preferring hardcoded literal values in tests to ensure they are self-contained and less brittle, this comment addresses the duplication of complex test configurations within the same test file. Refactoring such configurations into a shared data structure within the test file improves maintainability without necessarily violating the principle of self-contained tests or introducing brittleness from external dependencies.


Expand Down Expand Up @@ -235,11 +335,30 @@ describe('editor utils', () => {
});

// Windows tests
const mockCommand = (cmd: string, exists: boolean) => {
if (cmd.includes('\\') || cmd.includes('/')) {
if (exists) {
(existsSync as Mock).mockReturnValueOnce(true);
} else {
(existsSync as Mock).mockReturnValueOnce(false);
}
} else {
if (exists) {
(execSync as Mock).mockReturnValueOnce(
Buffer.from(`C:\\Program Files\\...\\${cmd}`),
);
} else {
(execSync as Mock).mockImplementationOnce(() => {
throw new Error();
});
}
}
};

it(`should use first command "${win32Commands[0]}" when it exists on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock).mockReturnValue(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`),
);
mockCommand(win32Commands[0], true);

const diffCommand = getDiffCommand('old.txt', 'new.txt', editor);
expect(diffCommand).toEqual({
command: win32Commands[0],
Expand All @@ -250,13 +369,8 @@ describe('editor utils', () => {
if (win32Commands.length > 1) {
it(`should use second command "${win32Commands[1]}" when first doesn't exist on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock)
.mockImplementationOnce(() => {
throw new Error(); // first command not found
})
.mockReturnValueOnce(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`),
); // second command found
mockCommand(win32Commands[0], false);
mockCommand(win32Commands[1], true);

const diffCommand = getDiffCommand('old.txt', 'new.txt', editor);
expect(diffCommand).toEqual({
Expand All @@ -266,11 +380,11 @@ describe('editor utils', () => {
});
}

it(`should fall back to last command "${win32Commands[win32Commands.length - 1]}" when none exist on windows`, () => {
it(`should fall back to last command "${
win32Commands[win32Commands.length - 1]
}" when none exist on windows`, () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
win32Commands.slice(0, -1).forEach((cmd) => mockCommand(cmd, false));

const diffCommand = getDiffCommand('old.txt', 'new.txt', editor);
expect(diffCommand).toEqual({
Expand Down
Loading