diff --git a/TODO.md b/TODO.md index 2536c7b..c84c9c0 100644 --- a/TODO.md +++ b/TODO.md @@ -106,6 +106,18 @@ Reliability rule for runtime/mod tasks: evidence: manual `2026-02-28` `pwsh ./tools/lua-harness/run-lua-harness.ps1 -Strict` evidence: bundle `TestResults/runs/20260228-171028/repro-bundle.json` (`classification=blocked_environment`) evidence: bundle `TestResults/runs/20260228-171159/repro-bundle.json` (`classification=blocked_environment`) +- [x] Functional closure wave: deterministic native host bootstrap, promoted `set_unit_cap` enable->disable matrix semantics, helper forced-profile fallback diagnostics, and Codex-launched live process matrix (EAW + SWFOC tactical + AOTR + ROE) with strict bundle validation. + evidence: test `tests/SwfocTrainer.Tests/Runtime/ProcessLocatorForcedContextTests.cs` + evidence: test `tests/SwfocTrainer.Tests/Profiles/LivePromotedActionMatrixTests.cs` + evidence: test `tests/SwfocTrainer.Tests/Profiles/LiveHeroHelperWorkflowTests.cs` + evidence: manual `2026-02-28` `dotnet build SwfocTrainer.sln -c Release --no-restore` => `Build succeeded` + evidence: manual `2026-02-28` `dotnet test tests/SwfocTrainer.Tests/SwfocTrainer.Tests.csproj -c Release --no-build --filter "FullyQualifiedName!~SwfocTrainer.Tests.Profiles.Live&FullyQualifiedName!~RuntimeAttachSmokeTests"` => `Passed: 192` + evidence: manual `2026-02-28` `powershell.exe -File tools/validate-workshop-topmods.ps1 -Path tools/fixtures/workshop_topmods_sample.json -Strict` => `validation passed` + evidence: manual `2026-02-28` `powershell.exe -File tools/validate-generated-profile-seed.ps1 -Path tools/fixtures/generated_profile_seeds_sample.json -Strict` => `validation passed` + evidence: manual `2026-02-28` Session A EAW snapshot `TestResults/runs/LIVE-EAW-20260228-204540/eaw-process-snapshot.json` + evidence: bundle `TestResults/runs/LIVE-TACTICAL-20260228-211256/repro-bundle.json` + evidence: bundle `TestResults/runs/LIVE-AOTR-20260228-211521/repro-bundle.json` + evidence: bundle `TestResults/runs/LIVE-ROE-20260228-211757/repro-bundle.json` ## Later (M2 + M3 + M4) diff --git a/docs/LIVE_VALIDATION_RUNBOOK.md b/docs/LIVE_VALIDATION_RUNBOOK.md index 9daa482..bd250c7 100644 --- a/docs/LIVE_VALIDATION_RUNBOOK.md +++ b/docs/LIVE_VALIDATION_RUNBOOK.md @@ -5,8 +5,10 @@ Use this runbook to gather real-machine evidence for runtime/mod issues and mile ## 1. Preconditions - Launch the target game session first (`swfoc.exe` / `StarWarsG.exe`). -- Ensure extender bridge host is running for extender-routed credits checks: - - `SwfocExtender.Host` on pipe `SwfocExtenderBridge`. +- `tools/run-live-validation.ps1` preflights native host build and wires: + - `native/runtime/SwfocExtender.Host.exe` + - `SWFOC_EXTENDER_HOST_PATH` for every `dotnet test` subprocess. +- If running a live test command manually (outside run-live script), set `SWFOC_EXTENDER_HOST_PATH` explicitly to avoid nondeterministic host resolution. - Promoted actions are extender-authoritative and fail-closed: - no managed/memory fallback is accepted for promoted matrix evidence. - missing or unverified promoted capability must surface fail-closed diagnostics. @@ -166,6 +168,13 @@ Expected evidence behavior for promoted actions: - `hasFallbackMarker=false` - fail-closed outcomes use explicit route diagnostics (`SAFETY_FAIL_CLOSED`) and block issue `#7` closure. +`set_unit_cap` promoted matrix contract: + +- run as a deterministic two-step sequence per profile: + 1. enable (`enable=true`, snapshot/capture) + 2. disable (`enable=false`, restore) +- disable-first behavior is intentionally fail-closed and must not be asserted as pass in matrix evidence. + ## 5. Bundle Validation ```powershell @@ -202,8 +211,8 @@ Close issues only when all required evidence is present: Issue `#7` evidence decision gate: - `actionStatusDiagnostics.status` is `captured`. -- `actionStatusDiagnostics.summary.total=15`, `passed=15`, `failed=0`, `skipped=0`. -- every matrix entry for the five promoted actions across `base_swfoc`, `aotr_1397421866_swfoc`, and `roe_3447786229_swfoc` is present. +- `actionStatusDiagnostics.summary.total=18`, `passed=18`, `failed=0`, `skipped=0` for full-context closure runs. +- every matrix entry for the promoted actions across `base_swfoc`, `aotr_1397421866_swfoc`, and `roe_3447786229_swfoc` is present, including `set_unit_cap[1/2]` and `set_unit_cap[2/2]` per profile. - no matrix entry includes fallback markers or blocked diagnostics (`hasFallbackMarker=true`, `backendRoute` not `Extender`, or non-pass route/probe reason codes). - top-mod rows (when present) must never be silent omissions; each row must be explicit `Passed`, `Failed`, or `Skipped` with `skipReasonCode` when skipped. diff --git a/tests/SwfocTrainer.Tests/Profiles/LiveHeroHelperWorkflowTests.cs b/tests/SwfocTrainer.Tests/Profiles/LiveHeroHelperWorkflowTests.cs index 2f4245f..1036757 100644 --- a/tests/SwfocTrainer.Tests/Profiles/LiveHeroHelperWorkflowTests.cs +++ b/tests/SwfocTrainer.Tests/Profiles/LiveHeroHelperWorkflowTests.cs @@ -13,6 +13,12 @@ namespace SwfocTrainer.Tests.Profiles; public sealed class LiveHeroHelperWorkflowTests { + private static readonly HashSet ForcedHelperProfileIds = new(StringComparer.OrdinalIgnoreCase) + { + "aotr_1397421866_swfoc", + "roe_3447786229_swfoc" + }; + private readonly ITestOutputHelper _output; public LiveHeroHelperWorkflowTests(ITestOutputHelper output) @@ -40,6 +46,7 @@ public async Task Hero_Helper_Workflow_Should_Return_Action_Result_For_Aotr_Or_R var profiles = await ResolveProfilesAsync(profileRepo); var target = SelectHelperTargetContext(supported, profiles); + WriteHelperContextDiagnostics(supported, target); if (target is null) { @@ -114,12 +121,86 @@ private static void AssertHelperActionResult(ActionExecutionResult result) x.LaunchContext ?? new LaunchContextResolver().Resolve(x, profiles))) .ToList(); - return contexts + var selected = contexts .OrderByDescending(x => x.Context.Recommendation.ProfileId == "roe_3447786229_swfoc") .ThenByDescending(x => x.Context.Recommendation.ProfileId == "aotr_1397421866_swfoc") .FirstOrDefault(x => x.Context.Recommendation.ProfileId == "roe_3447786229_swfoc" || x.Context.Recommendation.ProfileId == "aotr_1397421866_swfoc"); + if (selected is not null) + { + return selected; + } + + var forcedProfileId = NormalizeProfileId(Environment.GetEnvironmentVariable("SWFOC_FORCE_PROFILE_ID")); + if (!IsSupportedForcedHelperProfile(forcedProfileId)) + { + return null; + } + + var forcedTarget = contexts + .OrderByDescending(x => x.Context.Source.Equals("forced", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(x => x.Process.ProcessName.Equals("StarWarsG", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(); + if (forcedTarget is null) + { + return null; + } + + var forcedContext = forcedTarget.Context with + { + Recommendation = new ProfileRecommendation( + forcedProfileId, + ReasonCode: "forced_profile_env_fallback", + Confidence: 1.0), + Source = "forced" + }; + return forcedTarget with { Context = forcedContext }; + } + + private void WriteHelperContextDiagnostics( + IReadOnlyList supported, + SupportedProcessContext? selected) + { + var forcedProfileId = NormalizeProfileId(Environment.GetEnvironmentVariable("SWFOC_FORCE_PROFILE_ID")); + var forcedWorkshopIds = Environment.GetEnvironmentVariable("SWFOC_FORCE_WORKSHOP_IDS") ?? string.Empty; + _output.WriteLine($"helper forced profile env={forcedProfileId ?? ""}"); + _output.WriteLine($"helper forced workshop env={forcedWorkshopIds}"); + + foreach (var process in supported.Where(x => x.ExeTarget == ExeTarget.Swfoc)) + { + var launchContext = process.LaunchContext; + var recommendation = launchContext?.Recommendation; + var steamIds = launchContext is null + ? "" + : string.Join(",", launchContext.SteamModIds); + _output.WriteLine( + $"helper candidate pid={process.ProcessId} name={process.ProcessName} path={process.ProcessPath} source={launchContext?.Source ?? ""} profile={recommendation?.ProfileId ?? ""} reason={recommendation?.ReasonCode ?? ""} steammods={steamIds}"); + } + + if (selected is null) + { + _output.WriteLine("helper selection: "); + return; + } + + _output.WriteLine( + $"helper selected pid={selected.Process.ProcessId} profile={selected.Context.Recommendation.ProfileId} reason={selected.Context.Recommendation.ReasonCode} source={selected.Context.Source}"); + } + + private static bool IsSupportedForcedHelperProfile(string? profileId) + { + if (string.IsNullOrWhiteSpace(profileId)) + { + return false; + } + + return ForcedHelperProfileIds.Contains(profileId); + } + + private static string? NormalizeProfileId(string? profileId) + { + return string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim(); } private static async Task> ResolveProfilesAsync(FileSystemProfileRepository profileRepo) diff --git a/tests/SwfocTrainer.Tests/Profiles/LivePromotedActionMatrixTests.cs b/tests/SwfocTrainer.Tests/Profiles/LivePromotedActionMatrixTests.cs index 854ae9f..9aae7b6 100644 --- a/tests/SwfocTrainer.Tests/Profiles/LivePromotedActionMatrixTests.cs +++ b/tests/SwfocTrainer.Tests/Profiles/LivePromotedActionMatrixTests.cs @@ -28,38 +28,58 @@ public sealed class LivePromotedActionMatrixTests [ new( "freeze_timer", - () => new JsonObject - { - ["symbol"] = "game_timer_freeze", - ["boolValue"] = false - }), + () => + [ + new JsonObject + { + ["symbol"] = "game_timer_freeze", + ["boolValue"] = false + } + ]), new( "toggle_fog_reveal", - () => new JsonObject - { - ["symbol"] = "fog_reveal", - ["boolValue"] = false - }), + () => + [ + new JsonObject + { + ["symbol"] = "fog_reveal", + ["boolValue"] = false + } + ]), new( "toggle_ai", - () => new JsonObject - { - ["symbol"] = "ai_enabled", - ["boolValue"] = true - }), + () => + [ + new JsonObject + { + ["symbol"] = "ai_enabled", + ["boolValue"] = true + } + ]), new( "set_unit_cap", - () => new JsonObject - { - ["intValue"] = 300, - ["enable"] = false - }), + () => + [ + new JsonObject + { + ["intValue"] = 300, + ["enable"] = true + }, + new JsonObject + { + ["intValue"] = 300, + ["enable"] = false + } + ]), new( "toggle_instant_build_patch", - () => new JsonObject - { - ["enable"] = false - }) + () => + [ + new JsonObject + { + ["enable"] = false + } + ]) ]; private readonly ITestOutputHelper _output; @@ -197,38 +217,49 @@ private async Task ExecuteAndAssertActionAsync( because: $"profile '{profileId}' must expose promoted action '{actionSpec.ActionId}'"); var action = profile.Actions[actionSpec.ActionId]; - var request = new ActionExecutionRequest(action, actionSpec.BuildPayload(), profileId, mode); - var result = await runtime.ExecuteAsync(request); - var backendRoute = ReadDiagnosticString(result.Diagnostics, "backendRoute"); - var routeReasonCode = ReadDiagnosticString(result.Diagnostics, "routeReasonCode"); - var capabilityProbeReasonCode = ReadDiagnosticString(result.Diagnostics, "capabilityProbeReasonCode"); - var hybridExecution = ReadDiagnosticBool(result.Diagnostics, "hybridExecution"); - var hasFallbackMarker = HasHybridFallbackMarker(result, backendRoute, routeReasonCode, result.Diagnostics); - - matrixEntries.Add(new ActionStatusEntry( - ProfileId: profileId, - ActionId: actionSpec.ActionId, - Outcome: result.Succeeded ? "Passed" : "Failed", - Message: result.Message, - BackendRoute: backendRoute, - RouteReasonCode: routeReasonCode, - CapabilityProbeReasonCode: capabilityProbeReasonCode, - HybridExecution: hybridExecution, - HasFallbackMarker: hasFallbackMarker, - SkipReasonCode: null)); - - _output.WriteLine( - $"matrix profile={profileId} action={actionSpec.ActionId} success={result.Succeeded} backend={backendRoute} route={routeReasonCode} cap={capabilityProbeReasonCode} hybrid={hybridExecution} fallbackMarker={hasFallbackMarker} msg={result.Message}"); - - AssertPromotedActionExecution( - result, - profileId, - actionSpec.ActionId, - backendRoute, - routeReasonCode, - capabilityProbeReasonCode, - hybridExecution, - hasFallbackMarker); + var payloads = actionSpec.BuildPayloads(); + payloads.Should().NotBeEmpty( + because: $"promoted action '{actionSpec.ActionId}' should include at least one payload step for profile '{profileId}'."); + + for (var index = 0; index < payloads.Count; index++) + { + var payload = payloads[index]; + var actionIdWithStep = payloads.Count > 1 + ? $"{actionSpec.ActionId}[{index + 1}/{payloads.Count}]" + : actionSpec.ActionId; + var request = new ActionExecutionRequest(action, payload, profileId, mode); + var result = await runtime.ExecuteAsync(request); + var backendRoute = ReadDiagnosticString(result.Diagnostics, "backendRoute"); + var routeReasonCode = ReadDiagnosticString(result.Diagnostics, "routeReasonCode"); + var capabilityProbeReasonCode = ReadDiagnosticString(result.Diagnostics, "capabilityProbeReasonCode"); + var hybridExecution = ReadDiagnosticBool(result.Diagnostics, "hybridExecution"); + var hasFallbackMarker = HasHybridFallbackMarker(result, backendRoute, routeReasonCode, result.Diagnostics); + + matrixEntries.Add(new ActionStatusEntry( + ProfileId: profileId, + ActionId: actionIdWithStep, + Outcome: result.Succeeded ? "Passed" : "Failed", + Message: result.Message, + BackendRoute: backendRoute, + RouteReasonCode: routeReasonCode, + CapabilityProbeReasonCode: capabilityProbeReasonCode, + HybridExecution: hybridExecution, + HasFallbackMarker: hasFallbackMarker, + SkipReasonCode: null)); + + _output.WriteLine( + $"matrix profile={profileId} action={actionIdWithStep} success={result.Succeeded} backend={backendRoute} route={routeReasonCode} cap={capabilityProbeReasonCode} hybrid={hybridExecution} fallbackMarker={hasFallbackMarker} msg={result.Message}"); + + AssertPromotedActionExecution( + result, + profileId, + actionIdWithStep, + backendRoute, + routeReasonCode, + capabilityProbeReasonCode, + hybridExecution, + hasFallbackMarker); + } } private bool TrySkipUnavailableProfileContext( @@ -432,7 +463,7 @@ private static bool TryGetLiveOutputDirectory(out string outputDir) return true; } - private sealed record PromotedActionSpec(string ActionId, Func BuildPayload); + private sealed record PromotedActionSpec(string ActionId, Func> BuildPayloads); private sealed record RuntimeDependencies( FileSystemProfileRepository ProfileRepository, diff --git a/tests/SwfocTrainer.Tests/Runtime/ProcessLocatorForcedContextTests.cs b/tests/SwfocTrainer.Tests/Runtime/ProcessLocatorForcedContextTests.cs new file mode 100644 index 0000000..f10955d --- /dev/null +++ b/tests/SwfocTrainer.Tests/Runtime/ProcessLocatorForcedContextTests.cs @@ -0,0 +1,108 @@ +using System.Reflection; +using FluentAssertions; +using SwfocTrainer.Core.Models; +using SwfocTrainer.Runtime.Services; +using Xunit; + +namespace SwfocTrainer.Tests.Runtime; + +public sealed class ProcessLocatorForcedContextTests +{ + [Fact] + public void ResolveForcedContext_Should_Return_Forced_When_NoMarkers_And_ForcedHintsPresent() + { + var resolution = InvokeResolveForcedContext( + commandLine: "StarWarsG.exe NOARTPROCESS IGNOREASSERTS", + modPathRaw: null, + detectedSteamModIds: Array.Empty(), + options: new ProcessLocatorOptions( + ForcedWorkshopIds: new[] { "3447786229", "1397421866" }, + ForcedProfileId: "roe_3447786229_swfoc")); + + ReadStringProperty(resolution, "Source").Should().Be("forced"); + ReadStringProperty(resolution, "ForcedWorkshopIdsCsv").Should().Be("1397421866,3447786229"); + ReadStringProperty(resolution, "ForcedProfileId").Should().Be("roe_3447786229_swfoc"); + ReadStringSequenceProperty(resolution, "EffectiveSteamModIds") + .Should() + .Equal("1397421866", "3447786229"); + } + + [Fact] + public void ResolveForcedContext_Should_Stay_Detected_When_ModMarkers_Are_Present() + { + var resolution = InvokeResolveForcedContext( + commandLine: "\"C:\\Game\\corruption\\StarWarsG.exe\" STEAMMOD=1397421866", + modPathRaw: null, + detectedSteamModIds: new[] { "1397421866" }, + options: new ProcessLocatorOptions( + ForcedWorkshopIds: new[] { "3447786229" }, + ForcedProfileId: "roe_3447786229_swfoc")); + + ReadStringProperty(resolution, "Source").Should().Be("detected"); + ReadStringProperty(resolution, "ForcedWorkshopIdsCsv").Should().BeEmpty(); + ReadStringProperty(resolution, "ForcedProfileId").Should().BeNull(); + ReadStringSequenceProperty(resolution, "EffectiveSteamModIds") + .Should() + .Equal("1397421866"); + } + + [Fact] + public void ResolveOptionsFromEnvironment_Should_Parse_Forced_Ids_And_Profile() + { + var previousWorkshopIds = Environment.GetEnvironmentVariable(ProcessLocator.ForceWorkshopIdsEnvVar); + var previousProfileId = Environment.GetEnvironmentVariable(ProcessLocator.ForceProfileIdEnvVar); + try + { + Environment.SetEnvironmentVariable(ProcessLocator.ForceWorkshopIdsEnvVar, "3447786229,1397421866,3447786229"); + Environment.SetEnvironmentVariable(ProcessLocator.ForceProfileIdEnvVar, " roe_3447786229_swfoc "); + + var options = InvokeResolveOptionsFromEnvironment(); + + options.ForcedWorkshopIds.Should().Equal("1397421866", "3447786229"); + options.ForcedProfileId.Should().Be("roe_3447786229_swfoc"); + } + finally + { + Environment.SetEnvironmentVariable(ProcessLocator.ForceWorkshopIdsEnvVar, previousWorkshopIds); + Environment.SetEnvironmentVariable(ProcessLocator.ForceProfileIdEnvVar, previousProfileId); + } + } + + private static object InvokeResolveForcedContext( + string? commandLine, + string? modPathRaw, + IReadOnlyList detectedSteamModIds, + ProcessLocatorOptions options) + { + var method = typeof(ProcessLocator).GetMethod("ResolveForcedContext", BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + var result = method!.Invoke(null, new object?[] { commandLine, modPathRaw, detectedSteamModIds, options }); + result.Should().NotBeNull(); + return result!; + } + + private static ProcessLocatorOptions InvokeResolveOptionsFromEnvironment() + { + var method = typeof(ProcessLocator).GetMethod("ResolveOptionsFromEnvironment", BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + var result = method!.Invoke(null, Array.Empty()); + result.Should().NotBeNull(); + return result!.Should().BeAssignableTo().Subject; + } + + private static string? ReadStringProperty(object instance, string propertyName) + { + var property = instance.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + property.Should().NotBeNull($"property '{propertyName}' should exist on forced context resolution payload."); + return property!.GetValue(instance) as string; + } + + private static IReadOnlyList ReadStringSequenceProperty(object instance, string propertyName) + { + var property = instance.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + property.Should().NotBeNull($"property '{propertyName}' should exist on forced context resolution payload."); + var value = property!.GetValue(instance); + value.Should().BeAssignableTo>(); + return value!.Should().BeAssignableTo>().Subject.ToArray(); + } +} diff --git a/tools/collect-mod-repro-bundle.ps1 b/tools/collect-mod-repro-bundle.ps1 index ba14315..fa05927 100644 --- a/tools/collect-mod-repro-bundle.ps1 +++ b/tools/collect-mod-repro-bundle.ps1 @@ -16,24 +16,211 @@ $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot function Resolve-PythonCommand { + $candidates = New-Object System.Collections.Generic.List[string[]] + $python = Get-Command python -ErrorAction SilentlyContinue if ($null -ne $python) { - return @($python.Source) + $candidates.Add(@($python.Source)) } $python3 = Get-Command python3 -ErrorAction SilentlyContinue if ($null -ne $python3) { - return @($python3.Source) + $candidates.Add(@($python3.Source)) } $py = Get-Command py -ErrorAction SilentlyContinue if ($null -ne $py) { - return @($py.Source, "-3") + $candidates.Add(@($py.Source, "-3")) + } + + $pathCandidates = @( + (Join-Path $env:SystemRoot "py.exe"), + (Join-Path $env:LocalAppData "Programs\\Python\\Python312\\python.exe"), + (Join-Path $env:LocalAppData "Programs\\Python\\Python311\\python.exe"), + (Join-Path $env:LocalAppData "Programs\\Python\\Python310\\python.exe"), + (Join-Path $env:ProgramFiles "Python312\\python.exe"), + (Join-Path $env:ProgramFiles "Python311\\python.exe"), + (Join-Path $env:ProgramFiles "Python310\\python.exe"), + (Join-Path ${env:ProgramFiles(x86)} "Python312\\python.exe"), + (Join-Path ${env:ProgramFiles(x86)} "Python311\\python.exe"), + (Join-Path ${env:ProgramFiles(x86)} "Python310\\python.exe"), + (Join-Path $env:LocalAppData "Microsoft\\WindowsApps\\python.exe"), + (Join-Path $env:LocalAppData "Microsoft\\WindowsApps\\python3.exe") + ) + + foreach ($candidate in $pathCandidates) { + if ([string]::IsNullOrWhiteSpace($candidate)) { + continue + } + + if (Test-Path -Path $candidate) { + if ($candidate.ToLowerInvariant().EndsWith("py.exe")) { + $candidates.Add(@($candidate, "-3")) + continue + } + + $candidates.Add(@($candidate)) + } + } + + $wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue + if ($null -eq $wsl) { + $wsl = Get-Command wsl -ErrorAction SilentlyContinue + } + if ($null -ne $wsl) { + $candidates.Add(@($wsl.Source, "-e", "python3")) + } + + foreach ($candidate in $candidates) { + if (Test-PythonInterpreter -Command $candidate) { + return $candidate + } + } + + if ($null -ne $wsl) { + return @($wsl.Source, "-e", "python3") } return @() } +function Test-PythonInterpreter { + param([string[]]$Command) + + if ($Command.Count -eq 0 -or [string]::IsNullOrWhiteSpace($Command[0])) { + return $false + } + + $probeArgs = @("-c", "print('ok')") + $captured = Invoke-CapturedCommand -Command $Command -Arguments $probeArgs + if ([int]$captured.ExitCode -ne 0) { + return $false + } + + $text = [string]$captured.Output + $text = $text.Trim() + return -not [string]::IsNullOrWhiteSpace($text) -and $text -match "(^|\r?\n)ok(\r?\n|$)" +} + +function Test-IsWslPythonCommand { + param([string[]]$Command) + + if ($Command.Count -eq 0 -or [string]::IsNullOrWhiteSpace($Command[0])) { + return $false + } + + $name = [System.IO.Path]::GetFileName($Command[0]) + return $name.Equals("wsl.exe", [System.StringComparison]::OrdinalIgnoreCase) ` + -or $name.Equals("wsl", [System.StringComparison]::OrdinalIgnoreCase) +} + +function Convert-ToWslPath { + param([Parameter(Mandatory = $true)][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $Path + } + + if ($Path.StartsWith("/", [System.StringComparison]::Ordinal)) { + return $Path + } + + $windowsPathMatch = [Regex]::Match($Path, '^(?[A-Za-z]):\\(?.*)$') + if ($windowsPathMatch.Success) { + $drive = $windowsPathMatch.Groups["drive"].Value.ToLowerInvariant() + $rest = ($windowsPathMatch.Groups["rest"].Value -replace "\\", "/") + if ([string]::IsNullOrWhiteSpace($rest)) { + return "/mnt/$drive" + } + + return "/mnt/$drive/$rest" + } + + return $Path +} + +function Invoke-CapturedCommand { + param( + [string[]]$Command, + [string[]]$Arguments = @() + ) + + if ($Command.Count -eq 0 -or [string]::IsNullOrWhiteSpace($Command[0])) { + return [PSCustomObject]@{ + ExitCode = 1 + Output = "" + } + } + + $invocationArgs = @() + if ($Command.Count -gt 1) { + $invocationArgs += $Command[1..($Command.Count - 1)] + } + $invocationArgs += $Arguments + + if (Test-IsWslPythonCommand -Command $Command) { + $processArgs = @() + foreach ($arg in $invocationArgs) { + $argText = [string]$arg + if ($argText.Contains('"')) { + $argText = $argText.Replace('"', '\"') + } + + if ($argText.Contains(" ") -or $argText.Contains("`t")) { + $argText = '"' + $argText + '"' + } + + $processArgs += $argText + } + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + try { + $proc = Start-Process ` + -FilePath $Command[0] ` + -ArgumentList $processArgs ` + -Wait ` + -NoNewWindow ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + $stdout = if (Test-Path -Path $stdoutPath) { Get-Content -Raw -Path $stdoutPath } else { "" } + $stderr = if (Test-Path -Path $stderrPath) { Get-Content -Raw -Path $stderrPath } else { "" } + $combined = $stdout + if (-not [string]::IsNullOrWhiteSpace($stderr)) { + if (-not [string]::IsNullOrWhiteSpace($combined)) { + $combined += [Environment]::NewLine + } + $combined += $stderr + } + + return [PSCustomObject]@{ + ExitCode = [int]$proc.ExitCode + Output = $combined + } + } + finally { + Remove-Item -Path $stdoutPath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $stderrPath -Force -ErrorAction SilentlyContinue + } + } + + try { + $output = & $Command[0] @invocationArgs 2>&1 + return [PSCustomObject]@{ + ExitCode = if (Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { [int]$global:LASTEXITCODE } else { 0 } + Output = ($output | Out-String) + } + } + catch { + return [PSCustomObject]@{ + ExitCode = 1 + Output = $_.Exception.Message + } + } +} + function ConvertTo-ForcedWorkshopIds { param([string[]]$RawIds) @@ -197,12 +384,26 @@ function Get-LaunchContext { if ($pythonCmd.Count -gt 1) { $commandArgs += $pythonCmd[1..($pythonCmd.Count - 1)] } + + $detectScriptPath = Join-Path $repoRoot "tools/detect-launch-context.py" + $profileRootArg = if ([System.IO.Path]::IsPathRooted($ProfileRootPath)) { + $ProfileRootPath + } + else { + Join-Path $repoRoot $ProfileRootPath + } + + if (Test-IsWslPythonCommand -Command $pythonCmd) { + $detectScriptPath = Convert-ToWslPath -Path $detectScriptPath + $profileRootArg = Convert-ToWslPath -Path $profileRootArg + } + $commandArgs += @( - "tools/detect-launch-context.py", - "--command-line", $Process.commandLine, - "--process-name", $Process.name, - "--process-path", $Process.path, - "--profile-root", $ProfileRootPath + $detectScriptPath, + "--command-line", ([string]$Process.commandLine), + "--process-name", ([string]$Process.name), + "--process-path", ([string]$Process.path), + "--profile-root", $profileRootArg ) $forcedWorkshopIdsCsv = if ($normalizedForcedWorkshopIds.Count -eq 0) { "" } else { $normalizedForcedWorkshopIds -join "," } @@ -217,12 +418,12 @@ function Get-LaunchContext { $commandArgs += "--pretty" try { - $output = & $pythonCmd[0] @commandArgs 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "detect-launch-context.py exited with $LASTEXITCODE" + $commandResult = Invoke-CapturedCommand -Command $pythonCmd -Arguments $commandArgs + if ([int]$commandResult.ExitCode -ne 0) { + throw "detect-launch-context.py exited with $($commandResult.ExitCode)" } - $raw = ($output | Out-String) + $raw = [string]$commandResult.Output if ([string]::IsNullOrWhiteSpace($raw)) { throw "detect-launch-context.py returned empty output" } diff --git a/tools/native/build-native.ps1 b/tools/native/build-native.ps1 index a9cc6ba..678994a 100644 --- a/tools/native/build-native.ps1 +++ b/tools/native/build-native.ps1 @@ -30,6 +30,68 @@ function Get-LastExitCodeOrZero { return 0 } +function Resolve-ProviderAbsolutePath { + param([Parameter(Mandatory = $true)][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Path cannot be empty." + } + + $candidate = if ([System.IO.Path]::IsPathRooted($Path)) { + $Path + } + else { + Join-Path (Get-Location) $Path + } + + return [System.IO.Path]::GetFullPath($candidate) +} + +function Resolve-HostArtifact { + param( + [Parameter(Mandatory = $true)][string]$OutDir, + [Parameter(Mandatory = $true)][string]$Config + ) + + $searchCount = 0 + $expectedArtifacts = @( + (Join-Path $OutDir "SwfocExtender.Bridge\\$Config\\SwfocExtender.Host.exe"), + (Join-Path $OutDir "SwfocExtender.Bridge\\SwfocExtender.Host.exe"), + (Join-Path $OutDir "SwfocExtender.Bridge\\x64\\$Config\\SwfocExtender.Host.exe"), + (Join-Path $OutDir "x64\\$Config\\SwfocExtender.Host.exe"), + (Join-Path $OutDir "$Config\\SwfocExtender.Host.exe"), + (Join-Path $OutDir "SwfocExtender.Host.exe") + ) + + foreach ($path in $expectedArtifacts) { + $searchCount += 1 + if (Test-Path -Path $path) { + return [PSCustomObject]@{ + Path = $path + SearchCount = $searchCount + Source = "expected_path" + } + } + } + + $recursiveHits = @(Get-ChildItem -Path $OutDir -Filter "SwfocExtender.Host.exe" -File -Recurse -ErrorAction SilentlyContinue) + $searchCount += $recursiveHits.Count + $recursiveArtifact = $recursiveHits | Select-Object -ExpandProperty FullName -First 1 + if (-not [string]::IsNullOrWhiteSpace($recursiveArtifact)) { + return [PSCustomObject]@{ + Path = $recursiveArtifact + SearchCount = $searchCount + Source = "recursive_scan" + } + } + + return [PSCustomObject]@{ + Path = $null + SearchCount = $searchCount + Source = "not_found" + } +} + function Resolve-WindowsGeneratorPlan { param( [string]$Configuration, @@ -81,15 +143,18 @@ function Invoke-WindowsBuild { throw "cmake --version failed for '$CmakePath'." } + $resolvedOutDir = Resolve-ProviderAbsolutePath -Path $OutDir $generatorPlan = Resolve-WindowsGeneratorPlan ` -Configuration $Config ` -RecommendedGenerator $RecommendedGenerator ` -VsInstancePath $VsInstancePath $expectedGenerator = [string]$generatorPlan.Name - $configureArgs = @("-S", "native", "-B", $OutDir) + $configureArgs = @("-S", "native", "-B", $resolvedOutDir) $configureArgs += @($generatorPlan.Args) Write-Output "Windows native configure generator: $expectedGenerator" + Write-Output "resolvedBuildDir: $resolvedOutDir" + Write-Output "target: SwfocExtender.Host" if (-not [string]::IsNullOrWhiteSpace($VsProductLineVersion)) { Write-Output "Visual Studio product line: $VsProductLineVersion" } @@ -97,15 +162,15 @@ function Invoke-WindowsBuild { Write-Output "Visual Studio instance: $VsInstancePath" } - $cachePath = Join-Path $OutDir "CMakeCache.txt" + $cachePath = Join-Path $resolvedOutDir "CMakeCache.txt" if (Test-Path -Path $cachePath) { $cacheLine = Select-String -Path $cachePath -Pattern '^CMAKE_GENERATOR:INTERNAL=(.+)$' | Select-Object -First 1 if ($null -ne $cacheLine) { $cachedGenerator = $cacheLine.Matches[0].Groups[1].Value if (-not [string]::Equals($cachedGenerator, $expectedGenerator, [System.StringComparison]::OrdinalIgnoreCase)) { - Write-Warning "CMake generator changed from '$cachedGenerator' to '$expectedGenerator'; clearing stale cache in '$OutDir'." + Write-Warning "CMake generator changed from '$cachedGenerator' to '$expectedGenerator'; clearing stale cache in '$resolvedOutDir'." Remove-Item -Path $cachePath -Force -ErrorAction SilentlyContinue - Remove-Item -Path (Join-Path $OutDir "CMakeFiles") -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $resolvedOutDir "CMakeFiles") -Recurse -Force -ErrorAction SilentlyContinue } } } @@ -115,7 +180,7 @@ function Invoke-WindowsBuild { throw "native configure failed for Windows mode." } - $buildArgs = @("--build", $OutDir, "--target", "SwfocExtender.Host") + $buildArgs = @("--build", $resolvedOutDir, "--target", "SwfocExtender.Host") if ([bool]$generatorPlan.UsesMultiConfig) { $buildArgs += @("--config", $Config) } @@ -125,27 +190,31 @@ function Invoke-WindowsBuild { throw "native build failed for Windows mode." } - $expectedArtifacts = @( - (Join-Path $OutDir "SwfocExtender.Bridge\\$Config\\SwfocExtender.Host.exe"), - (Join-Path $OutDir "SwfocExtender.Bridge\\SwfocExtender.Host.exe"), - (Join-Path $OutDir "SwfocExtender.Bridge\\x64\\$Config\\SwfocExtender.Host.exe"), - (Join-Path $OutDir "x64\\$Config\\SwfocExtender.Host.exe"), - (Join-Path $OutDir "$Config\\SwfocExtender.Host.exe"), - (Join-Path $OutDir "SwfocExtender.Host.exe") - ) + $artifactResolution = Resolve-HostArtifact -OutDir $resolvedOutDir -Config $Config + if ([string]::IsNullOrWhiteSpace($artifactResolution.Path)) { + Write-Warning "Host artifact not found on first scan; retrying target build with verbose output." + $fallbackBuildArgs = @("--build", $resolvedOutDir, "--target", "SwfocExtender.Host", "--verbose") + if ([bool]$generatorPlan.UsesMultiConfig) { + $fallbackBuildArgs += @("--config", $Config) + } + + & $CmakePath @fallbackBuildArgs + if ((Get-LastExitCodeOrZero) -ne 0) { + throw "native fallback build failed for Windows mode." + } - $artifact = $expectedArtifacts | Where-Object { Test-Path -Path $_ } | Select-Object -First 1 - if ($null -eq $artifact) { - # VS/CMake layout can vary across major versions; keep hard assertion but allow deterministic recursive discovery. - $artifact = Get-ChildItem -Path $OutDir -Filter "SwfocExtender.Host.exe" -File -Recurse -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty FullName -First 1 + $artifactResolution = Resolve-HostArtifact -OutDir $resolvedOutDir -Config $Config } - if ($null -eq $artifact) { - throw "native build completed but SwfocExtender.Host.exe artifact was not found under '$OutDir'." + $artifact = [string]$artifactResolution.Path + if ([string]::IsNullOrWhiteSpace($artifact)) { + throw "native build completed but SwfocExtender.Host.exe artifact was not found under '$resolvedOutDir'." } Write-Output "Windows host artifact: $artifact" + Write-Output "artifactSearchCount: $($artifactResolution.SearchCount)" + Write-Output "artifactResolvedPath: $artifact" + Write-Output "artifactResolutionSource: $($artifactResolution.Source)" $runtimeDir = Join-Path $repoRoot "native/runtime" if (-not (Test-Path -Path $runtimeDir)) { diff --git a/tools/run-live-validation.ps1 b/tools/run-live-validation.ps1 index 9863253..5193995 100644 --- a/tools/run-live-validation.ps1 +++ b/tools/run-live-validation.ps1 @@ -82,33 +82,104 @@ function Get-LastExitCodeOrZero { return 0 } -function Test-PythonInterpreter { - param([string[]]$Command) +function Invoke-CapturedCommand { + param( + [string[]]$Command, + [string[]]$Arguments = @() + ) if ($Command.Count -eq 0 -or [string]::IsNullOrWhiteSpace($Command[0])) { - return $false + return [PSCustomObject]@{ + ExitCode = 1 + Output = "" + } } - $probeArgs = @() + $invocationArgs = @() if ($Command.Count -gt 1) { - $probeArgs += $Command[1..($Command.Count - 1)] + $invocationArgs += $Command[1..($Command.Count - 1)] } + $invocationArgs += $Arguments - $probeArgs += @("-c", "print('ok')") + if (Test-IsWslPythonCommand -Command $Command) { + $processArgs = @() + foreach ($arg in $invocationArgs) { + $argText = [string]$arg + if ($argText.Contains('"')) { + $argText = $argText.Replace('"', '\"') + } - try { - $output = & $Command[0] @probeArgs 2>&1 - $exitCode = Get-LastExitCodeOrZero - if ($exitCode -ne 0) { - return $false + if ($argText.Contains(" ") -or $argText.Contains("`t")) { + $argText = '"' + $argText + '"' + } + + $processArgs += $argText + } + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + try { + $proc = Start-Process ` + -FilePath $Command[0] ` + -ArgumentList $processArgs ` + -Wait ` + -NoNewWindow ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + $stdout = if (Test-Path -Path $stdoutPath) { Get-Content -Raw -Path $stdoutPath } else { "" } + $stderr = if (Test-Path -Path $stderrPath) { Get-Content -Raw -Path $stderrPath } else { "" } + $combined = $stdout + if (-not [string]::IsNullOrWhiteSpace($stderr)) { + if (-not [string]::IsNullOrWhiteSpace($combined)) { + $combined += [Environment]::NewLine + } + $combined += $stderr + } + + return [PSCustomObject]@{ + ExitCode = [int]$proc.ExitCode + Output = $combined + } + } + finally { + Remove-Item -Path $stdoutPath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $stderrPath -Force -ErrorAction SilentlyContinue } + } - $text = ($output | Out-String).Trim() - return -not [string]::IsNullOrWhiteSpace($text) -and $text -match "(^|\r?\n)ok(\r?\n|$)" + try { + $output = & $Command[0] @invocationArgs 2>&1 + return [PSCustomObject]@{ + ExitCode = Get-LastExitCodeOrZero + Output = ($output | Out-String) + } } catch { + return [PSCustomObject]@{ + ExitCode = 1 + Output = $_.Exception.Message + } + } +} + +function Test-PythonInterpreter { + param([string[]]$Command) + + if ($Command.Count -eq 0 -or [string]::IsNullOrWhiteSpace($Command[0])) { return $false } + + $probeArgs = @("-c", "print('ok')") + $captured = Invoke-CapturedCommand -Command $Command -Arguments $probeArgs + if ([int]$captured.ExitCode -ne 0) { + return $false + } + + $text = [string]$captured.Output + $text = $text.Trim() + return -not [string]::IsNullOrWhiteSpace($text) -and $text -match "(^|\r?\n)ok(\r?\n|$)" } function Resolve-PythonCommand { @@ -129,6 +200,14 @@ function Resolve-PythonCommand { $candidates.Add(@($py.Source, "-3")) } + $wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue + if ($null -eq $wsl) { + $wsl = Get-Command wsl -ErrorAction SilentlyContinue + } + if ($null -ne $wsl) { + $candidates.Add(@($wsl.Source, "-e", "python3")) + } + $pathCandidates = @( (Join-Path $env:SystemRoot "py.exe"), (Join-Path $env:LocalAppData "Programs\\Python\\Python312\\python.exe"), @@ -163,9 +242,70 @@ function Resolve-PythonCommand { } } + if ($null -ne $wsl) { + return @($wsl.Source, "-e", "python3") + } + return @() } +function Test-IsWslPythonCommand { + param([string[]]$Command) + + if ($Command.Count -eq 0 -or [string]::IsNullOrWhiteSpace($Command[0])) { + return $false + } + + $name = [System.IO.Path]::GetFileName($Command[0]) + return $name.Equals("wsl.exe", [System.StringComparison]::OrdinalIgnoreCase) ` + -or $name.Equals("wsl", [System.StringComparison]::OrdinalIgnoreCase) +} + +function Convert-ToWslPath { + param([Parameter(Mandatory = $true)][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $Path + } + + if ($Path.StartsWith("/", [System.StringComparison]::Ordinal)) { + return $Path + } + + $windowsPathMatch = [Regex]::Match($Path, '^(?[A-Za-z]):\\(?.*)$') + if ($windowsPathMatch.Success) { + $drive = $windowsPathMatch.Groups["drive"].Value.ToLowerInvariant() + $rest = ($windowsPathMatch.Groups["rest"].Value -replace "\\", "/") + if ([string]::IsNullOrWhiteSpace($rest)) { + return "/mnt/$drive" + } + + return "/mnt/$drive/$rest" + } + + $wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue + if ($null -eq $wsl) { + $wsl = Get-Command wsl -ErrorAction SilentlyContinue + } + + if ($null -eq $wsl) { + throw "WSL command was requested for python fallback but wsl executable is not available." + } + + $converted = & $wsl.Source -e wslpath -a $Path 2>$null + $exitCode = Get-LastExitCodeOrZero + if ($exitCode -ne 0) { + throw "Failed to convert '$Path' into a WSL path using wslpath." + } + + $text = ($converted | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($text)) { + throw "WSL path conversion produced no output for '$Path'." + } + + return $text +} + function Test-NativeHostPreflight { param( [string]$Config, @@ -173,7 +313,7 @@ function Test-NativeHostPreflight { ) if (-not $Enabled) { - return + return "" } $buildScript = Join-Path $PSScriptRoot "native/build-native.ps1" @@ -185,16 +325,23 @@ function Test-NativeHostPreflight { # collisions when build-native.ps1 refreshes native/runtime/SwfocExtender.Host.exe. Get-Process -Name "SwfocExtender.Host" -ErrorAction SilentlyContinue | Stop-Process -Force - & $buildScript -Mode Windows -Configuration $Config + $buildOutput = & $buildScript -Mode Windows -Configuration $Config $buildExitCode = Get-LastExitCodeOrZero if ($buildExitCode -ne 0) { throw "ATTACH_NATIVE_HOST_PRECHECK_FAILED: native host build exited with code $buildExitCode." } + foreach ($line in @($buildOutput)) { + if (-not [string]::IsNullOrWhiteSpace([string]$line)) { + Write-Information ([string]$line) -InformationAction Continue + } + } $runtimeHostPath = Join-Path $repoRoot "native/runtime/SwfocExtender.Host.exe" if (-not (Test-Path -Path $runtimeHostPath)) { throw "ATTACH_NATIVE_HOST_MISSING: expected host artifact not found at '$runtimeHostPath'." } + + return (Resolve-ArtifactPath -Path $runtimeHostPath) } function Test-RunSelection { @@ -225,7 +372,8 @@ function Invoke-LiveTest { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$Filter, - [Parameter(Mandatory = $true)][string]$TrxName + [Parameter(Mandatory = $true)][string]$TrxName, + [string]$NativeHostPath = "" ) Write-Information "=== Running $Name ===" -InformationAction Continue @@ -247,6 +395,7 @@ function Invoke-LiveTest { $previousTestName = $env:SWFOC_LIVE_TEST_NAME $previousForcedWorkshopIds = $env:SWFOC_FORCE_WORKSHOP_IDS $previousForcedProfileId = $env:SWFOC_FORCE_PROFILE_ID + $previousNativeHostPath = $env:SWFOC_EXTENDER_HOST_PATH $env:SWFOC_LIVE_OUTPUT_DIR = $runResultsDirectory $env:SWFOC_LIVE_TEST_NAME = $Name if ([string]::IsNullOrWhiteSpace($forceWorkshopIdsCsv)) { @@ -263,6 +412,13 @@ function Invoke-LiveTest { $env:SWFOC_FORCE_PROFILE_ID = $forceProfileIdNormalized } + if ([string]::IsNullOrWhiteSpace($NativeHostPath)) { + Remove-Item Env:SWFOC_EXTENDER_HOST_PATH -ErrorAction SilentlyContinue + } + else { + $env:SWFOC_EXTENDER_HOST_PATH = $NativeHostPath + } + try { & $dotnetExe @dotnetArgs } @@ -294,6 +450,13 @@ function Invoke-LiveTest { else { $env:SWFOC_FORCE_PROFILE_ID = $previousForcedProfileId } + + if ($null -eq $previousNativeHostPath) { + Remove-Item Env:SWFOC_EXTENDER_HOST_PATH -ErrorAction SilentlyContinue + } + else { + $env:SWFOC_EXTENDER_HOST_PATH = $previousNativeHostPath + } } $exitCode = if (Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { [int]$global:LASTEXITCODE } else { 0 } @@ -388,7 +551,7 @@ $forceProfileIdNormalized = if ([string]::IsNullOrWhiteSpace($ForceProfileId)) { $dotnetExe = Resolve-DotnetCommand $pythonCmd = @(Resolve-PythonCommand) -Test-NativeHostPreflight -Config $Configuration -Enabled $PreflightNativeHost +$runtimeHostPath = Test-NativeHostPreflight -Config $Configuration -Enabled $PreflightNativeHost $runTimestamp = Get-Date $iso = $runTimestamp.ToString("yyyy-MM-dd HH:mm:ss zzz") $runStartedUtc = $runTimestamp.ToUniversalTime().ToString("o") @@ -468,7 +631,11 @@ foreach ($test in $testDefinitions) { } try { - $executedTrx = Invoke-LiveTest -Name $test.Name -Filter $test.Filter -TrxName ("{0}-{1}" -f $RunId, $test.TrxBase) + $executedTrx = Invoke-LiveTest ` + -Name $test.Name ` + -Filter $test.Filter ` + -TrxName ("{0}-{1}" -f $RunId, $test.TrxBase) ` + -NativeHostPath $runtimeHostPath $summary = Read-TrxSummary -TrxPath $executedTrx $summaries.Add([PSCustomObject]@{ Name = $test.TestName @@ -500,12 +667,9 @@ $summaryPath = Join-Path $runResultsDirectory "live-validation-summary.json" $summaries | ConvertTo-Json -Depth 6 | Set-Content -Path $summaryPath $launchContextJson = Join-Path $runResultsDirectory "launch-context-fixture.json" -$pythonArgs = @( - "tools/detect-launch-context.py", - "--from-process-json", "tools/fixtures/launch_context_cases.json", - "--profile-root", $ProfileRoot, - "--pretty" -) +$launchContextScriptPath = Resolve-ArtifactPath -Path "tools/detect-launch-context.py" +$launchContextFixturePath = Resolve-ArtifactPath -Path "tools/fixtures/launch_context_cases.json" +$launchContextProfileRoot = Resolve-ArtifactPath -Path $ProfileRoot if ($pythonCmd.Count -eq 0 -or $null -eq $pythonCmd[0]) { Write-Warning "Python was not found in this shell; skipping launch-context fixture generation." @@ -517,15 +681,28 @@ if ($pythonCmd.Count -eq 0 -or $null -eq $pythonCmd[0]) { } else { try { - $pythonInvocationArgs = @() - if ($pythonCmd.Count -gt 1) { - $pythonInvocationArgs += $pythonCmd[1..($pythonCmd.Count - 1)] + $pythonArgs = @() + if (Test-IsWslPythonCommand -Command $pythonCmd) { + $pythonArgs += @( + (Convert-ToWslPath -Path $launchContextScriptPath), + "--from-process-json", (Convert-ToWslPath -Path $launchContextFixturePath), + "--profile-root", (Convert-ToWslPath -Path $launchContextProfileRoot), + "--pretty" + ) + } + else { + $pythonArgs += @( + $launchContextScriptPath, + "--from-process-json", $launchContextFixturePath, + "--profile-root", $launchContextProfileRoot, + "--pretty" + ) } - $pythonInvocationArgs += $pythonArgs - $launchContextOutput = & $pythonCmd[0] @pythonInvocationArgs 2>&1 - $exitCode = if (Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { [int]$global:LASTEXITCODE } else { 0 } - $outputText = ($launchContextOutput | Out-String).Trim() + $launchContextResult = Invoke-CapturedCommand -Command $pythonCmd -Arguments $pythonArgs + $exitCode = [int]$launchContextResult.ExitCode + $outputText = [string]$launchContextResult.Output + $outputText = $outputText.Trim() if ($exitCode -ne 0) { throw ("python exited with code {0}. output: {1}" -f $exitCode, $outputText) @@ -535,7 +712,7 @@ else { throw ("python produced no output. executable: {0}" -f $pythonCmd[0]) } - $launchContextOutput | Set-Content -Path $launchContextJson + $outputText | Set-Content -Path $launchContextJson } catch { Write-Warning ("Launch-context fixture generation failed: {0}" -f $_.Exception.Message) @@ -606,6 +783,9 @@ Write-Output "" Write-Output "=== Live Validation Summary ($iso) ===" Write-Output "run id: $RunId" Write-Output "scope: $Scope" +if (-not [string]::IsNullOrWhiteSpace($runtimeHostPath)) { + Write-Output "native host path: $runtimeHostPath" +} foreach ($entry in $summaries) { $s = $entry.Summary Write-Output ("{0}: outcome={1} passed={2} failed={3} skipped={4} message='{5}'" -f $entry.Name, $s.Outcome, $s.Passed, $s.Failed, $s.Skipped, $s.Message)