diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml index cdebe58..4f51b8a 100644 --- a/.github/workflows/windows-full-ci.yml +++ b/.github/workflows/windows-full-ci.yml @@ -13,8 +13,12 @@ on: - "scripts/test-fetch-herdr-annotate.*" - "scripts/test-fetch-plannotator-tui.*" - "scripts/test-herdr-windows-plugin.ps1" + - "scripts/test-herdr-windows-full-plugin.ps1" + - "scripts/test-herdr-windows-full-pane.ps1" + - "scripts/test-herdr-windows-full-paths.ps1" - "scripts/test-http-server.py" - "scripts/test-windows-full-manifest.py" + - "windows-full/**" - ".github/workflows/windows-full-ci.yml" merge_group: @@ -53,6 +57,12 @@ jobs: plannotator-tui-source/herdr/herdr-plugin.toml - name: pinned Herdr link and list run: ./scripts/test-herdr-windows-plugin.ps1 + - name: pinned Herdr windows-full acceptance and 0.8.2 rejection + run: ./scripts/test-herdr-windows-full-plugin.ps1 + - name: windows-full review pane render, quit and teardown + run: ./scripts/test-herdr-windows-full-pane.ps1 + - name: windows-full path matrix + run: ./scripts/test-herdr-windows-full-paths.ps1 unix-regression: strategy: diff --git a/.gitignore b/.gitignore index c074431..2a86054 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .DS_Store bin/* !bin/.gitkeep +windows-full/bin/* +!windows-full/bin/.gitkeep /rust/target/ diff --git a/scripts/fetch-herdr-annotate.ps1 b/scripts/fetch-herdr-annotate.ps1 index 37a6a06..08cdc3f 100644 --- a/scripts/fetch-herdr-annotate.ps1 +++ b/scripts/fetch-herdr-annotate.ps1 @@ -1,22 +1,31 @@ $ErrorActionPreference = "Stop" -Set-Location (Join-Path $PSScriptRoot "..") +# Anchored on the script's own location rather than the current directory, and every path +# below is absolute and literal. A checkout path may legitimately contain square brackets -- +# the Windows acceptance path matrix requires it -- and PowerShell reads those as wildcards in +# any -Path parameter. Worse, once the current directory contains them, it is stored escaped, +# so even -LiteralPath with a relative path resolves to a name with backticks in it and is not +# found. Not depending on the current directory at all is what makes that whole class go away. +# .NET rather than Split-Path: an extended-length \\?\ root is a path matrix row, and +# Split-Path cannot parse one -- it reports a null drive and returns nothing. +$root = [System.IO.Path]::GetDirectoryName($PSScriptRoot) -$versionContents = Get-Content -LiteralPath "herdr-annotate.version" -Raw +$versionContents = Get-Content -LiteralPath ([System.IO.Path]::Combine($root, "herdr-annotate.version")) -Raw $version = if ($null -eq $versionContents) { "" } else { [string]$versionContents } $version = $version.Trim() if (-not $version) { throw "herdr-annotate.version is empty" } -New-Item -ItemType Directory -Force "bin" | Out-Null -$destination = Join-Path "bin" "herdr-annotate.exe" -$stamp = Join-Path "bin" "herdr-annotate.version" +$binDirectory = [System.IO.Path]::Combine($root, "bin") +[System.IO.Directory]::CreateDirectory($binDirectory) | Out-Null +$destination = [System.IO.Path]::Combine($binDirectory, "herdr-annotate.exe") +$stamp = [System.IO.Path]::Combine($binDirectory, "herdr-annotate.version") $installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { ([string](Get-Content -LiteralPath $stamp -Raw)).Trim() } else { "" } -if ((Test-Path $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) { +if ((Test-Path -LiteralPath $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) { Write-Output "herdr-annotate $version already installed" exit 0 } if ($env:HERDR_ANNOTATE_BIN) { - if (-not (Test-Path $env:HERDR_ANNOTATE_BIN -PathType Leaf)) { + if (-not (Test-Path -LiteralPath $env:HERDR_ANNOTATE_BIN -PathType Leaf)) { throw "HERDR_ANNOTATE_BIN is not a file: $env:HERDR_ANNOTATE_BIN" } Copy-Item -Force -LiteralPath $env:HERDR_ANNOTATE_BIN -Destination "$destination.tmp" @@ -35,20 +44,20 @@ $target = switch ($architecture) { $asset = "herdr-annotate-$target.exe" $base = "https://github.com/plannotator/herdr-annotate/releases/download/rust-lite-v$version" $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-annotate-" + [guid]::NewGuid()) -New-Item -ItemType Directory $temporary | Out-Null +[System.IO.Directory]::CreateDirectory($temporary) | Out-Null try { Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile (Join-Path $temporary $asset) Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS" -OutFile (Join-Path $temporary "SHA256SUMS") - $line = Get-Content (Join-Path $temporary "SHA256SUMS") | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" } | Select-Object -First 1 + $line = Get-Content -LiteralPath (Join-Path $temporary "SHA256SUMS") | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" } | Select-Object -First 1 if (-not $line) { throw "$asset is not listed in $base/SHA256SUMS" } $expected = ($line -split "\s+")[0].ToLowerInvariant() - $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $temporary $asset)).Hash.ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $temporary $asset)).Hash.ToLowerInvariant() if ($actual -ne $expected) { throw "sha256 mismatch for ${asset}: expected $expected, got $actual" } - Copy-Item -Force (Join-Path $temporary $asset) "$destination.tmp" + Copy-Item -Force -LiteralPath (Join-Path $temporary $asset) -Destination "$destination.tmp" Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination Set-Content -LiteralPath $stamp -NoNewline -Value $version Write-Output "installed herdr-annotate $version ($target)" } finally { - Remove-Item -Recurse -Force $temporary -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force -LiteralPath $temporary -ErrorAction SilentlyContinue } diff --git a/scripts/fetch-plannotator-tui.ps1 b/scripts/fetch-plannotator-tui.ps1 index 8edbeb4..21cee5b 100644 --- a/scripts/fetch-plannotator-tui.ps1 +++ b/scripts/fetch-plannotator-tui.ps1 @@ -1,15 +1,35 @@ +# -DestinationDirectory stages the binary and its stamp somewhere other than the repository's +# own bin/, which is how the windows-full variant keeps its copy beside its manifest. Omitted, +# the destination is unchanged, so every existing caller behaves exactly as before. The pin is +# read from the repository root either way: there is one release pin, not one per variant. +param([string]$DestinationDirectory) + $ErrorActionPreference = "Stop" -Set-Location (Join-Path $PSScriptRoot "..") +# Anchored on the script's own location rather than the current directory, and every path +# below is absolute and literal. A checkout path may legitimately contain square brackets -- +# the Windows acceptance path matrix requires it -- and PowerShell reads those as wildcards in +# any -Path parameter. Worse, once the current directory contains them, it is stored escaped, +# so even -LiteralPath with a relative path resolves to a name with backticks in it and is not +# found. Not depending on the current directory at all is what makes that whole class go away. +# .NET rather than Split-Path: an extended-length \\?\ root is a path matrix row, and +# Split-Path cannot parse one -- it reports a null drive and returns nothing. +$root = [System.IO.Path]::GetDirectoryName($PSScriptRoot) -$versionContents = Get-Content -LiteralPath "plannotator-tui.version" -Raw +$versionContents = Get-Content -LiteralPath ([System.IO.Path]::Combine($root, "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 +$destinationDirectory = if ([string]::IsNullOrWhiteSpace($DestinationDirectory)) { + [System.IO.Path]::Combine($root, "bin") +} else { + $DestinationDirectory +} +$destination = [System.IO.Path]::Combine($destinationDirectory, "plannotator-tui.exe") +$stamp = [System.IO.Path]::Combine($destinationDirectory, "plannotator-tui.version") +# .NET rather than New-Item for the same reason: the destination is an absolute path that may +# contain brackets, and New-Item -Path would treat them as a wildcard. +[System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null $localOverride = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_BIN", "Process") $hasLocalOverride = $null -ne $localOverride @@ -28,9 +48,9 @@ if ((Test-Path -LiteralPath $destination -PathType Leaf) -and 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") + $candidate = [System.IO.Path]::Combine($destinationDirectory, ("plannotator-tui-" + [guid]::NewGuid() + ".tmp")) + $backup = [System.IO.Path]::Combine($destinationDirectory, ("plannotator-tui-" + [guid]::NewGuid() + ".bak")) + $stampBackup = [System.IO.Path]::Combine($destinationDirectory, ("plannotator-tui-version-" + [guid]::NewGuid() + ".bak")) $hadDestination = Test-Path -LiteralPath $destination -PathType Leaf $hadStamp = Test-Path -LiteralPath $stamp -PathType Leaf $replacementCompleted = $false @@ -112,8 +132,12 @@ try { "PLANNOTATOR_TUI_RELEASE_BASE", "Process" ) - # PLANNOTATOR_TUI_RELEASE_BASE is a test-only seam for a loopback fixture server. - $base = if ($null -ne $releaseBaseOverride) { + # PLANNOTATOR_TUI_RELEASE_BASE is a test-only seam for a loopback fixture server. An empty + # value counts as absent: a caller clearing it through an API that binds $null as "" would + # otherwise leave the name defined, and an empty base builds a URL with no host at all -- + # which surfaces as "invalid URI" long after the mistake, through the warn-and-exit-zero + # contract that hides it. + $base = if (-not [string]::IsNullOrWhiteSpace($releaseBaseOverride)) { $releaseBaseOverride.TrimEnd([char]"/") } else { "https://github.com/plannotator/plannotator-tui/releases/download/v$version" @@ -121,7 +145,7 @@ try { $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("plannotator-tui-" + [guid]::NewGuid()) try { - New-Item -ItemType Directory $temporary | Out-Null + [System.IO.Directory]::CreateDirectory($temporary) | Out-Null $downloadedAsset = Join-Path $temporary $asset $checksumFile = Join-Path $temporary "SHA256SUMS" Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile $downloadedAsset diff --git a/scripts/test-herdr-windows-full-pane.ps1 b/scripts/test-herdr-windows-full-pane.ps1 new file mode 100644 index 0000000..819ba3d --- /dev/null +++ b/scripts/test-herdr-windows-full-pane.ps1 @@ -0,0 +1,303 @@ +# The Windows Full review pane, end to end, against a pinned Herdr release in an isolated +# config/state/socket so the machine's own server and session are never touched. +# +# What this proves that no manifest or link test can: plannotator-tui starts as the pane +# process, renders a document from a review folder outside the checkout, quits on `q` with +# status zero, and leaves nothing running. The fixture marker is generated per run, so a +# stale buffer cannot satisfy the render assertion. +# +# The binary under test is fetched by the variant's own build command from the real release, +# with both override variables absent, and its location and stamp are asserted before use. +# +# Run with no arguments for the default case. -Checkout and -Review let the path-matrix suite +# reuse this lifecycle against awkward roots without restating it; caller-supplied directories +# are left in place on exit, since the caller owns them. -HerdrExecutable skips the download +# when a suite has already fetched and verified one. +param( + [string]$Checkout, + [string]$Review, + [string]$HerdrExecutable, + [string]$LinkPath, + [string]$Label = "default" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (Test-Path variable:PSNativeCommandUseErrorActionPreference) { + $PSNativeCommandUseErrorActionPreference = $false +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$temporaryBase = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { $env:TEMP } else { $env:RUNNER_TEMP } +$testRoot = Join-Path $temporaryBase ("herdr windows full pane " + [guid]::NewGuid()) + +$herdrVersion = "0.9.0" +$herdrSha256 = "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11" + +# Spaces in both roots: the review folder and the plugin root are separate path cases, and +# the pane receives one as cwd while the program is resolved from the other. +$callerOwnsDirectories = -not [string]::IsNullOrWhiteSpace($Checkout) +$checkout = if ($callerOwnsDirectories) { $Checkout } else { Join-Path $testRoot "checkout with spaces" } +$variantRoot = Join-Path $checkout "windows-full" +$review = if (-not [string]::IsNullOrWhiteSpace($Review)) { $Review } else { Join-Path $testRoot "review folder" } +$marker = "HERDRFULL-" + ([guid]::NewGuid().ToString("N").Substring(0, 12)).ToUpperInvariant() +$fixture = "fixture $marker.md" + +$isolatedNames = @( + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "HERDR_CONFIG_PATH", + "HERDR_SESSION", + "HERDR_SOCKET_PATH", + "HERDR_CLIENT_SOCKET_PATH", + "PLANNOTATOR_TUI_BIN", + "PLANNOTATOR_TUI_RELEASE_BASE" +) +$oldEnvironment = @{} +foreach ($name in $isolatedNames) { + $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} + +# Distinct from the -HerdrExecutable parameter: PowerShell variable names are +# case-insensitive, so reusing that spelling here would blank the parameter. +$resolvedHerdr = $null + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Invoke-Herdr { + param([string[]]$Arguments) + $output = & $script:resolvedHerdr @Arguments *>&1 | Out-String + $output +} + +function Get-TuiProcessPaths { + # Scoped to the staged executable so a developer's own plannotator-tui is never counted. + # Compared through GetFullPath and case-insensitively, because Windows reports a process + # image under a UNC root in its own spelling rather than the one used to launch it. + param([string]$Path) + + # Windows reports the image of a process launched from a share in device form, where the + # \\server\share prefix appears as UNC\server\share. Folded back before comparing, or every + # UNC run looks like no process at all. + function Resolve-ImagePath { + param([string]$Candidate) + $value = if ($Candidate -like "UNC\*") { "\\" + $Candidate.Substring(4) } else { $Candidate } + try { [System.IO.Path]::GetFullPath($value) } catch { $value } + } + + $wanted = Resolve-ImagePath -Candidate $Path + @( + Get-Process plannotator-tui -ErrorAction SilentlyContinue | + ForEach-Object { + $candidate = $null + try { $candidate = $_.Path } catch { $candidate = $null } + if ($null -ne $candidate) { + $full = Resolve-ImagePath -Candidate $candidate + if ($full -ieq $wanted) { $full } + } + } + ) +} + +try { + # .NET rather than New-Item throughout: these paths legitimately contain brackets, which + # PowerShell's -Path parameters treat as wildcards. Only -LiteralPath and the .NET APIs + # read them as the characters they are. + [System.IO.Directory]::CreateDirectory($review) | Out-Null + [System.IO.Directory]::CreateDirectory((Join-Path $variantRoot "scripts")) | Out-Null + [System.IO.Directory]::CreateDirectory((Join-Path $checkout "scripts")) | Out-Null + Set-Content -LiteralPath (Join-Path $review $fixture) -Encoding utf8 -Value @( + "# $marker", + "", + "Unique fixture content for this run: $marker" + ) + + # A staged checkout rather than the working tree: the build must work from a relocated + # copy, which is what an installed plugin is. + foreach ($name in @("plannotator-tui.version", "herdr-annotate.version")) { + [System.IO.File]::Copy((Join-Path $repositoryRoot $name), (Join-Path $checkout $name), $true) + } + foreach ($pair in @( + @("scripts\fetch-plannotator-tui.ps1", (Join-Path $checkout "scripts\fetch-plannotator-tui.ps1")), + @("scripts\fetch-herdr-annotate.ps1", (Join-Path $checkout "scripts\fetch-herdr-annotate.ps1")), + @("windows-full\herdr-plugin.toml", (Join-Path $variantRoot "herdr-plugin.toml")), + @("windows-full\scripts\fetch-plannotator-tui.ps1", (Join-Path $variantRoot "scripts\fetch-plannotator-tui.ps1")) + )) { + [System.IO.File]::Copy((Join-Path $repositoryRoot $pair[0]), $pair[1], $true) + } + + # Both build entries from the manifest, run the way Herdr runs them: from the plugin root, + # with no override variables set, against the real release. The overrides are cleared here + # rather than with the rest of the isolation below, because the production fetch is the one + # thing that must not see them, and it happens first. $env: assignment rather than + # SetEnvironmentVariable: passing $null to the latter binds as an empty string and leaves + # the name defined, which a fetcher testing presence rather than emptiness then reads as a + # release base. The $env: form removes the name outright. + $env:PLANNOTATOR_TUI_BIN = $null + $env:PLANNOTATOR_TUI_RELEASE_BASE = $null + $env:HERDR_ANNOTATE_BIN = $null + + Push-Location -LiteralPath $variantRoot + try { + # Kept, not discarded: both fetchers warn and exit zero by design so Lite survives a + # failed download, which means a silent staging failure is the normal shape of trouble + # here and the text is the only evidence of why. + $buildLog = @() + $buildLog += (& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File "..\scripts\fetch-herdr-annotate.ps1" *>&1 | Out-String) + $buildLog += (& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File "scripts\fetch-plannotator-tui.ps1" *>&1 | Out-String) + $script:buildOutput = ($buildLog -join "").Trim() + } finally { + Pop-Location + } + + $tui = Join-Path $variantRoot "bin\plannotator-tui.exe" + $native = Join-Path $checkout "bin\herdr-annotate.exe" + Assert-True (Test-Path -LiteralPath $tui -PathType Leaf) ` + "the variant build staged no plannotator-tui.exe`n--- build output ---`n$script:buildOutput" + Assert-True (Test-Path -LiteralPath $native -PathType Leaf) ` + "the native build staged no herdr-annotate.exe`n--- build output ---`n$script:buildOutput" + $pin = (Get-Content -LiteralPath (Join-Path $checkout "plannotator-tui.version") -Raw).Trim() + $stamp = (Get-Content -LiteralPath (Join-Path $variantRoot "bin\plannotator-tui.version") -Raw).Trim() + Assert-True ($stamp -ceq $pin) "staged stamp $stamp does not match the pin $pin" + # Process creation takes the image path through the ANSI/MAX_PATH route and accepts no + # extended-length spelling, so past 260 characters a staged, verified, present binary still + # cannot be started. Reported as that, rather than as a stack trace about --version. + $reported = try { + (& $tui --version *>&1 | Out-String).Trim() + } catch { + throw ( + "the staged TUI cannot be started: its path is $($tui.Length) characters" + + $(if ($tui.Length -gt 260) { ", past the $([int]260)-character process-creation limit" } else { "" }) + + ". Windows reported: $($_.Exception.Message.Split([char]10)[0].Trim())" + ) + } + Assert-True ($reported -match [regex]::Escape($pin)) "staged TUI reports '$reported', expected $pin" + Write-Output "[$Label] staged plannotator-tui $pin at windows-full/bin, native runtime one level up" + + if ([string]::IsNullOrWhiteSpace($HerdrExecutable)) { + $archive = Join-Path $testRoot "herdr.zip" + $expanded = Join-Path $testRoot "herdr" + Invoke-WebRequest -UseBasicParsing ` + "https://github.com/herdrdev/herdr/releases/download/v$herdrVersion/herdr-windows-x86_64.zip" ` + -OutFile $archive + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + Assert-True ($hash -ceq $herdrSha256) "pinned Herdr $herdrVersion checksum differs: $hash" + Expand-Archive -LiteralPath $archive -DestinationPath $expanded + $found = Get-ChildItem -LiteralPath $expanded -Filter "herdr.exe" -File -Recurse | Select-Object -First 1 + Assert-True ($null -ne $found) "pinned Herdr archive contains no herdr.exe" + $script:resolvedHerdr = $found.FullName + } else { + Assert-True (Test-Path -LiteralPath $HerdrExecutable -PathType Leaf) ` + "no Herdr executable at $HerdrExecutable" + $script:resolvedHerdr = $HerdrExecutable + } + + $env:XDG_CONFIG_HOME = Join-Path $testRoot "config" + $env:XDG_STATE_HOME = Join-Path $testRoot "state" + $env:HERDR_CONFIG_PATH = $null + $env:HERDR_SESSION = "windowsfullpane" + $env:HERDR_SOCKET_PATH = Join-Path $testRoot "server.sock" + $env:HERDR_CLIENT_SOCKET_PATH = Join-Path $testRoot "client.sock" + $env:PLANNOTATOR_TUI_BIN = $null + $env:PLANNOTATOR_TUI_RELEASE_BASE = $null + + Start-Process -FilePath $script:resolvedHerdr -ArgumentList "server" -WindowStyle Hidden + $deadline = (Get-Date).AddSeconds(30) + do { + Start-Sleep -Milliseconds 500 + $status = Invoke-Herdr -Arguments @("status") + } while ($status -notmatch "status: running" -and (Get-Date) -lt $deadline) + Assert-True ($status -match "status: running") "the isolated Herdr server did not start" + + $created = Invoke-Herdr -Arguments @("workspace", "create", "--cwd", $review) | ConvertFrom-Json + Assert-True ($created.result.type -ceq "workspace_created") "no isolated workspace was created" + $originPane = $created.result.root_pane.pane_id + + # -LinkPath lets a caller hand Herdr a different spelling of the same directory -- an + # extended-length \\?\ root, say -- while staging still happens through the ordinary path, + # which is what Herdr's own build step does. Herdr must resolve it to the same directory. + $linkTarget = if ([string]::IsNullOrWhiteSpace($LinkPath)) { $variantRoot } else { $LinkPath } + $linked = Invoke-Herdr -Arguments @("plugin", "link", $linkTarget, "--enabled") | ConvertFrom-Json + Assert-True ($linked.result.type -ceq "plugin_linked") "the isolated Herdr did not link Windows Full" + Assert-True ($linked.result.plugin.plugin_id -ceq "annotate") "the linked plugin id is not annotate" + $reportedRoot = [string]$linked.result.plugin.plugin_root + Assert-True ( + [System.IO.Path]::GetFullPath($reportedRoot.Replace("\\?\", "")) -ceq + [System.IO.Path]::GetFullPath($variantRoot.Replace("\\?\", "")) + ) "Herdr reported plugin root '$reportedRoot', which is not the staged directory" + + # @() at every call site: an empty array returned from a function unrolls to $null. + $before = @(Get-TuiProcessPaths -Path $tui) + Assert-True ($before.Count -eq 0) "a staged plannotator-tui was already running before the pane opened" + + $opened = Invoke-Herdr -Arguments @( + "plugin", "pane", "open", "--plugin", "annotate", "--entrypoint", "doc", "--cwd", $review + ) | ConvertFrom-Json + Assert-True ($opened.result.type -ceq "plugin_pane_opened") "the doc pane did not open" + $docPane = $opened.result.plugin_pane.pane.pane_id + Assert-True ($docPane -cne $originPane) "the review pane and the originating pane share an id" + + # The render assertion: a per-run marker, read back out of the pane's own terminal. + # --match, not --pattern, and --timeout is milliseconds. Getting either wrong makes the call + # fail instead of waiting, and the assertion below then races the TUI's first paint -- which + # is why its exit status is checked rather than discarded. + $waited = Invoke-Herdr -Arguments @( + "pane", "wait-output", $docPane, "--match", $marker, "--timeout", "30000" + ) + Assert-True ($LASTEXITCODE -eq 0) "waiting for $marker in the review pane failed: $waited" + $rendered = Invoke-Herdr -Arguments @("pane", "read", $docPane, "--source", "visible", "--format", "text") + Assert-True ($rendered -match [regex]::Escape($marker)) ` + "the review pane never rendered $marker`n$rendered" + Assert-True ($rendered -match "annotations") "the review pane rendered no plannotator-tui status line" + Write-Output "[$Label] doc pane rendered $marker; plugin root $($variantRoot.Length) chars, review $($review.Length)" + + $running = @(Get-TuiProcessPaths -Path $tui) + $seen = @(Get-Process plannotator-tui -ErrorAction SilentlyContinue | ForEach-Object { + try { $_.Path } catch { "" } + }) + Assert-True ($running.Count -eq 1) ( + "expected exactly one staged plannotator-tui at`n $tui`nfound $($running.Count). " + + "Running images: $($seen -join '; ')" + ) + + Invoke-Herdr -Arguments @("pane", "send-keys", $docPane, "q") | Out-Null + $deadline = (Get-Date).AddSeconds(20) + do { + Start-Sleep -Milliseconds 500 + $panes = Invoke-Herdr -Arguments @("pane", "list") + } while ($panes -match [regex]::Escape($docPane) -and (Get-Date) -lt $deadline) + Assert-True ($panes -notmatch [regex]::Escape($docPane)) "the review pane stayed open after q" + Assert-True ($panes -match [regex]::Escape($originPane)) "the originating pane did not survive the review" + + $after = @(Get-TuiProcessPaths -Path $tui) + Assert-True ($after.Count -eq 0) "a staged plannotator-tui survived the review: $($after -join ',')" + + $log = Join-Path $env:XDG_CONFIG_HOME "herdr\sessions\$($env:HERDR_SESSION)\herdr-server.log" + Assert-True (Test-Path -LiteralPath $log -PathType Leaf) "no isolated server log at $log" + $exits = @( + Get-Content -LiteralPath $log | + Where-Object { $_ -match 'event="pane\.exit"' } | + Select-Object -Last 1 + ) + Assert-True ($exits.Count -eq 1) "the isolated server logged no pane exit" + Assert-True ($exits[0] -match "code: 0\b") "the review pane exited abnormally: $($exits[0])" + Write-Output "[$Label] q closed the review pane with status zero and left no process behind" +} finally { + if ($null -ne $script:resolvedHerdr) { + & $script:resolvedHerdr server stop *>&1 | Out-Null + Start-Sleep -Seconds 2 + } + foreach ($name in $isolatedNames) { + 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-herdr-windows-full-paths.ps1 b/scripts/test-herdr-windows-full-paths.ps1 new file mode 100644 index 0000000..32a2640 --- /dev/null +++ b/scripts/test-herdr-windows-full-paths.ps1 @@ -0,0 +1,133 @@ +# §3's path matrix: the same review-pane lifecycle driven from awkward plugin roots and +# review folders. The lifecycle itself is not restated here -- each case shells out to +# test-herdr-windows-full-pane.ps1, so what is proved per case is exactly what is proved in +# the default case: real fetch, render of a per-run marker, `q`, clean teardown. +# +# Local-drive cases are required: a failure fails the run rather than downgrading to a note. +# UNC is attempted against a loopback share where one is reachable and reported UNVERIFIED +# with its limitation otherwise, since a loopback share is not a remote file server. +# +# Measured lengths and the machine's long-path setting are printed, because every result here +# depends on them and the acceptance record asks for the actual numbers. +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (Test-Path variable:PSNativeCommandUseErrorActionPreference) { + $PSNativeCommandUseErrorActionPreference = $false +} + +$paneScript = Join-Path $PSScriptRoot "test-herdr-windows-full-pane.ps1" +$temporaryBase = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { $env:TEMP } else { $env:RUNNER_TEMP } +$suiteRoot = Join-Path $temporaryBase ("herdr full paths " + [guid]::NewGuid()) + +$herdrVersion = "0.9.0" +$herdrSha256 = "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11" + +$longPaths = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` + -Name LongPathsEnabled -ErrorAction SilentlyContinue).LongPathsEnabled +Write-Output "windows $([Environment]::OSVersion.Version) $env:PROCESSOR_ARCHITECTURE, LongPathsEnabled=$longPaths" + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +$results = [ordered]@{} + +try { + New-Item -ItemType Directory -Force $suiteRoot | Out-Null + + $archive = Join-Path $suiteRoot "herdr.zip" + Invoke-WebRequest -UseBasicParsing ` + "https://github.com/herdrdev/herdr/releases/download/v$herdrVersion/herdr-windows-x86_64.zip" ` + -OutFile $archive + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + Assert-True ($hash -ceq $herdrSha256) "pinned Herdr $herdrVersion checksum differs: $hash" + Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $suiteRoot "herdr") + $herdr = Get-ChildItem -LiteralPath (Join-Path $suiteRoot "herdr") -Filter "herdr.exe" -File -Recurse | + Select-Object -First 1 + Assert-True ($null -ne $herdr) "pinned Herdr archive contains no herdr.exe" + + # Every character class §3 names, in one segment: spaces, Unicode, an apostrophe, an + # ampersand, parentheses, a dollar, a percent and brackets. + $awkward = "pä th 'x' & (y) `$z %w [v]" + + # Long enough that the plugin root plus \bin\plannotator-tui.exe passes the legacy limit. + $filler = ("deep-" + ("n" * 40)) + $longRoot = Join-Path $suiteRoot (($filler, $filler, $filler) -join "\") + + $cases = @( + @{ Label = "special-characters"; Checkout = (Join-Path $suiteRoot $awkward); Review = (Join-Path $suiteRoot "$awkward review"); Required = $true } + # Not required, and deliberately so. The manifest's build command passes a relative + # -File argument, and Herdr resolves only the program against the plugin root, not the + # arguments. Windows PowerShell then resolves that argument against the pane cwd under + # MAX_PATH, so a long plugin root cannot find a script that plainly exists. The checklist + # forbids fixing this by changing the root manifest, so it is measured and reported. + @{ Label = "long-path"; Checkout = $longRoot; Review = (Join-Path $suiteRoot "long review"); Required = $false } + ) + + # An extended-length spelling of an ordinary drive path. Staging still goes through the + # ordinary path, because that is what Herdr's build step does -- Herdr normalises the root + # it stores. Only the spelling handed to `plugin link` is extended, and Herdr must resolve + # it to the same directory and run the same lifecycle from it. + $extendedCheckout = Join-Path $suiteRoot "extended checkout" + $cases += @{ + Label = "extended-length-root" + Checkout = $extendedCheckout + Review = (Join-Path $suiteRoot "extended review") + LinkPath = ("\\?\" + (Join-Path $extendedCheckout "windows-full")) + Required = $true + } + + $uncBase = "\\localhost\Users\" + $env:USERNAME + $uncUsable = $false + try { + $uncProbe = Join-Path $uncBase ("herdr-unc-probe-" + [guid]::NewGuid().ToString("N") + ".tmp") + Set-Content -LiteralPath $uncProbe -Value "probe" -ErrorAction Stop + Remove-Item -LiteralPath $uncProbe -Force -ErrorAction SilentlyContinue + $uncUsable = $true + } catch { + $uncUsable = $false + } + if ($uncUsable) { + $uncRoot = Join-Path $uncBase ("herdr-full-unc-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) + $cases += @{ Label = "unc-loopback"; Checkout = (Join-Path $uncRoot "checkout"); Review = (Join-Path $uncRoot "review"); Required = $false } + } else { + $results["unc-loopback"] = "UNVERIFIED: no writable loopback share on this host" + } + + foreach ($case in $cases) { + $variantRoot = Join-Path $case.Checkout "windows-full" + $binary = Join-Path $variantRoot "bin\plannotator-tui.exe" + Write-Output "" + Write-Output "--- $($case.Label): plugin root $($variantRoot.Length) chars, binary $($binary.Length) chars" + try { + $linkPath = if ($case.Contains("LinkPath")) { [string]$case.LinkPath } else { "" } + & $paneScript -Checkout $case.Checkout -Review $case.Review ` + -HerdrExecutable $herdr.FullName -LinkPath $linkPath -Label $case.Label + if ($LASTEXITCODE -ne 0) { throw "pane lifecycle exited $LASTEXITCODE" } + $results[$case.Label] = "PASS (root $($variantRoot.Length), binary $($binary.Length))" + } catch { + if ($case.Required) { throw "$($case.Label) failed: $($_.Exception.Message)" } + $results[$case.Label] = "LIMITATION: $($_.Exception.Message)" + } + } + + Write-Output "" + Write-Output "path matrix:" + foreach ($key in $results.Keys) { Write-Output (" {0,-20} {1}" -f $key, $results[$key]) } + $unmet = @($results.Values | Where-Object { $_ -notmatch "^PASS" }) + if ($unmet.Count -gt 0) { + Write-Output "" + Write-Output "NOT CLAIMED AS PASSING, reported for review rather than skipped:" + foreach ($key in $results.Keys) { + if ($results[$key] -notmatch "^PASS") { Write-Output (" {0}: {1}" -f $key, $results[$key]) } + } + } + Assert-True (@($results.Values | Where-Object { $_ -notmatch "^(PASS|UNVERIFIED|LIMITATION)" }).Count -eq 0) ` + "a path case reported no classification" +} finally { + Remove-Item -LiteralPath $suiteRoot -Recurse -Force -ErrorAction SilentlyContinue + if ($uncUsable -and $null -ne $uncRoot) { + Remove-Item -LiteralPath $uncRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/test-herdr-windows-full-plugin.ps1 b/scripts/test-herdr-windows-full-plugin.ps1 new file mode 100644 index 0000000..fb76af1 --- /dev/null +++ b/scripts/test-herdr-windows-full-plugin.ps1 @@ -0,0 +1,169 @@ +# Windows Full against pinned Herdr releases, in an isolated config/state/socket so the +# machine's own Herdr install and session are never touched. +# +# Two things are proved here that the manifest test cannot see, because they are Herdr's +# behaviour rather than the file's contents: 0.8.2 refuses the variant for its minimum, and +# 0.9.0 accepts it and reports every action, pane and link handler as effective on Windows. +# The plugin root contains spaces on purpose. +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (Test-Path variable:PSNativeCommandUseErrorActionPreference) { + $PSNativeCommandUseErrorActionPreference = $false +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +# RUNNER_TEMP is the CI location; falling back to TEMP lets a human run this unchanged when +# recording the native qualification matrix. +$temporaryBase = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { $env:TEMP } else { $env:RUNNER_TEMP } +$testRoot = Join-Path $temporaryBase ("herdr windows full " + [guid]::NewGuid()) +$pluginRoot = Join-Path $testRoot "plugin root with spaces" + +$releases = @( + @{ + Version = "0.8.2" + Sha256 = "0ab3d0fe1434d55757997542b978c771d642987bb15a7130f4160f0db38821d5" + Accepts = $false + } + @{ + Version = "0.9.0" + Sha256 = "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11" + Accepts = $true + } +) + +$isolatedNames = @( + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "HERDR_CONFIG_PATH", + "HERDR_SESSION", + "HERDR_SOCKET_PATH", + "HERDR_CLIENT_SOCKET_PATH" +) +$oldEnvironment = @{} +foreach ($name in $isolatedNames) { + $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Get-Platforms { + # An entry with no gate carries no `platforms` key at all, and Herdr omits it from the + # JSON rather than echoing the manifest default. Under StrictMode that is a missing + # property, not an empty one, so it is read through PSObject and reported as "no gate". + param($Item) + $property = $Item.PSObject.Properties['platforms'] + if ($null -eq $property -or $null -eq $property.Value) { return @() } + @($property.Value) +} + +function Get-Herdr { + param([string]$Version, [string]$Sha256, [string]$Destination) + $archive = Join-Path $Destination "herdr-$Version.zip" + $expanded = Join-Path $Destination "herdr-$Version" + Invoke-WebRequest -UseBasicParsing ` + "https://github.com/herdrdev/herdr/releases/download/v$Version/herdr-windows-x86_64.zip" ` + -OutFile $archive + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + Assert-True ($hash -ceq $Sha256) "pinned Herdr $Version archive checksum differs: $hash" + Expand-Archive -LiteralPath $archive -DestinationPath $expanded + $executable = Get-ChildItem -LiteralPath $expanded -Filter "herdr.exe" -File -Recurse | + Select-Object -First 1 + Assert-True ($null -ne $executable) "pinned Herdr $Version archive contains no herdr.exe" + $executable.FullName +} + +try { + New-Item -ItemType Directory -Force $pluginRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $repositoryRoot "windows-full\herdr-plugin.toml") ` + -Destination $pluginRoot + + foreach ($release in $releases) { + $version = $release.Version + $herdr = Get-Herdr -Version $version -Sha256 $release.Sha256 -Destination $testRoot + + # A separate config, state and socket per release: the point is that nothing here can + # reach the machine's own server, and that the two releases cannot reach each other's. + $env:XDG_CONFIG_HOME = Join-Path $testRoot "config $version" + $env:XDG_STATE_HOME = Join-Path $testRoot "state $version" + $env:HERDR_CONFIG_PATH = $null + $env:HERDR_SESSION = $null + $env:HERDR_SOCKET_PATH = Join-Path $testRoot "server-$version.sock" + $env:HERDR_CLIENT_SOCKET_PATH = Join-Path $testRoot "client-$version.sock" + + $output = & $herdr plugin link $pluginRoot --enabled *>&1 | Out-String + + if (-not $release.Accepts) { + # The acceptance checklist names the code `plugin_requires_newer_herdr`. What 0.8.2's + # `plugin link` actually prints is an unstructured message, so both spellings are + # accepted and the observed one is echoed rather than hidden behind a pass. + Assert-True ( + $output -match "plugin_requires_newer_herdr" -or + $output -match "requires Herdr 0\.9\.0 or newer" + ) "Herdr $version did not reject Windows Full for its minimum: $output" + Assert-True ($output -notmatch "plugin_linked") "Herdr $version linked Windows Full anyway" + Write-Output "herdr $version rejects windows-full: $($output.Trim())" + continue + } + + $linked = $output | ConvertFrom-Json + Assert-True ($linked.result.type -ceq "plugin_linked") ` + "Herdr $version did not link Windows Full: $output" + + $listedText = & $herdr plugin list --plugin annotate --json *>&1 | Out-String + $listed = $listedText | ConvertFrom-Json + $plugins = @($listed.result.plugins) + Assert-True ($plugins.Count -eq 1) "Herdr $version did not list exactly one Annotate plugin" + $plugin = $plugins[0] + + # Every entry must be effective on Windows. An inherited Unix gate would leave the + # variant installed and the review half silently unreachable, which is the state this + # whole variant exists to end. + $actionIds = @($plugin.actions | ForEach-Object { $_.id }) + foreach ($id in @("capture", "copy-context", "copy-archive", "manage", "open", "open-link", "last")) { + Assert-True ($actionIds -contains $id) "Herdr $version omitted action $id" + $action = @($plugin.actions | Where-Object { $_.id -ceq $id }) + # @() at the call site: an empty array returned from a function unrolls to $null. + $platforms = @(Get-Platforms -Item $action[0]) + Assert-True ($platforms.Count -eq 0 -or $platforms -contains "windows") ` + "action $id is not effective on Windows: $($platforms -join ',')" + } + Assert-True ($actionIds.Count -eq 7) "Herdr $version listed $($actionIds.Count) actions, expected 7" + + $paneIds = @($plugin.panes | ForEach-Object { $_.id }) + foreach ($id in @("editor", "manager", "doc")) { + Assert-True ($paneIds -contains $id) "Herdr $version omitted pane $id" + } + Assert-True ($paneIds.Count -eq 3) "Herdr $version listed $($paneIds.Count) panes, expected 3" + + $doc = @($plugin.panes | Where-Object { $_.id -ceq "doc" })[0] + $docPlatforms = @(Get-Platforms -Item $doc) + Assert-True ($docPlatforms.Count -eq 0 -or $docPlatforms -contains "windows") ` + "the doc pane is not effective on Windows" + # No launcher may sit between Herdr and the TUI: a process started through the + # extended-length path Herdr resolves to exits immediately unless it is native. + $docCommand = @($doc.command) + Assert-True ( + $docCommand.Count -eq 3 -and + $docCommand[0] -ceq "./bin/plannotator-tui.exe" -and + $docCommand[1] -ceq "herdr" -and + $docCommand[2] -ceq "pane" + ) "the doc pane is not direct argv: $($docCommand -join ' ')" + + $handler = @($plugin.link_handlers | Where-Object { $_.id -ceq "markdown-file" }) + Assert-True ($handler.Count -eq 1) "Herdr $version omitted the markdown-file link handler" + + Write-Output "herdr $version accepts windows-full: 7 actions, 3 panes, direct-argv doc pane" + } +} finally { + foreach ($name in $isolatedNames) { + 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-windows-full-manifest.py b/scripts/test-windows-full-manifest.py index 75a2daf..a577c0b 100644 --- a/scripts/test-windows-full-manifest.py +++ b/scripts/test-windows-full-manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check the gated distributed Full manifest and development parity.""" +"""Check the gated distributed Full manifest, Windows Full, and development parity.""" from __future__ import annotations @@ -10,6 +10,34 @@ PROGRAM = "./bin/plannotator-tui.exe" NATIVE_PROGRAM = "./bin/herdr-annotate.exe" +# Windows Full sits one directory down, so it shares the repository's single native runtime +# the way lite/ does and keeps only plannotator-tui in its own bin/. +WINDOWS_NATIVE_PROGRAM = "../bin/herdr-annotate.exe" +WINDOWS_FULL_BUILDS = [ + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "../scripts/fetch-herdr-annotate.ps1", + ], + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/fetch-plannotator-tui.ps1", + ], +] +WINDOWS_FULL_PANE = [PROGRAM, "herdr", "pane"] +PANE_SHAPE = { + "editor": ("Annotate", "popup", 88, 24), + "manager": ("Annotations", "popup", 100, 30), +} FULL_PLATFORMS = {"macos", "linux"} UNIX_BUILD = ["bash", "scripts/fetch-plannotator-tui.sh"] NATIVE_UNIX_BUILD = ["bash", "scripts/fetch-herdr-annotate.sh"] @@ -202,6 +230,96 @@ def check_development(path: Path) -> None: fail(path, f"development markdown-file points to {handler.get('action')!r}") +def surface(path: Path, manifest: dict[str, object], table: str) -> set[tuple[object, ...]]: + """Ids, titles, descriptions and contexts, so parity is compared rather than assumed.""" + entries = manifest.get(table, []) + if not isinstance(entries, list): + fail(path, f"[[{table}]] is not an array") + return { + ( + item.get("id"), + item.get("title"), + item.get("description"), + tuple(item.get("contexts", [])), + ) + for item in entries + if isinstance(item, dict) + } + + +def check_windows_full(path: Path, root_path: Path) -> None: + manifest = load(path) + root = load(root_path) + + if manifest.get("id") != root.get("id") or manifest.get("name") != root.get("name"): + fail(path, "id and name must match the root manifest") + if manifest.get("version") != root.get("version"): + fail(path, f"version {manifest.get('version')!r} differs from the root manifest") + if platforms(path, manifest, {}) != {"windows"}: + fail(path, f"top-level platforms are {platforms(path, manifest, {})!r}") + if manifest.get("min_herdr_version") != "0.9.0": + fail(path, "Windows Full requires Herdr 0.9.0, where panes resolve relative programs") + + build_entries = builds(path, manifest) + if [item.get("command") for item in build_entries] != WINDOWS_FULL_BUILDS: + fail(path, f"unexpected builds: {[i.get('command') for i in build_entries]!r}") + for item in build_entries: + # A build gated to macOS/Linux would stage nothing here, leaving the manifest + # pointing at binaries that were never fetched. + if "platforms" in item: + fail(path, f"a build carries a platform gate: {item['platforms']!r}") + + # Parity is the point of the variant: the same surface, reached a different way. + if surface(path, manifest, "actions") != surface(root_path, root, "actions"): + fail(path, "action ids, titles, descriptions or contexts differ from the root manifest") + + for table in ("actions", "panes", "link_handlers"): + for item in manifest.get(table, []): + identifier = item.get("id") + # An inherited Unix gate would silently disable the entry on the only platform + # this variant targets, which is the bug the variant exists to avoid. + if "platforms" in item: + fail(path, f"{table}.{identifier} carries a platform gate: {item['platforms']!r}") + command = item.get("command", []) + if any("sh" in argument.lower() or "$" in argument for argument in command): + fail(path, f"shell or interpolation in {table}.{identifier}: {command!r}") + + for identifier in NATIVE_ENTRIES["actions"]: + command = entry(path, manifest, "actions", identifier).get("command") + if command != [WINDOWS_NATIVE_PROGRAM, identifier]: + fail(path, f"actions.{identifier} must reuse the shared runtime: {command!r}") + for identifier, expected in ACTION_COMMANDS.items(): + command = entry(path, manifest, "actions", identifier).get("command") + if command != expected: + fail(path, f"unexpected actions.{identifier} argv: {command!r}") + + panes = manifest.get("panes", []) + if len(panes) != 3: + fail(path, f"expected exactly three panes, found {len(panes)}") + for identifier, (title, placement, width, height) in PANE_SHAPE.items(): + pane = entry(path, manifest, "panes", identifier) + if (pane.get("title"), pane.get("placement")) != (title, placement): + fail(path, f"panes.{identifier} shape differs from the root manifest") + if (pane.get("width"), pane.get("height")) != (width, height): + fail(path, f"panes.{identifier} is not {width}x{height}") + if pane.get("command") != [WINDOWS_NATIVE_PROGRAM, identifier]: + fail(path, f"panes.{identifier} must reuse the shared runtime") + + doc = entry(path, manifest, "panes", "doc") + if (doc.get("title"), doc.get("placement")) != ("Annotate", "overlay"): + fail(path, "panes.doc must stay an overlay titled Annotate") + # No launcher may sit between Herdr and the TUI: a process started through an + # extended-length path exits immediately unless it is the native binary itself. + if doc.get("command") != WINDOWS_FULL_PANE: + fail(path, f"panes.doc must be direct argv, found {doc.get('command')!r}") + + handler = entry(path, manifest, "link_handlers", "markdown-file") + root_handler = entry(root_path, root, "link_handlers", "markdown-file") + for key in ("title", "pattern", "action"): + if handler.get(key) != root_handler.get(key): + fail(path, f"markdown-file {key} differs from the root manifest") + + def main() -> None: if len(sys.argv) > 2: raise SystemExit("usage: test-windows-full-manifest.py [development-manifest]") @@ -211,6 +329,10 @@ def main() -> None: root / "plannotator-tui.version", root / "herdr-annotate.version", ) + check_windows_full( + root / "windows-full" / "herdr-plugin.toml", + root / "herdr-plugin.toml", + ) if len(sys.argv) == 2: check_development(Path(sys.argv[1])) diff --git a/windows-full/bin/.gitkeep b/windows-full/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/windows-full/herdr-plugin.toml b/windows-full/herdr-plugin.toml new file mode 100644 index 0000000..0bf99b4 --- /dev/null +++ b/windows-full/herdr-plugin.toml @@ -0,0 +1,120 @@ +# Herdr Annotate, Windows Full: the terminal annotation tools plus document review. +# Install with: herdr plugin install plannotator/herdr-annotate/windows-full +# The repository root keeps the macOS/Linux Full plugin; lite/ keeps the terminal-only +# variant, which is what Windows on Herdr 0.8.x should install. +# +# Herdr 0.9.0 is required because this variant starts plannotator-tui as the pane process +# itself. Herdr resolves an explicit relative program against the plugin root and passes the +# review folder as the pane cwd, so "./bin/plannotator-tui.exe" reaches the staged binary +# from a cwd that contains no bin/. Measured on 0.9.0 (Windows 11, x86_64): a missing +# program reports CreateProcessW `"\\?\\bin\.exe"` in cwd `Some()`, which is the resolution this manifest depends on. +# +# No launcher may sit between Herdr and the TUI. The resolved program reaches CreateProcessW +# in extended-length form, and while a native binary runs from a \\?\ path unchanged, a +# powershell.exe image started that way exits 0xFFFF0000 immediately. A wrapper would not be +# slower or less tidy here; it would not stay up. +# +# The native annotation runtime is shared with the repository root, reached one directory up +# the way lite/ reaches it. Only plannotator-tui is staged inside this variant, because its +# fetch is Windows-specific and the root must keep staging its own copy for Unix. + +id = "annotate" +name = "Annotate" +version = "0.4.0" +min_herdr_version = "0.9.0" +description = "Comment on terminal selections and copy annotations as agent context." +platforms = ["windows"] + +[[build]] +command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "../scripts/fetch-herdr-annotate.ps1"] + +# Stages plannotator-tui into windows-full/bin/. The wrapper resolves both the shared fetcher +# and the destination from its own location, so the build depends on neither the caller's cwd +# nor HERDR_PLUGIN_ROOT, and the shared fetcher keeps its default destination for every other +# caller. +[[build]] +command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch-plannotator-tui.ps1"] + +[[actions]] +id = "capture" +title = "Annotate selection" +description = "Open a comment dialog for the current terminal selection." +contexts = ["pane"] +command = ["../bin/herdr-annotate.exe", "capture"] + +[[actions]] +id = "copy-context" +title = "Copy annotations as context" +description = "Copy all saved annotations to the clipboard as Markdown." +contexts = ["global"] +command = ["../bin/herdr-annotate.exe", "copy-context"] + +[[actions]] +id = "copy-archive" +title = "Copy annotations as context and archive them" +description = "Copy all saved annotations to the clipboard as Markdown, then archive them." +contexts = ["global"] +command = ["../bin/herdr-annotate.exe", "copy-archive"] + +[[actions]] +id = "manage" +title = "Manage annotations" +description = "Browse, copy, archive, restore, and delete annotations." +contexts = ["global"] +command = ["../bin/herdr-annotate.exe", "manage"] + +[[panes]] +id = "editor" +title = "Annotate" +placement = "popup" +width = 88 +height = 24 +command = ["../bin/herdr-annotate.exe", "editor"] + +[[panes]] +id = "manager" +title = "Annotations" +placement = "popup" +width = 100 +height = 30 +command = ["../bin/herdr-annotate.exe", "manager"] + +# Where it opens (overlay | split | popup) remains the user's choice in plannotator-tui's +# config file. +[[panes]] +id = "doc" +title = "Annotate" +placement = "overlay" +command = ["./bin/plannotator-tui.exe", "herdr", "pane"] + +# Both actions run the TUI directly, which reads Herdr's invocation context: the focused +# pane's folder (open) or the clicked file:// link (open-link), and the focused pane's agent +# as the target the review is sent back to. +[[actions]] +id = "open" +title = "Annotate: open here" +description = "Review the focused pane's folder in plannotator-tui and send feedback to its agent." +contexts = ["workspace", "pane"] +command = ["./bin/plannotator-tui.exe", "herdr", "open"] + +[[actions]] +id = "open-link" +title = "Annotate this file" +description = "Open a Ctrl-clicked Markdown file in plannotator-tui." +contexts = ["pane"] +command = ["./bin/plannotator-tui.exe", "herdr", "open"] + +[[actions]] +id = "last" +title = "Annotate: agent's last message" +description = "Review the focused agent's most recent message in plannotator-tui and send feedback back." +contexts = ["pane"] +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]] +id = "markdown-file" +title = "Annotate this file" +pattern = "^file://.*\\.(md|markdown|mdx)$" +action = "open-link" diff --git a/windows-full/scripts/fetch-plannotator-tui.ps1 b/windows-full/scripts/fetch-plannotator-tui.ps1 new file mode 100644 index 0000000..2f9149c --- /dev/null +++ b/windows-full/scripts/fetch-plannotator-tui.ps1 @@ -0,0 +1,22 @@ +# Stages plannotator-tui into windows-full/bin/ by handing the shared fetcher an explicit +# destination. Both paths are derived from this script's own location, so the build does not +# depend on the caller's working directory, on HERDR_PLUGIN_ROOT being exported, or on the +# temporary checkout path surviving the install. The shared fetcher keeps the single release +# pin at the repository root and its own default destination for every other caller. +$ErrorActionPreference = "Stop" + +# .NET rather than Split-Path: an extended-length \\?\ root is a path matrix row, and +# Split-Path cannot parse one -- it reports a null drive and returns nothing. +$variantRoot = [System.IO.Path]::GetDirectoryName($PSScriptRoot) +$repositoryRoot = [System.IO.Path]::GetDirectoryName($variantRoot) +$shared = [System.IO.Path]::Combine($repositoryRoot, "scripts", "fetch-plannotator-tui.ps1") +if (-not (Test-Path -LiteralPath $shared -PathType Leaf)) { + throw "shared fetcher not found at $shared" +} + +& $shared -DestinationDirectory ([System.IO.Path]::Combine($variantRoot, "bin")) + +# The shared fetcher warns and exits zero when a download, checksum, architecture or +# replacement step fails, so that Lite stays available. Propagating its status keeps that +# contract rather than inventing one here. +exit $LASTEXITCODE