Files
kompas3d-mcp/tools/tests/KompasMcpBootstrap.Tests.ps1
T
mikhail 0ed211fe16 fix(plugin): mutex AbandonedMutexException, валидация server.lock.json, тесты гонки
Три предметных замечания ревью качества:
- Resolve-KompasMcpExecutable: WaitOne() перенесён в try/finally, отдельно
  ловится AbandonedMutexException (владелец умер посреди установки — владение
  переходит к нам, это не ошибка); ReleaseMutex вызывается только если mutex
  реально захвачен, иначе он бросает SynchronizationLockException и маскирует
  первопричину. Добавлен -Downloader passthrough для тестируемости.
- Read-KompasMcpLock: разбор JSON обёрнут в try/catch с сообщением, называющим
  файл; наличие полей version/url/sha256 проверяется через
  PSObject.Properties.Name (обращение к отсутствующему свойству под
  Set-StrictMode -Version Latest бросает PropertyNotFoundException); sha256
  валидируется на 64 hex-символа.
- Добавлены тесты на конкурентную логику: Resolve-KompasMcpExecutable
  (повторный вызов не скачивает), гонка "сосед выиграл" (целевой каталог уже
  содержит годную/негодную установку) в Install-KompasMcpServer, счастливый
  путь и все новые ветки валидации в Read-KompasMcpLock.

Мелочи: & $Downloader ... | Out-Null (внедряемый загрузчик не должен писать
в success stream); уточнён комментарий Test-KompasMcpInstall — оно намеренно
трактует любую ошибку чтения маркера (не только битый JSON, но и блокировку
антивирусом) как "установка негодная".

AbandonedMutexException-ветка вручную воспроизведена и проверена: процесс-
владелец mutex убит, пока другой процесс уже блокирован в WaitOne() —
тот получает AbandonedMutexException и корректно продолжает работу.
2026-07-31 02:34:41 +03:00

218 lines
11 KiB
PowerShell

BeforeAll {
Import-Module (Join-Path $PSScriptRoot '..' '..' 'plugin' 'scripts' 'KompasMcpBootstrap.psm1') -Force
function New-TempRoot {
$root = Join-Path ([System.IO.Path]::GetTempPath()) ("kompasmcp_" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $root -Force | Out-Null
return $root
}
# Готовит zip с «сервером» и возвращает лок с настоящим SHA256.
function New-FakeRelease([string] $Root, [string] $Version) {
$payload = Join-Path $Root 'payload'
New-Item -ItemType Directory -Path $payload -Force | Out-Null
Set-Content -LiteralPath (Join-Path $payload 'kompas-mcp.exe') -Value 'not-a-real-exe' -Encoding ascii
$zip = Join-Path $Root 'release.zip'
Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $zip -Force
Remove-Item -LiteralPath $payload -Recurse -Force
$sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
return [pscustomobject]@{
Zip = $zip
Lock = [pscustomobject]@{ version = $Version; sourceSha = 'deadbeef'; url = "file:///$zip"; sha256 = $sha }
}
}
}
Describe 'Install-KompasMcpServer' {
It 'ставит сервер, пишет маркер и возвращает путь к каталогу версии' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
}
$dir | Should -Be (Join-Path $installRoot '1.0.0')
Test-Path -LiteralPath (Join-Path $dir 'kompas-mcp.exe') | Should -BeTrue
(Get-Content -LiteralPath (Join-Path $dir 'install.json') -Raw | ConvertFrom-Json).sha256 |
Should -Be $rel.Lock.sha256
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'отказывается ставить при несовпадении SHA256 и не оставляет каталог версии' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$rel.Lock.sha256 = '0' * 64
$installRoot = Join-Path $root 'install'
{ Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
} } | Should -Throw '*SHA256*'
Test-Path -LiteralPath (Join-Path $installRoot '1.0.0') | Should -BeFalse
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'не скачивает повторно, если установка уже годная' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$script:calls = 0
$downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
$script:calls | Should -Be 1
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'считает установку негодной, если маркер не совпадает с локом' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
}
Set-Content -LiteralPath (Join-Path $dir 'install.json') -Value '{"version":"1.0.0","sha256":"нечто"}' -Encoding utf8
Test-KompasMcpInstall -Directory $dir -Lock $rel.Lock | Should -BeFalse
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'гонка: если целевой каталог версии уже содержит годную установку, не скачивает и возвращает его путь' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$target = Join-Path $installRoot '1.0.0'
# Имитируем "соседа", который уже успешно установил версию раньше нас.
New-Item -ItemType Directory -Path $target -Force | Out-Null
Set-Content -LiteralPath (Join-Path $target 'kompas-mcp.exe') -Value 'not-a-real-exe' -Encoding ascii
$neighborMarker = [ordered]@{ version = $rel.Lock.version; sha256 = $rel.Lock.sha256 }
Set-Content -LiteralPath (Join-Path $target 'install.json') -Value ($neighborMarker | ConvertTo-Json) -Encoding utf8
$script:calls = 0
$downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
$dir | Should -Be $target
$script:calls | Should -Be 0
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'гонка: если целевой каталог версии существует, но установка в нём негодная, бросает внятную ошибку' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$target = Join-Path $installRoot '1.0.0'
# Каталог версии существует (например, остался от оборванной установки),
# но без валидного маркера/exe — установка в нём негодная.
New-Item -ItemType Directory -Path $target -Force | Out-Null
{ Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
} } | Should -Throw '*негодная*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Resolve-KompasMcpExecutable' {
It 'возвращает путь к exe и не скачивает повторно при втором вызове' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$script:calls = 0
$downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
$exe1 = Resolve-KompasMcpExecutable -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
$exe2 = Resolve-KompasMcpExecutable -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
$exe1 | Should -Be (Join-Path $installRoot '1.0.0' 'kompas-mcp.exe')
$exe2 | Should -Be $exe1
Test-Path -LiteralPath $exe1 | Should -BeTrue
$script:calls | Should -Be 1
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Read-KompasMcpLock' {
It 'падает внятно, когда релиза ещё нет' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '{"version":"0.0.0","sourceSha":"","url":"","sha256":""}' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*релиз*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'счастливый путь: валидный опубликованный лок разбирается и возвращает поля' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
$sha = '1' * 64
Set-Content -LiteralPath $path -Value "{`"version`":`"1.2.3`",`"sourceSha`":`"deadbeef`",`"url`":`"https://example.invalid/release.zip`",`"sha256`":`"$sha`"}" -Encoding utf8
$lock = Read-KompasMcpLock -Path $path
$lock.version | Should -Be '1.2.3'
$lock.url | Should -Be 'https://example.invalid/release.zip'
$lock.sha256 | Should -Be $sha
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно на битом JSON' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '{ не json вообще' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*server.lock.json*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно, если в валидном JSON нет поля version' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '{"sourceSha":"deadbeef","url":"https://example.invalid/release.zip","sha256":"1111111111111111111111111111111111111111111111111111111111111111"}' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно, если у опубликованной версии нет поля url' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
$sha = '1' * 64
Set-Content -LiteralPath $path -Value "{`"version`":`"1.2.3`",`"sourceSha`":`"deadbeef`",`"sha256`":`"$sha`"}" -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*url*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно, если у опубликованной версии sha256 пустой или некорректной длины' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '{"version":"1.2.3","sourceSha":"deadbeef","url":"https://example.invalid/release.zip","sha256":"beef"}' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*sha256*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}