diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d65cd..1d4ff75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ will be documented in this file. ## [0.5.0] Unreleased +- Add new `Locate` command + [#25](https://github.com/continuous-delphi/delphi-inspect/issues/25) + - Ensure `PowerShell 5.1` compatibility for the delphi-inspect.ps1 script (Tests remain the newer `pwsh`) [#23](https://github.com/continuous-delphi/delphi-inspect/issues/23) @@ -47,7 +50,7 @@ Default platform parameter to `Win32`, default build parameter to `MSBuild`

-## `Delphi-Inspect` - a developer tool from Continuous Delphi +## `delphi-inspect` - a developer tool from Continuous Delphi ![continuous-delphi logo](https://continuous-delphi.github.io/assets/logos/continuous-delphi-480x270.png) diff --git a/README.md b/README.md index feb3bdb..764d190 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ $all = pwsh delphi-inspect.ps1 -ListKnown $inst = pwsh delphi-inspect.ps1 -ListInstalled -Platform Win32 -BuildSystem DCC -Readiness all $best = pwsh delphi-inspect.ps1 -DetectLatest +# Example using MSBuild with Delphi 13 Florence +# Use "Locate" to pin a Delphi version within your script so it can be run +# on developer machines that may have custom RAD Studio installation paths +# Locate with: VER370, Florence, Delphi 13, or 13 Florence...whichever is your preference +./delphi-inspect.ps1 -Locate -Name Florence | ./delphi-msbuild.ps1 -ProjectFile 'MyProj.dproj' -Platform Win64 + + # Text format -- human-readable output pwsh delphi-inspect.ps1 pwsh delphi-inspect.ps1 -Version -Format text @@ -65,6 +72,7 @@ Note: the test suite requires `pwsh`. | `ListKnown` | List all known Delphi versions from the dataset | | `ListInstalled` | List all Delphi versions with readiness state | | `DetectLatest` | Return the single highest-versioned ready install | +| `Locate` | Return the installation root directory for a specific installed version | | `Resolve` | Resolve an alias or VER### to a canonical entry | See [docs/commands.md](docs/commands.md) for full command reference including switches, diff --git a/docs/commands.md b/docs/commands.md index 5c0a431..9920068 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,11 +7,13 @@ This document describes the command-line interface for # Overview -`delphi-inspect.ps1` provides five primary actions: +`delphi-inspect.ps1` provides six primary actions: - `-Version` --- Display tool and dataset metadata - `-Resolve` --- Resolve a Delphi alias or VER### constant to canonical version data +- `-Locate` --- Return the installation root directory for a specific + installed version - `-ListKnown` --- List all known Delphi versions from the dataset - `-ListInstalled` --- List all Delphi versions with readiness state - `-DetectLatest` --- Return the single highest-versioned ready install @@ -152,6 +154,89 @@ being omitted. ------------------------------------------------------------------------ +## -Locate + +Look up a specific Delphi version by alias or VER### constant and return +its installation root directory (`RootDir`) from the Windows registry. + +This is the complement to `-Resolve`: where `-Resolve` returns dataset +metadata, `-Locate` returns the actual on-disk installation path for a +named version. It is intended for build scripts that need to pin to a +specific Delphi version and pass its root path to another tool (e.g. +`delphi-msbuild`). + +### Syntax + + -Locate + -Locate -Name + +`-Name` is mandatory. It may be supplied positionally (first argument +after `-Locate`) or explicitly via `-Name`. Omitting it is a +parameter binding error (exit code 1). + +Matching is case-insensitive. The lookup checks each entry in order: +`verDefine` (e.g. `VER150`), then `productName` (e.g. `Delphi 7`), +then the `aliases` array (e.g. `D7`, `Delphi 11`). The first match +wins. + +### Examples + + pwsh delphi-inspect.ps1 -Locate "Delphi 13" + pwsh delphi-inspect.ps1 -Locate VER370 + pwsh delphi-inspect.ps1 -Locate -Name VER370 + pwsh delphi-inspect.ps1 -Locate VER370 -Format json + + # Pass the root path to another tool + $root = (.\delphi-inspect.ps1 -Locate VER370).rootDir + & "$root\bin\rsvars.bat" + +### Output (object format, default) + +Returns one `pscustomobject` with properties: + + verDefine -- canonical VER### constant from the dataset + productName -- human-readable product name + rootDir -- installation root directory from the registry + +### Output (text format) + +Labels are left-padded to a 20-character column width. + + verDefine VER370 + productName Delphi 13 Florence + rootDir C:\Program Files (x86)\Embarcadero\Studio\24.0\ + +### Output (json format) + + { + "ok": true, + "command": "locate", + "tool": { + "name": "delphi-inspect", + "version": "0.1.0" + }, + "result": { + "verDefine": "VER370", + "productName": "Delphi 13 Florence", + "rootDir": "C:\\Program Files (x86)\\Embarcadero\\Studio\\24.0\\" + } + } + +### Exit codes + +| Code | Condition | +|------|-----------| +| `0` | Version found and installed; `rootDir` returned | +| `4` | Name not found in the dataset | +| `5` | Registry access error | +| `6` | Version known but not installed (registry entry absent or `RootDir` empty) | + +When exit code 6 is returned in json mode, a json error envelope is +emitted (`ok: false`) rather than a success envelope, because no partial +result is meaningful for a point lookup. + +------------------------------------------------------------------------ + ## -ListKnown List all known Delphi versions from the dataset. @@ -691,11 +776,12 @@ the versions present in that file. # Parameter Rules -- `-Version`, `-Resolve`, `-ListKnown`, `-ListInstalled`, and - `-DetectLatest` are mutually exclusive (enforced by PowerShell +- `-Version`, `-Resolve`, `-Locate`, `-ListKnown`, `-ListInstalled`, + and `-DetectLatest` are mutually exclusive (enforced by PowerShell parameter sets; exit code 1 if more than one is supplied). - With no action switch, the default action is `-Version`. -- `-Resolve` requires `-Name`; it may be supplied positionally. +- `-Resolve` and `-Locate` both require `-Name`; it may be supplied + positionally as the first argument after the action switch. - `-ListInstalled` requires both `-Platform` and `-BuildSystem`; neither may be supplied positionally. - `-DetectLatest` accepts `-Platform` and `-BuildSystem` as optional @@ -716,9 +802,9 @@ the versions present in that file. | `1` | PowerShell parameter binding error or unexpected internal error | | `2` | Reserved (script-body argument validation; not currently used) | | `3` | Dataset missing or unreadable | -| `4` | Alias not found (`-Resolve` only) | -| `5` | Registry access error (`-ListInstalled` and `-DetectLatest` only) | -| `6` | No installations found (`-ListInstalled` and `-DetectLatest` only) | +| `4` | Alias not found (`-Resolve` and `-Locate` only) | +| `5` | Registry access error (`-ListInstalled`, `-DetectLatest`, and `-Locate` only) | +| `6` | No installations found (`-ListInstalled`, `-DetectLatest`, and `-Locate` only) | **PowerShell implementation note:** the PowerShell binder runs before the @@ -748,14 +834,16 @@ written to stderr, nothing is written to stdout on error. - On dataset errors (exit 3): stderr contains the error message, stdout is empty. - On unknown alias (exit 4): stderr contains "Alias not found", - stdout is empty. + stdout is empty. Applies to `-Resolve` and `-Locate`. - On registry access error (exit 5): stderr contains the error message, stdout is empty. -- On no installations found (exit 6): in text mode, stdout contains - "No installations found" (for `-ListInstalled`) or - "No ready installation found" (for `-DetectLatest`), stderr is empty. - In object mode, nothing is emitted to the pipeline; exit code 6 is - the signal. +- On no installations found (exit 6): + - `-ListInstalled` (text): stdout contains "No installations found", + stderr is empty. Object mode: nothing emitted; exit code is signal. + - `-DetectLatest` (text): stdout contains "No ready installation found", + stderr is empty. Object mode: nothing emitted; exit code is signal. + - `-Locate` (text and object): stderr contains "Not installed: ", + stdout is empty. Exit code 6 is the signal. ## JSON format (-Format json) @@ -768,11 +856,13 @@ written to stderr, nothing is written to stdout on error. - On dataset errors (exit 3), unknown alias (exit 4), or registry access error (exit 5): stdout contains a JSON error envelope, stderr is empty. -- On no installations found (exit 6): stdout contains the normal - JSON success envelope (ok: true); stderr is empty. Exit code 6 is - the signal -- the envelope is still well-formed and machine-readable. - For `-ListInstalled`, all installations are listed as notFound. - For `-DetectLatest`, `installation` is null. +- On no installations found (exit 6): + - `-ListInstalled` and `-DetectLatest`: stdout contains a JSON success + envelope (ok: true); exit code 6 is the signal. For + `-ListInstalled`, all installations are listed as notFound. For + `-DetectLatest`, `installation` is null. + - `-Locate`: stdout contains a JSON error envelope (ok: false), + stderr is empty. Exit code 6 is the signal. JSON error envelope: diff --git a/source/delphi-inspect.ps1 b/source/delphi-inspect.ps1 index d0adf16..a68a8ce 100644 --- a/source/delphi-inspect.ps1 +++ b/source/delphi-inspect.ps1 @@ -17,6 +17,9 @@ USAGE pwsh ./source/delphi-inspect.ps1 -DataFile pwsh ./source/delphi-inspect.ps1 -DetectLatest -Platform Win32 -BuildSystem DCC pwsh ./source/delphi-inspect.ps1 -DetectLatest -Platform Win32 -BuildSystem DCC -Format json + pwsh ./source/delphi-inspect.ps1 -Locate -Name + pwsh ./source/delphi-inspect.ps1 -Locate + pwsh ./source/delphi-inspect.ps1 -Locate -Name -Format json pwsh ./source/delphi-inspect.ps1 -ListInstalled -Platform Win32 -BuildSystem DCC pwsh ./source/delphi-inspect.ps1 -ListInstalled -Platform Win32 -BuildSystem DCC -Readiness all pwsh ./source/delphi-inspect.ps1 -ListInstalled -Platform Win32 -BuildSystem DCC -Readiness partialInstall @@ -28,10 +31,21 @@ NOTES -Resolve looks up an alias or VER### string in the dataset (case-insensitive) and prints the canonical entry fields. Exit 4 when the alias is not found. + -Locate looks up an alias or VER### string (same matching as -Resolve) and + returns the RootDir of the installed version from the registry. + Exit 4 when the alias is not found in the dataset. + Exit 6 when the version is not installed (registry entry absent or RootDir empty). + -DetectLatest scans all dataset entries and returns the single highest-versioned entry whose readiness is 'ready' for the specified platform and build system. Exit 0 on success; exit 6 when no ready installation exists. + Dataset resolution order when -DataFile is not supplied: + 1. /delphi-compiler-versions.json (sibling / standalone deployment) + 2. /submodules/delphi-compiler-versions/data/delphi-compiler-versions.json + The first path that exists on disk is used. If neither exists, path 2 is + returned so Import-JsonData produces a meaningful "Data file not found" error. + -Format selects output format. Valid values: object (default), text, json. object -- emit PowerShell objects to the pipeline (default; best for scripting) text -- human-readable formatted output @@ -53,7 +67,11 @@ param( [Parameter(ParameterSetName='Resolve', Mandatory=$true)] [switch]$Resolve, + [Parameter(ParameterSetName='Locate', Mandatory=$true)] + [switch]$Locate, + [Parameter(ParameterSetName='Resolve', Mandatory=$true, Position=0)] + [Parameter(ParameterSetName='Locate', Mandatory=$true, Position=0)] [string]$Name, [Parameter(ParameterSetName='ListKnown')] @@ -127,15 +145,29 @@ function Resolve-DefaultDataFilePath { $scriptDir = Split-Path -Parent $ScriptPath - # Prefer the submodule layout: - # ../delphi-compiler-versions/data/delphi-compiler-versions.json + # Candidate 1 (preferred): sibling file next to the script + # /delphi-compiler-versions.json + # Checked first so a locally deployed dataset takes precedence over the submodule. + $siblingPath = Join-Path $scriptDir 'delphi-compiler-versions.json' + + if (Test-Path -LiteralPath $siblingPath) { + return $siblingPath + } + + # Candidate 2: submodule layout relative to repo root + # /submodules/delphi-compiler-versions/data/delphi-compiler-versions.json # Use Join-Path to remain path-separator-safe if invoked on non-Windows runners. - $repoRoot = Split-Path $scriptDir -Parent - $specRoot = Join-Path (Join-Path $repoRoot 'submodules') 'delphi-compiler-versions' - $dataDir = Join-Path $specRoot 'data' - $defaultPath = Join-Path $dataDir 'delphi-compiler-versions.json' + $repoRoot = Split-Path $scriptDir -Parent + $specRoot = Join-Path (Join-Path $repoRoot 'submodules') 'delphi-compiler-versions' + $submodulePath = Join-Path (Join-Path $specRoot 'data') 'delphi-compiler-versions.json' + + if (Test-Path -LiteralPath $submodulePath) { + return $submodulePath + } - return $defaultPath + # Neither found: return the submodule path so Import-JsonData produces a + # meaningful "Data file not found: ..." error pointing at the expected location. + return $submodulePath } function Import-JsonData { @@ -306,6 +338,43 @@ function Write-ResolveOutput { } } +function Write-LocateOutput { + param( + [psobject]$Entry, + [string]$RootDir, + [string]$ToolVersion = '', + [string]$Format = 'object' + ) + + if ($Format -eq 'object') { + Write-Output ([pscustomobject]@{ + verDefine = $Entry.verDefine + productName = $Entry.productName + rootDir = $RootDir + }) + return + } + + if ($Format -eq 'json') { + Write-JsonOutput ([pscustomobject]@{ + ok = $true + command = 'locate' + tool = [pscustomobject]@{ name = 'delphi-inspect'; version = $ToolVersion } + result = [pscustomobject]@{ + verDefine = $Entry.verDefine + productName = $Entry.productName + rootDir = $RootDir + } + }) + return + } + + # text format -- label column matches -Resolve at 20 chars + Write-Output ("verDefine {0}" -f $Entry.verDefine) + Write-Output ("productName {0}" -f $Entry.productName) + Write-Output ("rootDir {0}" -f $RootDir) +} + function Write-ListKnownOutput { param( [psobject]$Data, @@ -734,8 +803,8 @@ try { # Default behavior: if no action switches specified, treat as -Version. # Mutual exclusion and mandatory -Name are enforced by parameter sets. $doVersion = $Version - if (-not $doVersion -and -not $Resolve -and -not $ListKnown -and -not $ListInstalled -and -not $DetectLatest) { $doVersion = $true } - $commandName = if ($Resolve) { 'resolve' } elseif ($ListKnown) { 'listKnown' } elseif ($ListInstalled) { 'listInstalled' } elseif ($DetectLatest) { 'detectLatest' } else { 'version' } + if (-not $doVersion -and -not $Resolve -and -not $Locate -and -not $ListKnown -and -not $ListInstalled -and -not $DetectLatest) { $doVersion = $true } + $commandName = if ($Resolve) { 'resolve' } elseif ($Locate) { 'locate' } elseif ($ListKnown) { 'listKnown' } elseif ($ListInstalled) { 'listInstalled' } elseif ($DetectLatest) { 'detectLatest' } else { 'version' } if ([string]::IsNullOrWhiteSpace($DataFile)) { $DataFile = Resolve-DefaultDataFilePath -ScriptPath $scriptPath @@ -776,6 +845,47 @@ try { exit $ExitSuccess } + if ($Locate) { + $entry = Resolve-VersionEntry -Name $Name -Data $data + if ($null -eq $entry) { + if ($Format -eq 'json') { + Write-JsonError -ToolVersion $ToolVersion -Command 'locate' -Code $ExitAliasNotFound -Message "Alias not found: $Name" + } else { + Write-Error "Alias not found: $Name" -ErrorAction Continue + } + exit $ExitAliasNotFound + } + if ([string]::IsNullOrWhiteSpace($entry.regKeyRelativePath)) { + if ($Format -eq 'json') { + Write-JsonError -ToolVersion $ToolVersion -Command 'locate' -Code $ExitNoInstallationsFound -Message "No registry path known for: $Name" + } else { + Write-Error "No registry path known for: $Name" -ErrorAction Continue + } + exit $ExitNoInstallationsFound + } + $rootDir = $null + try { + $rootDir = Get-RegistryRootDir -RelativePath $entry.regKeyRelativePath + } catch { + if ($Format -eq 'json') { + Write-JsonError -ToolVersion $ToolVersion -Command 'locate' -Code $ExitRegistryError -Message "Registry access failed: $($_.Exception.Message)" + } else { + Write-Error "Registry access failed: $($_.Exception.Message)" -ErrorAction Continue + } + exit $ExitRegistryError + } + if ($null -eq $rootDir) { + if ($Format -eq 'json') { + Write-JsonError -ToolVersion $ToolVersion -Command 'locate' -Code $ExitNoInstallationsFound -Message "Not installed: $Name" + } else { + Write-Error "Not installed: $Name" -ErrorAction Continue + } + exit $ExitNoInstallationsFound + } + Write-LocateOutput -Entry $entry -RootDir $rootDir -ToolVersion $ToolVersion -Format $Format + exit $ExitSuccess + } + if ($ListKnown) { Write-ListKnownOutput -Data $data -ToolVersion $ToolVersion -Format $Format exit $ExitSuccess diff --git a/tests/pwsh/Resolve-DefaultDataFilePath.Tests.ps1 b/tests/pwsh/Resolve-DefaultDataFilePath.Tests.ps1 index 12566ec..fc59f5f 100644 --- a/tests/pwsh/Resolve-DefaultDataFilePath.Tests.ps1 +++ b/tests/pwsh/Resolve-DefaultDataFilePath.Tests.ps1 @@ -4,13 +4,21 @@ Tests for Resolve-DefaultDataFilePath in delphi-inspect.ps1 .DESCRIPTION - Covers: path construction from a given script location. + Covers: path resolution priority and fallback behaviour. - Context 1 - Pure construction (no filesystem access): - Verifies the returned path ends with the canonical data file name. - Verifies the returned path contains the spec submodule directory name. + Context 1 - Neither submodule nor sibling file present: + Verifies the returned path ends with the canonical data file name and + contains the spec submodule directory name (i.e. falls back to the + canonical submodule path for a meaningful error downstream). - Context 2 - Real repository layout: + Context 2 - Sibling data file present, no submodule: + Verifies that a delphi-compiler-versions.json placed next to the script + is returned when the submodule path does not exist. + + Context 3 - Both submodule and sibling data file present: + Verifies that the sibling file takes priority over the submodule path. + + Context 4 - Real repository layout: Verifies the resolved path exists on disk. Requires delphi-compiler-versions to be present as a submodule. #> @@ -32,13 +40,13 @@ Describe 'Resolve-DefaultDataFilePath' { . $script:scriptUnderTest } - Context 'Given a script path in a standard source layout' { + Context 'Given neither the submodule path nor a sibling data file exists' { BeforeAll { $fakeRepo = Join-Path ([System.IO.Path]::GetTempPath()) 'delphi-inspect-test-repo' $fakeScriptDir = Join-Path $fakeRepo 'source' $script:fakeScriptPath = Join-Path $fakeScriptDir 'delphi-inspect.ps1' - # Create a placeholder file so the guard's Test-Path check passes. + # Create a placeholder script file only -- no data files anywhere. $null = New-Item -ItemType Directory -Path $fakeScriptDir -Force $null = New-Item -ItemType File -Path $script:fakeScriptPath -Force } @@ -54,9 +62,63 @@ Describe 'Resolve-DefaultDataFilePath' { $result | Should -Match ([regex]::Escape('delphi-compiler-versions.json')) } - It 'returns a path containing the spec submodule directory name' { + It 'returns the submodule path (canonical fallback for error reporting)' { + $result = Resolve-DefaultDataFilePath -ScriptPath $script:fakeScriptPath + $result | Should -Match ([regex]::Escape('submodules')) + } + + } + + Context 'Given only a sibling data file exists next to the script' { + + BeforeAll { + $fakeRepo = Join-Path ([System.IO.Path]::GetTempPath()) 'delphi-inspect-test-sibling' + $fakeScriptDir = Join-Path $fakeRepo 'source' + $script:fakeScriptPath = Join-Path $fakeScriptDir 'delphi-inspect.ps1' + $script:siblingPath = Join-Path $fakeScriptDir 'delphi-compiler-versions.json' + $null = New-Item -ItemType Directory -Path $fakeScriptDir -Force + $null = New-Item -ItemType File -Path $script:fakeScriptPath -Force + $null = New-Item -ItemType File -Path $script:siblingPath -Force + } + + AfterAll { + foreach ($p in @($script:fakeScriptPath, $script:siblingPath)) { + if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force } + } + } + + It 'returns the sibling file path' { + $result = Resolve-DefaultDataFilePath -ScriptPath $script:fakeScriptPath + $result | Should -Be $script:siblingPath + } + + } + + Context 'Given both a submodule data file and a sibling data file exist' { + + BeforeAll { + $fakeRepo = Join-Path ([System.IO.Path]::GetTempPath()) 'delphi-inspect-test-both' + $fakeScriptDir = Join-Path $fakeRepo 'source' + $script:fakeScriptPath = Join-Path $fakeScriptDir 'delphi-inspect.ps1' + $script:siblingPath = Join-Path $fakeScriptDir 'delphi-compiler-versions.json' + $submoduleDataDir = Join-Path (Join-Path (Join-Path $fakeRepo 'submodules') 'delphi-compiler-versions') 'data' + $script:submodulePath = Join-Path $submoduleDataDir 'delphi-compiler-versions.json' + $null = New-Item -ItemType Directory -Path $fakeScriptDir -Force + $null = New-Item -ItemType Directory -Path $submoduleDataDir -Force + $null = New-Item -ItemType File -Path $script:fakeScriptPath -Force + $null = New-Item -ItemType File -Path $script:siblingPath -Force + $null = New-Item -ItemType File -Path $script:submodulePath -Force + } + + AfterAll { + foreach ($p in @($script:fakeScriptPath, $script:siblingPath, $script:submodulePath)) { + if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force } + } + } + + It 'returns the sibling path (sibling takes priority)' { $result = Resolve-DefaultDataFilePath -ScriptPath $script:fakeScriptPath - $result | Should -Match ([regex]::Escape('delphi-compiler-versions')) + $result | Should -Be $script:siblingPath } } diff --git a/tests/pwsh/Write-LocateOutput.Tests.ps1 b/tests/pwsh/Write-LocateOutput.Tests.ps1 new file mode 100644 index 0000000..e17c61d --- /dev/null +++ b/tests/pwsh/Write-LocateOutput.Tests.ps1 @@ -0,0 +1,131 @@ +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.7.0' } +<# +.SYNOPSIS + Tests for Write-LocateOutput in delphi-inspect.ps1 + +.DESCRIPTION + Covers: output produced for all three format modes. + + Context 1 - -Format text: + Verifies verDefine, productName, and rootDir lines are present, and + that the total line count is exactly 3. + + Context 2 - -Format json: + Verifies the output is a single item that parses as valid JSON, + ok is $true, command is 'locate', and result contains verDefine, + productName, and rootDir with the expected values. + + Context 3 - -Format object (default): + Verifies that one pscustomobject is emitted with the correct + verDefine, productName, and rootDir properties. +#> + +# PESTER 5 SCOPING RULES apply here -- see Resolve-DefaultDataFilePath.Tests.ps1 +# for the canonical explanation. Dot-source TestHelpers.ps1 and the script +# under test inside BeforeAll, not at the top level of the file. + +Describe 'Write-LocateOutput' { + + BeforeAll { + . "$PSScriptRoot/TestHelpers.ps1" + $script:scriptUnderTest = Get-ScriptUnderTestPath + . $script:scriptUnderTest + + $script:entry = [pscustomobject]@{ + verDefine = 'VER370' + productName = 'Delphi 13 Florence' + } + $script:rootDir = 'C:\Program Files (x86)\Embarcadero\Studio\24.0\' + } + + Context 'Given -Format text' { + + BeforeAll { + $script:output = Write-LocateOutput -Entry $script:entry -RootDir $script:rootDir -Format 'text' + } + + It 'output includes a line with the verDefine value' { + ($script:output -match 'verDefine\s+VER370') | Should -Not -BeNullOrEmpty + } + + It 'output includes a line with the productName value' { + ($script:output -match 'productName\s+Delphi 13 Florence') | Should -Not -BeNullOrEmpty + } + + It 'output includes a line with the rootDir value' { + ($script:output -match 'rootDir\s+') | Should -Not -BeNullOrEmpty + } + + It 'output has exactly three lines' { + $script:output | Should -HaveCount 3 + } + + } + + Context 'Given -Format json' { + + BeforeAll { + $script:output = Write-LocateOutput -Entry $script:entry -RootDir $script:rootDir -ToolVersion '0.1.0' -Format 'json' + $script:json = $script:output | ConvertFrom-Json + } + + It 'output is a single item' { + $script:output | Should -HaveCount 1 + } + + It 'output parses as valid JSON' { + { $script:output | ConvertFrom-Json } | Should -Not -Throw + } + + It 'ok is true' { + $script:json.ok | Should -Be $true + } + + It 'command is locate' { + $script:json.command | Should -Be 'locate' + } + + It 'result.verDefine matches the entry value' { + $script:json.result.verDefine | Should -Be 'VER370' + } + + It 'result.productName matches the entry value' { + $script:json.result.productName | Should -Be 'Delphi 13 Florence' + } + + It 'result.rootDir matches the supplied rootDir' { + $script:json.result.rootDir | Should -Be $script:rootDir + } + + It 'result does not contain unexpected properties' { + $props = $script:json.result.PSObject.Properties.Name | Sort-Object + $props | Should -Be @('productName', 'rootDir', 'verDefine') + } + + } + + Context 'Given -Format object (default)' { + + BeforeAll { + $script:output = Write-LocateOutput -Entry $script:entry -RootDir $script:rootDir + } + + It 'emits one pscustomobject' { + $script:output | Should -HaveCount 1 + } + + It 'has verDefine property with correct value' { + $script:output.verDefine | Should -Be 'VER370' + } + + It 'has productName property with correct value' { + $script:output.productName | Should -Be 'Delphi 13 Florence' + } + + It 'has rootDir property with correct value' { + $script:output.rootDir | Should -Be $script:rootDir + } + + } + +} diff --git a/tests/pwsh/delphi-inspect.Integration.Tests.ps1 b/tests/pwsh/delphi-inspect.Integration.Tests.ps1 index c765ff9..d855be7 100644 --- a/tests/pwsh/delphi-inspect.Integration.Tests.ps1 +++ b/tests/pwsh/delphi-inspect.Integration.Tests.ps1 @@ -146,6 +146,36 @@ Context 30 - -DetectLatest omitting -BuildSystem (uses MSBuild default): Exit 6, JSON result.buildSystem=MSBuild, result.platform=Win32 (explicit), clean stderr. + + Contexts 31-38 cover the -Locate dispatch branch. All supply -DataFile explicitly + using the resolve fixture (delphi-compiler-versions.resolve.json). + The test machine has no Delphi installed so all registry checks return null (not installed). + + Context 31 - -Locate -Name VER150 (text mode, not installed): + Exit 6, no stdout, stderr contains "Not installed". + + Context 32 - -Locate VER150 (positional -Name, text mode): + Exit 6. Verifies Position=0 on -Name. + + Context 33 - -Locate -Name ver150 (case-insensitive, text mode): + Exit 6, verifies the lookup is case-insensitive. + + Context 34 - -Locate -Name for an alias not in the dataset: + Exit 4, no stdout, stderr contains "Alias not found". + + Context 35 - -Locate without -Name: + Exit 1 (PowerShell parameter binding failure), no stdout, stderr present. + + Context 36 - -Locate -Name VER150 -Format json (not installed): + Exit 6, stdout is a JSON error envelope (ok=false, error.code=6, + error.message contains "Not installed"). Clean stderr. + + Context 37 - -Locate -Name for unknown alias -Format json: + Exit 4, stdout is a JSON error envelope (ok=false, error.code=4, + error.message contains "Alias not found"). Clean stderr. + + Context 38 - -Locate default format (object), not installed: + Exit 6, no stdout, stderr present. #> Describe 'delphi-inspect.ps1 (subprocess)' { @@ -1227,4 +1257,196 @@ Describe 'delphi-inspect.ps1 (subprocess)' { } + # ---- -Locate integration tests ---- + + Context 'Given -Locate -Name VER150 (text mode, not installed on test machine)' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', '-Name', 'VER150', '-Format', 'text', '-DataFile', $script:resolveFixturePath) + } + + It 'exits with code 6 (version known but not installed)' { + $script:run.ExitCode | Should -Be 6 + } + + It 'produces no stdout' { + $script:run.StdOut | Should -BeNullOrEmpty + } + + It 'emits at least one stderr line containing "Not installed"' { + $script:run.StdErr | Should -Not -BeNullOrEmpty + ($script:run.StdErr -join "`n") | Should -Match 'Not installed' + } + + } + + Context 'Given -Locate VER150 (positional -Name, text mode)' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', 'VER150', '-Format', 'text', '-DataFile', $script:resolveFixturePath) + } + + It 'exits with code 6' { + $script:run.ExitCode | Should -Be 6 + } + + It 'produces no stdout' { + $script:run.StdOut | Should -BeNullOrEmpty + } + + } + + Context 'Given -Locate -Name ver150 (case-insensitive, text mode)' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', '-Name', 'ver150', '-Format', 'text', '-DataFile', $script:resolveFixturePath) + } + + It 'exits with code 6 (match found, not installed)' { + # exit 4 would mean the dataset lookup failed; exit 6 proves it matched VER150 + $script:run.ExitCode | Should -Be 6 + } + + } + + Context 'Given -Locate -Name for an alias not in the dataset' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', '-Name', 'DelphiX', '-DataFile', $script:resolveFixturePath) + } + + It 'exits with code 4' { + $script:run.ExitCode | Should -Be 4 + } + + It 'produces no stdout' { + $script:run.StdOut | Should -BeNullOrEmpty + } + + It 'emits at least one stderr line containing "Alias not found"' { + $script:run.StdErr | Should -Not -BeNullOrEmpty + ($script:run.StdErr -join "`n") | Should -Match 'Alias not found' + } + + } + + Context 'Given -Locate without -Name' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', '-DataFile', $script:resolveFixturePath) + } + + It 'exits with code 1 (PowerShell parameter binding failure)' { + $script:run.ExitCode | Should -Be 1 + } + + It 'produces no stdout' { + $script:run.StdOut | Should -BeNullOrEmpty + } + + It 'emits stderr referencing the mandatory Name parameter' { + $script:run.StdErr | Should -Not -BeNullOrEmpty + ($script:run.StdErr -join "`n") | Should -Match 'Name' + } + + } + + Context 'Given -Locate -Name VER150 -Format json (not installed)' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', '-Name', 'VER150', '-Format', 'json', '-DataFile', $script:resolveFixturePath) + $script:json = ($script:run.StdOut -join "`n") | ConvertFrom-Json + } + + It 'exits with code 6' { + $script:run.ExitCode | Should -Be 6 + } + + It 'stdout parses as valid JSON' { + { ($script:run.StdOut -join "`n") | ConvertFrom-Json } | Should -Not -Throw + } + + It 'JSON ok is false' { + $script:json.ok | Should -Be $false + } + + It 'JSON command is locate' { + $script:json.command | Should -Be 'locate' + } + + It 'JSON error.code is 6' { + $script:json.error.code | Should -Be 6 + } + + It 'JSON error.message contains "Not installed"' { + $script:json.error.message | Should -Match 'Not installed' + } + + It 'produces no stderr' { + $script:run.StdErr | Should -BeNullOrEmpty + } + + } + + Context 'Given -Locate -Name for unknown alias -Format json' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', '-Name', 'DelphiX', '-Format', 'json', '-DataFile', $script:resolveFixturePath) + $script:json = ($script:run.StdOut -join "`n") | ConvertFrom-Json + } + + It 'exits with code 4' { + $script:run.ExitCode | Should -Be 4 + } + + It 'stdout parses as valid JSON' { + { ($script:run.StdOut -join "`n") | ConvertFrom-Json } | Should -Not -Throw + } + + It 'JSON ok is false' { + $script:json.ok | Should -Be $false + } + + It 'JSON error.code is 4' { + $script:json.error.code | Should -Be 4 + } + + It 'JSON error.message contains "Alias not found"' { + $script:json.error.message | Should -Match 'Alias not found' + } + + It 'produces no stderr' { + $script:run.StdErr | Should -BeNullOrEmpty + } + + } + + Context 'Given -Locate default format (object), not installed' { + + BeforeAll { + $script:run = Invoke-ToolProcess -ScriptPath $script:scriptPath ` + -Arguments @('-Locate', 'VER150', '-DataFile', $script:resolveFixturePath) + } + + It 'exits with code 6' { + $script:run.ExitCode | Should -Be 6 + } + + It 'produces no stdout' { + $script:run.StdOut | Should -BeNullOrEmpty + } + + It 'emits stderr' { + $script:run.StdErr | Should -Not -BeNullOrEmpty + } + + } + }