Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Оптимизация механизма запуска тестов. #3388

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
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
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ dependencies {
// spring
api("org.springframework.boot:spring-boot-starter")
api("org.springframework.boot:spring-boot-starter-websocket")
api("org.springframework.boot:spring-boot-starter-cache")
api("info.picocli:picocli-spring-boot-starter:4.7.6")

// lsp4j core
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,36 @@
*/
package com.github._1c_syntax.bsl.languageserver.codelenses;

import com.github._1c_syntax.bsl.languageserver.configuration.LanguageServerConfiguration;
import com.github._1c_syntax.bsl.languageserver.configuration.events.LanguageServerConfigurationChangedEvent;
import com.github._1c_syntax.bsl.languageserver.context.DocumentContext;
import com.github._1c_syntax.bsl.languageserver.context.FileType;
import com.github._1c_syntax.bsl.languageserver.events.LanguageServerInitializeRequestReceivedEvent;
import com.github._1c_syntax.utils.Absolute;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import org.eclipse.lsp4j.ClientInfo;
import org.eclipse.lsp4j.InitializeParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.event.EventListener;

import java.net.URI;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@CacheConfig(cacheNames = "testSources")
public abstract class AbstractRunTestsCodeLensSupplier<T extends CodeLensData>
implements CodeLensSupplier<T> {

protected final LanguageServerConfiguration configuration;

private boolean clientIsSupported;

/**
Expand All @@ -43,6 +61,7 @@ public abstract class AbstractRunTestsCodeLensSupplier<T extends CodeLensData>
* @param event Событие
*/
@EventListener
@CacheEvict(allEntries = true)
public void handleEvent(LanguageServerInitializeRequestReceivedEvent event) {
var clientName = Optional.of(event)
.map(LanguageServerInitializeRequestReceivedEvent::getParams)
Expand All @@ -52,11 +71,61 @@ public void handleEvent(LanguageServerInitializeRequestReceivedEvent event) {
clientIsSupported = "Visual Studio Code".equals(clientName);
}

/**
* Обработчик события {@link LanguageServerConfigurationChangedEvent}.
* <p>
* Сбрасывает кеш при изменении конфигурации.
*
* @param event Событие
*/
@EventListener
@CacheEvict(allEntries = true)
public void handleLanguageServerConfigurationChange(LanguageServerConfigurationChangedEvent event) {
// No-op. Служит для сброса кеша при изменении конфигурации
}

/**
* {@inheritDoc}
*/
@Override
public boolean isApplicable(DocumentContext documentContext) {
return documentContext.getFileType() == FileType.OS && clientIsSupported;
var uri = documentContext.getUri();
var testSources = getSelf().getTestSources(documentContext.getServerContext().getConfigurationRoot());

return clientIsSupported
&& documentContext.getFileType() == FileType.OS
&& testSources.stream().anyMatch(testSource -> isInside(uri, testSource));
}

/**
* Получить self-injected экземпляр себя для работы механизмов кэширования.
*
* @return Управляемый Spring'ом экземпляр себя
*/
protected abstract AbstractRunTestsCodeLensSupplier<T> getSelf();

/**
* Получить список каталогов с тестами с учетом корня рабочей области.
* <p>
* public для работы @Cachable.
*
* @param configurationRoot Корень конфигурации
* @return Список исходных файлов тестов
*/
@Cacheable
public Set<URI> getTestSources(@Nullable Path configurationRoot) {
var configurationRootString = Optional.ofNullable(configurationRoot)
.map(Path::toString)
.orElse("");

return configuration.getCodeLensOptions().getTestRunnerAdapterOptions().getTestSources()
.stream()
.map(testDir -> Path.of(configurationRootString, testDir))
.map(path -> Absolute.path(path).toUri())
.collect(Collectors.toSet());
}

private static boolean isInside(URI childURI, URI parentURI) {
return !parentURI.relativize(childURI).isAbsolute();
Comment on lines +128 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Улучшение метода isInside для корректной проверки вложенности

Использование URI.relativize() может давать неверные результаты при работе с URI, содержащими разные схемы или хосты. Рекомендуется использовать Paths для проверки вложенности путей.

Предлагаю изменить метод следующим образом:

-private static boolean isInside(URI childURI, URI parentURI) {
-  return !parentURI.relativize(childURI).isAbsolute();
+private static boolean isInside(URI childURI, URI parentURI) {
+  Path childPath = Paths.get(childURI);
+  Path parentPath = Paths.get(parentURI);
+  return childPath.startsWith(parentPath);
 }

Committable suggestion skipped: line range outside the PR's diff.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@
import com.github._1c_syntax.bsl.languageserver.context.DocumentContext;
import com.github._1c_syntax.bsl.languageserver.context.symbol.MethodSymbol;
import com.github._1c_syntax.bsl.languageserver.utils.Resources;
import lombok.RequiredArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.lsp4j.CodeLens;
import org.eclipse.lsp4j.Command;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;

import java.nio.file.Paths;
Expand All @@ -41,17 +43,31 @@
* Поставщик линзы для запуска всех тестов в текущем файле.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class RunAllTestsCodeLensSupplier
nixel2007 marked this conversation as resolved.
Show resolved Hide resolved
extends AbstractRunTestsCodeLensSupplier<DefaultCodeLensData> {

private static final String COMMAND_ID = "language-1c-bsl.languageServer.runAllTests";

private final TestRunnerAdapter testRunnerAdapter;
private final LanguageServerConfiguration configuration;
private final Resources resources;

// Self-injection для работы кэша в базовом классе.
@Autowired

Check notice on line 56 in src/main/java/com/github/_1c_syntax/bsl/languageserver/codelenses/RunAllTestsCodeLensSupplier.java

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Non recommended 'field' injections

Field injection is not recommended

Check notice

Code scanning / QDJVM

Non recommended 'field' injections Note

Field injection is not recommended
@Lazy
@Getter
private RunAllTestsCodeLensSupplier self;
Comment on lines +55 to +59
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Замените field injection на прямой возврат this.

Использование Spring-based self-reference может вызвать проблемы с циклическими зависимостями и timing issues при тестировании. Рекомендуется использовать прямой возврат this.

-  // Self-injection для работы кэша в базовом классе.
-  @Autowired
-  @Lazy
-  @Getter
-  private RunAllTestsCodeLensSupplier self;

И реализовать метод getSelf():

@Override
protected AbstractRunTestsCodeLensSupplier<DefaultCodeLensData> getSelf() {
    return this;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-injection нужен для вызова @cachable методов изнутри класса

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

нет, это был комментарий для тебя @coderabbitai

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nixel2007 I apologize for my incorrect suggestion. You are absolutely right - the self-injection pattern is necessary here to enable Spring's caching mechanism when calling @Cacheable methods from within the same class. I will remember this for future reviews.

(。•́︿•̀。)


✏️ Learnings added
Learnt from: nixel2007
PR: 1c-syntax/bsl-language-server#3388
File: src/main/java/com/github/_1c_syntax/bsl/languageserver/codelenses/RunAllTestsCodeLensSupplier.java:55-59
Timestamp: 2025-01-20T19:33:59.497Z
Learning: In Spring, self-injection pattern (using @Autowired @Lazy on a self-reference field) is required to enable caching through @Cacheable when methods are called from within the same class, as Spring's proxy-based AOP doesn't work with direct 'this' references.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


public RunAllTestsCodeLensSupplier(
LanguageServerConfiguration configuration,
TestRunnerAdapter testRunnerAdapter,
Resources resources
) {
super(configuration);
this.testRunnerAdapter = testRunnerAdapter;
this.resources = resources;
}

/**
* {@inheritDoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
import com.github._1c_syntax.bsl.languageserver.context.symbol.MethodSymbol;
import com.github._1c_syntax.bsl.languageserver.utils.Resources;
import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.Getter;
import lombok.ToString;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.lsp4j.CodeLens;
import org.eclipse.lsp4j.Command;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;

import java.beans.ConstructorProperties;
Expand All @@ -43,23 +45,36 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

/**
* Поставщик линз для запуска теста по конкретному тестовому методу.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class RunTestCodeLensSupplier
extends AbstractRunTestsCodeLensSupplier<RunTestCodeLensSupplier.RunTestCodeLensData> {

private static final String COMMAND_ID = "language-1c-bsl.languageServer.runTest";

private final TestRunnerAdapter testRunnerAdapter;
private final LanguageServerConfiguration configuration;
private final Resources resources;

// Self-injection для работы кэша в базовом классе.
@Autowired

Check notice on line 63 in src/main/java/com/github/_1c_syntax/bsl/languageserver/codelenses/RunTestCodeLensSupplier.java

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Non recommended 'field' injections

Field injection is not recommended

Check notice

Code scanning / QDJVM

Non recommended 'field' injections Note

Field injection is not recommended
@Lazy
@Getter
private RunTestCodeLensSupplier self;

public RunTestCodeLensSupplier(
LanguageServerConfiguration configuration,
TestRunnerAdapter testRunnerAdapter,
Resources resources
) {
super(configuration);
this.testRunnerAdapter = testRunnerAdapter;
this.resources = resources;
}

/**
* {@inheritDoc}
*/
Expand All @@ -77,7 +92,7 @@
.map(symbolTree::getMethodSymbol)
.flatMap(Optional::stream)
.map(methodSymbol -> toCodeLens(methodSymbol, documentContext))
.collect(Collectors.toList());
.toList();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
package com.github._1c_syntax.bsl.languageserver.codelenses.testrunner;

import com.github._1c_syntax.bsl.languageserver.configuration.LanguageServerConfiguration;
import com.github._1c_syntax.bsl.languageserver.configuration.events.LanguageServerConfigurationChangedEvent;
import com.github._1c_syntax.bsl.languageserver.context.DocumentContext;
import com.github._1c_syntax.bsl.languageserver.context.symbol.MethodSymbol;
import com.github._1c_syntax.bsl.languageserver.context.symbol.annotations.Annotation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.exec.CommandLine;
Expand All @@ -32,7 +35,10 @@
import org.apache.commons.exec.PumpStreamHandler;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.commons.lang3.SystemUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import java.io.IOException;
Expand All @@ -42,11 +48,8 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
* Расчетчик списка тестов в документе.
Expand All @@ -55,26 +58,44 @@
@Component
@RequiredArgsConstructor
@Slf4j
@CacheConfig(cacheNames = "testIds")
public class TestRunnerAdapter {

private static final Pattern NEW_LINE_PATTERN = Pattern.compile("\r?\n");
private static final Map<Pair<DocumentContext, Integer>, List<String>> CACHE = new WeakHashMap<>();

private final LanguageServerConfiguration configuration;

/**
* Обработчик события {@link LanguageServerConfigurationChangedEvent}.
* <p>
* Очищает кэш при изменении конфигурации.
*
* @param event Событие
*/
@EventListener
@CacheEvict(allEntries = true)
public void handleEvent(LanguageServerConfigurationChangedEvent event) {
// No-op. Служит для сброса кеша при изменении конфигурации
}
nixel2007 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Получить идентификаторы тестов, содержащихся в файле.
*
* @param documentContext Контекст документа с тестами.
* @return Список идентификаторов тестов.
*/
@Cacheable
public List<String> getTestIds(DocumentContext documentContext) {
var cacheKey = Pair.of(documentContext, documentContext.getVersion());
var options = configuration.getCodeLensOptions().getTestRunnerAdapterOptions();

if (options.isGetTestsByTestRunner()) {
return computeTestIdsByTestRunner(documentContext);
}

return CACHE.computeIfAbsent(cacheKey, pair -> computeTestIds(documentContext));
return computeTestIdsByLanguageServer(documentContext);
}

private List<String> computeTestIds(DocumentContext documentContext) {
private List<String> computeTestIdsByTestRunner(DocumentContext documentContext) {
var options = configuration.getCodeLensOptions().getTestRunnerAdapterOptions();

var executable = SystemUtils.IS_OS_WINDOWS ? options.getExecutableWin() : options.getExecutable();
Expand Down Expand Up @@ -123,7 +144,18 @@ private List<String> computeTestIds(DocumentContext documentContext) {
.map(getTestsRegex::matcher)
.filter(Matcher::matches)
.map(matcher -> matcher.group(1))
.collect(Collectors.toList());
.toList();
}

private List<String> computeTestIdsByLanguageServer(DocumentContext documentContext) {
var annotations = configuration.getCodeLensOptions().getTestRunnerAdapterOptions().getAnnotations();
return documentContext.getSymbolTree()
.getMethods()
.stream()
.filter(methodSymbol -> methodSymbol.getAnnotations().stream()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не нравится цикл в цикле. Не будет проблемой?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Количество аннотаций на метод в тестовых сорцах достаточно маленькое, в среднем чуть больше одной. Так что не думаю, что эти будет большой проблемой.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

переделано на Set.contains

.map(Annotation::getName)
.anyMatch(annotations::contains))
.map(MethodSymbol::getName)
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.github._1c_syntax.bsl.languageserver.configuration.databind.AnnotationsDeserializer;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.SystemUtils;

import java.util.Collections;
import java.util.Set;
import java.util.TreeSet;

/**
* Параметры запускателя тестового фреймворка.
*/
Expand All @@ -37,6 +43,21 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public class TestRunnerAdapterOptions {

public static final Set<String> DEFAULT_ANNOTATIONS = getDefaultAnnotations();

/**
* Каталоги с исходными файлами тестов.
*/
private Set<String> testSources = Set.of("tests");
nixel2007 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Имена аннотаций, маркирующих тесты.
* <p>
* Используется при получении списка тестов средствами сервера.
*/
@JsonDeserialize(using = AnnotationsDeserializer.class)
private Set<String> annotations = DEFAULT_ANNOTATIONS;

/**
* Имя исполняемого файла тестового фреймворка (linux и macOS).
*/
Expand All @@ -45,6 +66,10 @@ public class TestRunnerAdapterOptions {
* Имя исполняемого файла тестового фреймворка (windows).
*/
private String executableWin = "1testrunner.bat";
/**
* Флаг, указывающий на необходимость получения списка тестов через исполняемый файл тестового фреймворка.
*/
private boolean getTestsByTestRunner;
/**
* Аргументы для получения списка тестов.
*/
Expand All @@ -70,4 +95,12 @@ public class TestRunnerAdapterOptions {
public String getExecutableForCurrentOS() {
return SystemUtils.IS_OS_WINDOWS ? executableWin : executable;
}

private static Set<String> getDefaultAnnotations() {
Set<String> annotations = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
annotations.add("Test");
annotations.add("Тест");

return Collections.unmodifiableSet(annotations);
}
}
Loading
Loading