From ac09055140a5c30e73e9ada16986ef60c38c2138 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 19 Oct 2021 00:49:31 +0200 Subject: feat(web): batched xtext websocket prototype --- .../AwaitTerminationExecutorServiceProvider.java | 34 +++++ .../xtext/servlet/ProblemWebInjectorProvider.java | 47 +++++++ .../web/xtext/servlet/TransactionExecutorTest.java | 151 +++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java create mode 100644 language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java create mode 100644 language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java (limited to 'language-web/src/test') diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java new file mode 100644 index 00000000..08230335 --- /dev/null +++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java @@ -0,0 +1,34 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import org.eclipse.xtext.ide.ExecutorServiceProvider; + +import com.google.inject.Singleton; + +@Singleton +public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider { + private List servicesToShutDown = new ArrayList<>(); + + @Override + protected ExecutorService createInstance(String key) { + var instance = super.createInstance(key); + servicesToShutDown.add(instance); + return instance; + } + + @Override + public void dispose() { + super.dispose(); + for (var executorService : servicesToShutDown) { + try { + executorService.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // Continue normally. + } + } + } +} diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java new file mode 100644 index 00000000..3493c9eb --- /dev/null +++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java @@ -0,0 +1,47 @@ +package tools.refinery.language.web.xtext.servlet; + +import org.eclipse.xtext.ide.ExecutorServiceProvider; +import org.eclipse.xtext.util.DisposableRegistry; +import org.eclipse.xtext.util.Modules2; + +import com.google.inject.Guice; +import com.google.inject.Injector; + +import tools.refinery.language.ide.ProblemIdeModule; +import tools.refinery.language.tests.ProblemInjectorProvider; +import tools.refinery.language.web.ProblemWebModule; +import tools.refinery.language.web.ProblemWebSetup; + +public class ProblemWebInjectorProvider extends ProblemInjectorProvider { + + protected Injector internalCreateInjector() { + return new ProblemWebSetup() { + @Override + public Injector createInjector() { + return Guice.createInjector( + Modules2.mixin(createRuntimeModule(), new ProblemIdeModule(), createWebModule())); + } + }.createInjectorAndDoEMFRegistration(); + } + + protected ProblemWebModule createWebModule() { + // Await termination of the executor service to avoid race conditions between + // between the tasks in the service and the {@link + // org.eclipse.xtext.testing.extensions.InjectionExtension}. + return new ProblemWebModule() { + @SuppressWarnings("unused") + public Class bindExecutorServiceProvider() { + return AwaitTerminationExecutorServiceProvider.class; + } + }; + } + + @Override + public void restoreRegistry() { + // Also make sure to dispose any IDisposable instances (that may depend on the + // global state) created by Xtext before restoring the global state. + var disposableRegistry = getInjector().getInstance(DisposableRegistry.class); + disposableRegistry.dispose(); + super.restoreRegistry(); + } +} diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java new file mode 100644 index 00000000..6ad82d7f --- /dev/null +++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java @@ -0,0 +1,151 @@ +package tools.refinery.language.web.xtext.servlet; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.testing.InjectWith; +import org.eclipse.xtext.testing.extensions.InjectionExtension; +import org.eclipse.xtext.web.server.ServiceConflictResult; +import org.eclipse.xtext.web.server.model.DocumentStateResult; +import org.eclipse.xtext.web.server.validation.ValidationResult; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.google.inject.Inject; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(InjectionExtension.class) +@InjectWith(ProblemWebInjectorProvider.class) +class TransactionExecutorTest { + private static final String RESOURCE_NAME = "test.problem"; + + private static final String INVALID_STATE_ID = ""; + + private static final String TEST_PROBLEM = """ + class Person { + Person friend[0..*] opposite friend + } + + friend(a, b). + """; + + private static final Map UPDATE_FULL_TEXT_PARAMS = Map.of("serviceType", "update", "fullText", + TEST_PROBLEM); + + private static final Map VALIDATE_PARAMS = Map.of("serviceType", "validate"); + + @Inject + private IResourceServiceProvider.Registry resourceServiceProviderRegistry; + + private TransactionExecutor transactionExecutor; + + @BeforeEach + void beforeEach() { + transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry); + } + + @Test + void emptyBatchTest() { + performBatchRequest(null); + } + + @Test + void fullTextUpdateTest() { + var response = performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); + assertThat(response, hasResponseData(instanceOf(DocumentStateResult.class))); + } + + @Test + void validationAfterFullTextUpdateInSameBatchTest() { + var response = performBatchRequest(null, UPDATE_FULL_TEXT_PARAMS, VALIDATE_PARAMS).get(1); + assertThat(response, hasResponseData(instanceOf(ValidationResult.class))); + } + + @Test + void validationAfterFullTextUpdateInDifferentBatchTest() { + var stateId = updateFullText(); + var validateResponse = performSingleRequest(stateId, VALIDATE_PARAMS); + assertThat(validateResponse, hasResponseData(instanceOf(ValidationResult.class))); + } + + @Test + void conflictTest() { + updateFullText(); + var response = performSingleRequest(INVALID_STATE_ID, VALIDATE_PARAMS); + assertThat(response, hasResponseData(instanceOf(ServiceConflictResult.class))); + } + + @Test + void transactionCancelledDueToConflictTest() { + updateFullText(); + var response = performBatchRequest(INVALID_STATE_ID, VALIDATE_PARAMS, VALIDATE_PARAMS).get(1); + assertThat(response, hasErrorKind(equalTo(XtextWebSocketErrorKind.TRANSACTION_CANCELLED))); + } + + @SafeVarargs + private List performBatchRequest(String requiredStateId, Map... params) { + var id = UUID.randomUUID().toString(); + var request = new XtextWebSocketRequest(id, RESOURCE_NAME, null, requiredStateId, List.of(params)); + + var responseHandler = mock(ResponseHandler.class); + try { + transactionExecutor.handleRequest(request, responseHandler); + } catch (IOException e) { + fail("Unexpected IOException", e); + } + + var captor = ArgumentCaptor.forClass(XtextWebSocketResponse.class); + int nParams = params.length; + try { + verify(responseHandler, times(nParams)).onResponse(captor.capture()); + } catch (IOException e) { + throw new RuntimeException("Mockito threw unexcepted exception", e); + } + var allResponses = captor.getAllValues(); + for (int i = 0; i < nParams; i++) { + var response = allResponses.get(i); + assertThat(response, hasProperty("id", equalTo(id))); + assertThat(response, hasProperty("index", equalTo(i))); + } + return allResponses; + } + + private XtextWebSocketResponse performSingleRequest(String requiredStateId, Map param) { + return performBatchRequest(requiredStateId, param).get(0); + } + + private String updateFullText() { + var updateResponse = (XtextWebSocketOkResponse) performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); + var documentStateResult = (DocumentStateResult) updateResponse.getResponseData(); + var stateId = documentStateResult.getStateId(); + if (INVALID_STATE_ID.equals(stateId)) { + throw new RuntimeException("Service returned unexpected stateId: " + stateId); + } + return stateId; + } + + private static Matcher hasResponseData(Matcher responseDataMatcher) { + return hasProperty("responseData", responseDataMatcher); + } + + private static Matcher hasErrorKind( + Matcher errorKindMatcher) { + return hasProperty("errorKind", errorKindMatcher); + } +} -- cgit v1.2.3-70-g09d2