feat: Implement background shell commands (#14849)

This commit is contained in:
Gal Zahavi
2026-01-30 09:53:09 -08:00
committed by GitHub
parent d3bca5d97a
commit b611f9a519
52 changed files with 3957 additions and 470 deletions
@@ -76,7 +76,13 @@ vi.mock('../utils/getPty.js', () => ({
getPty: mockGetPty,
}));
vi.mock('../utils/terminalSerializer.js', () => ({
serializeTerminalToObject: mockSerializeTerminalToObject,
// Avoid passing the heavy Terminal object to the spy to prevent OOM
serializeTerminalToObject: (
_terminal: unknown,
...args: [number | undefined, number | undefined]
) => mockSerializeTerminalToObject(...args),
convertColorToHex: () => '#000000',
ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 },
}));
vi.mock('../utils/systemEncoding.js', () => ({
getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'),
@@ -318,6 +324,7 @@ describe('ShellExecutionService', () => {
}
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
},
{ ...shellExecutionConfig, maxSerializedLines: 100 },
);
expect(result.exitCode).toBe(0);
@@ -675,7 +682,7 @@ describe('ShellExecutionService', () => {
expect(result.rawOutput).toEqual(
Buffer.concat([binaryChunk1, binaryChunk2]),
);
expect(onOutputEventMock).toHaveBeenCalledTimes(3);
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
});
@@ -687,6 +694,11 @@ describe('ShellExecutionService', () => {
type: 'binary_progress',
bytesReceived: 8,
});
expect(onOutputEventMock.mock.calls[3][0]).toEqual({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it('should not emit data events after binary is detected', async () => {
@@ -705,6 +717,7 @@ describe('ShellExecutionService', () => {
'binary_detected',
'binary_progress',
'binary_progress',
'exit',
]);
});
});
@@ -763,9 +776,7 @@ describe('ShellExecutionService', () => {
coloredShellExecutionConfig,
);
expect(mockSerializeTerminalToObject).toHaveBeenCalledWith(
expect.anything(), // The terminal object
);
expect(mockSerializeTerminalToObject).toHaveBeenCalled();
expect(onOutputEventMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -932,11 +943,20 @@ describe('ShellExecutionService child_process fallback', () => {
expect(result.error).toBeNull();
expect(result.aborted).toBe(false);
expect(result.output).toBe('file1.txt\na warning');
expect(handle.pid).toBe(undefined);
expect(handle.pid).toBe(12345);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'file1.txt\na warning',
chunk: 'file1.txt\n',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'a warning',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'exit',
exitCode: 0,
signal: null,
});
});
@@ -948,12 +968,15 @@ describe('ShellExecutionService child_process fallback', () => {
});
expect(result.output.trim()).toBe('aredword');
expect(onOutputEventMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'data',
chunk: 'aredword',
}),
);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'a\u001b[31mred\u001b[0mword',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it('should correctly decode multi-byte characters split across chunks', async () => {
@@ -974,10 +997,14 @@ describe('ShellExecutionService child_process fallback', () => {
});
expect(result.output.trim()).toBe('');
expect(onOutputEventMock).not.toHaveBeenCalled();
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it.skip('should truncate stdout using a sliding window and show a warning', async () => {
it('should truncate stdout using a sliding window and show a warning', async () => {
const MAX_SIZE = 16 * 1024 * 1024;
const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5);
const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5);
@@ -1173,26 +1200,44 @@ describe('ShellExecutionService child_process fallback', () => {
expect(result.rawOutput).toEqual(
Buffer.concat([binaryChunk1, binaryChunk2]),
);
expect(onOutputEventMock).toHaveBeenCalledTimes(1);
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
});
expect(onOutputEventMock.mock.calls[1][0]).toEqual({
type: 'binary_progress',
bytesReceived: 4,
});
expect(onOutputEventMock.mock.calls[2][0]).toEqual({
type: 'binary_progress',
bytesReceived: 8,
});
expect(onOutputEventMock.mock.calls[3][0]).toEqual({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it('should not emit data events after binary is detected', async () => {
mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));
await simulateExecution('cat mixed_file', (cp) => {
cp.stdout?.emit('data', Buffer.from('some text'));
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
cp.stdout?.emit('data', Buffer.from('more text'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
const eventTypes = onOutputEventMock.mock.calls.map(
(call: [ShellOutputEvent]) => call[0].type,
);
expect(eventTypes).toEqual(['binary_detected']);
expect(eventTypes).toEqual([
'binary_detected',
'binary_progress',
'binary_progress',
'exit',
]);
});
});