Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions client-node-tests/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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<void> {
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await waitForNextTurn();
}

async function stopQuietly(client: lsclient.LanguageClient): Promise<void> {
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 () => {
Expand Down
47 changes: 47 additions & 0 deletions client/src/common/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -407,6 +417,7 @@ type ResolvedClientOptions = {
synchronize: SynchronizeOptions;
diagnosticCollectionName?: string;
outputChannelName: string;
outputChannelId: string;
revealOutputChannelOn: RevealOutputChannelOn;
stdioEncoding: string;
initializationOptions?: any | (() => any);
Expand Down Expand Up @@ -718,6 +729,7 @@ export abstract class BaseLanguageClient implements FeatureClient<Middleware, La
synchronize: clientOptions.synchronize ?? {},
diagnosticCollectionName: clientOptions.diagnosticCollectionName,
outputChannelName: clientOptions.outputChannelName ?? this._name,
outputChannelId: clientOptions.outputChannelId ?? this._id,
revealOutputChannelOn: clientOptions.revealOutputChannelOn ?? RevealOutputChannelOn.Error,
stdioEncoding: clientOptions.stdioEncoding ?? 'utf8',
initializationOptions: clientOptions.initializationOptions,
Expand Down Expand Up @@ -858,6 +870,41 @@ export abstract class BaseLanguageClient implements FeatureClient<Middleware, La
return this._outputChannel;
}

protected isOutputChannelVisible(): boolean {
if (this._outputChannel === undefined) {
return false;
}

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}`);
}

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;
}
Expand Down
2 changes: 2 additions & 0 deletions client/src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export class LanguageClient extends BaseLanguageClient {
}

public async restart(): Promise<void> {
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
Expand All @@ -247,6 +248,7 @@ export class LanguageClient extends BaseLanguageClient {
} else {
await this.start();
}
this.restoreOutputChannelVisibility(outputChannelVisible);
}

protected shutdown(mode: ShutdownMode, timeout: number = 2000): Promise<void> {
Expand Down