@eclipse-emfcloud/model-service-theia
Version:
Model service Theia
575 lines (440 loc) • 17 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// 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: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
import { ModelChangedCallback } from '@eclipse-emfcloud/model-manager';
import {
ModelDirtyStateChangedCallback,
ModelLoadedCallback,
ModelServiceSubscription,
ModelUnloadedCallback,
ModelValidatedCallback,
} from '@eclipse-emfcloud/model-service';
import { Diagnostic, ok } from '@eclipse-emfcloud/model-validation';
import { Container } from '@theia/core/shared/inversify';
import chai, { assert, expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { Operation } from 'fast-json-patch';
import cloneDeep from 'lodash/cloneDeep';
import sinon from 'sinon';
import { ModelHubProtocol } from '../../common';
import { ModelHubTracker } from '../../common/model-hub-tracker';
import {
FrontendModelHubSubscriber,
FrontendModelHubSubscriberImpl,
} from '../frontend-model-hub-subscriber';
import { FakeModelHubProtocol, connectClient } from './fake-model-hub-protocol';
import { testModule } from './test-module';
chai.use(chaiAsPromised);
function createTestContainer(): Container {
const container = new Container();
container.load(testModule);
return container;
}
const MODEL1_ID = 'test.model1';
const MODEL1 = { name: 'Model 1' };
describe('FrontendModelHubSubscriber', () => {
const appContext = 'test-app';
let sandbox: sinon.SinonSandbox;
let fake: FakeModelHubProtocol;
let subscriber: FrontendModelHubSubscriber;
let tracker: ModelHubTracker;
let onModelChanged: sinon.SinonStub<Parameters<ModelChangedCallback<string>>>;
let onModelDirtyState: sinon.SinonStub<
Parameters<ModelDirtyStateChangedCallback<string>>
>;
let onModelValidated: sinon.SinonStub<
Parameters<ModelValidatedCallback<string>>
>;
let onModelLoaded: sinon.SinonStub<Parameters<ModelLoadedCallback<string>>>;
let onModelUnloaded: sinon.SinonStub<
Parameters<ModelUnloadedCallback<string>>
>;
beforeEach(async () => {
sandbox = sinon.createSandbox();
const container = createTestContainer();
subscriber = container.get<FrontendModelHubSubscriber>(
FrontendModelHubSubscriber
);
tracker = container.get<ModelHubTracker>(ModelHubTracker);
fake = container.get(FakeModelHubProtocol);
fake.setModel(MODEL1_ID, MODEL1);
connectClient(fake, subscriber);
onModelChanged = sandbox.stub();
onModelDirtyState = sandbox.stub();
onModelValidated = sandbox.stub();
onModelLoaded = sandbox.stub();
onModelUnloaded = sandbox.stub();
});
const modelChangeCases: [string, string[]][] = [
['specific sub', [MODEL1_ID]],
['universal sub', <string[]>[]],
];
describe('notifies model change', () => {
modelChangeCases.forEach(([title, modelIds]) => {
it(title, async () => {
const sub = await subscriber.subscribe(appContext, ...modelIds);
sub.onModelChanged = onModelChanged;
const patch: Operation[] = [
{ op: 'replace', path: '/name', value: MODEL1.name },
];
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
sinon.assert.calledWithMatch(onModelChanged, MODEL1_ID, MODEL1, patch);
});
});
});
const dirtyStateCases: [string, string[]][] = [
['specific sub', [MODEL1_ID]],
['universal sub', <string[]>[]],
];
describe('notifies dirty state', () => {
dirtyStateCases.forEach(([title, modelIds]) => {
it(title, async () => {
const sub = await subscriber.subscribe(appContext, ...modelIds);
sub.onModelDirtyState = onModelDirtyState;
fake.fakeModelDirtyState(MODEL1_ID, true);
await asyncsResolved();
sinon.assert.calledWithMatch(
onModelDirtyState,
MODEL1_ID,
MODEL1,
true
);
});
});
});
const validationCases: [string, string[]][] = [
['specific sub', [MODEL1_ID]],
['universal sub', <string[]>[]],
];
describe('notifies model validation', () => {
validationCases.forEach(([title, modelIds]) => {
it(title, async () => {
const sub = await subscriber.subscribe(appContext, ...modelIds);
sub.onModelValidated = onModelValidated;
const diagnostic: Diagnostic = {
message: 'This is a test',
path: '/name',
severity: 'error',
source: 'test',
};
fake.fakeModelValidated(MODEL1_ID, diagnostic);
await asyncsResolved();
sinon.assert.calledWithMatch(
onModelValidated,
MODEL1_ID,
MODEL1,
diagnostic
);
});
});
});
const modelLoadedCases: [string, string[]][] = [
['specific sub', [MODEL1_ID]],
['universal sub', <string[]>[]],
];
describe('notifies model loaded', () => {
modelLoadedCases.forEach(([title, modelIds]) => {
it(title, async () => {
const sub = await subscriber.subscribe(appContext, ...modelIds);
sub.onModelLoaded = onModelLoaded;
fake.fakeModelLoaded(MODEL1_ID);
await asyncsResolved();
sinon.assert.calledWithMatch(onModelLoaded, MODEL1_ID);
});
});
});
const modelUnloadedCases: [string, string[]][] = [
['specific sub', [MODEL1_ID]],
['universal sub', <string[]>[]],
];
describe('notifies model unloaded', () => {
modelUnloadedCases.forEach(([title, modelIds]) => {
it(title, async () => {
const sub = await subscriber.subscribe(appContext, ...modelIds);
sub.onModelUnloaded = onModelUnloaded;
fake.fakeModelUnloaded(MODEL1_ID);
await asyncsResolved();
sinon.assert.calledWithMatch(onModelUnloaded, MODEL1_ID, MODEL1);
});
});
});
describe('closes subscriptions', () => {
const patch: Operation[] = [
{ op: 'replace', path: '/name', value: MODEL1.name },
];
let sub: ModelServiceSubscription;
beforeEach(async () => {
sub = await subscriber.subscribe(appContext, MODEL1_ID);
sub.onModelChanged = onModelChanged;
fake.fakeModelChange(MODEL1_ID, patch);
return asyncsResolved();
});
it('from the frontend', async () => {
sub.close();
await asyncsResolved();
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
expect(onModelChanged.callCount).to.be.equal(1);
});
it('from the backend', async () => {
fake.fakeSubscriptionClosed(MODEL1_ID);
await asyncsResolved();
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
expect(onModelChanged.callCount).to.be.equal(1);
});
it('is idempotent', async () => {
sub.close();
try {
sub.close();
sub.close();
} catch (error) {
assert.fail('Should have been OK to close subscription again.');
}
await asyncsResolved();
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
expect(onModelChanged.callCount).to.be.equal(1);
});
});
describe('non-interference from other remote subscriptions', () => {
it('notifies model change', async () => {
const foreignSub = await fake.subscribe(appContext, MODEL1_ID);
const sub = await subscriber.subscribe(appContext, MODEL1_ID);
sub.onModelChanged = onModelChanged;
let patch: Operation[] = [
{ op: 'replace', path: '/name', value: MODEL1.name },
];
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
sinon.assert.calledWithMatch(onModelChanged, MODEL1_ID, MODEL1, patch);
// This mustn't close the frontend-subscriber
await fake.closeSubscription(foreignSub);
patch = [{ op: 'replace', path: '/name', value: 'another name' }];
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
sinon.assert.calledWithMatch(onModelChanged, MODEL1_ID, MODEL1, patch);
});
it('closing in the other order', async () => {
const foreignSub = await fake.subscribe(appContext, MODEL1_ID);
// Hack into the subscriber for its subscriptions
const subscriptions = (
subscriber as unknown as {
subscriptions: Array<unknown>;
}
).subscriptions;
const sub = await subscriber.subscribe(appContext, MODEL1_ID);
expect(subscriptions).to.include(sub);
fake.closeSubscription(2); // This is the subscriber's self-subscription
// Closing the self-sub cleans up because now there are no
// notifications to forward
expect(subscriptions).not.to.include(sub);
// But this is harmless
await fake.closeSubscription(foreignSub);
expect(subscriptions).not.to.include(sub);
});
});
describe('corner cases', () => {
it("doesn't blow up on unassigned call-back", async () => {
await subscriber.subscribe(appContext, MODEL1_ID);
fake.fakeModelChange(MODEL1_ID, []);
fake.fakeModelDirtyState(MODEL1_ID, true);
fake.fakeModelValidated(MODEL1_ID, ok());
fake.fakeModelLoaded(MODEL1_ID);
fake.fakeModelUnloaded(MODEL1_ID);
fake.fakeModelHubDisposed();
await asyncsResolved();
});
it("doesn't blow up on subscription gone AWOL", async () => {
await subscriber.subscribe(appContext, MODEL1_ID);
await asyncsResolved();
// Hack out the subscription
(
subscriber as unknown as {
subscriptions: Array<unknown>;
}
).subscriptions.length = 0;
fake.fakeModelChange(MODEL1_ID, []);
fake.fakeModelDirtyState(MODEL1_ID, true);
fake.fakeModelValidated(MODEL1_ID, ok());
fake.fakeModelLoaded(MODEL1_ID);
fake.fakeModelUnloaded(MODEL1_ID);
fake.fakeModelHubDisposed();
await asyncsResolved();
});
it("doesn't blow up on subscription pipeline gone AWOL", async () => {
await subscriber.subscribe(appContext, MODEL1_ID);
await asyncsResolved();
// Hack out the subscription pipeline
(
subscriber as unknown as {
subscriptionPipelines: Map<string, unknown>;
}
).subscriptionPipelines.delete(appContext);
fake.fakeModelChange(MODEL1_ID, []);
fake.fakeModelDirtyState(MODEL1_ID, true);
fake.fakeModelValidated(MODEL1_ID, ok());
fake.fakeModelLoaded(MODEL1_ID);
fake.fakeModelUnloaded(MODEL1_ID);
fake.fakeModelHubDisposed();
await asyncsResolved();
});
it("doesn't blow up on bad patch", async () => {
await subscriber.subscribe(appContext, MODEL1_ID);
await asyncsResolved();
const original = cloneDeep(
await subscriber.getModel(appContext, MODEL1_ID)
);
const warn = sandbox.stub(console, 'warn');
// Issue a bad patch
fake.fakeModelChange(MODEL1_ID, [
{ op: 'test', path: '/nonesuch', value: '@@impossible@@' },
{ op: 'replace', path: '/name', value: 'NEW NAME' },
]);
await asyncsResolved();
expect(warn).to.have.been.calledWithMatch(
'Error applying received model delta'
);
const current = await subscriber.getModel(appContext, MODEL1_ID);
expect(current).to.eql(original);
});
it('close the self-subscription at the backend', async () => {
const sub = await subscriber.subscribe(appContext, MODEL1_ID);
sub.onModelChanged = onModelChanged;
fake.closeSubscription(1); // This is the subscriber's self-subscription
const patch: Operation[] = [
{ op: 'replace', path: '/name', value: MODEL1.name },
];
fake.fakeModelChange(MODEL1_ID, patch);
await asyncsResolved();
sinon.assert.notCalled(onModelChanged);
});
});
describe('model cache', () => {
it('updates by self-subscription', async () => {
const model = await subscriber.getModel<{ name: string }>(
appContext,
MODEL1_ID
);
// Issue a patch
fake.fakeModelChange(MODEL1_ID, [
{ op: 'replace', path: '/name', value: 'NEW NAME' },
]);
await asyncsResolved();
expect(model.name).to.equal('NEW NAME');
});
it('cleans up self-subscription', async () => {
const original = cloneDeep(
await subscriber.getModel(appContext, MODEL1_ID)
);
(subscriber as FrontendModelHubSubscriberImpl).setModelHub(
new FakeModelHubProtocol<string>() as unknown as ModelHubProtocol<string>
);
// Issue a patch
fake.fakeModelChange(MODEL1_ID, [
{ op: 'replace', path: '/name', value: 'NEW NAME' },
]);
await asyncsResolved();
const current = await subscriber.getModel(appContext, MODEL1_ID);
expect(current).to.eql(original);
});
it('purges by self-subscription', async () => {
(subscriber as FrontendModelHubSubscriberImpl).setModelHub(
new FakeModelHubProtocol<string>() as unknown as ModelHubProtocol<string>
);
// Remove the model
fake.removeModel(MODEL1_ID);
await asyncsResolved();
return expect(subscriber.getModel(appContext, MODEL1_ID)).eventually.to.be
.rejected;
});
});
describe('model hub tracking', () => {
it('isModelHubAvailable()', () => {
expect(tracker.isModelHubAvailable('new-context')).to.be.false;
fake.fakeModelHubCreated('new-context');
expect(tracker.isModelHubAvailable('new-context')).to.be.true;
});
it('notifies model hub creation', () => {
const sub = tracker.trackModelHubs();
sub.onModelHubCreated = sinon.stub();
fake.fakeModelHubCreated('new-context');
expect(sub.onModelHubCreated).to.have.been.calledWith('new-context');
});
it('notifies extant model hubs', () => {
fake.fakeModelHubCreated('new-context');
const sub = tracker.trackModelHubs();
sub.onModelHubCreated = sinon.stub();
expect(sub.onModelHubCreated).to.have.been.calledWith('new-context');
});
it('unset creation call-back', async () => {
const callback = sandbox.stub();
fake.fakeModelHubCreated('new-context');
const sub = tracker.trackModelHubs();
sub.onModelHubCreated = callback;
expect(callback).to.have.been.calledWith('new-context');
sub.onModelHubCreated = undefined;
fake.fakeModelHubCreated('second-context');
expect(callback).to.have.been.calledOnce;
expect(callback).not.to.have.been.calledWith('second-context');
});
it('notifies model hub destruction', () => {
const sub = tracker.trackModelHubs();
sub.onModelHubDestroyed = sinon.stub();
fake.fakeModelHubDestroyed('new-context');
expect(sub.onModelHubDestroyed).to.have.been.calledWith('new-context');
});
it('close subscription', () => {
const sub = tracker.trackModelHubs();
sub.onModelHubCreated = sinon.stub();
sub.onModelHubDestroyed = sinon.stub();
fake.fakeModelHubCreated('new-context');
sub.close();
fake.fakeModelHubDestroyed('new-context');
expect(sub.onModelHubCreated).to.have.been.called;
expect(sub.onModelHubDestroyed).not.to.have.been.called;
});
it('redundant close of tracking subscription', async () => {
const sub = tracker.trackModelHubs();
sub.onModelHubDestroyed = sandbox.stub();
fake.fakeModelHubCreated('new-context');
sub.close();
sub.close(); // Again, doesn't matter
fake.fakeModelHubDestroyed('new-context');
expect(sub.onModelHubDestroyed).not.to.have.been.called;
});
it('empty tracking subscription is harmless', () => {
const sub = tracker.trackModelHubs();
fake.fakeModelHubCreated('new-context');
fake.fakeModelHubDestroyed('new-context');
sub.close();
});
it('notifies model hub destruction', () => {
const sub = tracker.trackModelHubs();
sub.onModelHubDestroyed = sinon.stub();
fake.fakeModelHubDestroyed('new-context');
expect(sub.onModelHubDestroyed).to.have.been.calledWith('new-context');
});
});
});
function asyncsResolved(): Promise<void> {
// It only takes until the next tick because in the test fixture
// the promises involved are all a priori resolved
return new Promise((resolve) => {
setImmediate(() => {
resolve();
});
});
}