Skip to content

Commit 7ce200d

Browse files
authored
Merge pull request microsoft#210260 from microsoft/tyriar/si_integration_tests
Add shellIntegration ext api integration tests
2 parents dde0561 + 8b56160 commit 7ce200d

File tree

6 files changed

+262
-14
lines changed

6 files changed

+262
-14
lines changed

extensions/vscode-api-tests/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"taskPresentationGroup",
4545
"terminalDataWriteEvent",
4646
"terminalDimensions",
47+
"terminalShellIntegration",
4748
"tunnels",
4849
"testObserver",
4950
"textSearchProvider",
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { deepStrictEqual, notStrictEqual, ok, strictEqual } from 'assert';
7+
import { platform } from 'os';
8+
import { env, TerminalShellExecutionCommandLineConfidence, UIKind, window, workspace, type Disposable, type Terminal, type TerminalShellExecution, type TerminalShellExecutionCommandLine, type TerminalShellExecutionEndEvent, type TerminalShellIntegration } from 'vscode';
9+
import { assertNoRpc } from '../utils';
10+
11+
// Terminal integration tests are disabled on web https://github.com/microsoft/vscode/issues/92826
12+
// Windows images will often not have functional shell integration
13+
(env.uiKind === UIKind.Web || platform() === 'win32' ? suite.skip : suite)('vscode API - Terminal.shellIntegration', () => {
14+
const disposables: Disposable[] = [];
15+
16+
suiteSetup(async () => {
17+
const config = workspace.getConfiguration('terminal.integrated');
18+
await config.update('shellIntegration.enabled', true);
19+
});
20+
21+
suiteTeardown(async () => {
22+
const config = workspace.getConfiguration('terminal.integrated');
23+
await config.update('shellIntegration.enabled', undefined);
24+
});
25+
26+
teardown(async () => {
27+
assertNoRpc();
28+
disposables.forEach(d => d.dispose());
29+
disposables.length = 0;
30+
});
31+
32+
function createTerminalAndWaitForShellIntegration(): Promise<{ terminal: Terminal; shellIntegration: TerminalShellIntegration }> {
33+
return new Promise<{ terminal: Terminal; shellIntegration: TerminalShellIntegration }>(resolve => {
34+
disposables.push(window.onDidChangeTerminalShellIntegration(e => {
35+
if (e.terminal === terminal) {
36+
resolve({
37+
terminal,
38+
shellIntegration: e.shellIntegration
39+
});
40+
}
41+
}));
42+
const terminal = window.createTerminal();
43+
terminal.show();
44+
});
45+
}
46+
47+
function executeCommandAsync(shellIntegration: TerminalShellIntegration, command: string, args?: string[]): { execution: Promise<TerminalShellExecution>; endEvent: Promise<TerminalShellExecutionEndEvent> } {
48+
return {
49+
execution: new Promise<TerminalShellExecution>(resolve => {
50+
// Await a short period as pwsh's first SI prompt can fail when launched in quick succession
51+
setTimeout(() => {
52+
if (args) {
53+
resolve(shellIntegration.executeCommand(command, args));
54+
} else {
55+
resolve(shellIntegration.executeCommand(command));
56+
}
57+
}, 500);
58+
}),
59+
endEvent: new Promise<TerminalShellExecutionEndEvent>(resolve => {
60+
disposables.push(window.onDidEndTerminalShellExecution(e => {
61+
if (e.shellIntegration === shellIntegration) {
62+
resolve(e);
63+
}
64+
}));
65+
})
66+
};
67+
}
68+
69+
function closeTerminalAsync(terminal: Terminal): Promise<void> {
70+
return new Promise<void>(resolve => {
71+
disposables.push(window.onDidCloseTerminal(e => {
72+
if (e === terminal) {
73+
resolve();
74+
}
75+
}));
76+
terminal.dispose();
77+
});
78+
}
79+
80+
test('window.onDidChangeTerminalShellIntegration should activate for the default terminal', async () => {
81+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
82+
ok(terminal.shellIntegration);
83+
ok(shellIntegration);
84+
await closeTerminalAsync(terminal);
85+
});
86+
87+
test('execution events should fire in order when a command runs', async () => {
88+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
89+
const events: string[] = [];
90+
disposables.push(window.onDidStartTerminalShellExecution(() => events.push('start')));
91+
disposables.push(window.onDidEndTerminalShellExecution(() => events.push('end')));
92+
93+
await executeCommandAsync(shellIntegration, 'echo hello').endEvent;
94+
95+
deepStrictEqual(events, ['start', 'end']);
96+
97+
await closeTerminalAsync(terminal);
98+
});
99+
100+
test('end execution event should report zero exit code for successful commands', async () => {
101+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
102+
const events: string[] = [];
103+
disposables.push(window.onDidStartTerminalShellExecution(() => events.push('start')));
104+
disposables.push(window.onDidEndTerminalShellExecution(() => events.push('end')));
105+
106+
const endEvent = await executeCommandAsync(shellIntegration, 'echo hello').endEvent;
107+
strictEqual(endEvent.exitCode, 0);
108+
109+
await closeTerminalAsync(terminal);
110+
});
111+
112+
test('end execution event should report non-zero exit code for failed commands', async () => {
113+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
114+
const events: string[] = [];
115+
disposables.push(window.onDidStartTerminalShellExecution(() => events.push('start')));
116+
disposables.push(window.onDidEndTerminalShellExecution(() => events.push('end')));
117+
118+
const endEvent = await executeCommandAsync(shellIntegration, 'fakecommand').endEvent;
119+
notStrictEqual(endEvent.exitCode, 0);
120+
121+
await closeTerminalAsync(terminal);
122+
});
123+
124+
test('TerminalShellExecution.read iterables should be available between the start and end execution events', async () => {
125+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
126+
const events: string[] = [];
127+
disposables.push(window.onDidStartTerminalShellExecution(() => events.push('start')));
128+
disposables.push(window.onDidEndTerminalShellExecution(() => events.push('end')));
129+
130+
const { execution, endEvent } = executeCommandAsync(shellIntegration, 'echo hello');
131+
for await (const _ of (await execution).read()) {
132+
events.push('data');
133+
}
134+
await endEvent;
135+
136+
ok(events.length >= 3, `should have at least 3 events ${JSON.stringify(events)}`);
137+
strictEqual(events[0], 'start', `first event should be 'start' ${JSON.stringify(events)}`);
138+
strictEqual(events.at(-1), 'end', `last event should be 'end' ${JSON.stringify(events)}`);
139+
for (let i = 1; i < events.length - 1; i++) {
140+
strictEqual(events[i], 'data', `all middle events should be 'data' ${JSON.stringify(events)}`);
141+
}
142+
143+
await closeTerminalAsync(terminal);
144+
});
145+
146+
test('TerminalShellExecution.read events should fire with contents of command', async () => {
147+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
148+
const events: string[] = [];
149+
150+
const { execution, endEvent } = executeCommandAsync(shellIntegration, 'echo hello');
151+
for await (const data of (await execution).read()) {
152+
events.push(data);
153+
}
154+
await endEvent;
155+
156+
ok(events.join('').includes('hello'), `should include 'hello' in ${JSON.stringify(events)}`);
157+
158+
await closeTerminalAsync(terminal);
159+
});
160+
161+
test('TerminalShellExecution.read events should give separate iterables per call', async () => {
162+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
163+
164+
const { execution, endEvent } = executeCommandAsync(shellIntegration, 'echo hello');
165+
const executionSync = await execution;
166+
const firstRead = executionSync.read();
167+
const secondRead = executionSync.read();
168+
169+
const [firstReadEvents, secondReadEvents] = await Promise.all([
170+
new Promise<string[]>(resolve => {
171+
(async () => {
172+
const events: string[] = [];
173+
for await (const data of firstRead) {
174+
events.push(data);
175+
}
176+
resolve(events);
177+
})();
178+
}),
179+
new Promise<string[]>(resolve => {
180+
(async () => {
181+
const events: string[] = [];
182+
for await (const data of secondRead) {
183+
events.push(data);
184+
}
185+
resolve(events);
186+
})();
187+
}),
188+
]);
189+
await endEvent;
190+
191+
ok(firstReadEvents.join('').includes('hello'), `should include 'hello' in ${JSON.stringify(firstReadEvents)}`);
192+
deepStrictEqual(firstReadEvents, secondReadEvents);
193+
194+
await closeTerminalAsync(terminal);
195+
});
196+
197+
test('executeCommand(commandLine)', async () => {
198+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
199+
const { execution, endEvent } = executeCommandAsync(shellIntegration, 'echo hello');
200+
const executionSync = await execution;
201+
const expectedCommandLine: TerminalShellExecutionCommandLine = {
202+
value: 'echo hello',
203+
isTrusted: true,
204+
confidence: TerminalShellExecutionCommandLineConfidence.High
205+
};
206+
deepStrictEqual(executionSync.commandLine, expectedCommandLine);
207+
await endEvent;
208+
deepStrictEqual(executionSync.commandLine, expectedCommandLine);
209+
await closeTerminalAsync(terminal);
210+
});
211+
212+
test('executeCommand(executable, args)', async () => {
213+
const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration();
214+
const { execution, endEvent } = executeCommandAsync(shellIntegration, 'echo', ['hello']);
215+
const executionSync = await execution;
216+
const expectedCommandLine: TerminalShellExecutionCommandLine = {
217+
value: 'echo "hello"',
218+
isTrusted: true,
219+
confidence: TerminalShellExecutionCommandLineConfidence.High
220+
};
221+
deepStrictEqual(executionSync.commandLine, expectedCommandLine);
222+
await endEvent;
223+
deepStrictEqual(executionSync.commandLine, expectedCommandLine);
224+
await closeTerminalAsync(terminal);
225+
});
226+
});

src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ class UnixPtyHeuristics extends Disposable {
485485
}));
486486
}
487487

488-
async handleCommandStart(options?: IHandleCommandOptions) {
488+
handleCommandStart(options?: IHandleCommandOptions) {
489489
this._hooks.commitCommandFinished();
490490

491491
const currentCommand = this._capability.currentCommand;
@@ -638,7 +638,7 @@ class WindowsPtyHeuristics extends Disposable {
638638
}
639639
}
640640

641-
async handleCommandStart() {
641+
handleCommandStart() {
642642
this._capability.currentCommand.commandStartX = this._terminal.buffer.active.cursorX;
643643

644644
// On Windows track all cursor movements after the command start sequence

src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma
6969
currentCommand = undefined;
7070
const instanceId = e.instance.instanceId;
7171
instanceDataListeners.get(instanceId)?.dispose();
72-
this._proxy.$shellExecutionEnd(instanceId, e.data.command, convertToExtHostCommandLineConfidence(e.data), e.data.isTrusted, e.data.exitCode);
72+
// Send end in a microtask to ensure the data events are sent first
73+
setTimeout(() => {
74+
this._proxy.$shellExecutionEnd(instanceId, e.data.command, convertToExtHostCommandLineConfidence(e.data), e.data.isTrusted, e.data.exitCode);
75+
});
7376
}));
7477

7578
// onDidChangeTerminalShellIntegration via cwd

src/vs/workbench/api/common/extHostTerminalShellIntegration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,10 @@ class ShellExecutionDataStream extends Disposable {
292292
private _emitters: AsyncIterableEmitter<string>[] = [];
293293

294294
createIterable(): AsyncIterable<string> {
295-
const barrier = this._barrier = new Barrier();
295+
if (!this._barrier) {
296+
this._barrier = new Barrier();
297+
}
298+
const barrier = this._barrier;
296299
const iterable = new AsyncIterableObject<string>(async emitter => {
297300
this._emitters.push(emitter);
298301
await barrier.wait();

src/vs/workbench/contrib/terminal/browser/terminalInstance.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
165165
private _layoutSettingsChanged: boolean = true;
166166
private _dimensionsOverride: ITerminalDimensionsOverride | undefined;
167167
private _areLinksReady: boolean = false;
168+
private readonly _initialDataEventsListener: MutableDisposable<IDisposable> = this._register(new MutableDisposable());
168169
private _initialDataEvents: string[] | undefined = [];
169170
private _containerReadyBarrier: AutoOpenBarrier;
170171
private _attachBarrier: AutoOpenBarrier;
@@ -550,6 +551,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
550551
let initialDataEventsTimeout: number | undefined = dom.getWindow(this._container).setTimeout(() => {
551552
initialDataEventsTimeout = undefined;
552553
this._initialDataEvents = undefined;
554+
this._initialDataEventsListener.clear();
553555
}, 10000);
554556
this._register(toDisposable(() => {
555557
if (initialDataEventsTimeout) {
@@ -1394,10 +1396,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
13941396
}
13951397
}));
13961398

1397-
this._register(processManager.onProcessData(ev => {
1398-
this._initialDataEvents?.push(ev.data);
1399-
this._onData.fire(ev.data);
1400-
}));
1399+
this._initialDataEventsListener.value = processManager.onProcessData(ev => this._initialDataEvents?.push(ev.data));
14011400
this._register(processManager.onProcessReplayComplete(() => this._onProcessReplayComplete.fire()));
14021401
this._register(processManager.onEnvironmentVariableInfoChanged(e => this._onEnvironmentVariableInfoChanged(e)));
14031402
this._register(processManager.onPtyDisconnect(() => {
@@ -1480,19 +1479,35 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
14801479
}
14811480

14821481
private _onProcessData(ev: IProcessDataEvent): void {
1483-
if (ev.trackCommit) {
1484-
ev.writePromise = new Promise<void>(r => this._writeProcessData(ev, r));
1482+
// Ensure events are split by SI command execute sequence to ensure the output of the
1483+
// command can be read by extensions. This must be done here as xterm.js does not currently
1484+
// have a listener for when individual data events are parsed, only `onWriteParsed` which
1485+
// fires when the write buffer is flushed.
1486+
const execIndex = ev.data.indexOf('\x1b]633;C\x07');
1487+
if (execIndex !== -1) {
1488+
if (ev.trackCommit) {
1489+
this._writeProcessData(ev.data.substring(0, execIndex + '\x1b]633;C\x07'.length));
1490+
ev.writePromise = new Promise<void>(r => this._writeProcessData(ev.data.substring(execIndex + '\x1b]633;C\x07'.length), r));
1491+
} else {
1492+
this._writeProcessData(ev.data.substring(0, execIndex + '\x1b]633;C\x07'.length));
1493+
this._writeProcessData(ev.data.substring(execIndex + '\x1b]633;C\x07'.length));
1494+
}
14851495
} else {
1486-
this._writeProcessData(ev);
1496+
if (ev.trackCommit) {
1497+
ev.writePromise = new Promise<void>(r => this._writeProcessData(ev.data, r));
1498+
} else {
1499+
this._writeProcessData(ev.data);
1500+
}
14871501
}
14881502
}
14891503

1490-
private _writeProcessData(ev: IProcessDataEvent, cb?: () => void) {
1504+
private _writeProcessData(data: string, cb?: () => void) {
14911505
const messageId = ++this._latestXtermWriteData;
1492-
this.xterm?.raw.write(ev.data, () => {
1506+
this.xterm?.raw.write(data, () => {
14931507
this._latestXtermParseData = messageId;
1494-
this._processManager.acknowledgeDataEvent(ev.data.length);
1508+
this._processManager.acknowledgeDataEvent(data.length);
14951509
cb?.();
1510+
this._onData.fire(data);
14961511
});
14971512
}
14981513

0 commit comments

Comments
 (0)