From 42af9b4cf7e83c02022def095fab693aaf06643c Mon Sep 17 00:00:00 2001 From: Timo Tigges Date: Thu, 17 Sep 2026 09:43:51 +0200 Subject: [PATCH 1/5] feat: add the windows-full manifest variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds windows-full/herdr-plugin.toml against docs/windows-full-acceptance.md, covering §1 and the staging half of §2. The root and lite/ manifests are untouched, so Windows on Herdr 0.8.x keeps installing Lite and macOS/Linux keeps the Unix wrapper and its gates. The variant starts plannotator-tui as the pane process itself, which is what requires Herdr 0.9.0. Measured on 0.9.0 (Windows 11, x86_64): a pane whose program is written "./bin/.exe" is resolved against the plugin root while the review folder is passed separately as the pane cwd. Herdr's own refusal for a missing program is the evidence, naming the path it handed CreateProcessW: CreateProcessW `"\\?\\bin\nope.exe editor"` in cwd `Some("")` failed: os error 2 That also settles why no launcher may sit in between. 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 at once. Isolated on one binary with only the prefix differing: the plain absolute spelling exits 0, the \\?\-prefixed one 0xFFFF0000, and all four relative spellings reproduce the prefixed result. So the direct-argv rule is not a style preference on Windows; a wrapped pane cannot stay up. The native annotation runtime is shared rather than duplicated, reached at ../bin/herdr-annotate.exe the way lite/ reaches it, so annotations and archives keep one location across variants. Only plannotator-tui is staged inside the variant, because the root must keep staging its own copy for Unix. For that staging, scripts/fetch-plannotator-tui.ps1 gains an optional -DestinationDirectory. Omitted, the destination is unchanged, so existing callers behave exactly as before and both Windows fetcher cases still pass. A thin windows-full/scripts/fetch-plannotator-tui.ps1 supplies it, deriving 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 propagating the warn-and- exit-zero contract so Lite stays available when a fetch fails. The release pin stays single and at the repository root. test-windows-full-manifest.py gains check_windows_full, which compares action ids, titles, descriptions and contexts against the root manifest as sets rather than restating them, and asserts the three panes and their shapes, the direct-argv doc pane, the shared-runtime spellings, the absence of any inherited platform gate, and link-handler parity. The existing root, Lite and development assertions are unchanged. Verified against five mutations -- a platform gate on an action, a wrapper in the doc pane, min_herdr_version 0.8.0, a renamed action title, and an unshared native runtime -- each of which it rejects. windows-full/** joins the CI path filters, and .gitignore learns windows-full/bin/, which the root pattern did not cover because it is anchored. Not yet covered, and not claimed: §3's runner proof beyond the manifest step, §4's human qualification, and §5's README wording. Native ARM64 in §4 cannot be closed on the hardware this was measured on, which is x86_64. --- .github/workflows/windows-full-ci.yml | 1 + .gitignore | 2 + scripts/fetch-plannotator-tui.ps1 | 12 +- scripts/test-windows-full-manifest.py | 119 ++++++++++++++++- windows-full/bin/.gitkeep | 0 windows-full/herdr-plugin.toml | 120 ++++++++++++++++++ .../scripts/fetch-plannotator-tui.ps1 | 20 +++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 windows-full/bin/.gitkeep create mode 100644 windows-full/herdr-plugin.toml create mode 100644 windows-full/scripts/fetch-plannotator-tui.ps1 diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml index cdebe58..fa7f42a 100644 --- a/.github/workflows/windows-full-ci.yml +++ b/.github/workflows/windows-full-ci.yml @@ -15,6 +15,7 @@ on: - "scripts/test-herdr-windows-plugin.ps1" - "scripts/test-http-server.py" - "scripts/test-windows-full-manifest.py" + - "windows-full/**" - ".github/workflows/windows-full-ci.yml" merge_group: 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-plannotator-tui.ps1 b/scripts/fetch-plannotator-tui.ps1 index 8edbeb4..934d314 100644 --- a/scripts/fetch-plannotator-tui.ps1 +++ b/scripts/fetch-plannotator-tui.ps1 @@ -1,3 +1,9 @@ +# -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 "..") @@ -6,7 +12,11 @@ $version = if ($null -eq $versionContents) { "" } else { [string]$versionContent $version = $version.Trim() if (-not $version) { throw "plannotator-tui.version is empty" } -$destinationDirectory = Join-Path (Get-Location).Path "bin" +$destinationDirectory = if ([string]::IsNullOrWhiteSpace($DestinationDirectory)) { + Join-Path (Get-Location).Path "bin" +} else { + $DestinationDirectory +} $destination = Join-Path $destinationDirectory "plannotator-tui.exe" $stamp = Join-Path $destinationDirectory "plannotator-tui.version" New-Item -ItemType Directory -Force $destinationDirectory | Out-Null diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py index 75a2daf..ef8df7c 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,91 @@ 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}") + + # 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 +324,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..b03a205 --- /dev/null +++ b/windows-full/scripts/fetch-plannotator-tui.ps1 @@ -0,0 +1,20 @@ +# 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" + +$variantRoot = Split-Path -Parent $PSScriptRoot +$repositoryRoot = Split-Path -Parent $variantRoot +$shared = Join-Path $repositoryRoot "scripts\fetch-plannotator-tui.ps1" +if (-not (Test-Path -LiteralPath $shared -PathType Leaf)) { + throw "shared fetcher not found at $shared" +} + +& $shared -DestinationDirectory (Join-Path $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 From 8d34ba107013bc7f713560700b2d5692f78503b8 Mon Sep 17 00:00:00 2001 From: Timo Tigges Date: Thu, 17 Sep 2026 09:45:35 +0200 Subject: [PATCH 2/5] test: assert the windows-full builds are effective on Windows A build gated to macOS/Linux would stage nothing for this variant, leaving both manifest programs pointing at binaries that were never fetched -- the failure the variant exists to prevent, and one the command-equality check alone does not see. Verified against a macos/linux gate on the plannotator-tui build. --- scripts/test-windows-full-manifest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py index ef8df7c..a577c0b 100644 --- a/scripts/test-windows-full-manifest.py +++ b/scripts/test-windows-full-manifest.py @@ -263,6 +263,11 @@ def check_windows_full(path: Path, root_path: Path) -> None: 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"): From 97c1688dc37a0f92948d106e4900c0cbd5e369e3 Mon Sep 17 00:00:00 2001 From: Timo Tigges Date: Thu, 17 Sep 2026 09:56:46 +0200 Subject: [PATCH 3/5] test: prove the 0.8.2 rejection and 0.9.0 acceptance of windows-full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1 requires that Herdr 0.8.2 reject the variant for its minimum while 0.9.0 accepts it, on isolated installs. Neither is visible to the manifest test, being Herdr's behaviour rather than the file's contents, so this drives two pinned releases and asserts each. Both run against their own XDG_CONFIG_HOME, XDG_STATE_HOME and socket paths, so neither can reach the machine's own server or each other's, and the plugin root contains spaces. Verified locally on Windows 11 x86_64: the developer's running 0.9.0 server and its installed plugin were unchanged afterwards. 0.9.0 is pinned by SHA-256 the way 0.8.2 already was. RUNNER_TEMP falls back to TEMP so the script runs unchanged outside CI, which is what recording §4's human qualification will need. Two observations the assertions had to accommodate. The checklist names `plugin_requires_newer_herdr` as the rejection. What 0.8.2's `plugin link` actually prints is unstructured: Error: Custom { kind: Other, error: "plugin requires Herdr 0.9.0 or newer; current Herdr is 0.8.2" } Both spellings are accepted, the observed one is echoed rather than hidden behind a pass, and the run additionally asserts nothing linked. If the code is meant to surface on this path, that looks like a Herdr-side gap rather than a manifest one. An entry with no platform gate carries no `platforms` key at all -- Herdr omits it rather than echoing the manifest default -- so under StrictMode it is a missing property rather than an empty one, and is read through PSObject. That absence is the assertion: every action, the doc pane and the link handler must be effective on Windows, since an inherited Unix gate would leave the variant installed with its review half silently unreachable. --- .github/workflows/windows-full-ci.yml | 3 + scripts/test-herdr-windows-full-plugin.ps1 | 169 +++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 scripts/test-herdr-windows-full-plugin.ps1 diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml index fa7f42a..d14aae5 100644 --- a/.github/workflows/windows-full-ci.yml +++ b/.github/workflows/windows-full-ci.yml @@ -13,6 +13,7 @@ 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-http-server.py" - "scripts/test-windows-full-manifest.py" - "windows-full/**" @@ -54,6 +55,8 @@ 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 unix-regression: strategy: 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 +} From 3208f206fc603b8ba415fed36156edc0c16c56d6 Mon Sep 17 00:00:00 2001 From: Timo Tigges Date: Thu, 17 Sep 2026 10:22:55 +0200 Subject: [PATCH 4/5] test: drive the windows-full review pane end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining doubt about this variant was never the manifest, it was whether a native TUI can be the pane process on Windows at all. It can, and this drives it: a pinned Herdr 0.9.0 in an isolated config, state, socket and session, a staged checkout whose paths contain spaces, and the variant's own build commands run from the plugin root with both override variables absent, so the binary in the pane is the real release rather than a fixture. The run asserts the staged location and stamp before use, opens the doc pane with a review folder outside the checkout as cwd, and waits for a marker generated per run -- a stale buffer cannot satisfy it -- read back out of the pane's own terminal. Then `q` goes in through the pane, and the pane must disappear, the originating pane must survive, no staged plannotator-tui may still be running, and the server's own log must show the pane exiting with status zero. Teardown runs in finally, including when an assertion fails. Observed locally on Windows 11 x86_64 with Herdr 0.9.0: staged plannotator-tui 0.8.0 at windows-full/bin, native runtime one level up doc pane rendered HERDRFULL-0510C65C8F7B from a review folder outside the checkout q closed the review pane with status zero and left no process behind The developer's own server, session and installed plugin were unchanged afterwards, and no herdr or plannotator-tui process was left behind. Process identity is matched on the executable path rather than the image name, so a developer running their own plannotator-tui neither fails the run nor satisfies it. Still outstanding for §3: the long-path, extended-length and UNC rows of the path matrix, placements other than the default overlay, forced-termination exit status, and the open/open-link/last actions against controlled context fixtures. §4 is untouched, and its native ARM64 row is not closeable on x86_64. --- .github/workflows/windows-full-ci.yml | 3 + scripts/test-herdr-windows-full-pane.ps1 | 215 +++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 scripts/test-herdr-windows-full-pane.ps1 diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml index d14aae5..33c1b90 100644 --- a/.github/workflows/windows-full-ci.yml +++ b/.github/workflows/windows-full-ci.yml @@ -14,6 +14,7 @@ on: - "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-http-server.py" - "scripts/test-windows-full-manifest.py" - "windows-full/**" @@ -57,6 +58,8 @@ jobs: 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 unix-regression: strategy: diff --git a/scripts/test-herdr-windows-full-pane.ps1 b/scripts/test-herdr-windows-full-pane.ps1 new file mode 100644 index 0000000..c6b7160 --- /dev/null +++ b/scripts/test-herdr-windows-full-pane.ps1 @@ -0,0 +1,215 @@ +# 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. +$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. +$checkout = Join-Path $testRoot "checkout with spaces" +$variantRoot = Join-Path $checkout "windows-full" +$review = 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") +} + +$herdrExecutable = $null + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Invoke-Herdr { + param([string[]]$Arguments) + $output = & $script:herdrExecutable @Arguments *>&1 | Out-String + $output +} + +function Get-TuiProcessIds { + # Scoped to the staged executable so a developer's own plannotator-tui is never counted. + param([string]$Path) + @( + Get-Process plannotator-tui -ErrorAction SilentlyContinue | + Where-Object { + $candidate = $null + try { $candidate = $_.Path } catch { $candidate = $null } + $null -ne $candidate -and $candidate -ceq $Path + } | + Select-Object -ExpandProperty Id + ) +} + +try { + New-Item -ItemType Directory -Force $review | Out-Null + New-Item -ItemType Directory -Force (Join-Path $variantRoot "scripts") | Out-Null + New-Item -ItemType Directory -Force (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")) { + Copy-Item -LiteralPath (Join-Path $repositoryRoot $name) -Destination $checkout + } + Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts\fetch-plannotator-tui.ps1") ` + -Destination (Join-Path $checkout "scripts") + Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts\fetch-herdr-annotate.ps1") ` + -Destination (Join-Path $checkout "scripts") + Copy-Item -LiteralPath (Join-Path $repositoryRoot "windows-full\herdr-plugin.toml") ` + -Destination $variantRoot + Copy-Item -LiteralPath (Join-Path $repositoryRoot "windows-full\scripts\fetch-plannotator-tui.ps1") ` + -Destination (Join-Path $variantRoot "scripts") + + # 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. + Push-Location $variantRoot + try { + & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File "..\scripts\fetch-herdr-annotate.ps1" | Out-Null + & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File "scripts\fetch-plannotator-tui.ps1" | Out-Null + } 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" + Assert-True (Test-Path -LiteralPath $native -PathType Leaf) "the native build staged no herdr-annotate.exe" + $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" + $reported = (& $tui --version *>&1 | Out-String).Trim() + Assert-True ($reported -match [regex]::Escape($pin)) "staged TUI reports '$reported', expected $pin" + Write-Output "staged plannotator-tui $pin at windows-full/bin, native runtime one level up" + + $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:herdrExecutable = $found.FullName + + $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:herdrExecutable -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 + + $linked = Invoke-Herdr -Arguments @("plugin", "link", $variantRoot, "--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" + + # @() at every call site: an empty array returned from a function unrolls to $null. + $before = @(Get-TuiProcessIds -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. + Invoke-Herdr -Arguments @("pane", "wait-output", $docPane, "--pattern", $marker, "--timeout", "30") | Out-Null + $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 "doc pane rendered $marker from a review folder outside the checkout" + + $running = @(Get-TuiProcessIds -Path $tui) + Assert-True ($running.Count -eq 1) "expected exactly one staged plannotator-tui, found $($running.Count)" + + 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-TuiProcessIds -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 "q closed the review pane with status zero and left no process behind" +} finally { + if ($null -ne $script:herdrExecutable) { + & $script:herdrExecutable 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 +} From 9b78e3bdda068fc13a58a3df2ce82f530f38d3ef Mon Sep 17 00:00:00 2001 From: Timo Tigges Date: Thu, 17 Sep 2026 11:05:14 +0200 Subject: [PATCH 5/5] =?UTF-8?q?test:=20run=20the=20review=20pane=20across?= =?UTF-8?q?=20=C2=A73's=20path=20matrix,=20and=20fix=20what=20it=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix reuses the pane lifecycle rather than restating it: each case shells out to test-herdr-windows-full-pane.ps1, so a row proves exactly what the default case proves -- real fetch, per-run marker rendered, `q`, clean teardown. The lifecycle script gains -Checkout, -Review, -LinkPath and -HerdrExecutable for that, all defaulting to the previous standalone behaviour. Measured on Windows 11 22621 x86_64, LongPathsEnabled=1, Herdr 0.9.0: special-characters PASS (root 131, binary 155) extended-length-root PASS (root 123, binary 147) unc-loopback PASS (root 74, binary 98) long-path LIMITATION, reported below Three fixes to the shared PowerShell fetchers, each found by a row rather than by inspection, and each a real defect rather than a test accommodation. Square brackets in a checkout path. `Set-Location` and every -Path parameter read them as wildcards, so the fetchers could not find a directory that plainly existed. Worse, once the current directory contains brackets it is stored escaped, and even -LiteralPath with a relative path then resolves to a name with backticks in it. Both fetchers now anchor on their own location and use absolute literal paths throughout, depending on the current directory for nothing. Extended-length roots. `Split-Path` cannot parse a \\?\ path -- it reports a null drive and returns nothing -- and `Join-Path` refuses one for the same reason. Both are replaced with the .NET equivalents, which are prefix-agnostic. An empty PLANNOTATOR_TUI_RELEASE_BASE was treated as a release base rather than as absent, producing a URL with no host and an "invalid URI" that surfaced far from its cause, through the warn-and-exit-zero contract that hides it. Presence is now tested as non-empty. This is easy to hit: passing $null to SetEnvironmentVariable binds as an empty string and leaves the name defined. The long-path row is not claimed as passing. Staging now succeeds past the legacy limit, but a 267-character image cannot be started at all: process creation takes the image path through the MAX_PATH route and accepts no extended-length spelling, so a staged, checksum-verified, present binary still fails to launch. The checklist forbids resolving this by changing the root manifest, so it is measured and reported. A related constraint sits beside it: the manifest's build command passes a relative -File argument, and Herdr resolves only the program against the plugin root, not the arguments, so Windows PowerShell resolves that argument against the pane cwd under MAX_PATH. Both bear on how long an installed plugin root may be, and both are upstream's call. Two smaller things the rows forced. Build output is captured and shown when staging fails, because warn-and-exit-zero makes a silent failure the normal shape of trouble. And a process launched from a share reports its image in device form, UNC\server\share rather than \\server\share, which is folded back before comparing -- otherwise every UNC run looks like no process at all. UNC is a loopback share, not a remote file server, and is recorded as such. --- .github/workflows/windows-full-ci.yml | 3 + scripts/fetch-herdr-annotate.ps1 | 33 +-- scripts/fetch-plannotator-tui.ps1 | 38 ++-- scripts/test-herdr-windows-full-pane.ps1 | 196 +++++++++++++----- scripts/test-herdr-windows-full-paths.ps1 | 133 ++++++++++++ .../scripts/fetch-plannotator-tui.ps1 | 10 +- 6 files changed, 331 insertions(+), 82 deletions(-) create mode 100644 scripts/test-herdr-windows-full-paths.ps1 diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml index 33c1b90..4f51b8a 100644 --- a/.github/workflows/windows-full-ci.yml +++ b/.github/workflows/windows-full-ci.yml @@ -15,6 +15,7 @@ on: - "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/**" @@ -60,6 +61,8 @@ jobs: 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/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 934d314..21cee5b 100644 --- a/scripts/fetch-plannotator-tui.ps1 +++ b/scripts/fetch-plannotator-tui.ps1 @@ -5,21 +5,31 @@ 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 = if ([string]::IsNullOrWhiteSpace($DestinationDirectory)) { - Join-Path (Get-Location).Path "bin" + [System.IO.Path]::Combine($root, "bin") } else { $DestinationDirectory } -$destination = Join-Path $destinationDirectory "plannotator-tui.exe" -$stamp = Join-Path $destinationDirectory "plannotator-tui.version" -New-Item -ItemType Directory -Force $destinationDirectory | Out-Null +$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 @@ -38,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 @@ -122,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" @@ -131,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 index c6b7160..819ba3d 100644 --- a/scripts/test-herdr-windows-full-pane.ps1 +++ b/scripts/test-herdr-windows-full-pane.ps1 @@ -8,6 +8,19 @@ # # 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) { @@ -23,9 +36,10 @@ $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. -$checkout = Join-Path $testRoot "checkout with spaces" +$callerOwnsDirectories = -not [string]::IsNullOrWhiteSpace($Checkout) +$checkout = if ($callerOwnsDirectories) { $Checkout } else { Join-Path $testRoot "checkout with spaces" } $variantRoot = Join-Path $checkout "windows-full" -$review = Join-Path $testRoot "review folder" +$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" @@ -44,7 +58,9 @@ foreach ($name in $isolatedNames) { $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") } -$herdrExecutable = $null +# 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) @@ -53,28 +69,46 @@ function Assert-True { function Invoke-Herdr { param([string[]]$Arguments) - $output = & $script:herdrExecutable @Arguments *>&1 | Out-String + $output = & $script:resolvedHerdr @Arguments *>&1 | Out-String $output } -function Get-TuiProcessIds { +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 | - Where-Object { + ForEach-Object { $candidate = $null try { $candidate = $_.Path } catch { $candidate = $null } - $null -ne $candidate -and $candidate -ceq $Path - } | - Select-Object -ExpandProperty Id + if ($null -ne $candidate) { + $full = Resolve-ImagePath -Candidate $candidate + if ($full -ieq $wanted) { $full } + } + } ) } try { - New-Item -ItemType Directory -Force $review | Out-Null - New-Item -ItemType Directory -Force (Join-Path $variantRoot "scripts") | Out-Null - New-Item -ItemType Directory -Force (Join-Path $checkout "scripts") | Out-Null + # .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", "", @@ -84,51 +118,84 @@ try { # 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")) { - Copy-Item -LiteralPath (Join-Path $repositoryRoot $name) -Destination $checkout + [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) } - Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts\fetch-plannotator-tui.ps1") ` - -Destination (Join-Path $checkout "scripts") - Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts\fetch-herdr-annotate.ps1") ` - -Destination (Join-Path $checkout "scripts") - Copy-Item -LiteralPath (Join-Path $repositoryRoot "windows-full\herdr-plugin.toml") ` - -Destination $variantRoot - Copy-Item -LiteralPath (Join-Path $repositoryRoot "windows-full\scripts\fetch-plannotator-tui.ps1") ` - -Destination (Join-Path $variantRoot "scripts") # 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. - Push-Location $variantRoot + # 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 { - & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` - -File "..\scripts\fetch-herdr-annotate.ps1" | Out-Null - & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` - -File "scripts\fetch-plannotator-tui.ps1" | Out-Null + # 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" - Assert-True (Test-Path -LiteralPath $native -PathType Leaf) "the native build staged no 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" - $reported = (& $tui --version *>&1 | Out-String).Trim() + # 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 "staged plannotator-tui $pin at windows-full/bin, native runtime one level up" - - $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:herdrExecutable = $found.FullName + 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" @@ -139,7 +206,7 @@ try { $env:PLANNOTATOR_TUI_BIN = $null $env:PLANNOTATOR_TUI_RELEASE_BASE = $null - Start-Process -FilePath $script:herdrExecutable -ArgumentList "server" -WindowStyle Hidden + Start-Process -FilePath $script:resolvedHerdr -ArgumentList "server" -WindowStyle Hidden $deadline = (Get-Date).AddSeconds(30) do { Start-Sleep -Milliseconds 500 @@ -151,12 +218,21 @@ try { Assert-True ($created.result.type -ceq "workspace_created") "no isolated workspace was created" $originPane = $created.result.root_pane.pane_id - $linked = Invoke-Herdr -Arguments @("plugin", "link", $variantRoot, "--enabled") | ConvertFrom-Json + # -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-TuiProcessIds -Path $tui) + $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 @( @@ -167,15 +243,27 @@ try { 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. - Invoke-Herdr -Arguments @("pane", "wait-output", $docPane, "--pattern", $marker, "--timeout", "30") | Out-Null + # --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 "doc pane rendered $marker from a review folder outside the checkout" + Write-Output "[$Label] doc pane rendered $marker; plugin root $($variantRoot.Length) chars, review $($review.Length)" - $running = @(Get-TuiProcessIds -Path $tui) - Assert-True ($running.Count -eq 1) "expected exactly one staged plannotator-tui, found $($running.Count)" + $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) @@ -186,7 +274,7 @@ try { 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-TuiProcessIds -Path $tui) + $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" @@ -198,10 +286,10 @@ try { ) 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 "q closed the review pane with status zero and left no process behind" + Write-Output "[$Label] q closed the review pane with status zero and left no process behind" } finally { - if ($null -ne $script:herdrExecutable) { - & $script:herdrExecutable server stop *>&1 | Out-Null + if ($null -ne $script:resolvedHerdr) { + & $script:resolvedHerdr server stop *>&1 | Out-Null Start-Sleep -Seconds 2 } foreach ($name in $isolatedNames) { 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/windows-full/scripts/fetch-plannotator-tui.ps1 b/windows-full/scripts/fetch-plannotator-tui.ps1 index b03a205..2f9149c 100644 --- a/windows-full/scripts/fetch-plannotator-tui.ps1 +++ b/windows-full/scripts/fetch-plannotator-tui.ps1 @@ -5,14 +5,16 @@ # pin at the repository root and its own default destination for every other caller. $ErrorActionPreference = "Stop" -$variantRoot = Split-Path -Parent $PSScriptRoot -$repositoryRoot = Split-Path -Parent $variantRoot -$shared = Join-Path $repositoryRoot "scripts\fetch-plannotator-tui.ps1" +# .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 (Join-Path $variantRoot "bin") +& $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