diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index afa2afc14..5578484cc 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -294,6 +294,211 @@ 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 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 waitForOutputChannelVisible(testExtensionOutputChannelId, outputChannelName); + } finally { + await stopQuietly(client); + } + }); + + test('Does not restore hidden output channel after restart', async () => { + const outputChannelName = 'RestartHidden'; + const client = createRestartClient(outputChannelName); + try { + await client.start(); + assert.strictEqual(isOutputChannelVisible(testExtensionOutputChannelId, outputChannelName), false); + + await client.restart(); + await waitForNextTurn(); + + assert.strictEqual(isOutputChannelVisible(testExtensionOutputChannelId, outputChannelName), false); + } finally { + await stopQuietly(client); + } + }); + + 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', 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', 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', 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('Does not match another visible output channel by substring', async () => { + const visibleOutputChannel = createTestOutputChannel('Other'); + const clientOutputChannel = createTestOutputChannel('ESLint'); + const client = new RestartVisibilityTestLanguageClient(clientOutputChannel, testExtensionOutputChannelId); + + 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', 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 RestartVisibilityTestLanguageClient extends lsclient.LanguageClient { + + public constructor(outputChannel: vscode.LogOutputChannel, outputChannelId: string | undefined, clientId: string = 'test-restart') { + const clientOptions: lsclient.LanguageClientOptions = { outputChannel }; + if (outputChannelId !== undefined) { + clientOptions.outputChannelId = outputChannelId; + } + super(clientId, 'Test Restart Language Server', { module: 'unused', transport: lsclient.TransportKind.ipc }, clientOptions); + } + + public isTestOutputChannelVisible(): boolean { + return this.isOutputChannelVisible(); + } +} + +function sanitizeOutputChannelResourceSegment(value: string): string { + return value.replace(/[\\/:\*\?"<>\|]/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}`); +} + +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.`); +} + +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); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function closeAllEditors(): Promise { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await waitForNextTurn(); +} + +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', () => { test('Uses an OS-assigned port when transport.port is 0', async () => { diff --git a/client/src/common/client.ts b/client/src/common/client.ts index 4443902a4..094523a81 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\|]/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 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); + }); + } + + 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 a9606e76b..27f6ae3b5 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 {