Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts
13399 views
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 assert from 'assert';
7
import { DisposableStore } from '../../../../base/common/lifecycle.js';
8
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
9
import { ActionType, StateAction } from '../../common/state/protocol/actions.js';
10
import { TerminalContentPart } from '../../common/state/protocol/state.js';
11
import { Osc633Event, Osc633EventType, Osc633Parser } from '../../node/osc633Parser.js';
12
13
/**
14
* Tests for the command detection integration in AgentHostTerminalManager.
15
*
16
* Since AgentHostTerminalManager.createTerminal requires node-pty, these tests
17
* exercise the data-handling logic (OSC parsing → action dispatch → content
18
* tracking) in isolation by simulating the internal flow.
19
*/
20
21
// ── Helpers to simulate the terminal manager's data pipeline ─────────
22
23
/** Minimal command tracker mirroring AgentHostTerminalManager's ICommandTracker. */
24
interface ITestCommandTracker {
25
readonly parser: Osc633Parser;
26
readonly nonce: string;
27
commandCounter: number;
28
detectionAvailableEmitted: boolean;
29
pendingCommandLine?: string;
30
activeCommandId?: string;
31
activeCommandTimestamp?: number;
32
}
33
34
/**
35
* Simplified version of AgentHostTerminalManager's data handling pipeline
36
* that can be tested without node-pty or a real AgentHostStateManager.
37
*/
38
class TestTerminalDataHandler {
39
readonly dispatched: StateAction[] = [];
40
content: TerminalContentPart[] = [];
41
cwd = '/home/user';
42
43
constructor(
44
readonly uri: string,
45
readonly tracker: ITestCommandTracker,
46
) { }
47
48
/** Simulates AgentHostTerminalManager._handlePtyData */
49
handlePtyData(rawData: string): string {
50
const parseResult = this.tracker.parser.parse(rawData);
51
const cleanedData = parseResult.cleanedData;
52
53
for (const event of parseResult.events) {
54
this._handleOsc633Event(event);
55
}
56
57
if (cleanedData.length > 0) {
58
this._appendToContent(cleanedData);
59
}
60
61
return cleanedData;
62
}
63
64
private _handleOsc633Event(event: Osc633Event): void {
65
if (!this.tracker.detectionAvailableEmitted) {
66
this.tracker.detectionAvailableEmitted = true;
67
this.dispatched.push({
68
type: ActionType.TerminalCommandDetectionAvailable,
69
terminal: this.uri,
70
});
71
}
72
73
switch (event.type) {
74
case Osc633EventType.CommandLine: {
75
if (event.nonce === this.tracker.nonce) {
76
this.tracker.pendingCommandLine = event.commandLine;
77
}
78
break;
79
}
80
case Osc633EventType.CommandExecuted: {
81
const commandId = `cmd-${++this.tracker.commandCounter}`;
82
const commandLine = this.tracker.pendingCommandLine ?? '';
83
const timestamp = Date.now();
84
this.tracker.pendingCommandLine = undefined;
85
this.tracker.activeCommandId = commandId;
86
this.tracker.activeCommandTimestamp = timestamp;
87
88
this.content.push({
89
type: 'command',
90
commandId,
91
commandLine,
92
output: '',
93
timestamp,
94
isComplete: false,
95
});
96
97
this.dispatched.push({
98
type: ActionType.TerminalCommandExecuted,
99
terminal: this.uri,
100
commandId,
101
commandLine,
102
timestamp,
103
});
104
break;
105
}
106
case Osc633EventType.CommandFinished: {
107
const finishedCommandId = this.tracker.activeCommandId;
108
if (!finishedCommandId) {
109
break;
110
}
111
const durationMs = this.tracker.activeCommandTimestamp !== undefined
112
? Date.now() - this.tracker.activeCommandTimestamp
113
: undefined;
114
115
for (const part of this.content) {
116
if (part.type === 'command' && part.commandId === finishedCommandId) {
117
part.isComplete = true;
118
part.exitCode = event.exitCode;
119
part.durationMs = durationMs;
120
break;
121
}
122
}
123
124
this.tracker.activeCommandId = undefined;
125
this.tracker.activeCommandTimestamp = undefined;
126
127
this.dispatched.push({
128
type: ActionType.TerminalCommandFinished,
129
terminal: this.uri,
130
commandId: finishedCommandId,
131
exitCode: event.exitCode,
132
durationMs,
133
});
134
break;
135
}
136
case Osc633EventType.Property: {
137
if (event.key === 'Cwd') {
138
this.cwd = event.value;
139
this.dispatched.push({
140
type: ActionType.TerminalCwdChanged,
141
terminal: this.uri,
142
cwd: event.value,
143
});
144
}
145
break;
146
}
147
}
148
}
149
150
private _appendToContent(data: string): void {
151
const tail = this.content.length > 0 ? this.content[this.content.length - 1] : undefined;
152
if (tail && tail.type === 'command' && !tail.isComplete) {
153
tail.output += data;
154
} else if (tail && tail.type === 'unclassified') {
155
tail.value += data;
156
} else {
157
this.content.push({ type: 'unclassified', value: data });
158
}
159
}
160
}
161
162
function osc633(payload: string): string {
163
return `\x1b]633;${payload}\x07`;
164
}
165
166
function createHandler(nonce = 'test-nonce'): TestTerminalDataHandler {
167
return new TestTerminalDataHandler('terminal://test', {
168
parser: new Osc633Parser(),
169
nonce,
170
commandCounter: 0,
171
detectionAvailableEmitted: false,
172
});
173
}
174
175
suite('AgentHostTerminalManager – command detection integration', () => {
176
177
const disposables = new DisposableStore();
178
teardown(() => disposables.clear());
179
ensureNoDisposablesAreLeakedInTestSuite();
180
181
test('TerminalCommandDetectionAvailable is dispatched on first OSC 633', () => {
182
const handler = createHandler();
183
184
handler.handlePtyData(osc633('A'));
185
186
assert.strictEqual(handler.dispatched.length, 1);
187
assert.strictEqual(handler.dispatched[0].type, ActionType.TerminalCommandDetectionAvailable);
188
});
189
190
test('TerminalCommandDetectionAvailable is dispatched only once', () => {
191
const handler = createHandler();
192
193
handler.handlePtyData(osc633('A'));
194
handler.handlePtyData(osc633('B'));
195
handler.handlePtyData(osc633('A'));
196
197
const detectionActions = handler.dispatched.filter(
198
a => a.type === ActionType.TerminalCommandDetectionAvailable
199
);
200
assert.strictEqual(detectionActions.length, 1);
201
});
202
203
test('full command lifecycle dispatches correct actions', () => {
204
const handler = createHandler();
205
206
// Shell prompt
207
handler.handlePtyData(`${osc633('A')}$ ${osc633('B')}`);
208
// Command entered, shell reports command line and executes
209
handler.handlePtyData(`${osc633('E;echo\\x20hello;test-nonce')}${osc633('C')}`);
210
// Command output
211
handler.handlePtyData('hello\r\n');
212
// Command finishes
213
handler.handlePtyData(osc633('D;0'));
214
215
const actions = handler.dispatched;
216
// Expect: DetectionAvailable, CommandExecuted, CommandFinished
217
assert.strictEqual(actions[0].type, ActionType.TerminalCommandDetectionAvailable);
218
219
const executed = actions.find(a => a.type === ActionType.TerminalCommandExecuted);
220
assert.ok(executed);
221
assert.strictEqual(executed.commandId, 'cmd-1');
222
assert.strictEqual(executed.commandLine, 'echo hello');
223
224
const finished = actions.find(a => a.type === ActionType.TerminalCommandFinished);
225
assert.ok(finished);
226
assert.strictEqual(finished.commandId, 'cmd-1');
227
assert.strictEqual(finished.exitCode, 0);
228
});
229
230
test('content parts are structured correctly after command lifecycle', () => {
231
const handler = createHandler();
232
233
// Prompt output (before command)
234
handler.handlePtyData(`${osc633('A')}user@host:~ $ ${osc633('B')}`);
235
// Command line + execute
236
handler.handlePtyData(`${osc633('E;ls;test-nonce')}${osc633('C')}`);
237
// Command output
238
handler.handlePtyData('file1\nfile2\n');
239
// Command finishes
240
handler.handlePtyData(osc633('D;0'));
241
// New prompt
242
handler.handlePtyData(`${osc633('A')}user@host:~ $ `);
243
244
assert.deepStrictEqual(handler.content.map(p => ({
245
type: p.type,
246
...(p.type === 'unclassified' ? { value: p.value } : {
247
commandId: p.commandId,
248
commandLine: p.commandLine,
249
output: p.output,
250
isComplete: p.isComplete,
251
exitCode: p.exitCode,
252
}),
253
})), [
254
{ type: 'unclassified', value: 'user@host:~ $ ' },
255
{
256
type: 'command',
257
commandId: 'cmd-1',
258
commandLine: 'ls',
259
output: 'file1\nfile2\n',
260
isComplete: true,
261
exitCode: 0,
262
},
263
{ type: 'unclassified', value: 'user@host:~ $ ' },
264
]);
265
});
266
267
test('nonce validation rejects untrusted command lines', () => {
268
const handler = createHandler('my-secret-nonce');
269
270
// Malicious output containing a fake command line with wrong nonce
271
handler.handlePtyData(osc633('E;rm\\x20-rf\\x20/;wrong-nonce'));
272
handler.handlePtyData(osc633('C'));
273
274
const executed = handler.dispatched.find(a => a.type === ActionType.TerminalCommandExecuted);
275
assert.ok(executed);
276
// Command line should be empty because the nonce didn't match
277
assert.strictEqual(executed.commandLine, '');
278
});
279
280
test('nonce validation accepts trusted command lines', () => {
281
const handler = createHandler('my-secret-nonce');
282
283
handler.handlePtyData(osc633('E;echo\\x20safe;my-secret-nonce'));
284
handler.handlePtyData(osc633('C'));
285
286
const executed = handler.dispatched.find(a => a.type === ActionType.TerminalCommandExecuted);
287
assert.ok(executed);
288
assert.strictEqual(executed.commandLine, 'echo safe');
289
});
290
291
test('multiple sequential commands get sequential IDs', () => {
292
const handler = createHandler();
293
294
// First command
295
handler.handlePtyData(`${osc633('E;cmd1;test-nonce')}${osc633('C')}`);
296
handler.handlePtyData(osc633('D;0'));
297
298
// Second command
299
handler.handlePtyData(`${osc633('E;cmd2;test-nonce')}${osc633('C')}`);
300
handler.handlePtyData(osc633('D;1'));
301
302
const executed = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandExecuted);
303
assert.strictEqual(executed.length, 2);
304
assert.strictEqual(executed[0].commandId, 'cmd-1');
305
assert.strictEqual(executed[0].commandLine, 'cmd1');
306
assert.strictEqual(executed[1].commandId, 'cmd-2');
307
assert.strictEqual(executed[1].commandLine, 'cmd2');
308
309
const finished = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandFinished);
310
assert.strictEqual(finished.length, 2);
311
assert.strictEqual(finished[0].commandId, 'cmd-1');
312
assert.strictEqual(finished[0].exitCode, 0);
313
assert.strictEqual(finished[1].commandId, 'cmd-2');
314
assert.strictEqual(finished[1].exitCode, 1);
315
});
316
317
test('CWD property dispatches TerminalCwdChanged', () => {
318
const handler = createHandler();
319
320
handler.handlePtyData(osc633('P;Cwd=/new/working/dir'));
321
322
const cwdAction = handler.dispatched.find(a => a.type === ActionType.TerminalCwdChanged);
323
assert.ok(cwdAction);
324
assert.strictEqual(cwdAction.cwd, '/new/working/dir');
325
assert.strictEqual(handler.cwd, '/new/working/dir');
326
});
327
328
test('OSC 633 sequences are stripped from cleaned output', () => {
329
const handler = createHandler();
330
331
const cleaned = handler.handlePtyData(
332
`before${osc633('A')}prompt${osc633('B')}${osc633('E;ls;test-nonce')}${osc633('C')}output${osc633('D;0')}after`
333
);
334
335
assert.strictEqual(cleaned, 'beforepromptoutputafter');
336
});
337
338
test('data without shell integration passes through unmodified', () => {
339
const handler = new TestTerminalDataHandler('terminal://test', {
340
parser: new Osc633Parser(),
341
nonce: 'nonce',
342
commandCounter: 0,
343
detectionAvailableEmitted: false,
344
});
345
346
const data = 'regular terminal output with \x1b[31mcolors\x1b[0m';
347
const cleaned = handler.handlePtyData(data);
348
349
assert.strictEqual(cleaned, data);
350
assert.deepStrictEqual(handler.content, [
351
{ type: 'unclassified', value: data },
352
]);
353
assert.deepStrictEqual(handler.dispatched, []);
354
});
355
356
test('CommandFinished without active command is ignored', () => {
357
const handler = createHandler();
358
359
// Emit a PromptStart to trigger detection available, then finish without execute
360
handler.handlePtyData(osc633('A'));
361
handler.handlePtyData(osc633('D;0'));
362
363
const finished = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandFinished);
364
assert.strictEqual(finished.length, 0);
365
});
366
367
test('command output is accumulated in the command content part', () => {
368
const handler = createHandler();
369
370
handler.handlePtyData(`${osc633('E;test;test-nonce')}${osc633('C')}`);
371
handler.handlePtyData('line1\r\n');
372
handler.handlePtyData('line2\r\n');
373
handler.handlePtyData('line3\r\n');
374
handler.handlePtyData(osc633('D;0'));
375
376
const cmdParts = handler.content.filter(p => p.type === 'command');
377
assert.strictEqual(cmdParts.length, 1);
378
assert.strictEqual(cmdParts[0].type === 'command' && cmdParts[0].output, 'line1\r\nline2\r\nline3\r\n');
379
});
380
});
381
382