diff --git a/build.gradle.kts b/build.gradle.kts index ea84dbd..cc7059d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation("org.semver4j:semver4j:6.0.0") testImplementation("org.assertj:assertj-core:3.27.7") + testImplementation("org.mockito:mockito-core:5.14.2") testImplementation(platform("org.junit:junit-bom:6.1.1")) testImplementation("org.junit.jupiter:junit-jupiter-api") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 24dc6ef..07ba13b 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -25,11 +25,6 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; import org.semver4j.Semver; import java.io.IOException; @@ -50,6 +45,8 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -71,46 +68,34 @@ @Slf4j public class BslLanguageServerDownloader { - private static final String REPOSITORY = "1c-syntax/bsl-language-server"; private static final String SERVER_INFO_FILE = "SERVER-INFO"; private static final String INFO_VERSION = "version"; private static final String INFO_LAST_UPDATE = "lastUpdate"; private static final Duration DEFAULT_CHECK_INTERVAL = Duration.ofMinutes(8); - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10); + // Крупнее дефолта JDK (16 КБ у InputStream.transferTo): архивы сервера — десятки-сотни МБ, + // так меньше syscall'ов на чтение/запись и реже дёргается прогресс-колбэк. + private static final int DOWNLOAD_BUFFER_SIZE = 256 * 1024; private static final boolean POSIX = FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); private final Path installDir; - private final @Nullable String token; - private final Duration checkInterval; + private final GitHubReleaseClient releaseClient; + private final HttpClient httpClient; /** - * @param installDir каталог установки сервера; в нём создаются подпапки с версиями - * и файл {@code SERVER-INFO} + * @param installDir каталог установки сервера; в нём создаются подпапки с версиями + * и файл {@code SERVER-INFO} + * @param releaseClient источник сведений о последнем релизе + * @param httpClient клиент только для скачивания ассета (github-api эту загрузку не умеет); + * должен следовать редиректам — ассеты GitHub отдаются редиректом на CDN */ - public BslLanguageServerDownloader(Path installDir) { - this(installDir, null); - } - - /** - * @param installDir каталог установки сервера - * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} - */ - public BslLanguageServerDownloader(Path installDir, @Nullable String token) { - this(installDir, token, DEFAULT_CHECK_INTERVAL); - } - - /** - * @param installDir каталог установки сервера - * @param token GitHub OAuth-токен; может быть {@code null} - * @param checkInterval минимальный интервал между обращениями к GitHub API за новой версией - */ - public BslLanguageServerDownloader(Path installDir, @Nullable String token, Duration checkInterval) { + public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient, + HttpClient httpClient) { this.installDir = installDir.toAbsolutePath(); - this.token = token; - this.checkInterval = checkInterval; + this.releaseClient = releaseClient; + this.httpClient = httpClient; } /** @@ -138,7 +123,7 @@ public Optional installedVersion() { /** * Скачивает сервер при необходимости и возвращает путь к его исполняемому файлу. * - *

Если с момента прошлой проверки прошло меньше {@link #checkInterval}, GitHub не + *

Если с момента прошлой проверки прошло меньше {@link #DEFAULT_CHECK_INTERVAL}, GitHub не * опрашивается и возвращается уже установленный сервер. Если сеть недоступна, но сервер * уже установлен, возвращается установленная версия. Если сервер не установлен и скачать * его не удалось — выбрасывается {@link IOException}. @@ -148,6 +133,24 @@ public Optional installedVersion() { * @throws IOException если сервер не установлен и его не удалось скачать */ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOException { + return downloadIfNeeded(channel, DownloadProgressListener.NONE); + } + + /** + * Скачивает сервер при необходимости и возвращает путь к его исполняемому файлу, сообщая о + * прогрессе скачивания ассета. + * + *

Поведение совпадает с {@link #downloadIfNeeded(BslLanguageServerReleaseChannel)}; отличие — + * в передаче {@code progressListener}, который вызывается по мере вычитывания тела ответа + * (только когда действительно происходит скачивание новой версии). + * + * @param channel канал релизов (стабильный / pre-release) + * @param progressListener слушатель прогресса скачивания ассета + * @return путь к исполняемому файлу сервера + * @throws IOException если сервер не установлен и его не удалось скачать + */ + public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel, + DownloadProgressListener progressListener) throws IOException { var installed = installedVersion().orElse(null); if (installed != null && !checkIntervalElapsed()) { @@ -155,9 +158,9 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOE return binaryPath(installed); } - GHRelease release; + GitHubReleaseClient.Release release; try { - release = latestRelease(channel); + release = releaseClient.latestRelease(channel); } catch (IOException e) { if (installed != null) { LOGGER.warn("Failed to fetch BSL Language Server releases, using installed version {}", @@ -168,12 +171,12 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOE throw new IOException("Failed to fetch BSL Language Server releases", e); } - var latestVersion = normalizeVersion(release.getTagName()); + var latestVersion = normalizeVersion(release.version()); if (installed == null || compareVersions(latestVersion, installed) > 0) { LOGGER.info("Downloading BSL Language Server {}", latestVersion); try { - downloadAndExtract(release, latestVersion); + downloadAndExtract(release, latestVersion, progressListener); cleanupOtherVersions(latestVersion); installed = latestVersion; } catch (IOException e) { @@ -199,56 +202,26 @@ private boolean checkIntervalElapsed() { } try { var elapsed = System.currentTimeMillis() - Long.parseLong(lastUpdate); - return elapsed >= checkInterval.toMillis(); + return elapsed >= DEFAULT_CHECK_INTERVAL.toMillis(); } catch (NumberFormatException e) { return true; } } - private GHRelease latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var httpClient = HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .build(); - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); - } - GitHub github = builder.build(); - - GHRepository repository = github.getRepository(REPOSITORY); - - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft. - release = repository.listReleases().toList().stream() - .filter(it -> !it.isDraft()) - .findFirst() - .orElse(null); - } else { - release = repository.getLatestRelease(); - } - - if (release == null) { - throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); - } - return release; - } - - private void downloadAndExtract(GHRelease release, String version) throws IOException { + private void downloadAndExtract(GitHubReleaseClient.Release release, String version, + DownloadProgressListener progressListener) throws IOException { var assetName = assetName(); - var downloadUrl = release.listAssets().toList().stream() - .filter(asset -> asset.getName().equals(assetName)) - .map(asset -> asset.getBrowserDownloadUrl()) - .findFirst() - .orElseThrow(() -> new IOException( - "BSL Language Server release " + version + " has no asset " + assetName)); + var downloadUrl = release.assetDownloadUrls().get(assetName); + if (downloadUrl == null) { + throw new IOException("BSL Language Server release " + version + " has no asset " + assetName); + } Files.createDirectories(installDir); var archive = installDir.resolve(version + "-download-" + assetName); var versionDir = installDir.resolve(version); try { - download(downloadUrl, archive); + download(downloadUrl, archive, progressListener); deleteRecursively(versionDir); extract(archive, versionDir); } finally { @@ -256,20 +229,34 @@ private void downloadAndExtract(GHRelease release, String version) throws IOExce } } - private void download(String url, Path destination) throws IOException { - var client = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(CONNECT_TIMEOUT) - .build(); + private void download(String url, Path destination, DownloadProgressListener progressListener) + throws IOException { var request = HttpRequest.newBuilder(URI.create(url)) .header("User-Agent", "1c-syntax-utils") .timeout(DOWNLOAD_TIMEOUT) .GET() .build(); try { - var response = client.send(request, HttpResponse.BodyHandlers.ofFile(destination)); + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { - throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode()); + try (var ignored = response.body()) { + throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode()); + } + } + var totalBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + // Владелец потока — этот блок (здесь взят response.body()), он же его и закрывает. + // Тело читается лениво, поэтому request.timeout его не покрывает: по дедлайну сторож + // закрывает поток — единственный способ разбудить заблокированный read(). При штатном + // завершении сторож отменяется. copyToFile поток не закрывает. + try (var body = response.body()) { + var watchdog = CompletableFuture.runAsync( + () -> closeQuietly(body), + CompletableFuture.delayedExecutor(DOWNLOAD_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + try { + copyToFile(body, destination, totalBytes, progressListener); + } finally { + watchdog.cancel(false); + } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -277,6 +264,33 @@ private void download(String url, Path destination) throws IOException { } } + /** + * Копирует {@code source} в файл, сообщая о прогрессе. Поток {@code source} не закрывает — + * этим владеет вызывающий код. + */ + static void copyToFile(InputStream source, Path destination, long totalBytes, + DownloadProgressListener progressListener) throws IOException { + var buffer = new byte[DOWNLOAD_BUFFER_SIZE]; + long readTotal = 0; + try (var output = Files.newOutputStream(destination)) { + progressListener.onProgress(0, totalBytes); + int read; + while ((read = source.read(buffer)) != -1) { + output.write(buffer, 0, read); + readTotal += read; + progressListener.onProgress(readTotal, totalBytes); + } + } + } + + private static void closeQuietly(InputStream stream) { + try { + stream.close(); + } catch (IOException e) { + LOGGER.debug("Failed to close BSL Language Server download stream", e); + } + } + private static void extract(Path archive, Path targetDir) throws IOException { Files.createDirectories(targetDir); try (var zip = ZipFile.builder().setPath(archive).get()) { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java b/src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java new file mode 100644 index 0000000..a4fdf3c --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java @@ -0,0 +1,49 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +/** + * Слушатель прогресса скачивания ассета релиза. + * + *

Вызывается загрузчиком по мере вычитывания тела ответа: сначала один раз с нулём вычитанных + * байт, затем после каждого прочитанного блока. Позволяет клиенту (например, IntelliJ-плагину) + * рисовать прогресс-бар вместо неопределённого «крутящегося» индикатора. + * + *

Реализация может бросить {@link RuntimeException} (в частности, для отмены операции) — + * исключение пробрасывается наружу через вызов загрузки, а частично скачанный файл удаляется. + */ +@FunctionalInterface +public interface DownloadProgressListener { + + /** + * Слушатель, ничего не делающий — значение по умолчанию, когда прогресс не нужен. + */ + DownloadProgressListener NONE = (bytesRead, totalBytes) -> { + // no-op + }; + + /** + * @param bytesRead сколько байт ассета уже скачано + * @param totalBytes полный размер ассета в байтах или {@code -1}, если сервер его не сообщил + */ + void onProgress(long bytesRead, long totalBytes); +} diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java new file mode 100644 index 0000000..87eeee9 --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -0,0 +1,106 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.jspecify.annotations.Nullable; +import org.kohsuke.github.GHAsset; +import org.kohsuke.github.GHRelease; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.extras.HttpClientGitHubConnector; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории + * {@value #REPOSITORY}. HTTP-доступ обеспечивает сама github-api (свой {@link HttpClientGitHubConnector} + * со встроенным клиентом) — отдельный HTTP-клиент здесь не нужен. + * + *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток + * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. + */ +public class GitHubReleaseClient { + + private static final String REPOSITORY = "1c-syntax/bsl-language-server"; + + private final @Nullable String token; + + /** + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + */ + public GitHubReleaseClient(@Nullable String token) { + this.token = token; + } + + /** + * Возвращает последний релиз выбранного канала. + * + * @param channel канал релизов (стабильный / pre-release) + * @return версия релиза и ссылки на его ассеты + * @throws IOException если релизы недоступны или подходящего релиза нет + */ + public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector()); + if (token != null && !token.isBlank()) { + builder.withOAuthToken(token); + } + GitHub github = builder.build(); + + GHRepository repository = github.getRepository(REPOSITORY); + + GHRelease release; + if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { + // GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы. + release = null; + for (GHRelease candidate : repository.listReleases()) { + if (!candidate.isDraft()) { + release = candidate; + break; + } + } + } else { + release = repository.getLatestRelease(); + } + + if (release == null) { + throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + + var assetUrls = new HashMap(); + for (GHAsset asset : release.listAssets().toList()) { + assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + } + return new Release(release.getTagName(), Map.copyOf(assetUrls)); + } + + /** + * Сведения о релизе, нужные загрузчику. + * + * @param version тег/версия релиза (может начинаться с {@code v}) + * @param assetDownloadUrls карта «имя ассета → URL для скачивания» + */ + public record Release(String version, Map assetDownloadUrls) { + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 29dd0a4..38336ed 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -26,11 +26,32 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class BslLanguageServerDownloaderTest { @@ -54,7 +75,7 @@ void compareVersionsTreatsReleaseAsNewerThanPreRelease() { @Test void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { - var downloader = new BslLanguageServerDownloader(installDir); + var downloader = new BslLanguageServerDownloader(installDir, mock(GitHubReleaseClient.class), unusedHttpClient()); assertThat(downloader.installedVersion()).isEmpty(); assertThat(downloader.installedBinary()).isEmpty(); } @@ -63,7 +84,7 @@ void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { void installedVersionIsReadFromServerInfo(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.2"); - var downloader = new BslLanguageServerDownloader(installDir); + var downloader = new BslLanguageServerDownloader(installDir, mock(GitHubReleaseClient.class), unusedHttpClient()); assertThat(downloader.installedVersion()).contains("1.0.2"); } @@ -78,13 +99,233 @@ void installedBinaryResolvesLinuxLauncher(@TempDir Path installDir) throws IOExc Files.createDirectories(binary.getParent()); Files.createFile(binary); - var downloader = new BslLanguageServerDownloader(installDir); + var downloader = new BslLanguageServerDownloader(installDir, mock(GitHubReleaseClient.class), unusedHttpClient()); assertThat(downloader.installedBinary()).contains(binary); } + @Test + void copyToFileWritesContentAndReportsProgress(@TempDir Path installDir) throws IOException { + var payload = "bsl-language-server".getBytes(StandardCharsets.UTF_8); + var destination = installDir.resolve("asset.bin"); + var progress = new ArrayList(); + + BslLanguageServerDownloader.copyToFile( + new ByteArrayInputStream(payload), + destination, + payload.length, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(Files.readAllBytes(destination)).isEqualTo(payload); + assertThat(progress).isNotEmpty(); + assertThat(progress.get(0)).containsExactly(0, payload.length); + var last = progress.get(progress.size() - 1); + assertThat(last).containsExactly(payload.length, payload.length); + assertThat(last[0]).isLessThanOrEqualTo(last[1]); + } + + @Test + void copyToFileReportsProgressAcrossMultipleChunks(@TempDir Path installDir) throws IOException { + // Больше буфера copyToFile, чтобы цикл чтения выполнился несколько раз. + var payload = new byte[1024 * 1024]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) i; + } + var destination = installDir.resolve("asset.bin"); + var progress = new ArrayList(); + + BslLanguageServerDownloader.copyToFile( + new ByteArrayInputStream(payload), + destination, + payload.length, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(Files.readAllBytes(destination)).isEqualTo(payload); + assertThat(progress).hasSizeGreaterThan(2); + assertThat(progress).map(it -> it[0]).isSorted(); + assertThat(progress.get(progress.size() - 1)).containsExactly(payload.length, payload.length); + } + + @Test + void copyToFileLeavesSourceOpenForCaller(@TempDir Path installDir) throws IOException { + var closed = new AtomicInteger(); + var source = new ByteArrayInputStream(new byte[]{1, 2, 3}) { + @Override + public void close() { + closed.incrementAndGet(); + } + }; + + BslLanguageServerDownloader.copyToFile( + source, installDir.resolve("asset.bin"), 3, DownloadProgressListener.NONE); + + assertThat(closed.get()).isZero(); + } + + @Test + void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOException { + var destination = installDir.resolve("asset.bin"); + List totals = new ArrayList<>(); + + BslLanguageServerDownloader.copyToFile( + new ByteArrayInputStream(new byte[]{1, 2, 3}), + destination, + -1, + (bytesRead, totalBytes) -> totals.add(totalBytes)); + + assertThat(totals).isNotEmpty().allMatch(total -> total == -1); + } + + @Test + void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir) throws IOException { + var archive = zipWithLaunchers(300 * 1024); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("v1.2.3", allOsAssets())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, httpClientReturning(archive)); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary).exists(); + assertThat(binary).startsWith(installDir.resolve("1.2.3")); + assertThat(downloader.installedVersion()).contains("1.2.3"); + } + + @Test + void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { + var archive = zipWithLaunchers(300 * 1024); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("1.0.0", allOsAssets())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, httpClientReturning(archive)); + var progress = new ArrayList(); + + downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(progress).isNotEmpty(); + var last = progress.get(progress.size() - 1); + assertThat(last[0]).isEqualTo(archive.length); + assertThat(last[1]).isEqualTo(archive.length); + } + + @Test + void downloadIfNeededSkipsReleaseLookupWhenIntervalNotElapsed(@TempDir Path installDir) + throws IOException { + writeServerInfoAt(installDir, "1.0.0", System.currentTimeMillis()); + var releaseClient = mock(GitHubReleaseClient.class); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary.toString()).contains("1.0.0"); + verify(releaseClient, never()).latestRelease(any()); + } + + @Test + void downloadIfNeededFallsBackToInstalledWhenLookupFails(@TempDir Path installDir) throws IOException { + writeServerInfo(installDir, "1.0.0"); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())).thenThrow(new IOException("no network")); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary.toString()).contains("1.0.0"); + } + + @Test + void downloadIfNeededThrowsWhenNothingInstalledAndLookupFails(@TempDir Path installDir) + throws IOException { + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())).thenThrow(new IOException("no network")); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); + + assertThatThrownBy(() -> downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class); + } + + @Test + void downloadIfNeededPassesRequestedChannelToLookup(@TempDir Path installDir) throws IOException { + writeServerInfo(installDir, "1.0.0"); + var releaseClient = mock(GitHubReleaseClient.class); + // та же версия — скачивания не будет, проверяем только проброс канала + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("1.0.0", Map.of())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); + + downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.PRERELEASE); + + verify(releaseClient).latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + } + + /** + * Мок {@link HttpClient}, отдающий на любой {@code send} ответ 200 с телом {@code body} и + * заголовком {@code Content-Length}. Заменяет реальную сеть в тестах скачивания. + */ + @SuppressWarnings("unchecked") + private static HttpClient httpClientReturning(byte[] body) throws IOException { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(200); + when(response.headers()).thenReturn(HttpHeaders.of( + Map.of("Content-Length", List.of(String.valueOf(body.length))), (name, value) -> true)); + // Свежий поток на каждый send() — чтобы мок оставался корректным, если тело когда-нибудь + // прочитают повторно (ретрай/редирект). + when(response.body()).thenAnswer(invocation -> new ByteArrayInputStream(body)); + + var client = mock(HttpClient.class); + try { + // doReturn — без дженериков, чтобы не спорить с выводом типа T у send(...). + doReturn(response).when(client).send(any(), any()); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); // не бывает: это настройка мока, а не реальный вызов + } + return client; + } + + private static HttpClient unusedHttpClient() { + return mock(HttpClient.class); + } + + private static Map allOsAssets() { + var url = "https://example.invalid/asset.zip"; // мок игнорирует URL + return Map.of( + "bsl-language-server_win.zip", url, + "bsl-language-server_mac.zip", url, + "bsl-language-server_nix.zip", url); + } + + /** + * Zip с раскладкой лаунчеров для всех трёх ОС (тест OS-агностичен) плюс крупный «полезный» + * файл, чтобы скачивание шло несколькими блоками. + */ + private static byte[] zipWithLaunchers(int payloadSize) throws IOException { + var payload = new byte[payloadSize]; + new Random(1).nextBytes(payload); + var bytes = new ByteArrayOutputStream(); + try (var zip = new ZipOutputStream(bytes)) { + putEntry(zip, "bsl-language-server/bsl-language-server.exe", new byte[]{1}); + putEntry(zip, "bsl-language-server/bin/bsl-language-server", new byte[]{1}); + putEntry(zip, "bsl-language-server.app/Contents/MacOS/bsl-language-server", new byte[]{1}); + putEntry(zip, "payload.bin", payload); + } + return bytes.toByteArray(); + } + + private static void putEntry(ZipOutputStream zip, String name, byte[] data) throws IOException { + zip.putNextEntry(new ZipEntry(name)); + zip.write(data); + zip.closeEntry(); + } + private static void writeServerInfo(Path installDir, String version) throws IOException { + writeServerInfoAt(installDir, version, 0); + } + + private static void writeServerInfoAt(Path installDir, String version, long lastUpdate) + throws IOException { Files.createDirectories(installDir); Files.writeString(installDir.resolve("SERVER-INFO"), - "version=" + version + System.lineSeparator() + "lastUpdate=0" + System.lineSeparator()); + "version=" + version + System.lineSeparator() + + "lastUpdate=" + lastUpdate + System.lineSeparator()); } }