Path: blob/main/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { DisposableStore } from '../../../../base/common/lifecycle.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';8import { ActionType, StateAction } from '../../common/state/protocol/actions.js';9import { TerminalContentPart } from '../../common/state/protocol/state.js';10import { Osc633Event, Osc633EventType, Osc633Parser } from '../../node/osc633Parser.js';1112/**13* Tests for the command detection integration in AgentHostTerminalManager.14*15* Since AgentHostTerminalManager.createTerminal requires node-pty, these tests16* exercise the data-handling logic (OSC parsing → action dispatch → content17* tracking) in isolation by simulating the internal flow.18*/1920// ── Helpers to simulate the terminal manager's data pipeline ─────────2122/** Minimal command tracker mirroring AgentHostTerminalManager's ICommandTracker. */23interface ITestCommandTracker {24readonly parser: Osc633Parser;25readonly nonce: string;26commandCounter: number;27detectionAvailableEmitted: boolean;28pendingCommandLine?: string;29activeCommandId?: string;30activeCommandTimestamp?: number;31}3233/**34* Simplified version of AgentHostTerminalManager's data handling pipeline35* that can be tested without node-pty or a real AgentHostStateManager.36*/37class TestTerminalDataHandler {38readonly dispatched: StateAction[] = [];39content: TerminalContentPart[] = [];40cwd = '/home/user';4142constructor(43readonly uri: string,44readonly tracker: ITestCommandTracker,45) { }4647/** Simulates AgentHostTerminalManager._handlePtyData */48handlePtyData(rawData: string): string {49const parseResult = this.tracker.parser.parse(rawData);50const cleanedData = parseResult.cleanedData;5152for (const event of parseResult.events) {53this._handleOsc633Event(event);54}5556if (cleanedData.length > 0) {57this._appendToContent(cleanedData);58}5960return cleanedData;61}6263private _handleOsc633Event(event: Osc633Event): void {64if (!this.tracker.detectionAvailableEmitted) {65this.tracker.detectionAvailableEmitted = true;66this.dispatched.push({67type: ActionType.TerminalCommandDetectionAvailable,68terminal: this.uri,69});70}7172switch (event.type) {73case Osc633EventType.CommandLine: {74if (event.nonce === this.tracker.nonce) {75this.tracker.pendingCommandLine = event.commandLine;76}77break;78}79case Osc633EventType.CommandExecuted: {80const commandId = `cmd-${++this.tracker.commandCounter}`;81const commandLine = this.tracker.pendingCommandLine ?? '';82const timestamp = Date.now();83this.tracker.pendingCommandLine = undefined;84this.tracker.activeCommandId = commandId;85this.tracker.activeCommandTimestamp = timestamp;8687this.content.push({88type: 'command',89commandId,90commandLine,91output: '',92timestamp,93isComplete: false,94});9596this.dispatched.push({97type: ActionType.TerminalCommandExecuted,98terminal: this.uri,99commandId,100commandLine,101timestamp,102});103break;104}105case Osc633EventType.CommandFinished: {106const finishedCommandId = this.tracker.activeCommandId;107if (!finishedCommandId) {108break;109}110const durationMs = this.tracker.activeCommandTimestamp !== undefined111? Date.now() - this.tracker.activeCommandTimestamp112: undefined;113114for (const part of this.content) {115if (part.type === 'command' && part.commandId === finishedCommandId) {116part.isComplete = true;117part.exitCode = event.exitCode;118part.durationMs = durationMs;119break;120}121}122123this.tracker.activeCommandId = undefined;124this.tracker.activeCommandTimestamp = undefined;125126this.dispatched.push({127type: ActionType.TerminalCommandFinished,128terminal: this.uri,129commandId: finishedCommandId,130exitCode: event.exitCode,131durationMs,132});133break;134}135case Osc633EventType.Property: {136if (event.key === 'Cwd') {137this.cwd = event.value;138this.dispatched.push({139type: ActionType.TerminalCwdChanged,140terminal: this.uri,141cwd: event.value,142});143}144break;145}146}147}148149private _appendToContent(data: string): void {150const tail = this.content.length > 0 ? this.content[this.content.length - 1] : undefined;151if (tail && tail.type === 'command' && !tail.isComplete) {152tail.output += data;153} else if (tail && tail.type === 'unclassified') {154tail.value += data;155} else {156this.content.push({ type: 'unclassified', value: data });157}158}159}160161function osc633(payload: string): string {162return `\x1b]633;${payload}\x07`;163}164165function createHandler(nonce = 'test-nonce'): TestTerminalDataHandler {166return new TestTerminalDataHandler('terminal://test', {167parser: new Osc633Parser(),168nonce,169commandCounter: 0,170detectionAvailableEmitted: false,171});172}173174suite('AgentHostTerminalManager – command detection integration', () => {175176const disposables = new DisposableStore();177teardown(() => disposables.clear());178ensureNoDisposablesAreLeakedInTestSuite();179180test('TerminalCommandDetectionAvailable is dispatched on first OSC 633', () => {181const handler = createHandler();182183handler.handlePtyData(osc633('A'));184185assert.strictEqual(handler.dispatched.length, 1);186assert.strictEqual(handler.dispatched[0].type, ActionType.TerminalCommandDetectionAvailable);187});188189test('TerminalCommandDetectionAvailable is dispatched only once', () => {190const handler = createHandler();191192handler.handlePtyData(osc633('A'));193handler.handlePtyData(osc633('B'));194handler.handlePtyData(osc633('A'));195196const detectionActions = handler.dispatched.filter(197a => a.type === ActionType.TerminalCommandDetectionAvailable198);199assert.strictEqual(detectionActions.length, 1);200});201202test('full command lifecycle dispatches correct actions', () => {203const handler = createHandler();204205// Shell prompt206handler.handlePtyData(`${osc633('A')}$ ${osc633('B')}`);207// Command entered, shell reports command line and executes208handler.handlePtyData(`${osc633('E;echo\\x20hello;test-nonce')}${osc633('C')}`);209// Command output210handler.handlePtyData('hello\r\n');211// Command finishes212handler.handlePtyData(osc633('D;0'));213214const actions = handler.dispatched;215// Expect: DetectionAvailable, CommandExecuted, CommandFinished216assert.strictEqual(actions[0].type, ActionType.TerminalCommandDetectionAvailable);217218const executed = actions.find(a => a.type === ActionType.TerminalCommandExecuted);219assert.ok(executed);220assert.strictEqual(executed.commandId, 'cmd-1');221assert.strictEqual(executed.commandLine, 'echo hello');222223const finished = actions.find(a => a.type === ActionType.TerminalCommandFinished);224assert.ok(finished);225assert.strictEqual(finished.commandId, 'cmd-1');226assert.strictEqual(finished.exitCode, 0);227});228229test('content parts are structured correctly after command lifecycle', () => {230const handler = createHandler();231232// Prompt output (before command)233handler.handlePtyData(`${osc633('A')}user@host:~ $ ${osc633('B')}`);234// Command line + execute235handler.handlePtyData(`${osc633('E;ls;test-nonce')}${osc633('C')}`);236// Command output237handler.handlePtyData('file1\nfile2\n');238// Command finishes239handler.handlePtyData(osc633('D;0'));240// New prompt241handler.handlePtyData(`${osc633('A')}user@host:~ $ `);242243assert.deepStrictEqual(handler.content.map(p => ({244type: p.type,245...(p.type === 'unclassified' ? { value: p.value } : {246commandId: p.commandId,247commandLine: p.commandLine,248output: p.output,249isComplete: p.isComplete,250exitCode: p.exitCode,251}),252})), [253{ type: 'unclassified', value: 'user@host:~ $ ' },254{255type: 'command',256commandId: 'cmd-1',257commandLine: 'ls',258output: 'file1\nfile2\n',259isComplete: true,260exitCode: 0,261},262{ type: 'unclassified', value: 'user@host:~ $ ' },263]);264});265266test('nonce validation rejects untrusted command lines', () => {267const handler = createHandler('my-secret-nonce');268269// Malicious output containing a fake command line with wrong nonce270handler.handlePtyData(osc633('E;rm\\x20-rf\\x20/;wrong-nonce'));271handler.handlePtyData(osc633('C'));272273const executed = handler.dispatched.find(a => a.type === ActionType.TerminalCommandExecuted);274assert.ok(executed);275// Command line should be empty because the nonce didn't match276assert.strictEqual(executed.commandLine, '');277});278279test('nonce validation accepts trusted command lines', () => {280const handler = createHandler('my-secret-nonce');281282handler.handlePtyData(osc633('E;echo\\x20safe;my-secret-nonce'));283handler.handlePtyData(osc633('C'));284285const executed = handler.dispatched.find(a => a.type === ActionType.TerminalCommandExecuted);286assert.ok(executed);287assert.strictEqual(executed.commandLine, 'echo safe');288});289290test('multiple sequential commands get sequential IDs', () => {291const handler = createHandler();292293// First command294handler.handlePtyData(`${osc633('E;cmd1;test-nonce')}${osc633('C')}`);295handler.handlePtyData(osc633('D;0'));296297// Second command298handler.handlePtyData(`${osc633('E;cmd2;test-nonce')}${osc633('C')}`);299handler.handlePtyData(osc633('D;1'));300301const executed = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandExecuted);302assert.strictEqual(executed.length, 2);303assert.strictEqual(executed[0].commandId, 'cmd-1');304assert.strictEqual(executed[0].commandLine, 'cmd1');305assert.strictEqual(executed[1].commandId, 'cmd-2');306assert.strictEqual(executed[1].commandLine, 'cmd2');307308const finished = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandFinished);309assert.strictEqual(finished.length, 2);310assert.strictEqual(finished[0].commandId, 'cmd-1');311assert.strictEqual(finished[0].exitCode, 0);312assert.strictEqual(finished[1].commandId, 'cmd-2');313assert.strictEqual(finished[1].exitCode, 1);314});315316test('CWD property dispatches TerminalCwdChanged', () => {317const handler = createHandler();318319handler.handlePtyData(osc633('P;Cwd=/new/working/dir'));320321const cwdAction = handler.dispatched.find(a => a.type === ActionType.TerminalCwdChanged);322assert.ok(cwdAction);323assert.strictEqual(cwdAction.cwd, '/new/working/dir');324assert.strictEqual(handler.cwd, '/new/working/dir');325});326327test('OSC 633 sequences are stripped from cleaned output', () => {328const handler = createHandler();329330const cleaned = handler.handlePtyData(331`before${osc633('A')}prompt${osc633('B')}${osc633('E;ls;test-nonce')}${osc633('C')}output${osc633('D;0')}after`332);333334assert.strictEqual(cleaned, 'beforepromptoutputafter');335});336337test('data without shell integration passes through unmodified', () => {338const handler = new TestTerminalDataHandler('terminal://test', {339parser: new Osc633Parser(),340nonce: 'nonce',341commandCounter: 0,342detectionAvailableEmitted: false,343});344345const data = 'regular terminal output with \x1b[31mcolors\x1b[0m';346const cleaned = handler.handlePtyData(data);347348assert.strictEqual(cleaned, data);349assert.deepStrictEqual(handler.content, [350{ type: 'unclassified', value: data },351]);352assert.deepStrictEqual(handler.dispatched, []);353});354355test('CommandFinished without active command is ignored', () => {356const handler = createHandler();357358// Emit a PromptStart to trigger detection available, then finish without execute359handler.handlePtyData(osc633('A'));360handler.handlePtyData(osc633('D;0'));361362const finished = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandFinished);363assert.strictEqual(finished.length, 0);364});365366test('command output is accumulated in the command content part', () => {367const handler = createHandler();368369handler.handlePtyData(`${osc633('E;test;test-nonce')}${osc633('C')}`);370handler.handlePtyData('line1\r\n');371handler.handlePtyData('line2\r\n');372handler.handlePtyData('line3\r\n');373handler.handlePtyData(osc633('D;0'));374375const cmdParts = handler.content.filter(p => p.type === 'command');376assert.strictEqual(cmdParts.length, 1);377assert.strictEqual(cmdParts[0].type === 'command' && cmdParts[0].output, 'line1\r\nline2\r\nline3\r\n');378});379});380381382