diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml new file mode 100644 index 0000000..8743e51 --- /dev/null +++ b/.github/workflows/windows-full-ci.yml @@ -0,0 +1,61 @@ +name: windows-full-ci + +on: + pull_request: + paths: + - "herdr-plugin.toml" + - "plannotator-tui.version" + - "scripts/fetch-plannotator-tui.*" + - "scripts/plannotator-tui.sh" + - "scripts/smoke.sh" + - "scripts/test-fetch-plannotator-tui.*" + - "scripts/test-herdr-windows-plugin.ps1" + - "scripts/test-http-server.py" + - "scripts/test-windows-full-manifest.py" + - ".github/workflows/windows-full-ci.yml" + merge_group: + +concurrency: + group: windows-full-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + windows-full: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: local override and idempotence + run: ./scripts/test-fetch-plannotator-tui.ps1 -Case LocalOverride + - name: loopback download and checksum preservation + run: ./scripts/test-fetch-plannotator-tui.ps1 -Case Download + - uses: actions/checkout@v4 + with: + repository: plannotator/plannotator-tui + ref: 3d0f671e50613eb2354226a76209df995c5b2437 + path: plannotator-tui-source + persist-credentials: false + - name: manifest structure and development parity + run: | + python plannotator-tui-source/herdr/test-manifest.py + python scripts/test-windows-full-manifest.py ` + plannotator-tui-source/herdr/herdr-plugin.toml + - name: pinned Herdr link and list + run: ./scripts/test-herdr-windows-plugin.ps1 + + unix-regression: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - run: bash scripts/test-fetch-plannotator-tui.sh diff --git a/herdr-plugin.toml b/herdr-plugin.toml index f3cbaef..22f604a 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -44,10 +44,10 @@ command = ["bun", "src/manager.ts"] # --------------------------------------------------------------------------------------- # Document review with plannotator-tui (https://github.com/plannotator/plannotator-tui). -# Commands resolve through $HERDR_PLUGIN_ROOT: the review pane is opened with --cwd set to -# the folder under review, so a path relative to the plugin root would not resolve there. -# A prebuilt binary is fetched into bin/ at build time; macOS and Linux for now. Where it -# opens (overlay | split | popup) is the user's choice in ~/.config/plannotator-tui/config.toml. +# Actions resolve explicit relative programs against $HERDR_PLUGIN_ROOT. The review pane is +# opened with --cwd set to the folder under review, so it retains the plugin-root wrapper. +# A prebuilt binary is fetched into bin/ at build time on macOS and Linux. Where it opens +# (overlay | split | popup) remains the user's choice in plannotator-tui's config file. [[build]] platforms = ["macos", "linux"] @@ -69,7 +69,7 @@ title = "Annotate: open here" description = "Review the focused pane's folder in plannotator-tui and send feedback to its agent." contexts = ["workspace", "pane"] platforms = ["macos", "linux"] -command = ["sh", "-c", "exec bash \"$HERDR_PLUGIN_ROOT/scripts/plannotator-tui.sh\" herdr open"] +command = ["./bin/plannotator-tui.exe", "herdr", "open"] [[actions]] id = "open-link" @@ -77,7 +77,7 @@ title = "Annotate this file" description = "Open a Ctrl-clicked Markdown file in plannotator-tui." contexts = ["pane"] platforms = ["macos", "linux"] -command = ["sh", "-c", "exec bash \"$HERDR_PLUGIN_ROOT/scripts/plannotator-tui.sh\" herdr open"] +command = ["./bin/plannotator-tui.exe", "herdr", "open"] [[actions]] id = "last" @@ -85,7 +85,7 @@ title = "Annotate: agent's last message" description = "Review the focused agent's most recent message in plannotator-tui and send feedback back." contexts = ["pane"] platforms = ["macos", "linux"] -command = ["sh", "-c", "exec bash \"$HERDR_PLUGIN_ROOT/scripts/plannotator-tui.sh\" herdr last"] +command = ["./bin/plannotator-tui.exe", "herdr", "last"] # Ctrl-click on a file:// Markdown link. Anchored on the scheme so web links never match. [[link_handlers]] diff --git a/plannotator-tui.version b/plannotator-tui.version index 8f0916f..a918a2a 100644 --- a/plannotator-tui.version +++ b/plannotator-tui.version @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/scripts/fetch-plannotator-tui.ps1 b/scripts/fetch-plannotator-tui.ps1 new file mode 100644 index 0000000..8edbeb4 --- /dev/null +++ b/scripts/fetch-plannotator-tui.ps1 @@ -0,0 +1,157 @@ +$ErrorActionPreference = "Stop" +Set-Location (Join-Path $PSScriptRoot "..") + +$versionContents = Get-Content -LiteralPath "plannotator-tui.version" -Raw +$version = if ($null -eq $versionContents) { "" } else { [string]$versionContents } +$version = $version.Trim() +if (-not $version) { throw "plannotator-tui.version is empty" } + +$destinationDirectory = Join-Path (Get-Location).Path "bin" +$destination = Join-Path $destinationDirectory "plannotator-tui.exe" +$stamp = Join-Path $destinationDirectory "plannotator-tui.version" +New-Item -ItemType Directory -Force $destinationDirectory | Out-Null + +$localOverride = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_BIN", "Process") +$hasLocalOverride = $null -ne $localOverride +$installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { + ([string](Get-Content -LiteralPath $stamp -Raw)).Trim() +} else { + "" +} + +if ((Test-Path -LiteralPath $destination -PathType Leaf) -and + $installed -eq $version -and -not $hasLocalOverride) { + Write-Output "plannotator-tui $version already installed" + exit 0 +} + +function Install-PlannotatorTui { + param([Parameter(Mandatory = $true)][string]$Source) + + $candidate = Join-Path $destinationDirectory ("plannotator-tui-" + [guid]::NewGuid() + ".tmp") + $backup = Join-Path $destinationDirectory ("plannotator-tui-" + [guid]::NewGuid() + ".bak") + $stampBackup = Join-Path $destinationDirectory ("plannotator-tui-version-" + [guid]::NewGuid() + ".bak") + $hadDestination = Test-Path -LiteralPath $destination -PathType Leaf + $hadStamp = Test-Path -LiteralPath $stamp -PathType Leaf + $replacementCompleted = $false + $keepRecoveryFiles = $false + try { + if ($hadStamp) { + Copy-Item -LiteralPath $stamp -Destination $stampBackup + } + Copy-Item -LiteralPath $Source -Destination $candidate + if ($hadDestination) { + try { + [System.IO.File]::Replace( + [System.IO.Path]::GetFullPath($candidate), + [System.IO.Path]::GetFullPath($destination), + [System.IO.Path]::GetFullPath($backup) + ) + } catch { + throw "failed to replace ${destination}: $($_.Exception.Message)" + } + } else { + Move-Item -LiteralPath $candidate -Destination $destination + } + $replacementCompleted = $true + Set-Content -LiteralPath $stamp -NoNewline -Value $version + } catch { + $installFailure = $_ + if ($replacementCompleted) { + try { + if ($hadDestination) { + Remove-Item -LiteralPath $destination -Force + Move-Item -LiteralPath $backup -Destination $destination + } else { + Remove-Item -LiteralPath $destination -Force + } + if ($hadStamp) { + Remove-Item -LiteralPath $stamp -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $stampBackup -Destination $stamp + } else { + Remove-Item -LiteralPath $stamp -Force -ErrorAction SilentlyContinue + } + $replacementCompleted = $false + } catch { + $keepRecoveryFiles = $true + throw ( + "$($installFailure.Exception.Message); rollback also failed: " + + $_.Exception.Message + ) + } + } + throw $installFailure + } finally { + Remove-Item -LiteralPath $candidate -Force -ErrorAction SilentlyContinue + if (-not $keepRecoveryFiles) { + Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stampBackup -Force -ErrorAction SilentlyContinue + } + } +} + +if ($hasLocalOverride) { + if (-not (Test-Path -LiteralPath $localOverride -PathType Leaf)) { + throw "PLANNOTATOR_TUI_BIN is not a file: $localOverride" + } + Install-PlannotatorTui -Source $localOverride + Write-Output "installed plannotator-tui from $localOverride (local build, stamped $version)" + exit 0 +} + +try { + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + $target = switch ($architecture) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "no plannotator-tui release target for Windows/$architecture" } + } + + $asset = "plannotator-tui-$target.exe" + $releaseBaseOverride = [Environment]::GetEnvironmentVariable( + "PLANNOTATOR_TUI_RELEASE_BASE", + "Process" + ) + # PLANNOTATOR_TUI_RELEASE_BASE is a test-only seam for a loopback fixture server. + $base = if ($null -ne $releaseBaseOverride) { + $releaseBaseOverride.TrimEnd([char]"/") + } else { + "https://github.com/plannotator/plannotator-tui/releases/download/v$version" + } + + $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("plannotator-tui-" + [guid]::NewGuid()) + try { + New-Item -ItemType Directory $temporary | Out-Null + $downloadedAsset = Join-Path $temporary $asset + $checksumFile = Join-Path $temporary "SHA256SUMS" + Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile $downloadedAsset + Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS" -OutFile $checksumFile + + $matches = @( + Get-Content -LiteralPath $checksumFile | Where-Object { + $fields = @($_ -split "\s+") + $fields.Count -ge 2 -and $fields[-1] -ceq $asset + } + ) + if ($matches.Count -ne 1) { + throw "expected exactly one checksum for $asset in $base/SHA256SUMS; found $($matches.Count)" + } + $checksumFields = $matches[0].Trim() -split "\s+" + $expected = $checksumFields[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $downloadedAsset).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + throw "sha256 mismatch for ${asset}: expected $expected, got $actual" + } + + Install-PlannotatorTui -Source $downloadedAsset + Write-Output "installed plannotator-tui $version ($target)" + } finally { + Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue + } +} catch { + Write-Warning ( + "Full review is unavailable until the plugin is reinstalled or updated: " + + $_.Exception.Message + ) + exit 0 +} diff --git a/scripts/fetch-plannotator-tui.sh b/scripts/fetch-plannotator-tui.sh index 22cc08e..9917b3c 100755 --- a/scripts/fetch-plannotator-tui.sh +++ b/scripts/fetch-plannotator-tui.sh @@ -3,7 +3,7 @@ # (cwd = plugin root) and by hand for local testing. # # plannotator-tui.version the release to install (one line, e.g. 0.1.0) -# bin/plannotator-tui the binary +# bin/plannotator-tui.exe the binary # bin/plannotator-tui.version what is installed; matching the pin means nothing to do # # Modes, in order: @@ -19,19 +19,21 @@ cd "$(dirname "$0")/.." version="$(tr -d '[:space:]' < plannotator-tui.version)" [ -n "$version" ] || { echo "plannotator-tui.version is empty" >&2; exit 1; } mkdir -p bin +destination="bin/plannotator-tui.exe" +stamp="bin/plannotator-tui.version" installed="$(cat bin/plannotator-tui.version 2>/dev/null || true)" -if [ -x bin/plannotator-tui ] && [ "$installed" = "$version" ] && [ -z "${PLANNOTATOR_TUI_BIN:-}" ]; then +if [ -x "$destination" ] && [ "$installed" = "$version" ] && [ -z "${PLANNOTATOR_TUI_BIN:-}" ]; then echo "plannotator-tui $version already installed" exit 0 fi if [ -n "${PLANNOTATOR_TUI_BIN:-}" ]; then [ -x "$PLANNOTATOR_TUI_BIN" ] || { echo "PLANNOTATOR_TUI_BIN is not an executable: $PLANNOTATOR_TUI_BIN" >&2; exit 1; } - cp "$PLANNOTATOR_TUI_BIN" bin/plannotator-tui.tmp - chmod +x bin/plannotator-tui.tmp - mv bin/plannotator-tui.tmp bin/plannotator-tui - echo "$version" > bin/plannotator-tui.version + rm -f "$destination" + cp "$PLANNOTATOR_TUI_BIN" "$destination" + chmod +x "$destination" + printf '%s' "$version" > "$stamp" echo "installed plannotator-tui from $PLANNOTATOR_TUI_BIN (local build, stamped $version)" exit 0 fi @@ -74,6 +76,8 @@ fi [ "$actual" = "$expected" ] || give_up "sha256 mismatch for $asset: expected $expected, got $actual" chmod +x "$tmp/$asset" -mv "$tmp/$asset" bin/plannotator-tui -echo "$version" > bin/plannotator-tui.version +rm -f "$destination" +cp "$tmp/$asset" "$destination" +chmod +x "$destination" +printf '%s' "$version" > "$stamp" echo "installed plannotator-tui $version ($target)" diff --git a/scripts/plannotator-tui.sh b/scripts/plannotator-tui.sh index 480dba0..49ea111 100755 --- a/scripts/plannotator-tui.sh +++ b/scripts/plannotator-tui.sh @@ -1,11 +1,10 @@ #!/usr/bin/env bash -# Run the bundled plannotator-tui, or say clearly why it cannot run. Herdr invokes this for -# the review pane and actions; the pane's cwd is the folder under review, so the binary is -# located relative to this script, never to the cwd. +# The review pane runs with the folder under review as cwd. Resolve the staged binary from this +# script's plugin-root location instead. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" -if [ -x "$root/bin/plannotator-tui" ]; then - exec "$root/bin/plannotator-tui" "$@" +if [ -x "$root/bin/plannotator-tui.exe" ]; then + exec "$root/bin/plannotator-tui.exe" "$@" fi msg="plannotator-tui is not installed. Reinstall the plugin: herdr plugin install plannotator/herdr-annotate" echo "$msg" >&2 diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 0871cef..ed4daee 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -24,7 +24,7 @@ actions() { herdr plugin action list --plugin annotate | python3 -c " import json,sys; print(','.join(sorted(a['action_id'] for a in json.load(sys.stdin)['result']['actions'])))"; } bin_version() { local root; root="$(plugin_json | field "p['plugin_root']")" - local bin="$root/bin/plannotator-tui"; [ -x "$bin" ] && "$bin" --version | awk '{print $2}' || echo none + local bin="$root/bin/plannotator-tui.exe"; [ -x "$bin" ] && "$bin" --version | awk '{print $2}' || echo none } pin() { local root; root="$(plugin_json | field "p['plugin_root']")"; tr -d '[:space:]' < "$root/plannotator-tui.version" 2>/dev/null || echo none; } check() { if [ "$2" = "$3" ]; then echo " ok $1: $2"; else echo " FAIL $1: got '$2', want '$3'" >&2; failures=$((failures+1)); fi; } diff --git a/scripts/test-fetch-plannotator-tui.ps1 b/scripts/test-fetch-plannotator-tui.ps1 new file mode 100644 index 0000000..91df31b --- /dev/null +++ b/scripts/test-fetch-plannotator-tui.ps1 @@ -0,0 +1,185 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("LocalOverride", "Download")] + [string]$Case +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (Test-Path variable:PSNativeCommandUseErrorActionPreference) { + $PSNativeCommandUseErrorActionPreference = $false +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$testRoot = Join-Path $env:RUNNER_TEMP ("plannotator full fetch " + $Case + " " + [guid]::NewGuid()) +$pluginRoot = Join-Path $testRoot "plugin root with spaces" +$pluginScripts = Join-Path $pluginRoot "scripts" +$fetcher = Join-Path $pluginScripts "fetch-plannotator-tui.ps1" +$destination = Join-Path $pluginRoot "bin/plannotator-tui.exe" +$stamp = Join-Path $pluginRoot "bin/plannotator-tui.version" +$oldLocalOverride = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_BIN", "Process") +$oldReleaseBase = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_RELEASE_BASE", "Process") + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Invoke-Fetcher { + $output = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $fetcher *>&1 | + Out-String + [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } +} + +function Assert-BytesEqual { + param([string]$Left, [string]$Right, [string]$Message) + $leftBytes = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Left)) + $rightBytes = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Right)) + Assert-True ($leftBytes -ceq $rightBytes) $Message +} + +function Start-FixtureServer { + param([string]$Root, [string]$PortFile) + $start = [System.Diagnostics.ProcessStartInfo]::new() + $start.FileName = (Get-Command python).Source + $start.UseShellExecute = $false + $start.ArgumentList.Add((Join-Path $repositoryRoot "scripts/test-http-server.py")) + $start.ArgumentList.Add($Root) + $start.ArgumentList.Add($PortFile) + $process = [System.Diagnostics.Process]::Start($start) + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { + if ($process.HasExited) { throw "fixture server exited with $($process.ExitCode)" } + if ([DateTime]::UtcNow -gt $deadline) { throw "fixture server did not publish its port" } + Start-Sleep -Milliseconds 100 + } + $process +} + +try { + New-Item -ItemType Directory -Force $pluginScripts | Out-Null + Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts/fetch-plannotator-tui.ps1") -Destination $fetcher + Copy-Item -LiteralPath (Join-Path $repositoryRoot "plannotator-tui.version") -Destination $pluginRoot + + if ($Case -eq "LocalOverride") { + $sourceDirectory = Join-Path $testRoot "synthetic source with spaces" + $source = Join-Path $sourceDirectory "plannotator-tui local.exe" + New-Item -ItemType Directory -Force $sourceDirectory | Out-Null + Set-Content -LiteralPath $source -NoNewline -Value "local override bytes" + New-Item -ItemType Directory -Force (Split-Path -Parent $destination) | Out-Null + Set-Content -LiteralPath $destination -NoNewline -Value "old destination bytes" + Set-Content -LiteralPath $stamp -NoNewline -Value "old-version" + + $env:PLANNOTATOR_TUI_BIN = $source + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "local override failed: $($result.Output)" + Assert-BytesEqual $source $destination "local override bytes differ" + Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq "0.6.0") "local stamp differs" + + $env:PLANNOTATOR_TUI_BIN = $null + $env:PLANNOTATOR_TUI_RELEASE_BASE = "http://127.0.0.1:1/must-not-be-requested" + $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "idempotent run failed: $($result.Output)" + Assert-True ($result.Output -match "already installed") "idempotent run did not short-circuit" + Assert-True ( + (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash + ) "idempotent run replaced the destination" + + $env:PLANNOTATOR_TUI_BIN = Join-Path $testRoot "missing explicit override.exe" + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -ne 0) "missing explicit override exited successfully" + Assert-True ($result.Output -match "PLANNOTATOR_TUI_BIN is not a file") "missing override error differs" + Assert-True ( + (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash + ) "missing override changed the destination" + + $env:PLANNOTATOR_TUI_BIN = $null + Set-Content -LiteralPath (Join-Path $pluginRoot "plannotator-tui.version") -NoNewline -Value "" + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -ne 0) "empty version pin exited successfully" + Assert-True ($result.Output -match "plannotator-tui.version is empty") "empty pin error differs" + Assert-True ( + (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash + ) "empty pin changed the destination" + } else { + $webRoot = Join-Path $testRoot "loopback release with spaces" + $portFile = Join-Path $testRoot "fixture-server.port" + $asset = "plannotator-tui-x86_64-pc-windows-msvc.exe" + $source = Join-Path $webRoot $asset + New-Item -ItemType Directory -Force $webRoot | Out-Null + Set-Content -LiteralPath $source -NoNewline -Value "downloaded fixture bytes" + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash.ToLowerInvariant() + Set-Content -LiteralPath (Join-Path $webRoot "SHA256SUMS") -Value "$hash $asset" + $server = Start-FixtureServer -Root $webRoot -PortFile $portFile + try { + $port = (Get-Content -LiteralPath $portFile -Raw).Trim() + $env:PLANNOTATOR_TUI_BIN = $null + $env:PLANNOTATOR_TUI_RELEASE_BASE = "http://127.0.0.1:$port" + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "download fixture failed: $($result.Output)" + Assert-True ($result.Output -match "x86_64-pc-windows-msvc") "x64 target was not selected" + Assert-BytesEqual $source $destination "downloaded destination bytes differ" + Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq "0.6.0") "download stamp differs" + + Set-Content -LiteralPath $stamp -NoNewline -Value "preserve-this-stamp" + $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash + Set-Content -LiteralPath (Join-Path $webRoot "SHA256SUMS") -Value ("0" * 64 + " $asset") + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "wrong checksum was fatal: $($result.Output)" + Assert-True ($result.Output -match "Full review is unavailable") "wrong checksum warning differs" + Assert-True ( + (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash + ) "wrong checksum changed the prior destination" + Assert-True ( + (Get-Content -LiteralPath $stamp -Raw) -ceq "preserve-this-stamp" + ) "wrong checksum changed the prior stamp" + + Set-Content -LiteralPath $source -NoNewline -Value "replacement bytes while destination is locked" + $replacementHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash.ToLowerInvariant() + Set-Content -LiteralPath (Join-Path $webRoot "SHA256SUMS") ` + -Value "$replacementHash $asset" + Set-Content -LiteralPath $stamp -NoNewline -Value "preserve-locked-stamp" + $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash + $lock = [System.IO.File]::Open( + $destination, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + try { + $result = Invoke-Fetcher + } finally { + $lock.Dispose() + } + Assert-True ($result.ExitCode -eq 0) "locked destination was fatal: $($result.Output)" + Assert-True ($result.Output -match "Full review is unavailable") "locked warning differs" + Assert-True ($result.Output -match "plannotator-tui.exe") "locked warning omits destination" + Assert-True ( + (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash + ) "locked replacement changed the prior destination" + Assert-True ( + (Get-Content -LiteralPath $stamp -Raw) -ceq "preserve-locked-stamp" + ) "locked replacement changed the prior stamp" + } finally { + if ($null -ne $server -and -not $server.HasExited) { + $server.Kill($true) + $server.WaitForExit() + } + } + } +} finally { + if ($null -eq $oldLocalOverride) { + $env:PLANNOTATOR_TUI_BIN = $null + } else { + $env:PLANNOTATOR_TUI_BIN = $oldLocalOverride + } + if ($null -eq $oldReleaseBase) { + $env:PLANNOTATOR_TUI_RELEASE_BASE = $null + } else { + $env:PLANNOTATOR_TUI_RELEASE_BASE = $oldReleaseBase + } + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +exit 0 diff --git a/scripts/test-fetch-plannotator-tui.sh b/scripts/test-fetch-plannotator-tui.sh new file mode 100644 index 0000000..4c0edd5 --- /dev/null +++ b/scripts/test-fetch-plannotator-tui.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "$0")/.." && pwd)" +test_root="$(mktemp -d)" +trap 'rm -rf "$test_root"' EXIT +plugin_root="$test_root/plugin root with spaces" +source_root="$test_root/source binary with spaces" +mkdir -p "$plugin_root/scripts" "$plugin_root/bin" "$source_root" +cp "$repository_root/scripts/fetch-plannotator-tui.sh" "$plugin_root/scripts/" +cp "$repository_root/scripts/plannotator-tui.sh" "$plugin_root/scripts/" +cp "$repository_root/plannotator-tui.version" "$plugin_root/" + +source_binary="$source_root/plannotator-tui local" +cat > "$source_binary" <<'EOF' +#!/usr/bin/env sh +printf 'plannotator-tui 0.6.0\n' +EOF +chmod +x "$source_binary" +printf 'old destination' > "$plugin_root/bin/plannotator-tui.exe" +chmod +x "$plugin_root/bin/plannotator-tui.exe" +printf 'old-version' > "$plugin_root/bin/plannotator-tui.version" + +PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh" +cmp "$source_binary" "$plugin_root/bin/plannotator-tui.exe" +test "$(cat "$plugin_root/bin/plannotator-tui.version")" = 0.6.0 +test ! -e "$plugin_root/bin/plannotator-tui" +test "$("$plugin_root/bin/plannotator-tui.exe" --version)" = "plannotator-tui 0.6.0" +test "$(bash "$plugin_root/scripts/plannotator-tui.sh" --version)" = "plannotator-tui 0.6.0" + +output="$(bash "$plugin_root/scripts/fetch-plannotator-tui.sh")" +case "$output" in + *"already installed"*) ;; + *) echo "idempotent fetch did not short-circuit: $output" >&2; exit 1 ;; +esac diff --git a/scripts/test-herdr-windows-plugin.ps1 b/scripts/test-herdr-windows-plugin.ps1 new file mode 100644 index 0000000..a3ddb43 --- /dev/null +++ b/scripts/test-herdr-windows-plugin.ps1 @@ -0,0 +1,104 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (Test-Path variable:PSNativeCommandUseErrorActionPreference) { + $PSNativeCommandUseErrorActionPreference = $false +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$testRoot = Join-Path $env:RUNNER_TEMP ("pinned herdr plugin " + [guid]::NewGuid()) +$pluginRoot = Join-Path $testRoot "plugin root with spaces" +$archive = Join-Path $testRoot "herdr-windows-x86_64.zip" +$expanded = Join-Path $testRoot "herdr" +$oldEnvironment = @{} +foreach ($name in @( + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "HERDR_CONFIG_PATH", + "HERDR_SESSION", + "HERDR_SOCKET_PATH", + "HERDR_CLIENT_SOCKET_PATH" +)) { + $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Invoke-Herdr { + param([string]$Executable, [string[]]$Arguments) + $output = & $Executable @Arguments *>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "herdr $($Arguments -join ' ') failed: $output" } + $output +} + +try { + New-Item -ItemType Directory -Force $pluginRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $repositoryRoot "herdr-plugin.toml") -Destination $pluginRoot + + Invoke-WebRequest -UseBasicParsing ` + "https://github.com/herdrdev/herdr/releases/download/v0.8.2/herdr-windows-x86_64.zip" ` + -OutFile $archive + $archiveHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + Assert-True ( + $archiveHash -ceq "0ab3d0fe1434d55757997542b978c771d642987bb15a7130f4160f0db38821d5" + ) "pinned Herdr archive checksum differs" + Expand-Archive -LiteralPath $archive -DestinationPath $expanded + $herdr = Get-ChildItem -LiteralPath $expanded -Filter "herdr.exe" -File -Recurse | + Select-Object -First 1 + Assert-True ($null -ne $herdr) "pinned Herdr archive contains no herdr.exe" + + $env:XDG_CONFIG_HOME = Join-Path $testRoot "isolated config" + $env:XDG_STATE_HOME = Join-Path $testRoot "isolated state" + $env:HERDR_CONFIG_PATH = $null + $env:HERDR_SESSION = $null + $env:HERDR_SOCKET_PATH = $null + $env:HERDR_CLIENT_SOCKET_PATH = $null + $link = Invoke-Herdr -Executable $herdr.FullName -Arguments @("plugin", "link", $pluginRoot, "--enabled") + $linked = $link | ConvertFrom-Json + Assert-True ($linked.result.type -ceq "plugin_linked") "pinned Herdr did not link the plugin" + + $listedText = Invoke-Herdr -Executable $herdr.FullName -Arguments @( + "plugin", "list", "--plugin", "annotate", "--json" + ) + $listed = $listedText | ConvertFrom-Json + $plugins = @($listed.result.plugins) + Assert-True ($plugins.Count -eq 1) "pinned Herdr did not list exactly one Annotate plugin" + $plugin = $plugins[0] + $actionIds = @($plugin.actions | ForEach-Object { $_.id }) + foreach ($id in @("capture", "copy-context", "manage", "open", "open-link", "last")) { + Assert-True ($actionIds -contains $id) "pinned Herdr omitted action $id" + } + foreach ($id in @("open", "open-link", "last")) { + $action = @($plugin.actions | Where-Object { $_.id -ceq $id }) + $platforms = @($action[0].platforms) + Assert-True ( + $action.Count -eq 1 -and + $platforms -contains "macos" -and + $platforms -contains "linux" -and + -not ($platforms -contains "windows") + ) "pinned Herdr changed the Full action gate for $id" + } + $paneIds = @($plugin.panes | ForEach-Object { $_.id }) + foreach ($id in @("editor", "manager", "doc")) { + Assert-True ($paneIds -contains $id) "pinned Herdr omitted pane $id" + } + $doc = @($plugin.panes | Where-Object { $_.id -ceq "doc" }) + $docPlatforms = @($doc[0].platforms) + Assert-True ( + $doc.Count -eq 1 -and + $docPlatforms -contains "macos" -and + $docPlatforms -contains "linux" -and + -not ($docPlatforms -contains "windows") + ) "pinned Herdr changed the Full pane gate" +} finally { + foreach ($name in $oldEnvironment.Keys) { + if ($null -eq $oldEnvironment[$name]) { + [Environment]::SetEnvironmentVariable($name, $null, "Process") + } else { + [Environment]::SetEnvironmentVariable($name, $oldEnvironment[$name], "Process") + } + } + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test-http-server.py b/scripts/test-http-server.py new file mode 100644 index 0000000..9f89b92 --- /dev/null +++ b/scripts/test-http-server.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Serve one fixture directory on an ephemeral loopback port.""" + +from __future__ import annotations + +import sys +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +class QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, _format: str, *_args: object) -> None: + pass + + +def main() -> None: + root = Path(sys.argv[1]).resolve() + port_file = Path(sys.argv[2]).resolve() + handler = partial(QuietHandler, directory=str(root)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + port_file.write_text(str(server.server_port), encoding="ascii") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py new file mode 100644 index 0000000..f034444 --- /dev/null +++ b/scripts/test-windows-full-manifest.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Check the gated distributed Full manifest and development parity.""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + + +PROGRAM = "./bin/plannotator-tui.exe" +FULL_PLATFORMS = {"macos", "linux"} +UNIX_BUILD = ["bash", "scripts/fetch-plannotator-tui.sh"] +DISTRIBUTED_PANE = [ + "sh", + "-c", + 'exec bash "$HERDR_PLUGIN_ROOT/scripts/plannotator-tui.sh" herdr pane', +] +DEVELOPMENT_PANE = [ + "sh", + "-c", + 'exec "$HERDR_PLUGIN_ROOT/bin/plannotator-tui.exe" herdr pane', +] +ACTION_COMMANDS = { + "open": [PROGRAM, "herdr", "open"], + "open-link": [PROGRAM, "herdr", "open"], + "last": [PROGRAM, "herdr", "last"], +} +DEVELOPMENT_BUILDS = [ + ["cargo", "build", "--release", "--manifest-path", "../Cargo.toml"], + ["bash", "stage-plannotator-tui.sh"], + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "stage-plannotator-tui.ps1", + ], +] + + +def fail(path: Path, message: str) -> None: + raise AssertionError(f"{path}: {message}") + + +def load(path: Path) -> dict[str, object]: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def platforms( + path: Path, manifest: dict[str, object], item: dict[str, object] +) -> set[str]: + value = item.get("platforms", manifest.get("platforms", [])) + if not isinstance(value, list) or not all(isinstance(entry, str) for entry in value): + fail(path, f"invalid platforms: {value!r}") + return set(value) + + +def entry( + path: Path, + manifest: dict[str, object], + table: str, + entry_id: str, +) -> dict[str, object]: + entries = manifest.get(table, []) + if not isinstance(entries, list): + fail(path, f"[[{table}]] is not an array") + matches = [ + item + for item in entries + if isinstance(item, dict) and item.get("id") == entry_id + ] + if len(matches) != 1: + fail(path, f"expected one {table}.{entry_id}, found {len(matches)}") + return matches[0] + + +def builds(path: Path, manifest: dict[str, object]) -> list[dict[str, object]]: + value = manifest.get("build", []) + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + fail(path, "[[build]] is not an array of tables") + return value + + +def check_top_level_windows(path: Path, manifest: dict[str, object]) -> None: + if platforms(path, manifest, {}) != {"macos", "linux", "windows"}: + fail(path, "top-level platforms must be macOS, Linux, and Windows") + + +def check_distributed(path: Path, version_path: Path) -> None: + manifest = load(path) + check_top_level_windows(path, manifest) + if version_path.read_text(encoding="utf-8").strip() != "0.6.0": + fail(version_path, "plannotator-tui.version is not 0.6.0") + + build_entries = builds(path, manifest) + if len(build_entries) != 1: + fail(path, f"expected one Full build, found {len(build_entries)}") + build = build_entries[0] + if platforms(path, manifest, build) != FULL_PLATFORMS: + fail(path, f"Full build platforms are {platforms(path, manifest, build)!r}") + if build.get("command") != UNIX_BUILD: + fail(path, f"unexpected Unix build argv: {build.get('command')!r}") + + pane = entry(path, manifest, "panes", "doc") + if platforms(path, manifest, pane) != FULL_PLATFORMS: + fail(path, f"panes.doc platforms are {platforms(path, manifest, pane)!r}") + if pane.get("command") != DISTRIBUTED_PANE: + fail(path, f"unexpected panes.doc argv: {pane.get('command')!r}") + + for entry_id, expected in ACTION_COMMANDS.items(): + action = entry(path, manifest, "actions", entry_id) + if platforms(path, manifest, action) != FULL_PLATFORMS: + fail(path, f"actions.{entry_id} platforms are not macOS/Linux") + if action.get("command") != expected: + fail(path, f"unexpected actions.{entry_id} argv: {action.get('command')!r}") + if any("sh" in argument.lower() or "$" in argument for argument in expected): + fail(path, f"shell found in actions.{entry_id}: {expected!r}") + + handler = entry(path, manifest, "link_handlers", "markdown-file") + if platforms(path, manifest, handler) != FULL_PLATFORMS: + fail(path, "link_handlers.markdown-file platforms are not macOS/Linux") + if handler.get("action") != "open-link": + fail(path, f"markdown-file points to {handler.get('action')!r}") + + +def check_development(path: Path) -> None: + manifest = load(path) + check_top_level_windows(path, manifest) + + build_entries = builds(path, manifest) + commands = [item.get("command") for item in build_entries] + if commands != DEVELOPMENT_BUILDS: + fail(path, f"development build commands are {commands!r}") + if "windows" not in platforms(path, manifest, build_entries[0]): + fail(path, "development Cargo build does not run on Windows") + if platforms(path, manifest, build_entries[1]) != FULL_PLATFORMS: + fail(path, "development Unix staging platforms differ") + if platforms(path, manifest, build_entries[2]) != {"windows"}: + fail(path, "development PowerShell staging is not Windows-only") + + pane = entry(path, manifest, "panes", "doc") + if platforms(path, manifest, pane) != FULL_PLATFORMS: + fail(path, "development pane must be limited to macOS/Linux") + if pane.get("command") != DEVELOPMENT_PANE: + fail(path, f"unexpected development panes.doc argv: {pane.get('command')!r}") + + for entry_id, expected in ACTION_COMMANDS.items(): + action = entry(path, manifest, "actions", entry_id) + if "windows" not in platforms(path, manifest, action): + fail(path, f"development actions.{entry_id} lost Windows support") + if action.get("command") != expected: + fail(path, f"development actions.{entry_id} differs: {action.get('command')!r}") + + handler = entry(path, manifest, "link_handlers", "markdown-file") + if "windows" not in platforms(path, manifest, handler): + fail(path, "development markdown-file lost Windows support") + if handler.get("action") != "open-link": + fail(path, f"development markdown-file points to {handler.get('action')!r}") + + +def main() -> None: + if len(sys.argv) > 2: + raise SystemExit("usage: test-windows-full-manifest.py [development-manifest]") + root = Path(__file__).resolve().parent.parent + check_distributed(root / "herdr-plugin.toml", root / "plannotator-tui.version") + if len(sys.argv) == 2: + check_development(Path(sys.argv[1])) + + +if __name__ == "__main__": + main()