From c175c387742c41cac1b83a3de03677f226519548 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Wed, 2 Sep 2026 14:39:47 -0400 Subject: [PATCH 1/6] Preserve output channel visibility on restart --- client-node-tests/src/integration.test.ts | 46 +++++++++++++++++++++++ client/src/common/client.ts | 20 ++++++++++ client/src/node/main.ts | 2 + 3 files changed, 68 insertions(+) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index c4b4bfbe2..113d826bb 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -242,6 +242,52 @@ suite('Server output', () => { }); }); +suite('Client restart', () => { + + test('Preserves output channel visibility after restart', async () => { + const client = new RestartTestLanguageClient(true); + + await client.restart(); + + assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:true']); + }); + + test('Does not restore hidden output channel after restart', async () => { + const client = new RestartTestLanguageClient(false); + + await client.restart(); + + assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + }); +}); + +class RestartTestLanguageClient extends lsclient.LanguageClient { + + public readonly events: string[] = []; + + public constructor(private readonly outputChannelVisible: boolean) { + super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, {}); + } + + public override async start(): Promise { + this.events.push('start'); + } + + public override stop(): Promise { + this.events.push('stop'); + return Promise.resolve(); + } + + protected override isOutputChannelVisible(): boolean { + this.events.push('isOutputChannelVisible'); + return this.outputChannelVisible; + } + + protected override restoreOutputChannelVisibility(wasVisible: boolean): void { + this.events.push(`restoreOutputChannelVisibility:${wasVisible}`); + } +} + suite('Client integration', () => { let client!: lsclient.LanguageClient; diff --git a/client/src/common/client.ts b/client/src/common/client.ts index a5953c6ab..ed6dafd3a 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -858,6 +858,26 @@ export abstract class BaseLanguageClient implements FeatureClient { + if (editor.document.uri.scheme !== 'output') { + return false; + } + const outputResource = `${editor.document.uri.toString(true)}\n${editor.document.fileName}`.toLowerCase(); + return outputResource.includes(outputChannelName); + }); + } + + protected restoreOutputChannelVisibility(wasVisible: boolean): void { + if (wasVisible) { + this.outputChannel.show(true); + } + } + public get traceOutputChannel(): LogOutputChannel { return this._traceOutputChannel ? this._traceOutputChannel : this.outputChannel; } diff --git a/client/src/node/main.ts b/client/src/node/main.ts index 5f91530c4..7f128c5cf 100644 --- a/client/src/node/main.ts +++ b/client/src/node/main.ts @@ -236,6 +236,7 @@ export class LanguageClient extends BaseLanguageClient { } public async restart(): Promise { + const outputChannelVisible = this.isOutputChannelVisible(); await this.stop(); // We are in debug mode. Wait a little before we restart // so that the debug port can be freed. We can safely ignore @@ -247,6 +248,7 @@ export class LanguageClient extends BaseLanguageClient { } else { await this.start(); } + this.restoreOutputChannelVisibility(outputChannelVisible); } protected shutdown(mode: ShutdownMode, timeout: number = 2000): Promise { From 7734809416873c38451d647fabd87a3f156ccfee Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Thu, 10 Sep 2026 04:00:08 -0400 Subject: [PATCH 2/6] Tighten output channel visibility detection --- client-node-tests/src/integration.test.ts | 79 ++++++++++++++++++++--- client/src/common/client.ts | 20 ++++-- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index 113d826bb..3a044ccbc 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -245,19 +245,47 @@ suite('Server output', () => { suite('Client restart', () => { test('Preserves output channel visibility after restart', async () => { - const client = new RestartTestLanguageClient(true); + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:ms-vscode.test-extension.Restart.log', 'Restart.log') + ]); await client.restart(); - assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:true']); + assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:true']); }); test('Does not restore hidden output channel after restart', async () => { - const client = new RestartTestLanguageClient(false); + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:ms-vscode.restart.Other.log', 'Other.log') + ]); await client.restart(); - assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + }); + + test('Detects visible output channel by normalized log channel id suffix', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:ms-vscode.test-extension.ESLint.log', 'ESLint.log') + ]); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Does not match another visible output channel by substring', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:ms-vscode.eslint-extension.Other.log', 'Other.log') + ]); + + assert.strictEqual(client.isTestOutputChannelVisible(), false); + }); + + test('Detects output channels with VS Code sanitized log file names', () => { + const client = new RestartTestLanguageClient('C/C++', [ + createOutputTextEditor('output:ms-vscode.test-extension.CC++.log', 'CC++.log') + ]); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); }); }); @@ -265,8 +293,12 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { public readonly events: string[] = []; - public constructor(private readonly outputChannelVisible: boolean) { - super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, {}); + public constructor(outputChannelName: string, private readonly visibleTextEditors: readonly vscode.TextEditor[]) { + super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, { outputChannel: createLogOutputChannel(outputChannelName) }); + } + + public isTestOutputChannelVisible(): boolean { + return this.isOutputChannelVisible(); } public override async start(): Promise { @@ -278,9 +310,9 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { return Promise.resolve(); } - protected override isOutputChannelVisible(): boolean { - this.events.push('isOutputChannelVisible'); - return this.outputChannelVisible; + protected override getVisibleTextEditors(): readonly vscode.TextEditor[] { + this.events.push('getVisibleTextEditors'); + return this.visibleTextEditors; } protected override restoreOutputChannelVisibility(wasVisible: boolean): void { @@ -288,6 +320,35 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { } } +function createOutputTextEditor(uri: string, fileName: string): vscode.TextEditor { + return { + document: { + uri: vscode.Uri.parse(uri), + fileName + } + } as vscode.TextEditor; +} + +function createLogOutputChannel(name: string): vscode.LogOutputChannel { + return { + name, + append: () => undefined, + appendLine: () => undefined, + replace: () => undefined, + clear: () => undefined, + show: () => undefined, + hide: () => undefined, + dispose: () => undefined, + logLevel: vscode.LogLevel.Info, + onDidChangeLogLevel: () => ({ dispose: () => undefined }), + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined + } as vscode.LogOutputChannel; +} + suite('Client integration', () => { let client!: lsclient.LanguageClient; diff --git a/client/src/common/client.ts b/client/src/common/client.ts index ed6dafd3a..8d20619e0 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -862,16 +862,19 @@ export abstract class BaseLanguageClient implements FeatureClient { + const outputChannelResource = getOutputChannelResourceName(this._outputChannel.name); + return this.getVisibleTextEditors().some(editor => { if (editor.document.uri.scheme !== 'output') { return false; } - const outputResource = `${editor.document.uri.toString(true)}\n${editor.document.fileName}`.toLowerCase(); - return outputResource.includes(outputChannelName); + return matchesOutputChannelResource(editor.document.uri.toString(true), outputChannelResource) || matchesOutputChannelResource(editor.document.fileName, outputChannelResource); }); } + protected getVisibleTextEditors(): readonly TextEditor[] { + return Window.visibleTextEditors; + } + protected restoreOutputChannelVisibility(wasVisible: boolean): void { if (wasVisible) { this.outputChannel.show(true); @@ -2568,6 +2571,15 @@ function createConnection(input: MessageReader, output: MessageWriter, errorHand return result; } +function getOutputChannelResourceName(name: string): string { + return `${name.replace(/[\\/:\*\?"<>\|]/g, '')}.log`.toLowerCase(); +} + +function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { + const normalizedResource = resource.replace(/\\/g, '/').toLowerCase(); + return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`.${outputChannelResource}`); +} + // Exporting proposed protocol. export namespace ProposedFeatures { From b89791472b9aa513443a151c9b3e281e8e79f0f1 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Mon, 21 Sep 2026 00:48:53 -0400 Subject: [PATCH 3/6] Fix output channel visibility matching --- client-node-tests/src/integration.test.ts | 54 +++++++++++++++++------ client/src/common/client.ts | 26 +++++++++-- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index a0dcb0fc9..23f6ead29 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -246,8 +246,8 @@ suite('Client restart', () => { test('Preserves output channel visibility after restart', async () => { const client = new RestartTestLanguageClient('Restart', [ - createOutputTextEditor('output:ms-vscode.test-extension.Restart.log', 'Restart.log') - ]); + createOutputTextEditor('output:ms-vscode.test-extension.Restart.log', 'ms-vscode.test-extension.Restart.log') + ], 'ms-vscode.test-extension'); await client.restart(); @@ -256,34 +256,58 @@ suite('Client restart', () => { test('Does not restore hidden output channel after restart', async () => { const client = new RestartTestLanguageClient('Restart', [ - createOutputTextEditor('output:ms-vscode.restart.Other.log', 'Other.log') - ]); + createOutputTextEditor('output:ms-vscode.test-extension.Other.log', 'ms-vscode.test-extension.Other.log') + ], 'ms-vscode.test-extension'); await client.restart(); assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:false']); }); - test('Detects visible output channel by normalized log channel id suffix', () => { + test('Detects visible output channel by normalized log channel id', () => { const client = new RestartTestLanguageClient('ESLint', [ - createOutputTextEditor('output:ms-vscode.test-extension.ESLint.log', 'ESLint.log') - ]); + createOutputTextEditor('output:dbaeumer.vscode-eslint.ESLint.log', 'dbaeumer.vscode-eslint.ESLint.log') + ], 'dbaeumer.vscode-eslint'); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Uses language client id as the default output channel id', () => { + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:test-restart.Restart.log', 'test-restart.Restart.log') + ], undefined); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Does not match the same output channel name from another extension', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:publisher.other-extension.ESLint.log', 'publisher.other-extension.ESLint.log') + ], 'dbaeumer.vscode-eslint'); + + assert.strictEqual(client.isTestOutputChannelVisible(), false); + }); + + test('Detects visible output channel by file name', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:unknown', 'dbaeumer.vscode-eslint.ESLint.log') + ], 'dbaeumer.vscode-eslint'); assert.strictEqual(client.isTestOutputChannelVisible(), true); }); test('Does not match another visible output channel by substring', () => { const client = new RestartTestLanguageClient('ESLint', [ - createOutputTextEditor('output:ms-vscode.eslint-extension.Other.log', 'Other.log') - ]); + createOutputTextEditor('output:publisher.eslint-extension.Other.log', 'publisher.eslint-extension.Other.log') + ], 'dbaeumer.vscode-eslint'); assert.strictEqual(client.isTestOutputChannelVisible(), false); }); test('Detects output channels with VS Code sanitized log file names', () => { const client = new RestartTestLanguageClient('C/C++', [ - createOutputTextEditor('output:ms-vscode.test-extension.CC++.log', 'CC++.log') - ]); + createOutputTextEditor('output:ms-vscode.cpptools.CC++.log', 'ms-vscode.cpptools.CC++.log') + ], 'ms-vscode.cpptools'); assert.strictEqual(client.isTestOutputChannelVisible(), true); }); @@ -293,8 +317,12 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { public readonly events: string[] = []; - public constructor(outputChannelName: string, private readonly visibleTextEditors: readonly vscode.TextEditor[]) { - super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, { outputChannel: createLogOutputChannel(outputChannelName) }); + public constructor(outputChannelName: string, private readonly visibleTextEditors: readonly vscode.TextEditor[], outputChannelId: string | undefined) { + const clientOptions: lsclient.LanguageClientOptions = { outputChannel: createLogOutputChannel(outputChannelName) }; + if (outputChannelId !== undefined) { + clientOptions.outputChannelId = outputChannelId; + } + super('test-restart', 'Test Restart Language Server', { module: 'unused', transport: lsclient.TransportKind.ipc }, clientOptions); } public isTestOutputChannelVisible(): boolean { diff --git a/client/src/common/client.ts b/client/src/common/client.ts index 8d20619e0..184c4bb7a 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -359,6 +359,16 @@ export type LanguageClientOptions = { diagnosticCollectionName?: string; outputChannel?: LogOutputChannel; outputChannelName?: string; + /** + * The identifier VS Code uses as the prefix for this client's log output channel resource. + * + * For example, if an extension with id `publisher.extension` creates a log output channel + * named `Language`, VS Code represents the visible output document as + * `publisher.extension.Language.log`. Supplying the extension id here lets the client + * distinguish this channel from another extension's channel with the same name when preserving + * output channel visibility across restarts. If omitted, the language client id is used. + */ + outputChannelId?: string; traceOutputChannel?: LogOutputChannel; revealOutputChannelOn?: RevealOutputChannelOn; /** @@ -407,6 +417,7 @@ type ResolvedClientOptions = { synchronize: SynchronizeOptions; diagnosticCollectionName?: string; outputChannelName: string; + outputChannelId: string; revealOutputChannelOn: RevealOutputChannelOn; stdioEncoding: string; initializationOptions?: any | (() => any); @@ -718,6 +729,7 @@ export abstract class BaseLanguageClient implements FeatureClient { if (editor.document.uri.scheme !== 'output') { return false; @@ -2571,13 +2583,19 @@ function createConnection(input: MessageReader, output: MessageWriter, errorHand return result; } -function getOutputChannelResourceName(name: string): string { - return `${name.replace(/[\\/:\*\?"<>\|]/g, '')}.log`.toLowerCase(); +function getOutputChannelResourceName(id: string, name: string): string { + const resourceId = sanitizeOutputChannelResourceSegment(id); + const resourceName = sanitizeOutputChannelResourceSegment(name); + return `${resourceId}.${resourceName}.log`.toLowerCase(); +} + +function sanitizeOutputChannelResourceSegment(value: string): string { + return value.replace(/[\\/:\*\?"<>\|]/g, ''); } function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { const normalizedResource = resource.replace(/\\/g, '/').toLowerCase(); - return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`.${outputChannelResource}`); + return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`:${outputChannelResource}`); } // Exporting proposed protocol. From f7002244e3b1fe84c92e163d6d7096a6dc34af97 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Mon, 21 Sep 2026 15:33:06 -0400 Subject: [PATCH 4/6] Preserve output channel resource casing --- client-node-tests/src/integration.test.ts | 8 ++++++++ client/src/common/client.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index 23f6ead29..bbb3f3645 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -288,6 +288,14 @@ suite('Client restart', () => { assert.strictEqual(client.isTestOutputChannelVisible(), false); }); + test('Does not match output channel names with different casing', () => { + const client = new RestartTestLanguageClient('Server', [ + createOutputTextEditor('output:ms-vscode.test-extension.server.log', 'ms-vscode.test-extension.server.log') + ], 'ms-vscode.test-extension'); + + assert.strictEqual(client.isTestOutputChannelVisible(), false); + }); + test('Detects visible output channel by file name', () => { const client = new RestartTestLanguageClient('ESLint', [ createOutputTextEditor('output:unknown', 'dbaeumer.vscode-eslint.ESLint.log') diff --git a/client/src/common/client.ts b/client/src/common/client.ts index 184c4bb7a..f4c46f745 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -2586,7 +2586,7 @@ function createConnection(input: MessageReader, output: MessageWriter, errorHand function getOutputChannelResourceName(id: string, name: string): string { const resourceId = sanitizeOutputChannelResourceSegment(id); const resourceName = sanitizeOutputChannelResourceSegment(name); - return `${resourceId}.${resourceName}.log`.toLowerCase(); + return `${resourceId}.${resourceName}.log`; } function sanitizeOutputChannelResourceSegment(value: string): string { @@ -2594,7 +2594,7 @@ function sanitizeOutputChannelResourceSegment(value: string): string { } function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { - const normalizedResource = resource.replace(/\\/g, '/').toLowerCase(); + const normalizedResource = resource.replace(/\\/g, '/'); return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`:${outputChannelResource}`); } From 3d5644c8ee05f682889b21ffc7a8a6a548f79abd Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Mon, 28 Sep 2026 02:55:27 -0400 Subject: [PATCH 5/6] Address output channel visibility review --- client/src/common/client.ts | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/client/src/common/client.ts b/client/src/common/client.ts index d0fe74996..c1938974a 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -874,8 +874,24 @@ export abstract class BaseLanguageClient implements FeatureClient\|]/g, ''); + } + + function getOutputChannelResourceName(id: string, name: string): string { + const resourceId = sanitizeOutputChannelResourceSegment(id); + const resourceName = sanitizeOutputChannelResourceSegment(name); + return `${resourceId}.${resourceName}.log`; + } + + function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { + const normalizedResource = resource.replace(/\\/g, '/'); + return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`:${outputChannelResource}`); + } + const outputChannelResource = getOutputChannelResourceName(this._clientOptions.outputChannelId, this._outputChannel.name); - return this.getVisibleTextEditors().some(editor => { + return Window.visibleTextEditors.some(editor => { if (editor.document.uri.scheme !== 'output') { return false; } @@ -883,10 +899,6 @@ export abstract class BaseLanguageClient implements FeatureClient\|]/g, ''); -} - -function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { - const normalizedResource = resource.replace(/\\/g, '/'); - return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`:${outputChannelResource}`); -} - // Exporting proposed protocol. export namespace ProposedFeatures { From 01c1d1b845febb899365d607b5c6c18c80f1cda1 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Thu, 1 Oct 2026 10:35:53 -0400 Subject: [PATCH 6/6] Use real output channels in restart tests --- client-node-tests/src/integration.test.ts | 232 ++++++++++++++-------- 1 file changed, 147 insertions(+), 85 deletions(-) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index 632d7de34..c4db74bb3 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -296,145 +296,207 @@ suite('Server output', () => { suite('Client restart', () => { + const testExtensionOutputChannelId = 'ms-vscode.test-extension'; + const outputChannels: vscode.LogOutputChannel[] = []; + + setup(async () => { + await closeAllEditors(); + }); + + teardown(async () => { + for (const outputChannel of outputChannels.splice(0)) { + outputChannel.hide(); + outputChannel.dispose(); + } + await closeAllEditors(); + }); + + function createTestOutputChannel(name: string): vscode.LogOutputChannel { + const outputChannel = vscode.window.createOutputChannel(name, { log: true }); + outputChannels.push(outputChannel); + return outputChannel; + } + + function createRestartClient(outputChannelName: string): lsclient.LanguageClient { + const serverModule = path.join(__dirname, './servers/nullServer.js'); + const serverOptions: lsclient.ServerOptions = { + module: serverModule, + transport: lsclient.TransportKind.ipc + }; + const clientOptions: lsclient.LanguageClientOptions = { + outputChannelId: testExtensionOutputChannelId, + outputChannelName + }; + return new lsclient.LanguageClient(`test-restart-${outputChannelName.toLowerCase()}`, 'Test Restart Language Server', serverOptions, clientOptions); + } + test('Preserves output channel visibility after restart', async () => { - const client = new RestartTestLanguageClient('Restart', [ - createOutputTextEditor('output:ms-vscode.test-extension.Restart.log', 'ms-vscode.test-extension.Restart.log') - ], 'ms-vscode.test-extension'); + const outputChannelName = 'RestartVisible'; + const client = createRestartClient(outputChannelName); + try { + await client.start(); + client.outputChannel.appendLine('visible before restart'); + client.outputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, outputChannelName); - await client.restart(); + await client.restart(); - assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:true']); + await waitForOutputChannelVisible(testExtensionOutputChannelId, outputChannelName); + } finally { + await stopQuietly(client); + } }); test('Does not restore hidden output channel after restart', async () => { - const client = new RestartTestLanguageClient('Restart', [ - createOutputTextEditor('output:ms-vscode.test-extension.Other.log', 'ms-vscode.test-extension.Other.log') - ], 'ms-vscode.test-extension'); + const outputChannelName = 'RestartHidden'; + const client = createRestartClient(outputChannelName); + try { + await client.start(); + assert.strictEqual(isOutputChannelVisible(testExtensionOutputChannelId, outputChannelName), false); - await client.restart(); + await client.restart(); + await waitForNextTurn(); - assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + assert.strictEqual(isOutputChannelVisible(testExtensionOutputChannelId, outputChannelName), false); + } finally { + await stopQuietly(client); + } }); - test('Detects visible output channel by normalized log channel id', () => { - const client = new RestartTestLanguageClient('ESLint', [ - createOutputTextEditor('output:dbaeumer.vscode-eslint.ESLint.log', 'dbaeumer.vscode-eslint.ESLint.log') - ], 'dbaeumer.vscode-eslint'); + test('Detects visible output channel by normalized log channel id', async () => { + const outputChannel = createTestOutputChannel('ESLint'); + const client = new RestartVisibilityTestLanguageClient(outputChannel, testExtensionOutputChannelId); + + outputChannel.appendLine('visible output'); + outputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, 'ESLint'); assert.strictEqual(client.isTestOutputChannelVisible(), true); }); - test('Uses language client id as the default output channel id', () => { - const client = new RestartTestLanguageClient('Restart', [ - createOutputTextEditor('output:test-restart.Restart.log', 'test-restart.Restart.log') - ], undefined); + test('Uses language client id as the default output channel id', async () => { + const outputChannel = createTestOutputChannel('RestartDefault'); + const client = new RestartVisibilityTestLanguageClient(outputChannel, undefined, testExtensionOutputChannelId); + + outputChannel.appendLine('visible output'); + outputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, 'RestartDefault'); assert.strictEqual(client.isTestOutputChannelVisible(), true); }); - test('Does not match the same output channel name from another extension', () => { - const client = new RestartTestLanguageClient('ESLint', [ - createOutputTextEditor('output:publisher.other-extension.ESLint.log', 'publisher.other-extension.ESLint.log') - ], 'dbaeumer.vscode-eslint'); + test('Does not match the same output channel name from another extension', async () => { + const outputChannel = createTestOutputChannel('ESLint'); + const client = new RestartVisibilityTestLanguageClient(outputChannel, 'dbaeumer.vscode-eslint'); + + outputChannel.appendLine('visible output'); + outputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, 'ESLint'); assert.strictEqual(client.isTestOutputChannelVisible(), false); }); - test('Does not match output channel names with different casing', () => { - const client = new RestartTestLanguageClient('Server', [ - createOutputTextEditor('output:ms-vscode.test-extension.server.log', 'ms-vscode.test-extension.server.log') - ], 'ms-vscode.test-extension'); + test('Does not match output channel names with different casing', async () => { + const visibleOutputChannel = createTestOutputChannel('server'); + const clientOutputChannel = createTestOutputChannel('Server'); + const client = new RestartVisibilityTestLanguageClient(clientOutputChannel, testExtensionOutputChannelId); + + visibleOutputChannel.appendLine('visible output'); + visibleOutputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, 'server'); assert.strictEqual(client.isTestOutputChannelVisible(), false); }); - test('Detects visible output channel by file name', () => { - const client = new RestartTestLanguageClient('ESLint', [ - createOutputTextEditor('output:unknown', 'dbaeumer.vscode-eslint.ESLint.log') - ], 'dbaeumer.vscode-eslint'); - - assert.strictEqual(client.isTestOutputChannelVisible(), true); - }); + test('Does not match another visible output channel by substring', async () => { + const visibleOutputChannel = createTestOutputChannel('Other'); + const clientOutputChannel = createTestOutputChannel('ESLint'); + const client = new RestartVisibilityTestLanguageClient(clientOutputChannel, testExtensionOutputChannelId); - test('Does not match another visible output channel by substring', () => { - const client = new RestartTestLanguageClient('ESLint', [ - createOutputTextEditor('output:publisher.eslint-extension.Other.log', 'publisher.eslint-extension.Other.log') - ], 'dbaeumer.vscode-eslint'); + visibleOutputChannel.appendLine('visible output'); + visibleOutputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, 'Other'); assert.strictEqual(client.isTestOutputChannelVisible(), false); }); - test('Detects output channels with VS Code sanitized log file names', () => { - const client = new RestartTestLanguageClient('C/C++', [ - createOutputTextEditor('output:ms-vscode.cpptools.CC++.log', 'ms-vscode.cpptools.CC++.log') - ], 'ms-vscode.cpptools'); + test('Detects output channels with VS Code sanitized log file names', async () => { + const outputChannel = createTestOutputChannel('C/C++'); + const client = new RestartVisibilityTestLanguageClient(outputChannel, testExtensionOutputChannelId); + + outputChannel.appendLine('visible output'); + outputChannel.show(true); + await waitForOutputChannelVisible(testExtensionOutputChannelId, 'C/C++'); assert.strictEqual(client.isTestOutputChannelVisible(), true); }); }); -class RestartTestLanguageClient extends lsclient.LanguageClient { - - public readonly events: string[] = []; +class RestartVisibilityTestLanguageClient extends lsclient.LanguageClient { - public constructor(outputChannelName: string, private readonly visibleTextEditors: readonly vscode.TextEditor[], outputChannelId: string | undefined) { - const clientOptions: lsclient.LanguageClientOptions = { outputChannel: createLogOutputChannel(outputChannelName) }; + public constructor(outputChannel: vscode.LogOutputChannel, outputChannelId: string | undefined, clientId: string = 'test-restart') { + const clientOptions: lsclient.LanguageClientOptions = { outputChannel }; if (outputChannelId !== undefined) { clientOptions.outputChannelId = outputChannelId; } - super('test-restart', 'Test Restart Language Server', { module: 'unused', transport: lsclient.TransportKind.ipc }, clientOptions); + super(clientId, 'Test Restart Language Server', { module: 'unused', transport: lsclient.TransportKind.ipc }, clientOptions); } public isTestOutputChannelVisible(): boolean { return this.isOutputChannelVisible(); } +} - public override async start(): Promise { - this.events.push('start'); - } +function sanitizeOutputChannelResourceSegment(value: string): string { + return value.replace(/[\\/:\*\?"<>\|]/g, ''); +} - public override stop(): Promise { - this.events.push('stop'); - return Promise.resolve(); - } +function getOutputChannelResourceName(id: string, name: string): string { + const resourceId = sanitizeOutputChannelResourceSegment(id); + const resourceName = sanitizeOutputChannelResourceSegment(name); + return `${resourceId}.${resourceName}.log`; +} - protected override getVisibleTextEditors(): readonly vscode.TextEditor[] { - this.events.push('getVisibleTextEditors'); - return this.visibleTextEditors; - } +function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { + const normalizedResource = resource.replace(/\\/g, '/'); + return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`:${outputChannelResource}`); +} - protected override restoreOutputChannelVisibility(wasVisible: boolean): void { - this.events.push(`restoreOutputChannelVisibility:${wasVisible}`); - } +function isOutputChannelVisible(id: string, name: string): boolean { + const outputChannelResource = getOutputChannelResourceName(id, name); + return vscode.window.visibleTextEditors.some(editor => { + if (editor.document.uri.scheme !== 'output') { + return false; + } + return matchesOutputChannelResource(editor.document.uri.toString(true), outputChannelResource) || matchesOutputChannelResource(editor.document.fileName, outputChannelResource); + }); +} + +async function waitForOutputChannelVisible(id: string, name: string): Promise { + await waitUntil(() => isOutputChannelVisible(id, name), `Output channel ${getOutputChannelResourceName(id, name)} did not become visible.`); } -function createOutputTextEditor(uri: string, fileName: string): vscode.TextEditor { - return { - document: { - uri: vscode.Uri.parse(uri), - fileName +async function waitUntil(condition: () => boolean, message: string, timeout: number = 5000): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeout) { + throw new Error(message); } - } as vscode.TextEditor; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function closeAllEditors(): Promise { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await waitForNextTurn(); } -function createLogOutputChannel(name: string): vscode.LogOutputChannel { - return { - name, - append: () => undefined, - appendLine: () => undefined, - replace: () => undefined, - clear: () => undefined, - show: () => undefined, - hide: () => undefined, - dispose: () => undefined, - logLevel: vscode.LogLevel.Info, - onDidChangeLogLevel: () => ({ dispose: () => undefined }), - trace: () => undefined, - debug: () => undefined, - info: () => undefined, - warn: () => undefined, - error: () => undefined - } as vscode.LogOutputChannel; +async function stopQuietly(client: lsclient.LanguageClient): Promise { + try { + await client.stop(); + } catch { + // The test may fail before the client starts; keep cleanup from masking the assertion. + } } suite('Socket transport', () => {