Loading src/__mocks__/vscode.js +1 −0 Original line number Diff line number Diff line Loading @@ -68,6 +68,7 @@ module.exports = { onDidChangeActiveColorTheme: jest.fn(), onDidChangeTerminalShellIntegration: jest.fn(), onDidEndTerminalShellExecution: jest.fn(), onDidCloseTerminal: jest.fn(), createTerminal: jest.fn(), createWebviewPanel: jest.fn(), showTextDocument: jest.fn(), Loading src/common/duo_workflow/terminal_manager.test.ts +63 −9 Original line number Diff line number Diff line Loading @@ -58,15 +58,23 @@ describe('Terminal Manager', () => { expect.any(Function), ); }); it('listens to onDidCloseTerminal event', () => { terminalManager.setupRequests(mockClient); expect(window.onDidCloseTerminal).toHaveBeenCalledWith(expect.any(Function)); }); }); describe('dispose', () => { let eventFn: RunCommandEventHandler; let shellExecutionFn: (e: TerminalShellExecutionEndEvent) => Disposable; let listenerDispose: jest.Mock; let closeTerminalDispose: jest.Mock; beforeEach(async () => { listenerDispose = jest.fn(); closeTerminalDispose = jest.fn(); jest.mocked(mockClient.onRequest).mockImplementation((event: string, fn) => { if (event === '$/gitlab/runCommand') { eventFn = fn as RunCommandEventHandler; Loading @@ -77,6 +85,9 @@ describe('Terminal Manager', () => { shellExecutionFn = fn; return { dispose() {} }; }); jest.mocked(window.onDidCloseTerminal).mockImplementationOnce(() => { return { dispose: closeTerminalDispose }; }); jest.mocked(mockShellIntegration.executeCommand).mockReturnValue(mockExecution); terminalManager.setupRequests(mockClient); Loading Loading @@ -108,11 +119,15 @@ describe('Terminal Manager', () => { terminalManager.dispose(); expect(listenerDispose).toHaveBeenCalled(); expect(closeTerminalDispose).toHaveBeenCalled(); expect(mockTerminal.dispose).toHaveBeenCalled(); }); }); describe('$/gitlab/runCommand', () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; let eventFn: RunCommandEventHandler; let shellIntegrationFn: ( e: TerminalShellIntegrationChangeEvent, Loading @@ -120,12 +135,15 @@ describe('Terminal Manager', () => { // disposables?: Disposable[], ) => Disposable; let shellExecutionFn: (e: TerminalShellExecutionEndEvent) => Disposable; let onDidCloseTerminalListener: (terminal: Terminal) => void; let shellExecDispose: jest.Mock; let shellIntDispose: jest.Mock; beforeEach(() => { shellExecDispose = jest.fn(); shellIntDispose = jest.fn(); onDidCloseTerminalListener = jest.fn(); jest.mocked(mockClient.onRequest).mockImplementation((event: string, fn) => { if (event === '$/gitlab/runCommand') { eventFn = fn as RunCommandEventHandler; Loading @@ -141,14 +159,16 @@ describe('Terminal Manager', () => { return { dispose: shellExecDispose }; }); jest.mocked(mockShellIntegration.executeCommand).mockReturnValue(mockExecution); jest.mocked(window.onDidCloseTerminal).mockImplementationOnce(listener => { onDidCloseTerminalListener = listener; return { dispose() {} }; }); terminalManager.setupRequests(mockClient); }); it('executes a command when the shell integration is available', async () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; const processOutput = ['running tests...\n', 'all tests passed!']; mockShellIntegrationGetter.mockReturnValue(undefined); Loading Loading @@ -193,9 +213,6 @@ describe('Terminal Manager', () => { }); it('reuses a terminal when the same workflow ID is used', async () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; mockShellIntegrationGetter.mockReturnValue(mockShellIntegration); jest.mocked(mockExecution.read).mockImplementation(async function* read() { Loading Loading @@ -231,10 +248,47 @@ describe('Terminal Manager', () => { expect(window.createTerminal).toHaveBeenCalledTimes(1); }); it('re-creates terminal if the terminal is closed', async () => { mockShellIntegrationGetter.mockReturnValue(mockShellIntegration); jest.mocked(mockExecution.read).mockImplementation(async function* read() { yield ''; }); let result = eventFn({ workflowId, command, args }); await jest.advanceTimersToNextTimerAsync(); shellExecutionFn({ execution: mockExecution, exitCode: 0, terminal: mockTerminal, shellIntegration: mockShellIntegration, }); await result; expect(window.createTerminal).toHaveBeenCalledTimes(1); onDidCloseTerminalListener(mockTerminal); result = eventFn({ workflowId, command, args }); await jest.advanceTimersToNextTimerAsync(); shellExecutionFn({ execution: mockExecution, exitCode: 0, terminal: mockTerminal, shellIntegration: mockShellIntegration, }); await jest.advanceTimersToNextTimerAsync(); expect(window.createTerminal).toHaveBeenCalledTimes(2); }); it('throws when shell integration is not available', async () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; mockShellIntegrationGetter.mockReturnValue(undefined); const result = eventFn({ workflowId, command, args }); Loading src/common/duo_workflow/terminal_manager.ts +27 −12 Original line number Diff line number Diff line Loading @@ -3,22 +3,32 @@ import { BaseLanguageClient } from 'vscode-languageclient'; import { log } from '../log'; export class TerminalManager implements Disposable { #terminals: Record<string, Terminal>; #terminals: Map<string, Terminal>; #listener: Disposable | undefined; #disposables: Disposable[] = []; constructor() { this.#terminals = {}; this.#terminals = new Map(); } setupRequests(client: BaseLanguageClient) { this.#listener = client.onRequest( '$/gitlab/runCommand', async ({ workflowId, command, args }) => { this.#disposables.push( client.onRequest('$/gitlab/runCommand', async ({ workflowId, command, args }) => { log.debug(`Running command: ${command} ${args.join(' ')}`); return this.#executeCommand(workflowId, command, args); }, }), ); this.#disposables.push( window.onDidCloseTerminal(closedTerminal => { for (const [workflowId, terminal] of this.#terminals.entries()) { if (terminal === closedTerminal) { this.#terminals.delete(workflowId); break; } } }), ); } Loading Loading @@ -53,7 +63,7 @@ export class TerminalManager implements Disposable { } async #getOrCreateTerminal(workflowId: string) { return this.#terminals[workflowId] ?? this.#createTerminal(workflowId); return this.#terminals.get(workflowId) ?? this.#createTerminal(workflowId); } async #createTerminal(workflowId: string) { Loading @@ -62,11 +72,11 @@ export class TerminalManager implements Disposable { isTransient: true, }); this.#terminals[workflowId] = terminal; this.#terminals.set(workflowId, terminal); await this.#listenForShellIntegration(terminal); return this.#terminals[workflowId]; return terminal; } #listenForShellIntegration(term: Terminal) { Loading @@ -91,7 +101,12 @@ export class TerminalManager implements Disposable { } dispose() { Object.values(this.#terminals).forEach(term => term.dispose()); this.#listener?.dispose(); this.#terminals.forEach(term => { term.dispose(); }); this.#disposables.forEach(disposable => { disposable.dispose(); }); } } Loading
src/__mocks__/vscode.js +1 −0 Original line number Diff line number Diff line Loading @@ -68,6 +68,7 @@ module.exports = { onDidChangeActiveColorTheme: jest.fn(), onDidChangeTerminalShellIntegration: jest.fn(), onDidEndTerminalShellExecution: jest.fn(), onDidCloseTerminal: jest.fn(), createTerminal: jest.fn(), createWebviewPanel: jest.fn(), showTextDocument: jest.fn(), Loading
src/common/duo_workflow/terminal_manager.test.ts +63 −9 Original line number Diff line number Diff line Loading @@ -58,15 +58,23 @@ describe('Terminal Manager', () => { expect.any(Function), ); }); it('listens to onDidCloseTerminal event', () => { terminalManager.setupRequests(mockClient); expect(window.onDidCloseTerminal).toHaveBeenCalledWith(expect.any(Function)); }); }); describe('dispose', () => { let eventFn: RunCommandEventHandler; let shellExecutionFn: (e: TerminalShellExecutionEndEvent) => Disposable; let listenerDispose: jest.Mock; let closeTerminalDispose: jest.Mock; beforeEach(async () => { listenerDispose = jest.fn(); closeTerminalDispose = jest.fn(); jest.mocked(mockClient.onRequest).mockImplementation((event: string, fn) => { if (event === '$/gitlab/runCommand') { eventFn = fn as RunCommandEventHandler; Loading @@ -77,6 +85,9 @@ describe('Terminal Manager', () => { shellExecutionFn = fn; return { dispose() {} }; }); jest.mocked(window.onDidCloseTerminal).mockImplementationOnce(() => { return { dispose: closeTerminalDispose }; }); jest.mocked(mockShellIntegration.executeCommand).mockReturnValue(mockExecution); terminalManager.setupRequests(mockClient); Loading Loading @@ -108,11 +119,15 @@ describe('Terminal Manager', () => { terminalManager.dispose(); expect(listenerDispose).toHaveBeenCalled(); expect(closeTerminalDispose).toHaveBeenCalled(); expect(mockTerminal.dispose).toHaveBeenCalled(); }); }); describe('$/gitlab/runCommand', () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; let eventFn: RunCommandEventHandler; let shellIntegrationFn: ( e: TerminalShellIntegrationChangeEvent, Loading @@ -120,12 +135,15 @@ describe('Terminal Manager', () => { // disposables?: Disposable[], ) => Disposable; let shellExecutionFn: (e: TerminalShellExecutionEndEvent) => Disposable; let onDidCloseTerminalListener: (terminal: Terminal) => void; let shellExecDispose: jest.Mock; let shellIntDispose: jest.Mock; beforeEach(() => { shellExecDispose = jest.fn(); shellIntDispose = jest.fn(); onDidCloseTerminalListener = jest.fn(); jest.mocked(mockClient.onRequest).mockImplementation((event: string, fn) => { if (event === '$/gitlab/runCommand') { eventFn = fn as RunCommandEventHandler; Loading @@ -141,14 +159,16 @@ describe('Terminal Manager', () => { return { dispose: shellExecDispose }; }); jest.mocked(mockShellIntegration.executeCommand).mockReturnValue(mockExecution); jest.mocked(window.onDidCloseTerminal).mockImplementationOnce(listener => { onDidCloseTerminalListener = listener; return { dispose() {} }; }); terminalManager.setupRequests(mockClient); }); it('executes a command when the shell integration is available', async () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; const processOutput = ['running tests...\n', 'all tests passed!']; mockShellIntegrationGetter.mockReturnValue(undefined); Loading Loading @@ -193,9 +213,6 @@ describe('Terminal Manager', () => { }); it('reuses a terminal when the same workflow ID is used', async () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; mockShellIntegrationGetter.mockReturnValue(mockShellIntegration); jest.mocked(mockExecution.read).mockImplementation(async function* read() { Loading Loading @@ -231,10 +248,47 @@ describe('Terminal Manager', () => { expect(window.createTerminal).toHaveBeenCalledTimes(1); }); it('re-creates terminal if the terminal is closed', async () => { mockShellIntegrationGetter.mockReturnValue(mockShellIntegration); jest.mocked(mockExecution.read).mockImplementation(async function* read() { yield ''; }); let result = eventFn({ workflowId, command, args }); await jest.advanceTimersToNextTimerAsync(); shellExecutionFn({ execution: mockExecution, exitCode: 0, terminal: mockTerminal, shellIntegration: mockShellIntegration, }); await result; expect(window.createTerminal).toHaveBeenCalledTimes(1); onDidCloseTerminalListener(mockTerminal); result = eventFn({ workflowId, command, args }); await jest.advanceTimersToNextTimerAsync(); shellExecutionFn({ execution: mockExecution, exitCode: 0, terminal: mockTerminal, shellIntegration: mockShellIntegration, }); await jest.advanceTimersToNextTimerAsync(); expect(window.createTerminal).toHaveBeenCalledTimes(2); }); it('throws when shell integration is not available', async () => { const workflowId = '1234'; const command = 'npm'; const args = ['run', 'test:unit']; mockShellIntegrationGetter.mockReturnValue(undefined); const result = eventFn({ workflowId, command, args }); Loading
src/common/duo_workflow/terminal_manager.ts +27 −12 Original line number Diff line number Diff line Loading @@ -3,22 +3,32 @@ import { BaseLanguageClient } from 'vscode-languageclient'; import { log } from '../log'; export class TerminalManager implements Disposable { #terminals: Record<string, Terminal>; #terminals: Map<string, Terminal>; #listener: Disposable | undefined; #disposables: Disposable[] = []; constructor() { this.#terminals = {}; this.#terminals = new Map(); } setupRequests(client: BaseLanguageClient) { this.#listener = client.onRequest( '$/gitlab/runCommand', async ({ workflowId, command, args }) => { this.#disposables.push( client.onRequest('$/gitlab/runCommand', async ({ workflowId, command, args }) => { log.debug(`Running command: ${command} ${args.join(' ')}`); return this.#executeCommand(workflowId, command, args); }, }), ); this.#disposables.push( window.onDidCloseTerminal(closedTerminal => { for (const [workflowId, terminal] of this.#terminals.entries()) { if (terminal === closedTerminal) { this.#terminals.delete(workflowId); break; } } }), ); } Loading Loading @@ -53,7 +63,7 @@ export class TerminalManager implements Disposable { } async #getOrCreateTerminal(workflowId: string) { return this.#terminals[workflowId] ?? this.#createTerminal(workflowId); return this.#terminals.get(workflowId) ?? this.#createTerminal(workflowId); } async #createTerminal(workflowId: string) { Loading @@ -62,11 +72,11 @@ export class TerminalManager implements Disposable { isTransient: true, }); this.#terminals[workflowId] = terminal; this.#terminals.set(workflowId, terminal); await this.#listenForShellIntegration(terminal); return this.#terminals[workflowId]; return terminal; } #listenForShellIntegration(term: Terminal) { Loading @@ -91,7 +101,12 @@ export class TerminalManager implements Disposable { } dispose() { Object.values(this.#terminals).forEach(term => term.dispose()); this.#listener?.dispose(); this.#terminals.forEach(term => { term.dispose(); }); this.#disposables.forEach(disposable => { disposable.dispose(); }); } }