@eclipse-glsp/protocol
Version:
The protocol definition for client-server communication in GLSP
364 lines (340 loc) • 18 kB
text/typescript
/********************************************************************************
* Copyright (c) 2023-2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import { expect } from 'chai';
import * as sinon from 'sinon';
import { Disposable, Event, MessageConnection, NotificationHandler, ProgressType } from 'vscode-jsonrpc';
import { ActionMessage } from '../../action-protocol/base-protocol';
import { remove } from '../../utils/array-util';
import { Emitter } from '../../utils/event';
import { expectToThrowAsync } from '../../utils/test-util';
import { ClientState } from '../glsp-client';
import { InitializeResult } from '../types';
import { BaseJsonrpcGLSPClient } from './base-jsonrpc-glsp-client';
import { JsonrpcGLSPClient } from './glsp-jsonrpc-client';
class StubMessageConnection implements MessageConnection {
private mockEvent: Event<any> = (listener: (e: any) => any, thisArgs?: any, disposables?: Disposable[]): Disposable =>
Disposable.create(() => {});
sendRequest(...args: any[]): any {
throw new Error('Method not implemented.');
}
onRequest(...args: unknown[]): Disposable {
return Disposable.create(() => {});
}
hasPendingResponse(): boolean {
return false;
}
sendNotification(...args: unknown[]): Promise<void> {
return Promise.resolve();
}
onNotification(...args: unknown[]): Disposable {
return Disposable.create(() => {});
}
onProgress<P>(type: ProgressType<P>, token: string | number, handler: NotificationHandler<P>): Disposable {
throw new Error('Method not implemented.');
}
sendProgress<P>(type: ProgressType<P>, token: string | number, value: P): Promise<void> {
throw new Error('Method not implemented.');
}
onUnhandledProgress = this.mockEvent;
trace(...args: unknown[]): Promise<void> {
return Promise.resolve();
}
onError = this.mockEvent;
onClose = this.mockEvent;
listen(): void {}
onUnhandledNotification = this.mockEvent;
end(): void {}
onDispose = this.mockEvent;
dispose(): void {}
inspect(): void {}
}
class TestJsonRpcClient extends BaseJsonrpcGLSPClient {
protected override onActionMessageNotificationEmitter = new Emitter<ActionMessage>({
onFirstListenerAdd: () => (this.firstListenerAdded = true),
onLastListenerRemove: () => (this.lastListenerRemoved = true)
});
firstListenerAdded: boolean;
lastListenerRemoved: boolean;
}
describe('Base JSON-RPC GLSP Client', () => {
const sandbox = sinon.createSandbox();
const connection = sandbox.stub<StubMessageConnection>(new StubMessageConnection());
let client = new TestJsonRpcClient({ id: 'test', connectionProvider: connection });
async function resetClient(setRunning = true): Promise<void> {
sandbox.reset();
client = new TestJsonRpcClient({ id: 'test', connectionProvider: connection });
if (setRunning) {
return client.start();
}
}
describe('start', () => {
it('should successfully start & activate the connection', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
expect(client.currentState).to.be.equal(ClientState.Initial);
const startCompleted = client.start();
expect(client.currentState).to.be.equal(ClientState.Starting);
expect(stateChangeHandler.calledWith(ClientState.Starting)).to.be.true;
await startCompleted;
expect(client.currentState).to.be.equal(ClientState.Running);
expect(client.isConnectionActive()).to.be.true;
expect(stateChangeHandler.calledWith(ClientState.Running)).to.be.true;
});
it('should fail to start if connecting to the server fails', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
expect(client.currentState).to.be.equal(ClientState.Initial);
connection.listen.throws(new Error('Connection failed'));
await client.start();
expect(client.currentState).to.be.equal(ClientState.StartFailed);
expect(stateChangeHandler.calledWith(ClientState.StartFailed)).to.be.true;
});
it('should not start another connection if another start is already in progress', async () => {
await resetClient(false);
client.start();
await client.start();
expect(client.currentState).to.be.equal(ClientState.Running);
expect(client.isConnectionActive()).to.be.true;
expect(connection.listen.calledOnce).to.be.true;
});
});
describe('stop', () => {
it('should successfully stop if the client was not running', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
expect(client.currentState).to.be.equal(ClientState.Initial);
await client.stop();
expect(client.currentState).to.be.equal(ClientState.Stopped);
expect(stateChangeHandler.calledWith(ClientState.Stopped)).to.be.true;
expect(connection.dispose.called).to.be.false;
});
it('should successfully stop if the client was running', async () => {
await resetClient();
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
const stopped = client.stop();
expect(client.currentState).to.be.equal(ClientState.Stopping);
expect(stateChangeHandler.calledWith(ClientState.Stopping)).to.be.true;
await stopped;
expect(client.currentState).to.be.equal(ClientState.Stopped);
expect(stateChangeHandler.calledWith(ClientState.Stopped)).to.be.true;
expect(connection.dispose.called).to.be.true;
});
it('should only stop a running client once, if stop is called multiple times', async () => {
await resetClient();
client.stop();
expect(client.currentState).to.be.equal(ClientState.Stopping);
await client.stop();
expect(client.currentState).to.be.equal(ClientState.Stopped);
expect(connection.dispose.calledOnce).to.be.true;
});
});
describe('initialize', () => {
it('should fail if client is not running', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
await expectToThrowAsync(() => client.initializeServer({ applicationId: '', protocolVersion: '' }));
expect(connection.sendRequest.called).to.be.false;
expect(stateChangeHandler.called).to.be.false;
});
it('should forward the corresponding initialize request and cache result', async () => {
await resetClient();
const expectedResult = { protocolVersion: '1.0.0', serverActions: {} };
const params = { applicationId: 'id', protocolVersion: '1.0.0' };
const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params);
initializeMock.returns(expectedResult);
expect(client.initializeResult).to.be.undefined;
const result = await client.initializeServer({ applicationId: 'id', protocolVersion: '1.0.0' });
expect(result).to.deep.equals(expectedResult);
expect(initializeMock.calledOnce).to.be.true;
expect(client.initializeResult).to.be.equal(result);
});
it('should return cached result on consecutive invocation', async () => {
await resetClient();
const expectedResult = { protocolVersion: '1.0.0', serverActions: {} };
const params = { applicationId: 'id', protocolVersion: '1.0.0' };
const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params);
initializeMock.returns(expectedResult);
client.initializeServer({ applicationId: 'id', protocolVersion: '1.0.0' });
const result = await client.initializeServer({ applicationId: 'id', protocolVersion: '1.0.0' });
expect(result).to.be.deep.equal(client.initializeResult);
expect(initializeMock.calledOnce).to.be.true;
});
it('should fire event on first invocation', async () => {
await resetClient();
const expectedResult = { protocolVersion: '1.0.0', serverActions: {} };
const params = { applicationId: 'id', protocolVersion: '1.0.0' };
const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params);
initializeMock.returns(expectedResult);
const eventHandler = (result: InitializeResult): void => {};
const eventHandlerSpy = sinon.spy(eventHandler);
client.onServerInitialized(eventHandlerSpy);
await client.initializeServer(params);
await client.initializeServer(params);
expect(eventHandlerSpy.calledOnceWith(expectedResult)).to.be.true;
});
it('should not use cached result on consecutive invocation if previous invocation errored', async () => {
await resetClient();
const expectedResult = { protocolVersion: '1.0.0', serverActions: {} };
const params = { applicationId: 'id', protocolVersion: '1.0.0' };
const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params);
initializeMock.throws(new Error('SomeError'));
expectToThrowAsync(() => client.initializeServer(params));
expect(client.initializeResult).to.be.undefined;
initializeMock.returns(expectedResult);
const result = await client.initializeServer(params);
expect(result).to.be.deep.equal(expectedResult);
expect(initializeMock.calledTwice).to.be.true;
expect(client.initializeResult).to.be.equal(result);
});
});
describe('initializeClientSession', () => {
it('should fail if client is not running', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
await expectToThrowAsync(() => client.initializeClientSession({ clientSessionId: '', diagramType: '', clientActionKinds: [] }));
expect(connection.sendRequest.called).to.be.false;
expect(stateChangeHandler.called).to.be.false;
});
it('should invoke the corresponding server method', async () => {
await resetClient();
const params = { clientSessionId: '', diagramType: '', clientActionKinds: [] };
const initializeSessionMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeClientSessionRequest, params);
const result = await client.initializeClientSession(params);
expect(result).to.be.undefined;
expect(initializeSessionMock.calledOnce).to.be.true;
});
});
describe('disposeClientSession', () => {
it('should fail if client is not running', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
await expectToThrowAsync(() => client.disposeClientSession({ clientSessionId: '' }));
expect(connection.sendRequest.called).to.be.false;
expect(stateChangeHandler.called).to.be.false;
});
it('should invoke the corresponding server method', async () => {
await resetClient();
const params = { clientSessionId: 'someClient' };
const disposeSessionMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.DisposeClientSessionRequest, params);
const result = await client.disposeClientSession(params);
expect(result).to.be.undefined;
expect(disposeSessionMock.calledOnce).to.be.true;
});
});
describe('shutdownServer', () => {
it('should fail if client is not running', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
// `shutdownServer` is now async; the connection-state guard rejects the returned
// promise rather than throwing synchronously.
let rejection: unknown;
try {
await client.shutdownServer();
} catch (err) {
rejection = err;
}
expect(rejection).to.be.instanceOf(Error);
expect((rejection as Error).message).to.equal(JsonrpcGLSPClient.ClientNotReadyMsg);
expect(connection.sendNotification.called).to.be.false;
expect(stateChangeHandler.called).to.be.false;
});
it('should invoke the corresponding server method', async () => {
await resetClient();
const shutdownMock = connection.sendNotification.withArgs(JsonrpcGLSPClient.ShutdownNotification);
const result = await client.shutdownServer();
expect(result).to.be.undefined;
expect(shutdownMock.calledOnce).to.be.true;
});
});
describe('sendActionMessage', () => {
it('should fail if client is not running', async () => {
await resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
expect(() => client.sendActionMessage({ action: { kind: '' }, clientId: '' })).to.throw();
expect(connection.sendNotification.called).to.be.false;
expect(stateChangeHandler.called).to.be.false;
});
it('should invoke the corresponding server method', async () => {
await resetClient();
const message = { action: { kind: '' }, clientId: '' };
const messageMock = connection.sendNotification.withArgs(JsonrpcGLSPClient.ActionMessageNotification, message);
client.sendActionMessage({ action: { kind: '' }, clientId: '' });
expect(messageMock.calledOnce).to.be.true;
});
});
describe('onActionMessage', () => {
const handler = sandbox.spy((_message: ActionMessage): void => {});
it('should be registered to message emitter if client is not running', async () => {
await resetClient(false);
client.onActionMessage(handler);
expect(client.firstListenerAdded).to.be.true;
});
it('should be registered to message emitter if client is running', async () => {
await resetClient();
client.onActionMessage(handler, 'someId');
expect(client.firstListenerAdded).to.be.true;
});
it('should unregister lister if dispose is invoked', () => {
resetClient(false);
const clientId = 'clientId';
const toDispose = client.onActionMessage(handler, clientId);
expect(client.firstListenerAdded).to.be.true;
toDispose.dispose();
expect(client.lastListenerRemoved).to.be.true;
});
});
describe('Connection events', () => {
it('Should be in error state after connection error', async () => {
// mock setup
resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
const listeners: ((e: unknown) => unknown)[] = [];
connection.onError.callsFake(listener => {
listeners.push(listener);
return Disposable.create(() => remove(listeners, listener));
});
await client.start();
listeners.forEach(listener => listener(new Error('SomeError')));
expect(client.currentState).to.be.equal(ClientState.ServerError);
expect(stateChangeHandler.calledWith(ClientState.ServerError)).to.be.true;
});
it('Should be in error state after connection close while running', async () => {
// mock setup
resetClient(false);
const stateChangeHandler = sinon.spy();
client.onCurrentStateChanged(stateChangeHandler);
const listeners: ((e: unknown) => unknown)[] = [];
connection.onClose.callsFake(listener => {
listeners.push(listener);
return Disposable.create(() => remove(listeners, listener));
});
await client.start();
listeners.forEach(listener => listener(undefined));
expect(client.currentState).to.be.equal(ClientState.ServerError);
expect(stateChangeHandler.calledWith(ClientState.ServerError)).to.be.true;
});
});
});