From 7c5279b4b3300c0c632652558d9720d4f4327a84 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:39:55 -0700 Subject: [PATCH 01/46] Add production-backed compiled activation contract family Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ui.json | 18 ++++++ .../ValidationRunner/Ui/ActivationCases.cs | 58 +++++++++++++++++ .../Ui/Fixtures/UiaWindowActivationFixture.cs | 64 +++++++++++++++++++ .../Ui/Production/UiaWindowActivation.cs | 46 +++++++++++++ .../ValidationRunner/Ui/SuiteRegistration.cs | 45 +++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 Tools/windows/ValidationRunner/Manifests/ui.json create mode 100644 Tools/windows/ValidationRunner/Ui/ActivationCases.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs create mode 100644 Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json new file mode 100644 index 00000000..1d430de1 --- /dev/null +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -0,0 +1,18 @@ +{ + "units": [ + { "id": "ui.activation.foreground-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.activation.foreground-acquired", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.activation.ownership-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.activation.invalid-window", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.activation.source-boundary", "owner": "Ui", "minimumCount": 6, "requiredGroups": [] }, + { "id": "ui.input.batch", "owner": "Ui", "minimumCount": 21, "requiredGroups": [] }, + { "id": "ui.popup.predicate", "owner": "Ui", "minimumCount": 10, "requiredGroups": [] }, + { "id": "ui.popup.source", "owner": "Ui", "minimumCount": 9, "requiredGroups": [] }, + { "id": "ui.popup.effect", "owner": "Ui", "minimumCount": 11, "requiredGroups": [] }, + { "id": "ui.native.lookup", "owner": "Ui", "minimumCount": 14, "requiredGroups": [] }, + { "id": "ui.native.hit", "owner": "Ui", "minimumCount": 6, "requiredGroups": [] }, + { "id": "ui.geometry", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.host-info", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.gate-state", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] } + ] +} diff --git a/Tools/windows/ValidationRunner/Ui/ActivationCases.cs b/Tools/windows/ValidationRunner/Ui/ActivationCases.cs new file mode 100644 index 00000000..483bb356 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/ActivationCases.cs @@ -0,0 +1,58 @@ +using System; +using GraphCode.Validation.Ui.Fixtures; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui; + +public static class ActivationCases +{ + public static void ForegroundRefused() => ObserveAcquisition(false); + public static void ForegroundAcquired() => ObserveAcquisition(true); + + private static void ObserveAcquisition(bool acquire) + { + var probe = new UiaWindowActivationFixture(); + probe.Reset(acquire, true); + bool observed = probe.ActivateWindow(new IntPtr(10)); + Require(probe.GlobalKeys == 0 && probe.Attachments == 0, + "UIA_ACTIVATION_RED: actual activation emitted global key events or input-queue attachments"); + Require(observed == acquire, "UIA activation did not verify acquired foreground"); + string expectedCalls = "pid,owner,restore,bring,set,foreground" + (acquire ? ",pid,owner" : ""); + string expectedDiagnostic = acquire + ? "BringWindowToTop=True GetLastError=0 SetForegroundWindow=True GetLastError=0 observedOwnedForeground=True; no synthetic keys or input-queue attachment" + : "BringWindowToTop=False GetLastError=1400 SetForegroundWindow=False GetLastError=5 observedOwnedForeground=False; no synthetic keys or input-queue attachment"; + Require(probe.Calls == expectedCalls && probe.Mutations == 3 && + probe.OwnershipChecks == (acquire ? 2 : 1) && probe.LastActivationDiagnostic == expectedDiagnostic, + "UIA activation changed ownership/restore ordering or native return/error diagnostics"); + Require(typeof(IUiaWindowActivationOperations).Assembly == + probe.GetType().GetInterface(typeof(IUiaWindowActivationOperations).FullName!)!.Assembly, + "Activation fixture does not implement the current production component interface"); + } + + public static void OwnershipRefused() + { + var probe = new UiaWindowActivationFixture(); + probe.Reset(true, false); + Require(!probe.ActivateWindow(new IntPtr(10)) && probe.Mutations == 0 && + probe.Calls == "pid,owner" && probe.OwnershipChecks == 1 && + probe.GlobalKeys == 0 && probe.Attachments == 0 && + probe.LastActivationDiagnostic == "invalid target HWND or no retained live launch identity", + "UIA activation changed a window without retained process authority"); + } + + public static void InvalidWindowRefused() + { + var probe = new UiaWindowActivationFixture(); + probe.Reset(true, true); + Require(!probe.ActivateWindow(IntPtr.Zero) && probe.Mutations == 0 && + probe.Calls == "pid" && probe.OwnershipChecks == 0 && + probe.GlobalKeys == 0 && probe.Attachments == 0 && + probe.LastActivationDiagnostic == "invalid target HWND or no retained live launch identity", + "UIA activation accepted an invalid window"); + } + + internal static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs new file mode 100644 index 00000000..33cd51b7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui.Fixtures { + public sealed class UiaWindowActivationFixture : IUiaWindowActivationOperations { + public string LastActivationDiagnostic; + public int GlobalKeys, Attachments, Mutations, OwnershipChecks; + public bool Acquire, Owned; + public IntPtr Foreground; + private readonly List calls = new List(); + public string Calls { get { return String.Join(",", calls); } } + + public void Reset(bool acquire, bool owned) { + GlobalKeys = Attachments = Mutations = OwnershipChecks = 0; + Acquire = acquire; + Owned = owned; + Foreground = new IntPtr(99); + LastActivationDiagnostic = "not attempted"; + calls.Clear(); + } + + public bool ActivateWindow(IntPtr window) { + return UiaWindowActivation.ActivateWindow(this, window, out LastActivationDiagnostic); + } + + public uint WindowProcessId(IntPtr window) { + calls.Add("pid"); + return window == new IntPtr(10) ? 123u : window == IntPtr.Zero ? 0u : 999u; + } + + public bool IsRetainedProcessAlive(uint processId) { + calls.Add("owner"); + OwnershipChecks++; + return Owned && processId == 123; + } + + public void ShowWindow(IntPtr window, int command) { + if (window != new IntPtr(10) || command != 9) { + throw new InvalidOperationException("Activation did not restore the expected owned window"); + } + calls.Add("restore"); + Mutations++; + } + + public UiaActivationCallResult BringWindowToTop(IntPtr window) { + calls.Add("bring"); + Mutations++; + return new UiaActivationCallResult(Acquire, 1400); + } + + public UiaActivationCallResult SetForegroundWindow(IntPtr window) { + calls.Add("set"); + Mutations++; + if (Acquire) Foreground = window; + return new UiaActivationCallResult(Acquire, 5); + } + + public bool IsForegroundWindow(IntPtr window) { + calls.Add("foreground"); + return window != IntPtr.Zero && Foreground == window; + } + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs b/Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs new file mode 100644 index 00000000..24718782 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs @@ -0,0 +1,46 @@ +using System; + +namespace GraphCode.Windows { + public struct UiaActivationCallResult { + public readonly bool Succeeded; + public readonly int Error; + + public UiaActivationCallResult(bool succeeded, int error) { + Succeeded = succeeded; + Error = error; + } + } + + public interface IUiaWindowActivationOperations { + uint WindowProcessId(IntPtr window); + bool IsRetainedProcessAlive(uint processId); + void ShowWindow(IntPtr window, int command); + UiaActivationCallResult BringWindowToTop(IntPtr window); + UiaActivationCallResult SetForegroundWindow(IntPtr window); + bool IsForegroundWindow(IntPtr window); + } + + public static class UiaWindowActivation { + public static bool ActivateWindow( + IUiaWindowActivationOperations operations, IntPtr window, out string diagnostic + ) { + if (operations == null) throw new ArgumentNullException(nameof(operations)); + uint expectedProcessId = operations.WindowProcessId(window); + if (window == IntPtr.Zero || !operations.IsRetainedProcessAlive(expectedProcessId)) { + diagnostic = "invalid target HWND or no retained live launch identity"; + return false; + } + operations.ShowWindow(window, 9); + UiaActivationCallResult broughtToTop = operations.BringWindowToTop(window); + UiaActivationCallResult foregroundSet = operations.SetForegroundWindow(window); + bool observed = operations.IsForegroundWindow(window) && + operations.WindowProcessId(window) == expectedProcessId && + operations.IsRetainedProcessAlive(expectedProcessId); + diagnostic = String.Format( + "BringWindowToTop={0} GetLastError={1} SetForegroundWindow={2} GetLastError={3} observedOwnedForeground={4}; no synthetic keys or input-queue attachment", + broughtToTop.Succeeded, broughtToTop.Succeeded ? 0 : broughtToTop.Error, + foregroundSet.Succeeded, foregroundSet.Succeeded ? 0 : foregroundSet.Error, observed); + return observed; + } + } +} diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs new file mode 100644 index 00000000..c23148d2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ui; + +public static class SuiteRegistration +{ + public static IReadOnlyList CreateSuites() => new IValidationSuite[] { new ActivationSuite() }; +} + +public sealed class ActivationSuite : IValidationSuite +{ + public string Id => "ui.activation"; + public IReadOnlyList UnitIds { get; } = new[] + { + "ui.activation.foreground-refused", + "ui.activation.foreground-acquired", + "ui.activation.ownership-refused", + "ui.activation.invalid-window" + }; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + Action[] cases = { ActivationCases.ForegroundRefused, ActivationCases.ForegroundAcquired, + ActivationCases.OwnershipRefused, ActivationCases.InvalidWindowRefused }; + var results = new List(); + for (int i = 0; i < cases.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + cases[i](); + results.Add(new UnitResult(UnitIds[i], UnitStatus.Passed, 1, + "Actual shared UiaWindowActivation algorithm; typed test-only operations; nativeCalls=0")); + } + catch (InvalidOperationException failure) + { + results.Add(new UnitResult(UnitIds[i], UnitStatus.Failed, 1, failure.ToString())); + } + } + return Task.FromResult>(results); + } +} From a6fab309d69245558c4779dde9fe10b131d85928 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:41:21 -0700 Subject: [PATCH 02/46] Initialize activation fixture diagnostics under nullable checks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs index 33cd51b7..bdbe8216 100644 --- a/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/UiaWindowActivationFixture.cs @@ -4,7 +4,7 @@ namespace GraphCode.Validation.Ui.Fixtures { public sealed class UiaWindowActivationFixture : IUiaWindowActivationOperations { - public string LastActivationDiagnostic; + public string LastActivationDiagnostic = "not attempted"; public int GlobalKeys, Attachments, Mutations, OwnershipChecks; public bool Acquire, Owned; public IntPtr Foreground; From dfea333f70bee8eefccd7ada9c075814bb3bf7ba Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:41:51 -0700 Subject: [PATCH 03/46] Add compiled validation host with fail-closed coverage ledger Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/ValidationRunner/.gitignore | 2 + Tools/windows/ValidationRunner/Contracts.cs | 19 +++ .../windows/ValidationRunner/Host/Coverage.cs | 129 ++++++++++++++++++ .../ValidationRunner/Host/HostSelfTests.cs | 68 +++++++++ .../ValidationRunner/Manifests/host.json | 48 +++++++ Tools/windows/ValidationRunner/Program.cs | 69 ++++++++++ .../ValidationRunner/ValidationRunner.csproj | 14 ++ Tools/windows/ValidationRunner/global.json | 7 + 8 files changed, 356 insertions(+) create mode 100644 Tools/windows/ValidationRunner/.gitignore create mode 100644 Tools/windows/ValidationRunner/Contracts.cs create mode 100644 Tools/windows/ValidationRunner/Host/Coverage.cs create mode 100644 Tools/windows/ValidationRunner/Host/HostSelfTests.cs create mode 100644 Tools/windows/ValidationRunner/Manifests/host.json create mode 100644 Tools/windows/ValidationRunner/Program.cs create mode 100644 Tools/windows/ValidationRunner/ValidationRunner.csproj create mode 100644 Tools/windows/ValidationRunner/global.json diff --git a/Tools/windows/ValidationRunner/.gitignore b/Tools/windows/ValidationRunner/.gitignore new file mode 100644 index 00000000..59fc6761 --- /dev/null +++ b/Tools/windows/ValidationRunner/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/Tools/windows/ValidationRunner/Contracts.cs b/Tools/windows/ValidationRunner/Contracts.cs new file mode 100644 index 00000000..8aeaaff7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Contracts.cs @@ -0,0 +1,19 @@ +namespace GraphCode.Validation; + +public interface IValidationSuite +{ + string Id { get; } + IReadOnlyList UnitIds { get; } + Task> RunAsync(ValidationContext context, CancellationToken cancellationToken); +} + +public sealed record ValidationContext(string RepositoryRoot, string ArtifactRoot); + +public enum UnitStatus { Passed, Failed, Skipped, Incomplete } + +public sealed record UnitResult( + string Id, + UnitStatus Status, + int ExecutedCount, + string Evidence, + IReadOnlyList? Groups = null); diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs new file mode 100644 index 00000000..8d2ed4e3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GraphCode.Validation.Host; + +public sealed record RequiredUnit( + string Id, string Owner, int MinimumCount, IReadOnlyList? RequiredGroups = null, + int? ExactCount = null); + +public sealed record UnitManifest(IReadOnlyList Units); + +public sealed record CoverageReport( + string Runner, bool Complete, int RequiredCount, int PassedCount, + IReadOnlyList Units, IReadOnlyList Errors); + +public static class Coverage +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter() } + }; + + public static IReadOnlyList Load(string directory) + { + var units = new List(); + if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(directory); + foreach (var name in new[] { "host.json", "ui.json", "ownership.json" }) + { + var path = Path.Combine(directory, name); + if (!File.Exists(path)) + { + units.Add(new RequiredUnit($"missing-manifest.{name}", "Host", 1)); + continue; + } + var manifest = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Null required manifest: {path}"); + units.AddRange(manifest.Units); + } + return units; + } + + public static CoverageReport Evaluate( + IReadOnlyList required, + IReadOnlyList suites, + IReadOnlyList results, + IReadOnlyList? executionErrors = null) + { + var errors = new List(executionErrors ?? []); + var ledger = new List(); + var requirements = new Dictionary(StringComparer.Ordinal); + if (required.Count == 0) errors.Add("Required-unit manifest is empty"); + foreach (var unit in required) + { + if (string.IsNullOrWhiteSpace(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || + unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) + errors.Add($"Invalid required unit: {unit.Id}"); + if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); + if (unit.RequiredGroups is not null && + (unit.RequiredGroups.Any(string.IsNullOrWhiteSpace) || + unit.RequiredGroups.Distinct(StringComparer.Ordinal).Count() != unit.RequiredGroups.Count)) + errors.Add($"Invalid required groups: {unit.Id}"); + } + var executors = new Dictionary(StringComparer.Ordinal); + var suiteNames = new HashSet(StringComparer.Ordinal); + foreach (var suite in suites) + { + if (string.IsNullOrWhiteSpace(suite.Id) || !suiteNames.Add(suite.Id)) + errors.Add($"Invalid or duplicate suite ID: {suite.Id}"); + if (suite.UnitIds.Count == 0) errors.Add($"Suite has no units: {suite.Id}"); + foreach (var id in suite.UnitIds) + { + if (!requirements.ContainsKey(id)) errors.Add($"Undeclared executor unit: {id}"); + executors[id] = executors.GetValueOrDefault(id) + 1; + } + } + var observed = new Dictionary>(StringComparer.Ordinal); + foreach (var result in results) + { + if (!requirements.ContainsKey(result.Id)) errors.Add($"Undeclared result unit: {result.Id}"); + if (!observed.TryGetValue(result.Id, out var rows)) + { + rows = []; + observed.Add(result.Id, rows); + } + rows.Add(result); + } + foreach (var (id, unit) in requirements) + { + var count = executors.GetValueOrDefault(id); + if (count != 1) + { + var detail = count == 0 ? "Executor is not implemented" : $"Duplicate executors: {count}"; + errors.Add($"{id}: {detail}"); + ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); + continue; + } + if (!observed.TryGetValue(id, out var rows) || rows.Count != 1) + { + var detail = rows is null ? "Mandatory unit did not run" : $"Duplicate results: {rows.Count}"; + errors.Add($"{id}: {detail}"); + ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); + continue; + } + var result = rows[0]; + var problems = new List(); + if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); + if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); + if (unit.ExactCount is not null && result.ExecutedCount != unit.ExactCount) + problems.Add($"Executed {result.ExecutedCount}; expected exactly {unit.ExactCount}"); + if (string.IsNullOrWhiteSpace(result.Evidence)) problems.Add("No execution evidence"); + foreach (var group in unit.RequiredGroups ?? []) + if ((result.Groups ?? []).Count(value => string.Equals(value, group, StringComparison.Ordinal)) != 1) + problems.Add($"Required group did not pass exactly once: {group}"); + if (problems.Count != 0) + { + errors.Add($"{id}: {string.Join("; ", problems)}"); + ledger.Add(result with { Status = UnitStatus.Failed, Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" }); + } + else ledger.Add(result); + } + var passed = ledger.Count(result => result.Status == UnitStatus.Passed); + return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && + passed == required.Count, required.Count, passed, ledger, errors); + } +} diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs new file mode 100644 index 00000000..16e78248 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -0,0 +1,68 @@ +namespace GraphCode.Validation.Host; + +public static class HostSelfTests +{ + public static int Run() + { + var executed = 0; + var errors = new List(); + void Check(string id, Action body) + { + executed++; + try { body(); } + catch (Exception error) { errors.Add($"{id}: {error.Message}"); } + } + static void Assert(bool condition, string evidence) + { + if (!condition) throw new InvalidOperationException(evidence); + } + var unit = new RequiredUnit("test.case", "Host", 1); + var suite = new StaticSuite("test", ["test.case"]); + var passed = new UnitResult("test.case", UnitStatus.Passed, 1, "actual assertion ran"); + CoverageReport Evaluate(IReadOnlyList? units = null, + IReadOnlyList? registry = null, + IReadOnlyList? results = null) => + Coverage.Evaluate(units ?? [unit], registry ?? [suite], results ?? [passed]); + + Check("positive", () => Assert(Evaluate().Complete, "positive executed case rejected")); + Check("missing-result", () => Assert(!Evaluate(results: []).Complete, "missing result admitted")); + Check("duplicate-result", () => Assert(!Evaluate(results: [passed, passed]).Complete, "duplicate result admitted")); + Check("duplicate-required", () => Assert(!Evaluate(units: [unit, unit]).Complete, "duplicate manifest admitted")); + Check("missing-executor", () => Assert(!Evaluate(registry: []).Complete, "unimplemented unit admitted")); + Check("duplicate-executor", () => Assert(!Evaluate(registry: [suite, suite]).Complete, "duplicate executor admitted")); + Check("zero-run", () => Assert(!Evaluate(results: [passed with { ExecutedCount = 0 }]).Complete, "zero run admitted")); + Check("skipped", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Skipped }]).Complete, "skip admitted")); + Check("incomplete", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]).Complete, "incomplete admitted")); + Check("failed", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Failed }]).Complete, "failure admitted")); + Check("unknown-result", () => Assert(!Evaluate(results: [passed, passed with { Id = "foreign" }]).Complete, "unknown result admitted")); + Check("unknown-executor", () => Assert(!Evaluate(registry: [suite, new StaticSuite("foreign", ["foreign"])]).Complete, "unknown executor admitted")); + Check("empty-manifest", () => Assert(!Evaluate(units: [], registry: [], results: []).Complete, "empty run admitted")); + Check("no-evidence", () => Assert(!Evaluate(results: [passed with { Evidence = "" }]).Complete, "empty evidence admitted")); + Check("missing-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }]).Complete, "missing group admitted")); + Check("duplicate-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }], + results: [passed with { Groups = ["required", "required"] }]).Complete, "duplicate group admitted")); + Check("positive-group", () => Assert(Evaluate(units: [unit with { RequiredGroups = ["required"] }], + results: [passed with { Groups = ["required"] }]).Complete, "positive group rejected")); + Check("exact-count", () => Assert(!Evaluate(units: [unit with { ExactCount = 2 }]).Complete, "wrong exact count admitted")); + Check("minimum-count", () => Assert(!Evaluate(units: [unit with { MinimumCount = 2 }]).Complete, "insufficient count admitted")); + Check("invalid-minimum", () => Assert(!Evaluate(units: [unit with { MinimumCount = 0 }]).Complete, "zero minimum admitted")); + Check("execution-error", () => Assert(!Coverage.Evaluate([unit], [suite], [passed], ["child failed"]).Complete, "execution error admitted")); + Check("missing-manifests", () => + { + try { Coverage.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); } + catch (DirectoryNotFoundException) { return; } + throw new InvalidOperationException("missing manifests admitted"); + }); + foreach (var error in errors) Console.Error.WriteLine(error); + Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); + return errors.Count == 0 ? 0 : 1; + } + + private sealed class StaticSuite(string id, IReadOnlyList unitIds) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => unitIds; + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) => + throw new NotSupportedException("Coverage self-tests do not execute synthetic suites"); + } +} diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json new file mode 100644 index 00000000..18f7a696 --- /dev/null +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -0,0 +1,48 @@ +{ + "units": [ + { "id": "host.task-inventory", "owner": "Host", "minimumCount": 1 }, + { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 1 }, + { "id": "host.unknown-task", "owner": "Host", "minimumCount": 1 }, + { "id": "host.untracked-format", "owner": "Host", "minimumCount": 1 }, + { "id": "host.foreign-fixture", "owner": "Host", "minimumCount": 1 }, + { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 1 }, + { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-partition", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-build", "owner": "Host", "minimumCount": 1 }, + { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 1 }, + { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 1 }, + { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 1 }, + { "id": "host.missing-providers", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.pester-version", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.fixture-directory", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.ui-scenario", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.ui-capture", "owner": "Host", "minimumCount": 182, "exactCount": 182, + "requiredGroups": [ + "matched v12 fixture binds exact node type wiring reason and outcome fields", + "matched v12 fixture refuses identity type wiring selection reason and outcome mutations", + "matched v12 complete scene inventory is admitted and missing scenes refuse", + "matched S tab actual worker seed write preserves scene path and not-created cleanup", + "matched all eighteen scenes across both phases create write and clean isolated seed paths", + "matched tab observations preserve exact owned IDs parents selection and close bounds", + "real restart transcript requires daemon confirmation resume and new owned attach", + "real restart scene invokes existing UIA route and refuses offline peer authority", + "matched transparent proxy relays unchanged real pipe frames with connection identity", + "matched manifest seals scene inventory and refuses premature restart success", + "DPI matched geometry derives exact 96 and 192 physical frames", + "DPI matched geometry refuses insufficient work area and invalid scaling", + "DPI matched logical zoom normalizes physical UIA width", + "DPI matched manifest preserves raw logical physical metadata without resampling" + ] }, + { "id": "ps.action-worker", "owner": "Host", "minimumCount": 18, "exactCount": 18 }, + { "id": "ps.action-identity", "owner": "Host", "minimumCount": 62, "exactCount": 62 }, + { "id": "ps.action-topology", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, + { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 56, "exactCount": 56 }, + { "id": "ps.agent-command", "owner": "Host", "minimumCount": 6, "exactCount": 6 } + ] +} diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs new file mode 100644 index 00000000..3b939c8b --- /dev/null +++ b/Tools/windows/ValidationRunner/Program.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using System.Text.Json; +using GraphCode.Validation; +using GraphCode.Validation.Host; + +if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); + +string? repositoryRoot = null; +string? artifactRoot = null; +string? selectedSuite = null; +for (var index = 0; index < args.Length; index++) +{ + if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); + var value = args[++index]; + switch (args[index - 1]) + { + case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; + case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; + case "--suite": selectedSuite = value; break; + default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); + } +} +if (repositoryRoot is null || artifactRoot is null) + throw new ArgumentException("--repository-root and --artifact-root are required"); +if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) + throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); +Directory.CreateDirectory(artifactRoot); +var results = new List(); +var suites = new List(); +var errors = new List(); +IReadOnlyList required = []; +try +{ + required = Coverage.Load(Path.Combine(AppContext.BaseDirectory, "Manifests")); + foreach (var name in new[] { "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration" }) + { + var type = Assembly.GetExecutingAssembly().GetType(name); + if (type is null) continue; + var method = type.GetMethod("CreateSuites", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidDataException($"{name}.CreateSuites is missing"); + if (method.Invoke(null, null) is not IReadOnlyList registered) + throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); + suites.AddRange(registered); + } + if (selectedSuite is not null && suites.All(suite => suite.Id != selectedSuite)) + throw new ArgumentException($"No registered suite: {selectedSuite}"); + var context = new ValidationContext(repositoryRoot, artifactRoot); + foreach (var suite in suites.Where(suite => selectedSuite is null || suite.Id == selectedSuite)) + { + try { results.AddRange(await suite.RunAsync(context, CancellationToken.None)); } + catch (Exception error) + { + errors.Add($"{suite.Id}: {error}"); + break; + } + } +} +catch (Exception error) { errors.Add(error.ToString()); } +var report = Coverage.Evaluate(required, suites, results, errors); +var reportPath = Path.Combine(artifactRoot, "coverage.json"); +await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); +foreach (var unit in report.Units) + Console.WriteLine($"{unit.Status}: {unit.Id} executed={unit.ExecutedCount} {unit.Evidence}"); +foreach (var error in report.Errors) Console.Error.WriteLine(error); +Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} complete={report.Complete} report={reportPath}"); +if (!report.Complete) return 1; +Console.WriteLine("CompiledValidationRunner: PASS"); +Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); +return 0; diff --git a/Tools/windows/ValidationRunner/ValidationRunner.csproj b/Tools/windows/ValidationRunner/ValidationRunner.csproj new file mode 100644 index 00000000..ad5429b2 --- /dev/null +++ b/Tools/windows/ValidationRunner/ValidationRunner.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0-windows + true + enable + enable + true + false + + + + + diff --git a/Tools/windows/ValidationRunner/global.json b/Tools/windows/ValidationRunner/global.json new file mode 100644 index 00000000..76d91698 --- /dev/null +++ b/Tools/windows/ValidationRunner/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.403", + "rollForward": "disable", + "allowPrerelease": false + } +} From eae3d1ea5f940ed350d1a68ee9c45603bff38bfb Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:42:09 -0700 Subject: [PATCH 04/46] Normalize compiled host sources and isolate owned SDK caches Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/ValidationRunner/.gitignore | 5 +- Tools/windows/ValidationRunner/Contracts.cs | 38 +-- .../windows/ValidationRunner/Host/Coverage.cs | 258 +++++++++--------- .../ValidationRunner/Host/HostSelfTests.cs | 136 ++++----- .../ValidationRunner/Manifests/host.json | 96 +++---- Tools/windows/ValidationRunner/Program.cs | 138 +++++----- .../ValidationRunner/ValidationRunner.csproj | 28 +- Tools/windows/ValidationRunner/global.json | 14 +- 8 files changed, 357 insertions(+), 356 deletions(-) diff --git a/Tools/windows/ValidationRunner/.gitignore b/Tools/windows/ValidationRunner/.gitignore index 59fc6761..5ed3dd92 100644 --- a/Tools/windows/ValidationRunner/.gitignore +++ b/Tools/windows/ValidationRunner/.gitignore @@ -1,2 +1,3 @@ -bin/ -obj/ +bin/ +obj/ +.local/ diff --git a/Tools/windows/ValidationRunner/Contracts.cs b/Tools/windows/ValidationRunner/Contracts.cs index 8aeaaff7..747817e7 100644 --- a/Tools/windows/ValidationRunner/Contracts.cs +++ b/Tools/windows/ValidationRunner/Contracts.cs @@ -1,19 +1,19 @@ -namespace GraphCode.Validation; - -public interface IValidationSuite -{ - string Id { get; } - IReadOnlyList UnitIds { get; } - Task> RunAsync(ValidationContext context, CancellationToken cancellationToken); -} - -public sealed record ValidationContext(string RepositoryRoot, string ArtifactRoot); - -public enum UnitStatus { Passed, Failed, Skipped, Incomplete } - -public sealed record UnitResult( - string Id, - UnitStatus Status, - int ExecutedCount, - string Evidence, - IReadOnlyList? Groups = null); +namespace GraphCode.Validation; + +public interface IValidationSuite +{ + string Id { get; } + IReadOnlyList UnitIds { get; } + Task> RunAsync(ValidationContext context, CancellationToken cancellationToken); +} + +public sealed record ValidationContext(string RepositoryRoot, string ArtifactRoot); + +public enum UnitStatus { Passed, Failed, Skipped, Incomplete } + +public sealed record UnitResult( + string Id, + UnitStatus Status, + int ExecutedCount, + string Evidence, + IReadOnlyList? Groups = null); diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs index 8d2ed4e3..367dec7b 100644 --- a/Tools/windows/ValidationRunner/Host/Coverage.cs +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -1,129 +1,129 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace GraphCode.Validation.Host; - -public sealed record RequiredUnit( - string Id, string Owner, int MinimumCount, IReadOnlyList? RequiredGroups = null, - int? ExactCount = null); - -public sealed record UnitManifest(IReadOnlyList Units); - -public sealed record CoverageReport( - string Runner, bool Complete, int RequiredCount, int PassedCount, - IReadOnlyList Units, IReadOnlyList Errors); - -public static class Coverage -{ - public static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = false, - WriteIndented = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, - Converters = { new JsonStringEnumConverter() } - }; - - public static IReadOnlyList Load(string directory) - { - var units = new List(); - if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(directory); - foreach (var name in new[] { "host.json", "ui.json", "ownership.json" }) - { - var path = Path.Combine(directory, name); - if (!File.Exists(path)) - { - units.Add(new RequiredUnit($"missing-manifest.{name}", "Host", 1)); - continue; - } - var manifest = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) - ?? throw new InvalidDataException($"Null required manifest: {path}"); - units.AddRange(manifest.Units); - } - return units; - } - - public static CoverageReport Evaluate( - IReadOnlyList required, - IReadOnlyList suites, - IReadOnlyList results, - IReadOnlyList? executionErrors = null) - { - var errors = new List(executionErrors ?? []); - var ledger = new List(); - var requirements = new Dictionary(StringComparer.Ordinal); - if (required.Count == 0) errors.Add("Required-unit manifest is empty"); - foreach (var unit in required) - { - if (string.IsNullOrWhiteSpace(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || - unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) - errors.Add($"Invalid required unit: {unit.Id}"); - if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); - if (unit.RequiredGroups is not null && - (unit.RequiredGroups.Any(string.IsNullOrWhiteSpace) || - unit.RequiredGroups.Distinct(StringComparer.Ordinal).Count() != unit.RequiredGroups.Count)) - errors.Add($"Invalid required groups: {unit.Id}"); - } - var executors = new Dictionary(StringComparer.Ordinal); - var suiteNames = new HashSet(StringComparer.Ordinal); - foreach (var suite in suites) - { - if (string.IsNullOrWhiteSpace(suite.Id) || !suiteNames.Add(suite.Id)) - errors.Add($"Invalid or duplicate suite ID: {suite.Id}"); - if (suite.UnitIds.Count == 0) errors.Add($"Suite has no units: {suite.Id}"); - foreach (var id in suite.UnitIds) - { - if (!requirements.ContainsKey(id)) errors.Add($"Undeclared executor unit: {id}"); - executors[id] = executors.GetValueOrDefault(id) + 1; - } - } - var observed = new Dictionary>(StringComparer.Ordinal); - foreach (var result in results) - { - if (!requirements.ContainsKey(result.Id)) errors.Add($"Undeclared result unit: {result.Id}"); - if (!observed.TryGetValue(result.Id, out var rows)) - { - rows = []; - observed.Add(result.Id, rows); - } - rows.Add(result); - } - foreach (var (id, unit) in requirements) - { - var count = executors.GetValueOrDefault(id); - if (count != 1) - { - var detail = count == 0 ? "Executor is not implemented" : $"Duplicate executors: {count}"; - errors.Add($"{id}: {detail}"); - ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); - continue; - } - if (!observed.TryGetValue(id, out var rows) || rows.Count != 1) - { - var detail = rows is null ? "Mandatory unit did not run" : $"Duplicate results: {rows.Count}"; - errors.Add($"{id}: {detail}"); - ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); - continue; - } - var result = rows[0]; - var problems = new List(); - if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); - if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); - if (unit.ExactCount is not null && result.ExecutedCount != unit.ExactCount) - problems.Add($"Executed {result.ExecutedCount}; expected exactly {unit.ExactCount}"); - if (string.IsNullOrWhiteSpace(result.Evidence)) problems.Add("No execution evidence"); - foreach (var group in unit.RequiredGroups ?? []) - if ((result.Groups ?? []).Count(value => string.Equals(value, group, StringComparison.Ordinal)) != 1) - problems.Add($"Required group did not pass exactly once: {group}"); - if (problems.Count != 0) - { - errors.Add($"{id}: {string.Join("; ", problems)}"); - ledger.Add(result with { Status = UnitStatus.Failed, Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" }); - } - else ledger.Add(result); - } - var passed = ledger.Count(result => result.Status == UnitStatus.Passed); - return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && - passed == required.Count, required.Count, passed, ledger, errors); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GraphCode.Validation.Host; + +public sealed record RequiredUnit( + string Id, string Owner, int MinimumCount, IReadOnlyList? RequiredGroups = null, + int? ExactCount = null); + +public sealed record UnitManifest(IReadOnlyList Units); + +public sealed record CoverageReport( + string Runner, bool Complete, int RequiredCount, int PassedCount, + IReadOnlyList Units, IReadOnlyList Errors); + +public static class Coverage +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter() } + }; + + public static IReadOnlyList Load(string directory) + { + var units = new List(); + if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(directory); + foreach (var name in new[] { "host.json", "ui.json", "ownership.json" }) + { + var path = Path.Combine(directory, name); + if (!File.Exists(path)) + { + units.Add(new RequiredUnit($"missing-manifest.{name}", "Host", 1)); + continue; + } + var manifest = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Null required manifest: {path}"); + units.AddRange(manifest.Units); + } + return units; + } + + public static CoverageReport Evaluate( + IReadOnlyList required, + IReadOnlyList suites, + IReadOnlyList results, + IReadOnlyList? executionErrors = null) + { + var errors = new List(executionErrors ?? []); + var ledger = new List(); + var requirements = new Dictionary(StringComparer.Ordinal); + if (required.Count == 0) errors.Add("Required-unit manifest is empty"); + foreach (var unit in required) + { + if (string.IsNullOrWhiteSpace(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || + unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) + errors.Add($"Invalid required unit: {unit.Id}"); + if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); + if (unit.RequiredGroups is not null && + (unit.RequiredGroups.Any(string.IsNullOrWhiteSpace) || + unit.RequiredGroups.Distinct(StringComparer.Ordinal).Count() != unit.RequiredGroups.Count)) + errors.Add($"Invalid required groups: {unit.Id}"); + } + var executors = new Dictionary(StringComparer.Ordinal); + var suiteNames = new HashSet(StringComparer.Ordinal); + foreach (var suite in suites) + { + if (string.IsNullOrWhiteSpace(suite.Id) || !suiteNames.Add(suite.Id)) + errors.Add($"Invalid or duplicate suite ID: {suite.Id}"); + if (suite.UnitIds.Count == 0) errors.Add($"Suite has no units: {suite.Id}"); + foreach (var id in suite.UnitIds) + { + if (!requirements.ContainsKey(id)) errors.Add($"Undeclared executor unit: {id}"); + executors[id] = executors.GetValueOrDefault(id) + 1; + } + } + var observed = new Dictionary>(StringComparer.Ordinal); + foreach (var result in results) + { + if (!requirements.ContainsKey(result.Id)) errors.Add($"Undeclared result unit: {result.Id}"); + if (!observed.TryGetValue(result.Id, out var rows)) + { + rows = []; + observed.Add(result.Id, rows); + } + rows.Add(result); + } + foreach (var (id, unit) in requirements) + { + var count = executors.GetValueOrDefault(id); + if (count != 1) + { + var detail = count == 0 ? "Executor is not implemented" : $"Duplicate executors: {count}"; + errors.Add($"{id}: {detail}"); + ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); + continue; + } + if (!observed.TryGetValue(id, out var rows) || rows.Count != 1) + { + var detail = rows is null ? "Mandatory unit did not run" : $"Duplicate results: {rows.Count}"; + errors.Add($"{id}: {detail}"); + ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); + continue; + } + var result = rows[0]; + var problems = new List(); + if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); + if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); + if (unit.ExactCount is not null && result.ExecutedCount != unit.ExactCount) + problems.Add($"Executed {result.ExecutedCount}; expected exactly {unit.ExactCount}"); + if (string.IsNullOrWhiteSpace(result.Evidence)) problems.Add("No execution evidence"); + foreach (var group in unit.RequiredGroups ?? []) + if ((result.Groups ?? []).Count(value => string.Equals(value, group, StringComparison.Ordinal)) != 1) + problems.Add($"Required group did not pass exactly once: {group}"); + if (problems.Count != 0) + { + errors.Add($"{id}: {string.Join("; ", problems)}"); + ledger.Add(result with { Status = UnitStatus.Failed, Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" }); + } + else ledger.Add(result); + } + var passed = ledger.Count(result => result.Status == UnitStatus.Passed); + return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && + passed == required.Count, required.Count, passed, ledger, errors); + } +} diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index 16e78248..f08c7cbb 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -1,68 +1,68 @@ -namespace GraphCode.Validation.Host; - -public static class HostSelfTests -{ - public static int Run() - { - var executed = 0; - var errors = new List(); - void Check(string id, Action body) - { - executed++; - try { body(); } - catch (Exception error) { errors.Add($"{id}: {error.Message}"); } - } - static void Assert(bool condition, string evidence) - { - if (!condition) throw new InvalidOperationException(evidence); - } - var unit = new RequiredUnit("test.case", "Host", 1); - var suite = new StaticSuite("test", ["test.case"]); - var passed = new UnitResult("test.case", UnitStatus.Passed, 1, "actual assertion ran"); - CoverageReport Evaluate(IReadOnlyList? units = null, - IReadOnlyList? registry = null, - IReadOnlyList? results = null) => - Coverage.Evaluate(units ?? [unit], registry ?? [suite], results ?? [passed]); - - Check("positive", () => Assert(Evaluate().Complete, "positive executed case rejected")); - Check("missing-result", () => Assert(!Evaluate(results: []).Complete, "missing result admitted")); - Check("duplicate-result", () => Assert(!Evaluate(results: [passed, passed]).Complete, "duplicate result admitted")); - Check("duplicate-required", () => Assert(!Evaluate(units: [unit, unit]).Complete, "duplicate manifest admitted")); - Check("missing-executor", () => Assert(!Evaluate(registry: []).Complete, "unimplemented unit admitted")); - Check("duplicate-executor", () => Assert(!Evaluate(registry: [suite, suite]).Complete, "duplicate executor admitted")); - Check("zero-run", () => Assert(!Evaluate(results: [passed with { ExecutedCount = 0 }]).Complete, "zero run admitted")); - Check("skipped", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Skipped }]).Complete, "skip admitted")); - Check("incomplete", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]).Complete, "incomplete admitted")); - Check("failed", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Failed }]).Complete, "failure admitted")); - Check("unknown-result", () => Assert(!Evaluate(results: [passed, passed with { Id = "foreign" }]).Complete, "unknown result admitted")); - Check("unknown-executor", () => Assert(!Evaluate(registry: [suite, new StaticSuite("foreign", ["foreign"])]).Complete, "unknown executor admitted")); - Check("empty-manifest", () => Assert(!Evaluate(units: [], registry: [], results: []).Complete, "empty run admitted")); - Check("no-evidence", () => Assert(!Evaluate(results: [passed with { Evidence = "" }]).Complete, "empty evidence admitted")); - Check("missing-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }]).Complete, "missing group admitted")); - Check("duplicate-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }], - results: [passed with { Groups = ["required", "required"] }]).Complete, "duplicate group admitted")); - Check("positive-group", () => Assert(Evaluate(units: [unit with { RequiredGroups = ["required"] }], - results: [passed with { Groups = ["required"] }]).Complete, "positive group rejected")); - Check("exact-count", () => Assert(!Evaluate(units: [unit with { ExactCount = 2 }]).Complete, "wrong exact count admitted")); - Check("minimum-count", () => Assert(!Evaluate(units: [unit with { MinimumCount = 2 }]).Complete, "insufficient count admitted")); - Check("invalid-minimum", () => Assert(!Evaluate(units: [unit with { MinimumCount = 0 }]).Complete, "zero minimum admitted")); - Check("execution-error", () => Assert(!Coverage.Evaluate([unit], [suite], [passed], ["child failed"]).Complete, "execution error admitted")); - Check("missing-manifests", () => - { - try { Coverage.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); } - catch (DirectoryNotFoundException) { return; } - throw new InvalidOperationException("missing manifests admitted"); - }); - foreach (var error in errors) Console.Error.WriteLine(error); - Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); - return errors.Count == 0 ? 0 : 1; - } - - private sealed class StaticSuite(string id, IReadOnlyList unitIds) : IValidationSuite - { - public string Id => id; - public IReadOnlyList UnitIds => unitIds; - public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) => - throw new NotSupportedException("Coverage self-tests do not execute synthetic suites"); - } -} +namespace GraphCode.Validation.Host; + +public static class HostSelfTests +{ + public static int Run() + { + var executed = 0; + var errors = new List(); + void Check(string id, Action body) + { + executed++; + try { body(); } + catch (Exception error) { errors.Add($"{id}: {error.Message}"); } + } + static void Assert(bool condition, string evidence) + { + if (!condition) throw new InvalidOperationException(evidence); + } + var unit = new RequiredUnit("test.case", "Host", 1); + var suite = new StaticSuite("test", ["test.case"]); + var passed = new UnitResult("test.case", UnitStatus.Passed, 1, "actual assertion ran"); + CoverageReport Evaluate(IReadOnlyList? units = null, + IReadOnlyList? registry = null, + IReadOnlyList? results = null) => + Coverage.Evaluate(units ?? [unit], registry ?? [suite], results ?? [passed]); + + Check("positive", () => Assert(Evaluate().Complete, "positive executed case rejected")); + Check("missing-result", () => Assert(!Evaluate(results: []).Complete, "missing result admitted")); + Check("duplicate-result", () => Assert(!Evaluate(results: [passed, passed]).Complete, "duplicate result admitted")); + Check("duplicate-required", () => Assert(!Evaluate(units: [unit, unit]).Complete, "duplicate manifest admitted")); + Check("missing-executor", () => Assert(!Evaluate(registry: []).Complete, "unimplemented unit admitted")); + Check("duplicate-executor", () => Assert(!Evaluate(registry: [suite, suite]).Complete, "duplicate executor admitted")); + Check("zero-run", () => Assert(!Evaluate(results: [passed with { ExecutedCount = 0 }]).Complete, "zero run admitted")); + Check("skipped", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Skipped }]).Complete, "skip admitted")); + Check("incomplete", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]).Complete, "incomplete admitted")); + Check("failed", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Failed }]).Complete, "failure admitted")); + Check("unknown-result", () => Assert(!Evaluate(results: [passed, passed with { Id = "foreign" }]).Complete, "unknown result admitted")); + Check("unknown-executor", () => Assert(!Evaluate(registry: [suite, new StaticSuite("foreign", ["foreign"])]).Complete, "unknown executor admitted")); + Check("empty-manifest", () => Assert(!Evaluate(units: [], registry: [], results: []).Complete, "empty run admitted")); + Check("no-evidence", () => Assert(!Evaluate(results: [passed with { Evidence = "" }]).Complete, "empty evidence admitted")); + Check("missing-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }]).Complete, "missing group admitted")); + Check("duplicate-group", () => Assert(!Evaluate(units: [unit with { RequiredGroups = ["required"] }], + results: [passed with { Groups = ["required", "required"] }]).Complete, "duplicate group admitted")); + Check("positive-group", () => Assert(Evaluate(units: [unit with { RequiredGroups = ["required"] }], + results: [passed with { Groups = ["required"] }]).Complete, "positive group rejected")); + Check("exact-count", () => Assert(!Evaluate(units: [unit with { ExactCount = 2 }]).Complete, "wrong exact count admitted")); + Check("minimum-count", () => Assert(!Evaluate(units: [unit with { MinimumCount = 2 }]).Complete, "insufficient count admitted")); + Check("invalid-minimum", () => Assert(!Evaluate(units: [unit with { MinimumCount = 0 }]).Complete, "zero minimum admitted")); + Check("execution-error", () => Assert(!Coverage.Evaluate([unit], [suite], [passed], ["child failed"]).Complete, "execution error admitted")); + Check("missing-manifests", () => + { + try { Coverage.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); } + catch (DirectoryNotFoundException) { return; } + throw new InvalidOperationException("missing manifests admitted"); + }); + foreach (var error in errors) Console.Error.WriteLine(error); + Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); + return errors.Count == 0 ? 0 : 1; + } + + private sealed class StaticSuite(string id, IReadOnlyList unitIds) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => unitIds; + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) => + throw new NotSupportedException("Coverage self-tests do not execute synthetic suites"); + } +} diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 18f7a696..4eeef571 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -1,48 +1,48 @@ -{ - "units": [ - { "id": "host.task-inventory", "owner": "Host", "minimumCount": 1 }, - { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 1 }, - { "id": "host.unknown-task", "owner": "Host", "minimumCount": 1 }, - { "id": "host.untracked-format", "owner": "Host", "minimumCount": 1 }, - { "id": "host.foreign-fixture", "owner": "Host", "minimumCount": 1 }, - { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 1 }, - { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 1 }, - { "id": "host.ci-partition", "owner": "Host", "minimumCount": 1 }, - { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 1 }, - { "id": "host.ci-topology", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-build", "owner": "Host", "minimumCount": 1 }, - { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 1 }, - { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 1 }, - { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 1 }, - { "id": "host.missing-providers", "owner": "Host", "minimumCount": 1 }, - { "id": "ps.pester-version", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, - { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 1 }, - { "id": "ps.fixture-directory", "owner": "Host", "minimumCount": 1 }, - { "id": "ps.ui-scenario", "owner": "Host", "minimumCount": 1 }, - { "id": "ps.ui-capture", "owner": "Host", "minimumCount": 182, "exactCount": 182, - "requiredGroups": [ - "matched v12 fixture binds exact node type wiring reason and outcome fields", - "matched v12 fixture refuses identity type wiring selection reason and outcome mutations", - "matched v12 complete scene inventory is admitted and missing scenes refuse", - "matched S tab actual worker seed write preserves scene path and not-created cleanup", - "matched all eighteen scenes across both phases create write and clean isolated seed paths", - "matched tab observations preserve exact owned IDs parents selection and close bounds", - "real restart transcript requires daemon confirmation resume and new owned attach", - "real restart scene invokes existing UIA route and refuses offline peer authority", - "matched transparent proxy relays unchanged real pipe frames with connection identity", - "matched manifest seals scene inventory and refuses premature restart success", - "DPI matched geometry derives exact 96 and 192 physical frames", - "DPI matched geometry refuses insufficient work area and invalid scaling", - "DPI matched logical zoom normalizes physical UIA width", - "DPI matched manifest preserves raw logical physical metadata without resampling" - ] }, - { "id": "ps.action-worker", "owner": "Host", "minimumCount": 18, "exactCount": 18 }, - { "id": "ps.action-identity", "owner": "Host", "minimumCount": 62, "exactCount": 62 }, - { "id": "ps.action-topology", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, - { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 56, "exactCount": 56 }, - { "id": "ps.agent-command", "owner": "Host", "minimumCount": 6, "exactCount": 6 } - ] -} +{ + "units": [ + { "id": "host.task-inventory", "owner": "Host", "minimumCount": 1 }, + { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 1 }, + { "id": "host.unknown-task", "owner": "Host", "minimumCount": 1 }, + { "id": "host.untracked-format", "owner": "Host", "minimumCount": 1 }, + { "id": "host.foreign-fixture", "owner": "Host", "minimumCount": 1 }, + { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 1 }, + { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-partition", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-build", "owner": "Host", "minimumCount": 1 }, + { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 1 }, + { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 1 }, + { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 1 }, + { "id": "host.missing-providers", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.pester-version", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.fixture-directory", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.ui-scenario", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.ui-capture", "owner": "Host", "minimumCount": 182, "exactCount": 182, + "requiredGroups": [ + "matched v12 fixture binds exact node type wiring reason and outcome fields", + "matched v12 fixture refuses identity type wiring selection reason and outcome mutations", + "matched v12 complete scene inventory is admitted and missing scenes refuse", + "matched S tab actual worker seed write preserves scene path and not-created cleanup", + "matched all eighteen scenes across both phases create write and clean isolated seed paths", + "matched tab observations preserve exact owned IDs parents selection and close bounds", + "real restart transcript requires daemon confirmation resume and new owned attach", + "real restart scene invokes existing UIA route and refuses offline peer authority", + "matched transparent proxy relays unchanged real pipe frames with connection identity", + "matched manifest seals scene inventory and refuses premature restart success", + "DPI matched geometry derives exact 96 and 192 physical frames", + "DPI matched geometry refuses insufficient work area and invalid scaling", + "DPI matched logical zoom normalizes physical UIA width", + "DPI matched manifest preserves raw logical physical metadata without resampling" + ] }, + { "id": "ps.action-worker", "owner": "Host", "minimumCount": 18, "exactCount": 18 }, + { "id": "ps.action-identity", "owner": "Host", "minimumCount": 62, "exactCount": 62 }, + { "id": "ps.action-topology", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, + { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 56, "exactCount": 56 }, + { "id": "ps.agent-command", "owner": "Host", "minimumCount": 6, "exactCount": 6 } + ] +} diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index 3b939c8b..99ea1ad1 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -1,69 +1,69 @@ -using System.Reflection; -using System.Text.Json; -using GraphCode.Validation; -using GraphCode.Validation.Host; - -if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); - -string? repositoryRoot = null; -string? artifactRoot = null; -string? selectedSuite = null; -for (var index = 0; index < args.Length; index++) -{ - if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); - var value = args[++index]; - switch (args[index - 1]) - { - case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; - case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; - case "--suite": selectedSuite = value; break; - default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); - } -} -if (repositoryRoot is null || artifactRoot is null) - throw new ArgumentException("--repository-root and --artifact-root are required"); -if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) - throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); -Directory.CreateDirectory(artifactRoot); -var results = new List(); -var suites = new List(); -var errors = new List(); -IReadOnlyList required = []; -try -{ - required = Coverage.Load(Path.Combine(AppContext.BaseDirectory, "Manifests")); - foreach (var name in new[] { "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration" }) - { - var type = Assembly.GetExecutingAssembly().GetType(name); - if (type is null) continue; - var method = type.GetMethod("CreateSuites", BindingFlags.Public | BindingFlags.Static) - ?? throw new InvalidDataException($"{name}.CreateSuites is missing"); - if (method.Invoke(null, null) is not IReadOnlyList registered) - throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); - suites.AddRange(registered); - } - if (selectedSuite is not null && suites.All(suite => suite.Id != selectedSuite)) - throw new ArgumentException($"No registered suite: {selectedSuite}"); - var context = new ValidationContext(repositoryRoot, artifactRoot); - foreach (var suite in suites.Where(suite => selectedSuite is null || suite.Id == selectedSuite)) - { - try { results.AddRange(await suite.RunAsync(context, CancellationToken.None)); } - catch (Exception error) - { - errors.Add($"{suite.Id}: {error}"); - break; - } - } -} -catch (Exception error) { errors.Add(error.ToString()); } -var report = Coverage.Evaluate(required, suites, results, errors); -var reportPath = Path.Combine(artifactRoot, "coverage.json"); -await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); -foreach (var unit in report.Units) - Console.WriteLine($"{unit.Status}: {unit.Id} executed={unit.ExecutedCount} {unit.Evidence}"); -foreach (var error in report.Errors) Console.Error.WriteLine(error); -Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} complete={report.Complete} report={reportPath}"); -if (!report.Complete) return 1; -Console.WriteLine("CompiledValidationRunner: PASS"); -Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); -return 0; +using System.Reflection; +using System.Text.Json; +using GraphCode.Validation; +using GraphCode.Validation.Host; + +if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); + +string? repositoryRoot = null; +string? artifactRoot = null; +string? selectedSuite = null; +for (var index = 0; index < args.Length; index++) +{ + if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); + var value = args[++index]; + switch (args[index - 1]) + { + case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; + case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; + case "--suite": selectedSuite = value; break; + default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); + } +} +if (repositoryRoot is null || artifactRoot is null) + throw new ArgumentException("--repository-root and --artifact-root are required"); +if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) + throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); +Directory.CreateDirectory(artifactRoot); +var results = new List(); +var suites = new List(); +var errors = new List(); +IReadOnlyList required = []; +try +{ + required = Coverage.Load(Path.Combine(AppContext.BaseDirectory, "Manifests")); + foreach (var name in new[] { "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration" }) + { + var type = Assembly.GetExecutingAssembly().GetType(name); + if (type is null) continue; + var method = type.GetMethod("CreateSuites", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidDataException($"{name}.CreateSuites is missing"); + if (method.Invoke(null, null) is not IReadOnlyList registered) + throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); + suites.AddRange(registered); + } + if (selectedSuite is not null && suites.All(suite => suite.Id != selectedSuite)) + throw new ArgumentException($"No registered suite: {selectedSuite}"); + var context = new ValidationContext(repositoryRoot, artifactRoot); + foreach (var suite in suites.Where(suite => selectedSuite is null || suite.Id == selectedSuite)) + { + try { results.AddRange(await suite.RunAsync(context, CancellationToken.None)); } + catch (Exception error) + { + errors.Add($"{suite.Id}: {error}"); + break; + } + } +} +catch (Exception error) { errors.Add(error.ToString()); } +var report = Coverage.Evaluate(required, suites, results, errors); +var reportPath = Path.Combine(artifactRoot, "coverage.json"); +await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); +foreach (var unit in report.Units) + Console.WriteLine($"{unit.Status}: {unit.Id} executed={unit.ExecutedCount} {unit.Evidence}"); +foreach (var error in report.Errors) Console.Error.WriteLine(error); +Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} complete={report.Complete} report={reportPath}"); +if (!report.Complete) return 1; +Console.WriteLine("CompiledValidationRunner: PASS"); +Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); +return 0; diff --git a/Tools/windows/ValidationRunner/ValidationRunner.csproj b/Tools/windows/ValidationRunner/ValidationRunner.csproj index ad5429b2..bcbdd702 100644 --- a/Tools/windows/ValidationRunner/ValidationRunner.csproj +++ b/Tools/windows/ValidationRunner/ValidationRunner.csproj @@ -1,14 +1,14 @@ - - - Exe - net10.0-windows - true - enable - enable - true - false - - - - - + + + Exe + net10.0-windows + true + enable + enable + true + false + + + + + diff --git a/Tools/windows/ValidationRunner/global.json b/Tools/windows/ValidationRunner/global.json index 76d91698..794b4150 100644 --- a/Tools/windows/ValidationRunner/global.json +++ b/Tools/windows/ValidationRunner/global.json @@ -1,7 +1,7 @@ -{ - "sdk": { - "version": "10.0.403", - "rollForward": "disable", - "allowPrerelease": false - } -} +{ + "sdk": { + "version": "10.0.403", + "rollForward": "disable", + "allowPrerelease": false + } +} From 85658af0b430aea439bacfbac2ea2ac8fe0612ea Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:44:34 -0700 Subject: [PATCH 05/46] Integrate frozen Cards production dependencies by verified three-way merge Do not execute the legacy monolithic runner. Preserve source-only baseline dependencies for compiled contract migration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/OwnedLiveProcess.ps1 | 247 ++++ Tools/windows/ShellOwnedProcess.cs | 263 ++++ Tools/windows/ShellProcessOwnership.psm1 | 274 +++++ .../ValidationFixtureDirectory.Tests.ps1 | 319 +++++ .../Tests/ValidationFixtureDirectory.ps1 | 122 ++ .../Tests/ValidationIsolation.Tests.ps1 | 348 +++++- Tools/windows/Tests/WindowsShell.Tests.ps1 | 47 +- Tools/windows/UiaWindowActivation.cs | 46 + Tools/windows/uia-live-gate.ps1 | 1056 ++++++++++++----- Tools/windows/validate.ps1 | 147 ++- Tools/windows/windows-shell.ps1 | 232 ++-- 11 files changed, 2657 insertions(+), 444 deletions(-) create mode 100644 Tools/windows/OwnedLiveProcess.ps1 create mode 100644 Tools/windows/ShellOwnedProcess.cs create mode 100644 Tools/windows/ShellProcessOwnership.psm1 create mode 100644 Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 create mode 100644 Tools/windows/Tests/ValidationFixtureDirectory.ps1 create mode 100644 Tools/windows/UiaWindowActivation.cs diff --git a/Tools/windows/OwnedLiveProcess.ps1 b/Tools/windows/OwnedLiveProcess.ps1 new file mode 100644 index 00000000..d716322a --- /dev/null +++ b/Tools/windows/OwnedLiveProcess.ps1 @@ -0,0 +1,247 @@ +function Stop-OwnedLiveProcess { + [CmdletBinding()] + param( + [Parameter(Mandatory)][Diagnostics.Process] $Process, + [Parameter(Mandatory)][string] $Name, + [ValidateRange(1, 60000)][int] $TimeoutMilliseconds = 10000 + ) + + if ($Process.HasExited) { return } + try { + $Process.Kill() + } catch { + $killError = $_.Exception + try { + $exited = $Process.HasExited + } catch { + throw [AggregateException]::new( + "Could not stop or verify the owned $Name process.", + [Exception[]]@($killError, $_.Exception)) + } + if (-not $exited) { throw $killError } + } + if (-not $Process.WaitForExit($TimeoutMilliseconds)) { + throw [TimeoutException]::new( + "The owned $Name process did not exit within $TimeoutMilliseconds ms.") + } +} + +function Write-OwnedLiveText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][IO.TextWriter] $Writer, + [AllowEmptyString()][string] $Text, + [Parameter(Mandatory)][ref] $Pending, + [switch] $Final + ) + + $textToWrite = $Pending.Value + $Text + $Pending.Value = "" + if (-not $Final -and $textToWrite.Length -gt 0 -and + [char]::IsHighSurrogate($textToWrite[$textToWrite.Length - 1])) { + $Pending.Value = $textToWrite.Substring($textToWrite.Length - 1) + $textToWrite = $textToWrite.Substring(0, $textToWrite.Length - 1) + } + if ($textToWrite.Length -gt 0) { + $Writer.Write($textToWrite) + $Writer.Flush() + } +} + +function Write-OwnedLiveOutputChunk { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Scope, + [Parameter(Mandatory)][Diagnostics.Process] $Process, + [Parameter(Mandatory)][hashtable] $Pending + ) + + $maximumCharacters = 65536 + $chunk = Receive-ShellOwnedOutput $Scope $Process -MaximumCharacters $maximumCharacters + if ($null -eq $chunk -or $chunk.Output -isnot [string] -or + $chunk.Error -isnot [string] -or + $chunk.Output.Length -gt $maximumCharacters -or + $chunk.Error.Length -gt $maximumCharacters) { + throw "Owned incremental output returned an invalid or oversized chunk." + } + $outputPending = $Pending.Output + $errorPending = $Pending.Error + Write-OwnedLiveText -Writer ([Console]::Out) -Text $chunk.Output -Pending ([ref]$outputPending) + Write-OwnedLiveText -Writer ([Console]::Error) -Text $chunk.Error -Pending ([ref]$errorPending) + $Pending.Output = $outputPending + $Pending.Error = $errorPending + return ($chunk.Output.Length -gt 0 -or $chunk.Error.Length -gt 0) +} + +function Invoke-OwnedLiveCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Scope, + [Parameter(Mandatory)][Diagnostics.ProcessStartInfo] $StartInfo, + [switch] $StreamOutput + ) + + $StartInfo.RedirectStandardOutput = $true + $StartInfo.RedirectStandardError = $true + $process = Start-ShellOwnedProcess $Scope $StartInfo + if ($StreamOutput) { + $pending = @{ Output = ""; Error = "" } + do { + $received = Write-OwnedLiveOutputChunk -Scope $Scope -Process $process -Pending $pending + $wait = if ($received) { 0 } else { 100 } + $exited = $process.WaitForExit($wait) + } while (-not $exited) + } else { + [void]$process.WaitForExit() + } + Complete-ShellOwnedProcess $Scope $process + if ($StreamOutput) { + do { + $received = Write-OwnedLiveOutputChunk -Scope $Scope -Process $process -Pending $pending + } while ($received) + $outputPending = $pending.Output + $errorPending = $pending.Error + Write-OwnedLiveText -Writer ([Console]::Out) -Text "" -Pending ([ref]$outputPending) -Final + Write-OwnedLiveText -Writer ([Console]::Error) -Text "" -Pending ([ref]$errorPending) -Final + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = Read-ShellOwnedOutput $Scope $process + Error = Read-ShellOwnedOutput $Scope $process -StandardError + } +} + +function Get-OwnedLiveProcessRetainedRoots { + [CmdletBinding()] + param( + [Exception[]] $Errors = @(), + [string[]] $RetainedRoots = @() + ) + + $observed = [Collections.Generic.List[string]]::new() + $pathsSeen = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + $pending = [Collections.Generic.Stack[Exception]]::new() + $errorsSeen = [Collections.Generic.HashSet[Exception]]::new() + foreach ($root in $RetainedRoots) { + if ($root -and [IO.Directory]::Exists($root) -and $pathsSeen.Add($root)) { + $observed.Add($root) + } + } + foreach ($error in $Errors) { + if ($error) { $pending.Push($error) } + } + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if (-not $errorsSeen.Add($current)) { continue } + if ($current.Data.Contains("GraphCodeValidationRetainedRoots")) { + foreach ($root in @($current.Data["GraphCodeValidationRetainedRoots"])) { + if ($root -is [string] -and $root -and [IO.Directory]::Exists($root) -and + $pathsSeen.Add($root)) { + $observed.Add($root) + } + } + } + if ($current -is [AggregateException]) { + foreach ($inner in $current.InnerExceptions) { $pending.Push($inner) } + } elseif ($current.InnerException) { + $pending.Push($current.InnerException) + } + } + return $observed.ToArray() +} + +function New-OwnedLiveProcessCleanupError { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $Message, + [Parameter(Mandatory)][Exception[]] $Errors, + [string[]] $RetainedRoots = @() + ) + + $allErrors = [Collections.Generic.List[Exception]]::new() + foreach ($error in $Errors) { $allErrors.Add($error) } + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + try { + if (-not $environment.Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "GRAPHCODE_VALIDATION_ROOT is absent; a retention receipt cannot be written." + } + $root = [string]$environment["GRAPHCODE_VALIDATION_ROOT"] + if ([string]::IsNullOrWhiteSpace($root) -or + -not [IO.Path]::IsPathFullyQualified($root)) { + throw "GRAPHCODE_VALIDATION_ROOT is not a fully qualified path." + } + $directory = [IO.DirectoryInfo]::new($root) + while ($directory) { + if (-not $directory.Exists -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Validation root or an ancestor is missing or is a reparse point: $($directory.FullName)" + } + $directory = $directory.Parent + } + $receiptPath = Join-Path $root "ownership-unproven.json" + $receiptExists = $true + try { + [void][IO.File]::GetAttributes($receiptPath) + } catch [IO.FileNotFoundException] { + $receiptExists = $false + } + if ($receiptExists) { throw "Ownership receipt already exists: $receiptPath" } + $receipt = [ordered]@{ + schemaVersion = 1 + validationRoot = $root + reason = "ownership_unproven" + } | ConvertTo-Json -Compress + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($receipt) + if ($bytes.Length -gt 4096) { throw "Ownership receipt exceeds 4096 bytes." } + $stream = [IO.File]::Open( + $receiptPath, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + } catch { + $allErrors.Add($_.Exception) + try { + [Console]::Out.WriteLine( + "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") + [Console]::Out.Flush() + } catch { + $allErrors.Add($_.Exception) + } + } + + $result = [AggregateException]::new($Message, [Exception[]]$allErrors.ToArray()) + $result.Data["GraphCodeValidationRootRetentionRequired"] = $true + $result.Data["GraphCodeValidationRetainedRoots"] = [string[]]@( + Get-OwnedLiveProcessRetainedRoots -Errors $Errors -RetainedRoots $RetainedRoots) + return ,$result +} + +function Test-OwnedLiveProcessRetentionRequired { + [CmdletBinding()] + param([Parameter(Mandatory)][Exception] $Exception) + + $pending = [Collections.Generic.Stack[Exception]]::new() + $seen = [Collections.Generic.HashSet[Exception]]::new() + $pending.Push($Exception) + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if (-not $seen.Add($current)) { continue } + if ($current.Data.Contains("GraphCodeValidationRootRetentionRequired") -and + $current.Data["GraphCodeValidationRootRetentionRequired"] -is [bool] -and + $current.Data["GraphCodeValidationRootRetentionRequired"]) { + return $true + } + if ($current -is [AggregateException]) { + foreach ($inner in $current.InnerExceptions) { $pending.Push($inner) } + } elseif ($current.InnerException) { + $pending.Push($current.InnerException) + } + } + return $false +} diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs new file mode 100644 index 00000000..333a5e7f --- /dev/null +++ b/Tools/windows/ShellOwnedProcess.cs @@ -0,0 +1,263 @@ +public sealed class ShellOwnedLaunch { + [StructLayout(LayoutKind.Sequential)] + struct Security { public int Length; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct Startup { + public int Size; + public string Reserved, Desktop, Title; + public int X, Y, Width, Height, XChars, YChars, Fill, Flags; + public short Show, ReservedSize; + public IntPtr ReservedBytes, Input, Output, Error, Attributes; + } + [StructLayout(LayoutKind.Sequential)] + struct ProcessInfo { public IntPtr Process, Thread; public int Pid, Tid; } + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref Security security, int size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern IntPtr CreateFileW(string name, uint access, uint share, ref Security security, + uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, + IntPtr value, IntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] + static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern bool CreateProcessW(string file, StringBuilder command, IntPtr processSecurity, + IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string directory, + ref Startup startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetProcessTimes(IntPtr process, out long created, out long exited, + out long kernel, out long user); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr handle); + + public Process Process { get; private set; } + public IntPtr Handle { get; private set; } + public Task Output { get; private set; } + public Task Error { get; private set; } + IntPtr thread; + StreamReader outputReader, errorReader; + OutputCapture outputCapture, errorCapture; + + sealed class OutputCapture { + readonly TextReader reader; + readonly TextWriter forward; + readonly object gate = new object(); + StringBuilder captured = new StringBuilder(); + int forwarded; + public Task Completion { get; private set; } + public OutputCapture(TextReader reader) : this(reader, null) {} + public OutputCapture(TextReader reader, TextWriter forward) { + this.reader = reader; + this.forward = forward; + Completion = Pump(); + } + async Task Pump() { + var buffer = new char[4096]; + char? pendingHigh = null; + int count; + while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) != 0) { + lock (gate) captured.Append(buffer, 0, count); + if (forward != null) { + char[] chunk = buffer; + int length = count; + if (pendingHigh.HasValue) { + chunk = new char[count + 1]; + chunk[0] = pendingHigh.Value; + Array.Copy(buffer, 0, chunk, 1, count); + length++; + pendingHigh = null; + } + if (Char.IsHighSurrogate(chunk[length - 1])) { + pendingHigh = chunk[length - 1]; + length--; + } + if (length > 0) { + forward.Write(chunk, 0, length); + forward.Flush(); + } + } + } + if (forward != null && pendingHigh.HasValue) { + forward.Write(new[] { pendingHigh.Value }, 0, 1); + forward.Flush(); + } + lock (gate) return captured.ToString(); + } + public string Take(int maximumCharacters) { + if (maximumCharacters < 2 || maximumCharacters > 65536) + throw new ArgumentOutOfRangeException(nameof(maximumCharacters)); + if (Completion.IsFaulted || Completion.IsCanceled) Completion.GetAwaiter().GetResult(); + lock (gate) { + if (captured == null) throw new ObjectDisposedException(nameof(OutputCapture)); + int count = Math.Min(maximumCharacters, captured.Length - forwarded); + if (count > 0 && Char.IsHighSurrogate(captured[forwarded + count - 1])) count--; + string result = captured.ToString(forwarded, count); + forwarded += count; + return result; + } + } + public void Release() { lock (gate) captured = null; } + } + + static TextWriter ForwardWriter(bool redirected, TextWriter writer) { + if (!redirected && writer == null) throw new ArgumentNullException(nameof(writer)); + return redirected ? null : writer; + } + + static string Quote(string value) { + var result = new StringBuilder("\""); + int slashes = 0; + foreach (char c in value) { + if (c == '\\') { slashes++; continue; } + result.Append('\\', c == '"' ? slashes * 2 + 1 : slashes); + result.Append(c); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { + if (info.UseShellExecute || !String.IsNullOrEmpty(info.Arguments)) + throw new InvalidOperationException("Owned launch requires UseShellExecute=false and ArgumentList."); + var security = new Security { Length = Marshal.SizeOf(), Inherit = 1 }; + IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero, errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + IntPtr input = IntPtr.Zero, attributes = IntPtr.Zero, handles = IntPtr.Zero, environment = IntPtr.Zero; + bool initialized = false; + var run = new ShellOwnedLaunch(); + try { + if (!CreatePipe(out outRead, out outWrite, ref security, 0) || + !CreatePipe(out errRead, out errWrite, ref security, 0) || + !SetHandleInformation(outRead, 1, 0) || !SetHandleInformation(errRead, 1, 0)) + throw new Win32Exception(); + input = CreateFileW("NUL", 0x80000000, 3, ref security, 3, 0, IntPtr.Zero); + if (input == new IntPtr(-1)) throw new Win32Exception(); + IntPtr size = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size); + attributes = Marshal.AllocHGlobal(size); + if (!InitializeProcThreadAttributeList(attributes, 1, 0, ref size)) throw new Win32Exception(); + initialized = true; + handles = Marshal.AllocHGlobal(IntPtr.Size * 3); + Marshal.WriteIntPtr(handles, 0, input); + Marshal.WriteIntPtr(handles, IntPtr.Size, outWrite); + Marshal.WriteIntPtr(handles, IntPtr.Size * 2, errWrite); + if (!UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x20002), handles, + new IntPtr(IntPtr.Size * 3), IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(); + var block = new StringBuilder(); + foreach (var pair in info.Environment.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + block.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); + block.Append('\0'); + environment = Marshal.StringToHGlobalUni(block.ToString()); + var command = new StringBuilder(Quote(info.FileName)); + foreach (string argument in info.ArgumentList) command.Append(' ').Append(Quote(argument)); + var startup = new Startup { + Size = Marshal.SizeOf(), Flags = 0x101, Input = input, + Show = (short)(info.WindowStyle == ProcessWindowStyle.Hidden ? 0 : + info.WindowStyle == ProcessWindowStyle.Minimized ? 2 : + info.WindowStyle == ProcessWindowStyle.Maximized ? 3 : 1), + Output = outWrite, Error = errWrite, Attributes = attributes + }; + ProcessInfo created; + uint flags = 0x4 | 0x400 | 0x80000; // suspended, Unicode environment, explicit handle list + if (info.CreateNoWindow) flags |= 0x08000000; + if (!CreateProcessW(info.FileName, command, IntPtr.Zero, IntPtr.Zero, true, flags, + environment, String.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory, ref startup, out created)) throw new Win32Exception(); + run.Handle = created.Process; + run.thread = created.Thread; + run.Process = Process.GetProcessById(created.Pid); + // Retain both handles before releasing the suspended target. + var retained = run.Process.Handle; + long nativeCreated, managedCreated, ignoredExit, ignoredKernel, ignoredUser; + if (!GetProcessTimes(run.Handle, out nativeCreated, out ignoredExit, out ignoredKernel, out ignoredUser) || + !GetProcessTimes(retained, out managedCreated, out ignoredExit, out ignoredKernel, out ignoredUser)) + throw new Win32Exception(); + if (nativeCreated != managedCreated) + throw new InvalidOperationException("Managed process generation differs from the held launch."); + run.outputReader = new StreamReader(new FileStream( + new Microsoft.Win32.SafeHandles.SafeFileHandle(outRead, true), FileAccess.Read)); + outRead = IntPtr.Zero; + run.errorReader = new StreamReader(new FileStream( + new Microsoft.Win32.SafeHandles.SafeFileHandle(errRead, true), FileAccess.Read)); + errRead = IntPtr.Zero; + run.outputCapture = new OutputCapture(run.outputReader, ForwardWriter(info.RedirectStandardOutput, Console.Out)); + run.errorCapture = new OutputCapture(run.errorReader, ForwardWriter(info.RedirectStandardError, Console.Error)); + run.Output = run.outputCapture.Completion; + run.Error = run.errorCapture.Completion; + return run; + } catch (Exception primary) { + if (run.Handle != IntPtr.Zero) { + try { run.Stop(); run.Close(); } + catch (Exception cleanup) { + var error = new InvalidOperationException("Owned launch failed: " + primary.Message + + "; OWNED_CLEANUP_UNPROVEN: " + cleanup.Message, primary); + error.Data["GraphCodeValidationRootRetentionRequired"] = true; + throw error; + } + } + throw; + } finally { + foreach (IntPtr handle in new[] { outRead, outWrite, errRead, errWrite, input }) + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) CloseHandle(handle); + if (initialized) DeleteProcThreadAttributeList(attributes); + foreach (IntPtr allocation in new[] { attributes, handles, environment }) + if (allocation != IntPtr.Zero) Marshal.FreeHGlobal(allocation); + } + } + + public void Resume() { + if (thread == IntPtr.Zero) throw new InvalidOperationException("Owned thread handle is unavailable."); + uint previous = ResumeThread(thread); + if (previous == UInt32.MaxValue) throw new Win32Exception(); + if (previous != 1) { + var error = new InvalidOperationException("Owned launch suspension count changed before release."); + error.Data["GraphCodeValidationRootRetentionRequired"] = true; + throw error; + } + if (!CloseHandle(thread)) throw new Win32Exception(); + thread = IntPtr.Zero; + } + public void Stop() { + uint state = WaitForSingleObject(Handle, 0); + if (state == 0) return; + if (state != 258) throw new Win32Exception(); + if (!TerminateProcess(Handle, 124) && WaitForSingleObject(Handle, 0) != 0) throw new Win32Exception(); + if (WaitForSingleObject(Handle, 5000) != 0) throw new InvalidOperationException("Owned process did not exit."); + } + public void RequireExited() { + if (Handle == IntPtr.Zero || WaitForSingleObject(Handle, 0) != 0) + throw new InvalidOperationException("Original owned root is still live or its exit is unproved."); + } + public string TakeOutput(int maximumCharacters) { + if (outputCapture == null) throw new InvalidOperationException("Owned stdout capture is unavailable."); + return outputCapture.Take(maximumCharacters); + } + public string TakeError(int maximumCharacters) { + if (errorCapture == null) throw new InvalidOperationException("Owned stderr capture is unavailable."); + return errorCapture.Take(maximumCharacters); + } + public void Drain() { + foreach (var task in new[] { Output, Error }) + if (task != null && !task.Wait(5000)) throw new InvalidOperationException("Owned output EOF is unproved."); + } + public void Close() { + if (Handle != IntPtr.Zero) RequireExited(); + Drain(); + if (thread != IntPtr.Zero) { if (!CloseHandle(thread)) throw new Win32Exception(); thread = IntPtr.Zero; } + if (Handle != IntPtr.Zero) { if (!CloseHandle(Handle)) throw new Win32Exception(); Handle = IntPtr.Zero; } + if (outputReader != null) outputReader.Dispose(); + if (errorReader != null) errorReader.Dispose(); + if (outputCapture != null) outputCapture.Release(); + if (errorCapture != null) errorCapture.Release(); + if (Process != null) Process.Dispose(); + } +} diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 new file mode 100644 index 00000000..76b57b8a --- /dev/null +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -0,0 +1,274 @@ +Set-StrictMode -Version Latest +# Targets start suspended, join a private non-breakaway job, then resume. CIM +# observations are never admitted into this scope; disposed managed objects do +# not release the native launch handles retained until verified job teardown. +# Complete-ShellOwnedProcess must follow root exit and survivor sampling. It +# empties only that launch's private job before requiring both pipe EOFs; +# output reads refuse unfinished launches, and scope cleanup handles failures. +# Receive-ShellOwnedOutput forwards buffered characters without implying EOF. +# It has independent per-stream cursors; Read still returns the full capture. +# One incremental caller may take 2..65536 UTF-16 units per stream per call. +# Empty batches are not EOF; batch bounds do not cap full-capture storage. +# False redirection flags tee that stream live while retaining capture; true +# flags stay quiet. Child stdio still uses pipes/NUL, not console equivalence. +# Failed diagnostic transports remain attached to the original tagged exception; +# a failed sentinel does not prevent the independent receipt attempt. +# ShellProcessOwnership.Tests.ps1 covers mocks, receipt files and compilation +# only. Actual suspended launch/job containment still requires native validation. + +function Initialize-ShellProcessOwnership { + if ("ShellOwnedLaunch" -as [type]) { return } + $path = Join-Path $PSScriptRoot "UiCaptureDriver.psm1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + if ($errors.Count) { throw "Owned shell job source does not parse" } + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.Value.Contains("public static class CapProcessJob") + }, $true)) + if ($definitions.Count -ne 1) { throw "Owned shell requires exactly one existing CapProcessJob definition" } + if (-not ("CapProcessJob" -as [type])) { Add-Type -TypeDefinition $definitions[0].Value } + $source = "using System; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices;`n" + + "using System.IO; using System.Text; using System.Threading.Tasks; using System.Linq;`n" + + (Get-Content -LiteralPath (Join-Path $PSScriptRoot "ShellOwnedProcess.cs") -Raw) + Add-Type -TypeDefinition $source +} + +function Invoke-ShellJob([string] $Operation, [IntPtr] $Job = [IntPtr]::Zero, + [IntPtr] $Handle = [IntPtr]::Zero) { + switch ($Operation) { + "Create" { [CapProcessJob]::Create() } + "Assign" { [CapProcessJob]::Assign($Job, $Handle) } + "Contains" { [CapProcessJob]::Contains($Job, $Handle) } + "Terminate" { [CapProcessJob]::Terminate($Job) } + "Wait" { [CapProcessJob]::WaitForEmpty($Job, 5000) } + "Active" { [CapProcessJob]::Active($Job) } + "Close" { [CapProcessJob]::Close($Job) } + default { throw "Unknown owned-job operation: $Operation" } + } +} + +function New-ShellSuspendedLaunch([Diagnostics.ProcessStartInfo] $StartInfo) { + [ShellOwnedLaunch]::Suspended($StartInfo) +} + +function Test-ShellOwnershipUnproven($Failure) { + $exception = if ($Failure -is [Management.Automation.ErrorRecord]) { $Failure.Exception } else { $Failure } + while ($exception) { + if ($exception.Data["GraphCodeValidationRootRetentionRequired"] -is [bool] -and + $exception.Data["GraphCodeValidationRootRetentionRequired"]) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Write-ShellOwnershipDiagnostic([Exception] $Exception, [string] $Message) { + try { + Write-Host $Message -ErrorAction Stop + } catch { + $failure = $_.Exception + $previous = @($Exception.Data["GraphCodeValidationRetentionDiagnosticFailures"] | + Where-Object { $null -ne $_ }) + $Exception.Data["GraphCodeValidationRetentionDiagnosticFailures"] = $previous + $failure + } +} + +function Set-ShellOwnershipUnproven([Exception] $Exception, [string[]] $RetainedRoots = @()) { + $Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + $roots = @($RetainedRoots | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) }) + $Exception.Data["GraphCodeValidationRetainedRoots"] = $roots + Write-ShellOwnershipDiagnostic $Exception "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + if ([Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT")) { + $stream = $null + try { + $configured = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + if (-not $configured -or -not [IO.Path]::IsPathFullyQualified($configured)) { + throw "Validation retention root is not an absolute directory" + } + $root = [IO.Path]::GetFullPath($configured) + $directory = Get-Item -LiteralPath $root -Force -ErrorAction Stop + if (-not $directory.PSIsContainer) { throw "Validation retention root is not a directory" } + while ($directory) { + if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Validation retention root has a reparse ancestor" + } + $directory = $directory.Parent + } + $payload = [ordered]@{ schemaVersion = 1; validationRoot = $root; reason = "ownership_unproven" } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($payload | ConvertTo-Json -Compress)) + if ($bytes.Length -gt 4096) { throw "Validation retention receipt exceeds 4096 bytes" } + $stream = [IO.File]::Open((Join-Path $root "ownership-unproven.json"), + [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } catch { + $Exception.Data["GraphCodeValidationRetentionReceiptFailure"] = $_.Exception.Message + Write-ShellOwnershipDiagnostic $Exception "SHELL_RETENTION_RECEIPT_UNPROVEN=$($_.Exception.Message)" + } finally { + if ($stream) { $stream.Dispose() } + } + } + return $Exception +} + +function New-ShellProcessScope { + Initialize-ShellProcessOwnership + [pscustomobject]@{ + Job = Invoke-ShellJob "Create" + Runs = [Collections.Generic.List[object]]::new() + Members = [Collections.Generic.List[Diagnostics.Process]]::new() + Closed = $false + } +} + +function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartInfo) { + if ($Scope.Closed) { throw "Owned shell scope is closed" } + try { $run = New-ShellSuspendedLaunch $StartInfo } catch { + if (Test-ShellOwnershipUnproven $_) { + throw (Set-ShellOwnershipUnproven $_.Exception) + } + throw + } + $run | Add-Member -NotePropertyMembers @{ + LaunchJob = [IntPtr]::Zero; ScopeAssigned = $false; LaunchAssigned = $false + Finalized = $false; LaunchJobClosed = $false + } -Force + $Scope.Runs.Add($run) + try { + Invoke-ShellJob "Assign" $Scope.Job $run.Handle + $run.ScopeAssigned = $true + $run.LaunchJob = Invoke-ShellJob "Create" + Invoke-ShellJob "Assign" $run.LaunchJob $run.Handle + $run.LaunchAssigned = $true + $run.Resume() + return $run.Process + } catch { + # This handle came from CreateProcess; no target code ran before assignment. + $primary = $_ + try { $run.Stop() } catch { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "Owned launch failed: $($primary.Exception.Message); OWNED_CLEANUP_UNPROVEN: $($_.Exception.Message)", $primary.Exception))) + } + if (Test-ShellOwnershipUnproven $primary) { + throw (Set-ShellOwnershipUnproven $primary.Exception) + } + throw $primary + } +} + +function Get-ShellOwnedMember($Scope, [int] $ProcessId) { + if ($Scope.Closed) { throw "UNATTESTED_PROCESS: owned shell scope is closed" } + $process = $null + try { + $process = [Diagnostics.Process]::GetProcessById($ProcessId) + if ($Scope.Closed -or -not (Invoke-ShellJob "Contains" $Scope.Job $process.Handle)) { + throw "UNATTESTED_PROCESS: pid=$ProcessId is not a retained job member" + } + $Scope.Members.Add($process) + return $process + } catch { + if ($process) { $process.Dispose() } + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +function Stop-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($Scope.Closed -or $runs.Count -ne 1) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_PROCESS: termination requires the original launch record"))) + } + if (-not (Invoke-ShellJob "Contains" $Scope.Job $runs[0].Handle)) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_PROCESS: retained launch is not in its owned job"))) + } + $runs[0].Stop() +} + +function Read-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, [switch] $StandardError) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($runs.Count -ne 1) { throw "Owned output requires the original launch record" } + if (-not $runs[0].Finalized) { throw "Owned output requires explicit launch finalization after survivor sampling" } + $task = if ($StandardError) { $runs[0].Error } else { $runs[0].Output } + if (-not $task.Wait(5000)) { throw "Owned output EOF is unproved" } + $task.GetAwaiter().GetResult() +} + +function Receive-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, + [ValidateRange(2, 65536)][int] $MaximumCharacters = 65536) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($Scope.Closed -or $runs.Count -ne 1) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "Owned incremental output requires an open scope and the original launch record"))) + } + try { + [pscustomobject]@{ + Output = $runs[0].TakeOutput($MaximumCharacters) + Error = $runs[0].TakeError($MaximumCharacters) + } + } catch { + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +function Complete-ShellLaunch($Run) { + if ($Run.Finalized) { return } + try { + $Run.RequireExited() + if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { + Invoke-ShellJob "Terminate" $Run.LaunchJob + Invoke-ShellJob "Wait" $Run.LaunchJob + if ((Invoke-ShellJob "Active" $Run.LaunchJob) -ne 0) { + throw "OWNED_CLEANUP_UNPROVEN: private launch job not empty" + } + } + $Run.Drain() + if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { + Invoke-ShellJob "Close" $Run.LaunchJob + $Run.LaunchJobClosed = $true + } + $Run.Finalized = $true + } catch { + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +function Complete-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { + $runs = @($Scope.Runs | Where-Object { [object]::ReferenceEquals($_.Process, $Process) }) + if ($runs.Count -ne 1) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_PROCESS: finalization requires the original launch record"))) + } + if ($runs[0].Finalized) { return } + if ($Scope.Closed -or -not $runs[0].LaunchAssigned) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "Owned launch finalization requires an open scope and its assigned private job"))) + } + Complete-ShellLaunch $runs[0] +} + +function Close-ShellProcessScope($Scope) { + if ($Scope.Closed) { return } + try { + Invoke-ShellJob "Terminate" $Scope.Job + Invoke-ShellJob "Wait" $Scope.Job + if ((Invoke-ShellJob "Active" $Scope.Job) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: job not empty" } + foreach ($run in $Scope.Runs) { + if (-not $run.ScopeAssigned) { $run.Stop() } + Complete-ShellLaunch $run + $run.Close() + } + foreach ($member in $Scope.Members) { $member.Dispose() } + Invoke-ShellJob "Close" $Scope.Job + $Scope.Closed = $true + Write-Host "SHELL_OWNED_JOB_CLEANUP=verified active=0 launches=$($Scope.Runs.Count)" + } catch { + throw (Set-ShellOwnershipUnproven $_.Exception) + } +} + +Export-ModuleMember -Function New-ShellProcessScope, Start-ShellOwnedProcess, + Get-ShellOwnedMember, Stop-ShellOwnedProcess, Read-ShellOwnedOutput, Close-ShellProcessScope, + Complete-ShellOwnedProcess, Receive-ShellOwnedOutput, + Test-ShellOwnershipUnproven, Set-ShellOwnershipUnproven diff --git a/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 b/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 new file mode 100644 index 00000000..623d850e --- /dev/null +++ b/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 @@ -0,0 +1,319 @@ +[CmdletBinding()] +param( + [string] $ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ( + "validation-fixture-" + [guid]::NewGuid().ToString("N"))) +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +$results = [Collections.Generic.List[object]]::new() + +function Assert-Fixture([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} + +function Assert-FixtureNativeCollision($Failure) { + $exception = $Failure.Exception + while ($null -ne $exception -and $exception -isnot [ComponentModel.Win32Exception]) { + $exception = $exception.InnerException + } + Assert-Fixture ($null -ne $exception) "Exclusive-create failure lost its Win32 error" + Assert-Fixture ($exception.NativeErrorCode -eq 183) "Exclusive create did not report ERROR_ALREADY_EXISTS" + Write-Output "NATIVE_CREATE_ERROR=$($exception.NativeErrorCode)" +} + +function Invoke-FixtureCase([string] $CaseName, [scriptblock] $Action) { + try { + & $Action + $results.Add([pscustomobject]@{ name = $CaseName; passed = $true }) + Write-Output "PASS $CaseName" + } catch { + $results.Add([pscustomobject]@{ name = $CaseName; passed = $false }) + Write-Output "FAIL ${CaseName}: $($_.Exception.Message)" + } +} + +Invoke-FixtureCase "helper import does not load or invoke its native binding" { + Assert-Fixture ($null -eq ("GraphCode.ValidationFixtureDirectoryNative" -as [type])) ` + "Helper import eagerly loaded its native binding" +} + +$root = (New-ValidationFixtureDirectory -Path $ScratchRoot).Path + +Invoke-FixtureCase "harness acquires scratch exclusively and retains its contents" { + $source = [IO.File]::ReadAllText($PSCommandPath) + Assert-Fixture ($source.Contains( + '$root = (New-ValidationFixtureDirectory -Path $ScratchRoot).Path')) "Harness root admission is not exclusive" + Assert-Fixture (-not ($source -match 'Remove-Item\s+-LiteralPath\s+\$root\b')) ` + "Harness removes unrecognized scratch contents" +} + +foreach ($name in @("validation-runner-untracked", "OwnershipSentinel")) { + Invoke-FixtureCase "$name creates missing parent containers without owning their cleanup" { + $parent = Join-Path $root "$name-missing-parent\Sources\GraphcodeWindowsContracts" + $path = Join-Path $parent $name + Assert-Fixture (-not [IO.Directory]::Exists($parent)) "Clean parent hierarchy already exists" + $fixture = New-ValidationFixtureDirectory -Path $path + Assert-Fixture ($fixture.Path -ceq $path) "Receipt adopted a parent rather than the exclusive leaf" + Remove-ValidationFixtureDirectory -Fixture $fixture + Assert-Fixture (-not [IO.Directory]::Exists($path)) "Owned leaf survived cleanup" + Assert-Fixture ([IO.Directory]::Exists($parent)) "Cleanup removed a generated parent container" + Assert-Fixture ([IO.Directory]::Exists((Join-Path $root "$name-missing-parent\Sources"))) ` + "Cleanup removed a generated ancestor" + } + + Invoke-FixtureCase "$name preserves an existing parent's contents after leaf cleanup" { + $parent = Join-Path $root "$name-existing-parent" + $parentFixture = New-ValidationFixtureDirectory -Path $parent + New-ValidationFixtureFile -Fixture $parentFixture -Name "retained.txt" -Content "existing parent" + $marker = Join-Path $parent "retained.txt" + $before = (Get-FileHash -LiteralPath $marker).Hash + $path = Join-Path $parent $name + $fixture = New-ValidationFixtureDirectory -Path $path + Assert-Fixture ($fixture.Path -ceq $path) "Receipt adopted the existing parent" + Remove-ValidationFixtureDirectory -Fixture $fixture + Assert-Fixture ([IO.Directory]::Exists($parent)) "Existing parent was removed" + Assert-Fixture ((Get-FileHash -LiteralPath $marker).Hash -ceq $before) "Existing parent contents changed" + } + + Invoke-FixtureCase "$name preserves pre-existing directory contents" { + $path = Join-Path $root "$name-existing-directory" + [void](New-Item -ItemType Directory -Path $path) + $file = Join-Path $path "Unformatted.swift" + [IO.File]::WriteAllText($file, "pre-existing content") + $before = (Get-FileHash -LiteralPath $file).Hash + $failure = $null + try { $null = New-ValidationFixtureDirectory -Path $path } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Existing directory was accepted as owned" + Assert-FixtureNativeCollision $failure + Assert-Fixture ((Get-FileHash -LiteralPath $file).Hash -ceq $before) "Existing contents changed" + } + + Invoke-FixtureCase "$name preserves pre-existing file" { + $path = Join-Path $root "$name-existing-file" + [IO.File]::WriteAllText($path, "pre-existing file") + $before = (Get-FileHash -LiteralPath $path).Hash + $failure = $null + try { $null = New-ValidationFixtureDirectory -Path $path } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Existing file was accepted as owned" + Assert-FixtureNativeCollision $failure + Assert-Fixture ((Get-FileHash -LiteralPath $path).Hash -ceq $before) "Existing file changed" + } + + Invoke-FixtureCase "$name refuses a collision at the directory-create boundary" { + $path = Join-Path $root "$name-create-collision" + $file = Join-Path $path "retained.txt" + $script:fixtureCollisionCount = 0 + $script:fixtureCollisionCleanupCount = 0 + $nativeCreate = Get-Command Invoke-ValidationFixtureDirectoryCreate -ErrorAction SilentlyContinue + $nativeCreateBody = if ($null -ne $nativeCreate) { $nativeCreate.ScriptBlock } else { $null } + + function Add-FixtureCollision { + [void][IO.Directory]::CreateDirectory($path) + [IO.File]::WriteAllText($file, "concurrent creator") + $script:fixtureCollisionCount++ + } + + # Model the provider's idempotent final create after its earlier existence check. + function New-Item { + param($ItemType, $Path, $ErrorAction) + Add-FixtureCollision + return Get-Item -LiteralPath $Path + } + + function Invoke-ValidationFixtureDirectoryCreate { + param([string] $Path) + Add-FixtureCollision + & $nativeCreateBody -Path $Path + } + + function Remove-ValidationFixtureDirectory { + param($Fixture) + $script:fixtureCollisionCleanupCount++ + } + + $failure = $null + $fixture = $null + try { + $fixture = New-ValidationFixtureDirectory -Path $path + try { } finally { Remove-ValidationFixtureDirectory -Fixture $fixture } + } catch { $failure = $_ } + Assert-Fixture ($script:fixtureCollisionCount -eq 1) "Collision did not occur at the create boundary" + Assert-Fixture ($null -ne $failure -and $null -eq $fixture) "Concurrent directory was accepted as owned" + Assert-Fixture ($script:fixtureCollisionCleanupCount -eq 0) "Failed acquisition reached cleanup" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "concurrent creator") "Concurrent contents changed" + if ($null -ne $nativeCreateBody) { + Assert-FixtureNativeCollision $failure + } + } + + Invoke-FixtureCase "$name removes only a successfully created fixture" { + $path = Join-Path $root "$name-owned" + $sibling = Join-Path $root "$name-sibling" + [IO.File]::WriteAllText($sibling, "sibling") + $fixture = New-ValidationFixtureDirectory -Path $path + try { + Assert-Fixture ([IO.Directory]::Exists($path)) "Owned directory was not created" + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + } finally { + Remove-ValidationFixtureDirectory -Fixture $fixture + } + Assert-Fixture (-not (Test-Path -LiteralPath $path)) "Owned directory survived cleanup" + Assert-Fixture ([IO.File]::ReadAllText($sibling) -ceq "sibling") "Sibling changed" + } + + Invoke-FixtureCase "$name cleans its owned fixture after workload failure" { + $path = Join-Path $root "$name-failed-workload" + $fixture = New-ValidationFixtureDirectory -Path $path + $primary = [InvalidOperationException]::new("controlled workload failure") + $failure = $null + try { + try { throw $primary } finally { Remove-ValidationFixtureDirectory -Fixture $fixture } + } catch { $failure = $_ } + Assert-Fixture ([object]::ReferenceEquals($primary, $failure.Exception)) "Primary error changed" + Assert-Fixture (-not (Test-Path -LiteralPath $path)) "Failed workload left its owned directory" + } + + foreach ($kind in @("file", "directory")) { + Invoke-FixtureCase "$name cleanup preserves an unexpected child $kind" { + $path = Join-Path $root "$name-unexpected-$kind" + $fixture = New-ValidationFixtureDirectory -Path $path + if ($name -eq "validation-runner-untracked") { + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + Assert-Fixture ($fixture.OwnedFiles.Count -eq 1) "Untracked known-file path was not exercised" + $ownedFile = Join-Path $path "Unformatted.swift" + $ownedBefore = (Get-FileHash -LiteralPath $ownedFile).Hash + } + $unexpected = Join-Path $path "unexpected" + $file = $unexpected + if ($kind -eq "directory") { + [void][IO.Directory]::CreateDirectory($unexpected) + $file = Join-Path $unexpected "retained.txt" + } + [IO.File]::WriteAllText($file, "unexpected child") + $before = (Get-FileHash -LiteralPath $file).Hash + $failure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Cleanup accepted unexpected contents" + Assert-Fixture ((Get-FileHash -LiteralPath $file).Hash -ceq $before) "Unexpected contents changed" + if ($name -eq "validation-runner-untracked") { + Assert-Fixture ((Get-FileHash -LiteralPath $ownedFile).Hash -ceq $ownedBefore) ` + "Cleanup removed its known file before refusing unexpected contents" + Write-Output "UNTRACKED_OWNED_FILE_PREFLIGHT_KIND=$kind OWNED_FILES=1 PRESERVED=1" + } + } + } + + Invoke-FixtureCase "$name refuses an existing file without acquiring cleanup ownership" { + $path = Join-Path $root "$name-file-create-collision" + $fixture = New-ValidationFixtureDirectory -Path $path + $file = Join-Path $path "Unformatted.swift" + [IO.File]::WriteAllText($file, "concurrent file") + $failure = $null + try { + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Existing file was overwritten" + Assert-Fixture ($fixture.OwnedFiles.Count -eq 0) "Failed file acquisition granted cleanup ownership" + $cleanupFailure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $cleanupFailure = $_ } + Assert-Fixture ($null -ne $cleanupFailure) "Unowned file did not prevent directory cleanup" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "concurrent file") "Existing file contents changed" + } + + Invoke-FixtureCase "$name preserves both workload and cleanup errors" { + $path = Join-Path $root "$name-workload-and-cleanup-errors" + $fixture = New-ValidationFixtureDirectory -Path $path + $file = Join-Path $path "unexpected.txt" + [IO.File]::WriteAllText($file, "preserved") + $primary = [InvalidOperationException]::new("controlled primary failure") + $primaryRecord = $null + $failure = $null + try { + try { throw $primary } catch { + $primaryRecord = $_ + throw + } finally { + Remove-ValidationFixtureDirectory -Fixture $fixture -PrimaryError $primaryRecord + } + } catch { $failure = $_ } + Assert-Fixture ($failure.Exception -is [AggregateException]) "Cleanup masked the workload error" + Assert-Fixture ($failure.Exception.InnerExceptions.Count -eq 2) "Both errors were not retained" + Assert-Fixture ([object]::ReferenceEquals($primary, $failure.Exception.InnerExceptions[0])) ` + "Original workload exception was replaced" + Assert-Fixture ($failure.Exception.InnerExceptions[1].Message -like "*Unexpected validation fixture contents*") ` + "Cleanup refusal was not retained" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "preserved") "Unexpected file changed" + } +} + +Invoke-FixtureCase "cleanup refuses a replaced fixture and preserves its contents" { + $path = Join-Path $root "replaced" + $fixture = New-ValidationFixtureDirectory -Path $path + [IO.Directory]::Delete($path, $false) + [void](New-Item -ItemType Directory -Path $path) + [IO.Directory]::SetCreationTimeUtc($path, $fixture.CreationTimeUtc.AddDays(-1)) + $file = Join-Path $path "retained.txt" + [IO.File]::WriteAllText($file, "replacement") + $failure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Replacement directory was removed" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "replacement") "Replacement contents changed" +} + +Invoke-FixtureCase "cleanup refuses a replaced owned file and preserves its contents" { + $path = Join-Path $root "replaced-file" + $fixture = New-ValidationFixtureDirectory -Path $path + New-ValidationFixtureFile -Fixture $fixture -Name "Unformatted.swift" -Content "let value=1" + $file = Join-Path $path "Unformatted.swift" + [IO.File]::Delete($file) + [IO.File]::WriteAllText($file, "replacement file") + [IO.File]::SetCreationTimeUtc($file, $fixture.OwnedFiles[0].CreationTimeUtc.AddDays(-1)) + $failure = $null + try { Remove-ValidationFixtureDirectory -Fixture $fixture } catch { $failure = $_ } + Assert-Fixture ($null -ne $failure) "Replacement file was removed" + Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "replacement file") "Replacement file contents changed" +} + +# Read the blocked runner only as data; never import or execute its contents. +$runnerSource = [IO.File]::ReadAllText((Join-Path $PSScriptRoot "ValidationRunner.Tests.ps1")) +foreach ($binding in @( + @{ name = "untracked"; path = "untrackedDirectory"; fixture = "untrackedFixture"; failure = "untrackedFailure" }, + @{ name = "sentinel"; path = "foreignJunction"; fixture = "foreignFixture"; failure = "foreignFailure" } + )) { + Invoke-FixtureCase "$($binding.name) runner wiring acquires before try and cleans only its fixture" { + $acquire = '\$' + $binding.fixture + ' = New-ValidationFixtureDirectory -Path \$' + + $binding.path + '\s+try \{' + $cleanup = '\} finally \{\s+Remove-ValidationFixtureDirectory -Fixture \$' + + $binding.fixture + ' -PrimaryError \$' + $binding.failure + '\s+\}' + $primary = '\} catch \{\s+\$' + $binding.failure + ' = \$_\s+throw\s+' + $cleanup + Assert-Fixture ($runnerSource -match $acquire) "Runner does not acquire an exclusive fixture before try" + Assert-Fixture ($runnerSource -match $cleanup) "Runner cleanup does not use its acquired fixture" + Assert-Fixture ($runnerSource -match $primary) "Runner does not retain its primary workload error" + Assert-Fixture ($runnerSource -notmatch ( + 'New-Item -ItemType Directory -Force \$' + $binding.path)) "Runner still adopts a pre-existing directory" + Assert-Fixture ($runnerSource -notmatch ( + 'Remove-Item -LiteralPath \$' + $binding.path)) "Runner still removes the unowned fixed path" + Assert-Fixture ($runnerSource.Contains( + '. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1")')) "Runner does not import fixture helper" + if ($binding.name -eq "untracked") { + Assert-Fixture ($runnerSource.Contains( + 'New-ValidationFixtureFile -Fixture $untrackedFixture -Name "Unformatted.swift" -Content "let value=1"')) ` + "Runner does not acquire its known unformatted file exclusively" + } + } +} + +$failed = @($results | Where-Object { -not $_.passed }).Count +[ordered]@{ + suite = "ValidationFixtureDirectory" + executed = $results.Count + passed = $results.Count - $failed + failed = $failed + skipped = 0 + notRun = 0 +} | ConvertTo-Json -Compress | Write-Output +if ($results.Count -ne 28 -or $failed -ne 0) { + throw "Validation fixture contracts failed; retained evidence: $root" +} +Write-Output "FIXTURE_SCRATCH_RETAINED=$root" diff --git a/Tools/windows/Tests/ValidationFixtureDirectory.ps1 b/Tools/windows/Tests/ValidationFixtureDirectory.ps1 new file mode 100644 index 00000000..6f378475 --- /dev/null +++ b/Tools/windows/Tests/ValidationFixtureDirectory.ps1 @@ -0,0 +1,122 @@ +function Invoke-ValidationFixtureDirectoryCreate { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $Path) + + if (-not ("GraphCode.ValidationFixtureDirectoryNative" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +namespace GraphCode { + public static class ValidationFixtureDirectoryNative { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); + public static void Create(string path) { + if (!CreateDirectoryW(path, IntPtr.Zero)) { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, + "Exclusive validation fixture create failed (Win32 " + error + "): " + path); + } + } + } +} +'@ + } + [GraphCode.ValidationFixtureDirectoryNative]::Create($Path) +} + +function New-ValidationFixtureDirectory { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $Path) + + $fullPath = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + $parent = [IO.Path]::GetDirectoryName($fullPath) + if (-not [string]::IsNullOrEmpty($parent)) { + # Parent containers are shared, never part of the fixture's cleanup ownership. + [void][IO.Directory]::CreateDirectory($parent) + } + Invoke-ValidationFixtureDirectoryCreate -Path $fullPath + $directory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + return [pscustomobject]@{ + Path = $directory.FullName + CreationTimeUtc = $directory.CreationTimeUtc + OwnedFiles = [Collections.Generic.List[object]]::new() + } +} + +function Get-ValidationFixtureDirectory { + [CmdletBinding()] + param([Parameter(Mandatory)] $Fixture) + + $directory = Get-Item -LiteralPath $Fixture.Path -Force -ErrorAction Stop + if (-not $directory.PSIsContainer -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $directory.CreationTimeUtc -ne $Fixture.CreationTimeUtc) { + throw "Validation fixture identity changed; refusing cleanup: $($Fixture.Path)" + } + return $directory +} + +function New-ValidationFixtureFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Fixture, + [Parameter(Mandatory)][string] $Name, + [Parameter(Mandatory)][string] $Content + ) + + if ([IO.Path]::GetFileName($Name) -cne $Name -or $Name -in @(".", "..")) { + throw "Validation fixture file must be a leaf name: $Name" + } + $directory = Get-ValidationFixtureDirectory -Fixture $Fixture + $path = Join-Path $directory.FullName $Name + $stream = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $file = Get-Item -LiteralPath $path -Force -ErrorAction Stop + $Fixture.OwnedFiles.Add([pscustomobject]@{ + Path = $file.FullName + CreationTimeUtc = $file.CreationTimeUtc + }) + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content + [Environment]::NewLine) + $stream.Write($bytes, 0, $bytes.Length) + } finally { + $stream.Dispose() + } +} + +function Remove-ValidationFixtureDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Fixture, + [Management.Automation.ErrorRecord] $PrimaryError + ) + + try { + $directory = Get-ValidationFixtureDirectory -Fixture $Fixture + $ownedPaths = @($Fixture.OwnedFiles | ForEach-Object { $_.Path }) + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($directory.FullName)) { + if ($ownedPaths -cnotcontains $entry) { + throw "Unexpected validation fixture contents; refusing cleanup: $entry" + } + } + $files = @(foreach ($owned in $Fixture.OwnedFiles) { + $file = Get-Item -LiteralPath $owned.Path -Force -ErrorAction Stop + if ($file.PSIsContainer -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $file.CreationTimeUtc -ne $owned.CreationTimeUtc) { + throw "Validation fixture file identity changed; refusing cleanup: $($owned.Path)" + } + $file + }) + foreach ($file in $files) { + [IO.File]::Delete($file.FullName) + } + [IO.Directory]::Delete($directory.FullName, $false) + } catch { + if ($null -ne $PrimaryError) { + throw [AggregateException]::new("Validation workload and fixture cleanup both failed.", + [Exception[]]@($PrimaryError.Exception, $_.Exception)) + } + throw + } +} diff --git a/Tools/windows/Tests/ValidationIsolation.Tests.ps1 b/Tools/windows/Tests/ValidationIsolation.Tests.ps1 index a7156f8e..085a7fb9 100644 --- a/Tools/windows/Tests/ValidationIsolation.Tests.ps1 +++ b/Tools/windows/Tests/ValidationIsolation.Tests.ps1 @@ -21,6 +21,7 @@ BeforeAll { } $requiredFunctions = @( + "Invoke-Native", "Get-WindowsShellProfileSnapshot", "Assert-WindowsShellProfileUnchanged", "Invoke-WindowsShellValidationIsolation" @@ -51,6 +52,8 @@ BeforeEach { Out-Null $capturePath = Join-Path $caseRoot "environment.json" $savedEnvironment = @{} + $savedEnvironmentMembership = @{} + $processEnvironment = [Environment]::GetEnvironmentVariables("Process") foreach ($name in @( "GRAPHCODE_VALIDATION_ROOT", "GRAPHCODE_SUPPORT_DIR", @@ -58,6 +61,7 @@ BeforeEach { "TEMP", "TMP" )) { + $savedEnvironmentMembership[$name] = $processEnvironment.Contains($name) $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) [Environment]::SetEnvironmentVariable($name, "before-$name") } @@ -65,7 +69,11 @@ BeforeEach { AfterEach { foreach ($entry in $savedEnvironment.GetEnumerator()) { - [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + if ($savedEnvironmentMembership[$entry.Key]) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } elseif (Test-Path -LiteralPath "Env:\$($entry.Key)") { + Remove-Item -LiteralPath "Env:\$($entry.Key)" -ErrorAction Stop + } } Remove-Item -LiteralPath $caseRoot -Recurse -Force -ErrorAction SilentlyContinue } @@ -134,7 +142,7 @@ AfterEach { $env:TMP | Should -BeExactly "before-TMP" } - It "restores and cleans owned roots when the workload fails" { + It "restores and retains owned roots when the workload fails" { $missingFunctions.Count | Should -Be 0 $caught = $null try { @@ -152,7 +160,8 @@ AfterEach { $caught.Exception.Message | Should -BeExactly "controlled workload failure" $ownedRoot = Get-Content -LiteralPath $capturePath -Raw - Test-Path -LiteralPath $ownedRoot.Trim() | Should -Be $false + Test-Path -LiteralPath $ownedRoot.Trim() | Should -Be $true + $caught.Exception.Data["GraphCodeValidationRootRetentionReason"] | Should -BeExactly "workload_failed_without_cleanup_proof" $env:GRAPHCODE_VALIDATION_ROOT | Should -BeExactly "before-GRAPHCODE_VALIDATION_ROOT" $env:GRAPHCODE_SUPPORT_DIR | Should -BeExactly "before-GRAPHCODE_SUPPORT_DIR" $env:LOCALAPPDATA | Should -BeExactly "before-LOCALAPPDATA" @@ -160,6 +169,339 @@ AfterEach { $env:TMP | Should -BeExactly "before-TMP" } + It "opaque child with unavailable receipt and sentinel" -TestCases @( + @{ outcome = "failure retains isolation"; childExit = 1; retain = $true }, + @{ outcome = "forced-zero counterexample remains a limitation"; childExit = 0; retain = $false } + ) { + param($childExit, $retain) + $helperPath = Join-Path $repoRoot "Tools\windows\OwnedLiveProcess.ps1" + $helperLiteral = "'" + $helperPath.Replace("'", "''") + "'" + $childSource = @' +using System; +using System.IO; +using System.Text; +public sealed class UnavailableOwnershipOutput : TextWriter { + public override Encoding Encoding { get { return Encoding.UTF8; } } + public override void WriteLine(string value) { throw new IOException("mock stdout unavailable"); } +} +'@ + $childCommand = @" +`$ErrorActionPreference = 'Stop' +. $helperLiteral +Add-Type -TypeDefinition @' +$childSource +'@ +`$env:GRAPHCODE_VALIDATION_ROOT = Join-Path `$env:GRAPHCODE_VALIDATION_ROOT 'absent-child-root' +[Console]::Error.WriteLine('OPAQUE_CHILD_RECEIPT_UNAVAILABLE=true') +[Console]::SetOut([UnavailableOwnershipOutput]::new()) +try { + throw (New-OwnedLiveProcessCleanupError -Message 'mock ownership cleanup refusal' -Errors @([IO.IOException]::new('mock survivor'))) +} catch { + if ($childExit -eq 0) { + [Console]::Error.WriteLine('OPAQUE_CHILD_FORCED_EXIT_ZERO=true') + exit 0 + } + throw +} +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childCommand)) + $pwsh = (Get-Command pwsh -ErrorAction Stop).Source + $caught = $null + $output = [Collections.Generic.List[string]]::new() + $oldExitCode = $global:LASTEXITCODE + $observedExit = $null + try { + Invoke-WindowsShellValidationIsolation -WorkspaceRoot $workspaceRoot ` + -ValidationRootParent (Join-Path $caseRoot "short") ` + -UserProfileRoot $userProfileRoot -LocalAppDataRoot $localAppDataRoot -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + Invoke-Native "opaque mock child" { + & $pwsh -NoProfile -NonInteractive -EncodedCommand $encoded + } | ForEach-Object { $output.Add([string]$_) } + } + $observedExit = $global:LASTEXITCODE + } catch { $caught = $_; $observedExit = $global:LASTEXITCODE } + finally { $global:LASTEXITCODE = $oldExitCode } + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + $observedExit | Should -Be $childExit + @($output | Where-Object { $_ -ceq "OPAQUE_CHILD_RECEIPT_UNAVAILABLE=true" }).Count | Should -Be 1 + @($output | Where-Object { $_ -ceq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" }).Count | Should -Be 0 + Test-Path -LiteralPath (Join-Path $ownedRoot "ownership-unproven.json") | Should -BeFalse + Test-Path -LiteralPath $ownedRoot | Should -Be $retain + if ($retain) { + $caught | Should -Not -BeNullOrEmpty + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + } else { + $caught | Should -BeNullOrEmpty + @($output | Where-Object { $_ -ceq "OPAQUE_CHILD_FORCED_EXIT_ZERO=true" }).Count | Should -Be 1 + Write-Host "OPAQUE_EXIT_ZERO_COUNTEREXAMPLE=observed rootRemoved=true realProducerSwallowingProven=false" + } + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "setup failure before workload entry removes only its partial owned root" { + $original = [IO.IOException]::new("controlled setup failure") + $script:actionRan = $false + Mock New-Item { + $partialRoot = Split-Path -Path $Path[0] -Parent + [void][IO.Directory]::CreateDirectory($partialRoot) + [IO.File]::WriteAllText($capturePath, $partialRoot) + throw $original + } -ParameterFilter { @($Path).Count -eq 3 } + $caught = $null + try { + Invoke-WindowsShellValidationIsolation -WorkspaceRoot $workspaceRoot ` + -ValidationRootParent (Join-Path $caseRoot "short") ` + -UserProfileRoot $userProfileRoot -LocalAppDataRoot $localAppDataRoot ` + -Action { $script:actionRan = $true } + } catch { $caught = $_ } + $script:actionRan | Should -BeFalse + [object]::ReferenceEquals($caught.Exception, $original) | Should -BeTrue + Test-Path -LiteralPath ([IO.File]::ReadAllText($capturePath)) | Should -BeFalse + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeNullOrEmpty + } + + It "tagged ownership failure retains evidence and restores the environment" { + $original = [InvalidOperationException]::new("controlled unattested survivor") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + Set-Content -LiteralPath (Join-Path $env:GRAPHCODE_VALIDATION_ROOT "evidence.log") -Value "advisory snapshot" + throw $original + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + Get-Content -LiteralPath (Join-Path $ownedRoot "evidence.log") | Should -BeExactly "advisory snapshot" + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged nested failure propagates both retained roots" { + $original = [InvalidOperationException]::new("controlled nested ownership failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot (Join-Path $workspaceRoot "inner") ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { throw $original } + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + $roots = @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) + $roots.Count | Should -Be 2 + foreach ($root in $roots) { Test-Path -LiteralPath $root | Should -Be $true } + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged retention reporting failure preserves the original error and root" { + Mock Write-Host { throw [IO.IOException]::new("controlled retention reporting failure") } ` + -ParameterFilter { "$Object" -like "WINDOWS_SHELL_VALIDATION_ISOLATION_ROOT_RETAINED=*" } + $original = [InvalidOperationException]::new("controlled original ownership failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + throw $original + } + } catch { $caught = $_ } + $caught.Exception | Should -BeOfType ([AggregateException]) + [object]::ReferenceEquals($caught.Exception.InnerExceptions[0], $original) | Should -Be $true + $caught.Exception.InnerExceptions[1].Message | Should -BeExactly "controlled retention reporting failure" + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged missing root reports retention failure without masking the original" { + $original = [InvalidOperationException]::new("controlled missing-root ownership failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Remove-Item -LiteralPath $env:GRAPHCODE_VALIDATION_ROOT -Recurse -Force + throw $original + } + } catch { $caught = $_ } + $caught.Exception | Should -BeOfType ([AggregateException]) + [object]::ReferenceEquals($caught.Exception.InnerExceptions[0], $original) | Should -Be $true + $caught.Exception.InnerExceptions[1].Message | Should -Match "no longer exists" + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged external receipt retains the exact root" -TestCases @( + @{ variant = "valid"; invalid = $false }, + @{ variant = "valid-limit"; invalid = $false }, + @{ variant = "wrong-root"; invalid = $true }, + @{ variant = "malformed"; invalid = $true }, + @{ variant = "oversize"; invalid = $true }, + @{ variant = "wrong-schema"; invalid = $true }, + @{ variant = "wrong-reason"; invalid = $true } + ) { + param($variant, $invalid) + $original = [InvalidOperationException]::new("controlled child failure without exception data") + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + $receipt = [ordered]@{ + schemaVersion = 1; validationRoot = $env:GRAPHCODE_VALIDATION_ROOT + reason = "ownership_unproven" + } + if ($variant -eq "wrong-root") { $receipt.validationRoot += "-foreign" } + if ($variant -eq "wrong-schema") { $receipt.schemaVersion = 2 } + if ($variant -eq "wrong-reason") { $receipt.reason = "ordinary_failure" } + $text = if ($variant -eq "malformed") { "not-json" } elseif ($variant -eq "oversize") { + "x" * 4097 + } else { $receipt | ConvertTo-Json -Compress } + if ($variant -eq "valid-limit") { $text += " " * (4096 - [Text.Encoding]::UTF8.GetByteCount($text)) } + [IO.File]::WriteAllText((Join-Path $env:GRAPHCODE_VALIDATION_ROOT "ownership-unproven.json"), + $text, [Text.UTF8Encoding]::new($false)) + throw $original + } + } catch { $caught = $_ } + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + if ($invalid) { + $caught.Exception | Should -BeOfType ([AggregateException]) + [object]::ReferenceEquals($caught.Exception.InnerExceptions[0], $original) | Should -Be $true + if ($variant -eq "oversize") { $caught.Exception.InnerExceptions[1].Message | Should -Match "4096-byte" } + } else { + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + } + } + + It "tagged native refusal propagates without a writable receipt" -TestCases @( + @{ variant = "failed-exit"; childExit = 1; terminating = $false }, + @{ variant = "zero-exit"; childExit = 0; terminating = $false }, + @{ variant = "terminating"; childExit = 1; terminating = $true } + ) { + param($variant, $childExit, $terminating) + $caught = $null + $oldExitCode = $global:LASTEXITCODE + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + Invoke-Native "mocked ownership child" { + $global:LASTEXITCODE = $childExit + Write-Output "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + if ($terminating) { throw "controlled terminating child" } + } + } + } catch { $caught = $_ } finally { $global:LASTEXITCODE = $oldExitCode } + $caught | Should -Not -BeNullOrEmpty + $caught.Exception.Data["GraphCodeValidationRootRetentionRequired"] | Should -Be $true + $ownedRoot = (Get-Content -LiteralPath $capturePath -Raw).Trim() + Test-Path -LiteralPath $ownedRoot | Should -Be $true + @($caught.Exception.Data["GraphCodeValidationRetainedRoots"]) | Should -Contain $ownedRoot + if ($terminating) { $caught.Exception.Message | Should -BeExactly "controlled terminating child" } + foreach ($name in $savedEnvironment.Keys) { + [Environment]::GetEnvironmentVariable($name) | Should -BeExactly "before-$name" + } + } + + It "tagged string truthiness does not prove cleanup after workload failure" { + $original = [InvalidOperationException]::new("controlled ordinary failure") + $original.Data["GraphCodeValidationRootRetentionRequired"] = "true" + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + Set-Content -LiteralPath $capturePath -Value $env:GRAPHCODE_VALIDATION_ROOT + throw $original + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + Test-Path -LiteralPath (Get-Content -LiteralPath $capturePath -Raw).Trim() | Should -Be $true + $caught.Exception.Data["GraphCodeValidationRootRetentionReason"] | Should -BeExactly "workload_failed_without_cleanup_proof" + } + + It "tagged restoration preserves originally membership" -TestCases @( + foreach ($variable in @("GRAPHCODE_VALIDATION_ROOT", "GRAPHCODE_SUPPORT_DIR", "LOCALAPPDATA", "TEMP", "TMP")) { + foreach ($initialState in @("absent", "empty", "valued")) { + @{ state = $initialState; variableName = $variable } + } + } + ) { + param($state, $variableName) + if ($state -eq "absent") { + Remove-Item -LiteralPath "Env:\$variableName" -ErrorAction Stop + } else { + [Environment]::SetEnvironmentVariable($variableName, $(if ($state -eq "empty") { "" } else { "original-value" }), "Process") + } + $expected = [Environment]::GetEnvironmentVariables("Process") + $expected.Contains($variableName) | Should -Be ($state -ne "absent") + $original = [InvalidOperationException]::new("controlled membership restoration") + $original.Data["GraphCodeValidationRootRetentionRequired"] = $true + $caught = $null + try { + Invoke-WindowsShellValidationIsolation ` + -WorkspaceRoot $workspaceRoot ` + -UserProfileRoot $userProfileRoot ` + -LocalAppDataRoot $localAppDataRoot ` + -Action { + $isolated = [Environment]::GetEnvironmentVariables("Process") + $isolated.Contains($variableName) | Should -Be $true + $isolated[$variableName] | Should -Not -BeNullOrEmpty + throw $original + } + } catch { $caught = $_ } + [object]::ReferenceEquals($caught.Exception, $original) | Should -Be $true + $actual = [Environment]::GetEnvironmentVariables("Process") + $actual.Contains($variableName) | Should -Be $expected.Contains($variableName) + if ($expected.Contains($variableName)) { $actual[$variableName] | Should -BeExactly $expected[$variableName] } + } + It "uses an explicit short validation parent and removes only the owned run root" { $missingFunctions.Count | Should -Be 0 $shortParent = Join-Path $caseRoot "short" diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 38a31b02..1d9fd647 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -340,7 +340,7 @@ Assert-Contract ($appSource -match '(?s)smoke_tick >= 16 and !app\.smoke_input_requested.*?if \(app\.workspace\) \|workspace\| \{\s*app\.smoke_input_requested = true;') ` "smoke input must wait for its workspace instead of consuming the one-shot action early" Assert-Contract ($shellSource -match - '(?s)\$inputDeadline = .*?AddSeconds\(8\).*?\$inputApp = Start-Process.*?\$attachReady.*?pwsh.*?Write-OwnedResourceMetrics "windows-shell:large-paste".*?WaitForExit\(\$remainingMilliseconds\)') ` + '(?s)\$inputDeadline = .*?AddSeconds\(8\).*?\$inputApp = Start-SmokeProcess.*?\$attachReady.*?pwsh.*?Write-OwnedResourceMetrics "windows-shell:large-paste".*?WaitForExit\(\$remainingMilliseconds\)') ` "large-paste sampling must observe the owned attach within the shared eight-second deadline" Assert-Contract ($shellSource -match '(?s)while \(\[DateTime\]::UtcNow -lt \$inputDeadline.*?Write-OwnedResourceMetrics "windows-shell:large-paste".*?Start-Sleep -Milliseconds 50') ` @@ -442,7 +442,7 @@ Assert-Contract ($shellSource -match if ($shellSource -match '(?m)^\s*Write-OwnedResourceMetrics\s*$') { throw "Windows shell contract: empty resource metric phase" } -Assert-Contract ($shellSource -match '(?s)\$inputApp\s*=\s*Start-Process.*?\$inputApp\.Id.*?Write-OwnedResourceMetrics "windows-shell:large-paste" @\(\$inputApp\.Id\)') ` +Assert-Contract ($shellSource -match '(?s)\$inputApp\s*=\s*Start-SmokeProcess.*?\$inputApp\.Id.*?Write-OwnedResourceMetrics "windows-shell:large-paste" @\(\$inputApp\.Id\)') ` "large-paste metric is not tied to the recorded inputApp PID" Assert-Contract ($shellSource -match '(?s)GraphCode Windows shell restart smoke.*?Invoke-ShellProcess \$arguments "windows-shell:restart"') ` "restart snapshot is not assigned the restart phase" @@ -517,6 +517,30 @@ function Resolve-TestZig { throw "A working Zig executable is required for executable Windows shell tests." } +Invoke-Native "Shell process ownership mock contracts" { + . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") + $ownershipResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "ShellProcessOwnership.Tests.ps1") -PassThru + if ($ownershipResults.TotalCount -ne 66 -or + $ownershipResults.PassedCount -ne $ownershipResults.TotalCount -or + $ownershipResults.FailedCount -ne 0 -or $ownershipResults.SkippedCount -ne 0) { + throw "Shell process ownership mock contracts did not pass all 66 unskipped cases" + } + Write-Host "SHELL_PROCESS_OWNERSHIP_MOCK_CASES=$($ownershipResults.TotalCount)" + $global:LASTEXITCODE = 0 +} + +Invoke-Native "Stable canvas card context menu mock contracts" { + . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") + $cardMenuResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "CanvasCardContextMenu.Tests.ps1") -PassThru + if ($cardMenuResults.TotalCount -ne 16 -or $cardMenuResults.PassedCount -ne 16 -or + $cardMenuResults.FailedCount -ne 0 -or $cardMenuResults.SkippedCount -ne 0 -or + $cardMenuResults.NotRunCount -ne 0) { + throw "Stable canvas card context menu mock contracts did not pass all 16 unskipped cases" + } + Write-Host "CANVAS_CARD_CONTEXT_MENU_MOCK_CASES=$($cardMenuResults.TotalCount)" + $global:LASTEXITCODE = 0 +} + foreach ($path in @( "build.zig", "build.zig.zon", @@ -793,6 +817,10 @@ Invoke-Native "Sketch promotion executable tests" { Push-Location $shellRoot try { & $zig test src\SketchPromotion.zig --test-filter 'sketch promotion' } finally { Pop-Location } } +Invoke-Native "Card transfer data-only executable tests" { + Push-Location $shellRoot + try { & $zig test src\CardTransfer.zig --test-filter 'card transfer ' } finally { Pop-Location } +} Invoke-Native "Context menu and gate fixture message executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") @@ -832,6 +860,21 @@ Invoke-Native "Onboarding executable tests" { -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } +Invoke-Native "Owned live process cleanup mock contracts" { + & (Join-Path $PSScriptRoot "OwnedLiveProcessCleanup.Tests.ps1") + $global:LASTEXITCODE = 0 +} +Invoke-Native "Owned live caller and adapter mock integration" { + . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") + $adapterResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "OwnedLiveAdapter.Tests.ps1") -PassThru + if ($adapterResults.TotalCount -ne 12 -or $adapterResults.PassedCount -ne 12 -or + $adapterResults.FailedCount -ne 0 -or $adapterResults.SkippedCount -ne 0 -or + $adapterResults.NotRunCount -ne 0) { + throw "Owned live caller and adapter mock integration did not pass all 12 cases" + } + Write-Host "OWNED_LIVE_ADAPTER_MOCK_CASES=$($adapterResults.PassedCount)" + $global:LASTEXITCODE = 0 +} Invoke-Native "GDI+ asynchronous startup live test" { $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") if (-not $winghosttyRoot) { diff --git a/Tools/windows/UiaWindowActivation.cs b/Tools/windows/UiaWindowActivation.cs new file mode 100644 index 00000000..24718782 --- /dev/null +++ b/Tools/windows/UiaWindowActivation.cs @@ -0,0 +1,46 @@ +using System; + +namespace GraphCode.Windows { + public struct UiaActivationCallResult { + public readonly bool Succeeded; + public readonly int Error; + + public UiaActivationCallResult(bool succeeded, int error) { + Succeeded = succeeded; + Error = error; + } + } + + public interface IUiaWindowActivationOperations { + uint WindowProcessId(IntPtr window); + bool IsRetainedProcessAlive(uint processId); + void ShowWindow(IntPtr window, int command); + UiaActivationCallResult BringWindowToTop(IntPtr window); + UiaActivationCallResult SetForegroundWindow(IntPtr window); + bool IsForegroundWindow(IntPtr window); + } + + public static class UiaWindowActivation { + public static bool ActivateWindow( + IUiaWindowActivationOperations operations, IntPtr window, out string diagnostic + ) { + if (operations == null) throw new ArgumentNullException(nameof(operations)); + uint expectedProcessId = operations.WindowProcessId(window); + if (window == IntPtr.Zero || !operations.IsRetainedProcessAlive(expectedProcessId)) { + diagnostic = "invalid target HWND or no retained live launch identity"; + return false; + } + operations.ShowWindow(window, 9); + UiaActivationCallResult broughtToTop = operations.BringWindowToTop(window); + UiaActivationCallResult foregroundSet = operations.SetForegroundWindow(window); + bool observed = operations.IsForegroundWindow(window) && + operations.WindowProcessId(window) == expectedProcessId && + operations.IsRetainedProcessAlive(expectedProcessId); + diagnostic = String.Format( + "BringWindowToTop={0} GetLastError={1} SetForegroundWindow={2} GetLastError={3} observedOwnedForeground={4}; no synthetic keys or input-queue attachment", + broughtToTop.Succeeded, broughtToTop.Succeeded ? 0 : broughtToTop.Error, + foregroundSet.Succeeded, foregroundSet.Succeeded ? 0 : foregroundSet.Error, observed); + return observed; + } + } +} diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 92e7e804..ffe8b04a 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -5,13 +5,30 @@ param( [string[]] $ArgumentList = @(), [switch]$SidebarParityOnly, [switch]$UnwiredRecoveryOnly, - [switch]$CycleHierarchyOnly + [switch]$CycleHierarchyOnly, + [string] $DeleteSelectFailureReceipt = $env:GRAPHCODE_UIA_DELETE_SELECT_FAILURE_RECEIPT ) $ErrorActionPreference = "Stop" +Import-Module -Name (Join-Path $PSScriptRoot "ShellProcessOwnership.psm1") -ErrorAction Stop Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes Add-Type -AssemblyName System.Drawing +$uiaActivationType = "GraphCode.Windows.UiaWindowActivation" -as [type] +if ($null -eq $uiaActivationType) { + $uiaActivationDirectory = Join-Path ([IO.Path]::GetTempPath()) ("gc-uia-activation-" + [guid]::NewGuid().ToString("N")) + $null = New-Item -ItemType Directory -Path $uiaActivationDirectory -ErrorAction Stop + $uiaActivationAssemblyPath = Join-Path $uiaActivationDirectory "UiaWindowActivation.dll" + # Keep the assembly file for this host's lifetime so later helper compilation can reference it. + Add-Type -Path (Join-Path $PSScriptRoot "UiaWindowActivation.cs") -OutputAssembly $uiaActivationAssemblyPath + Add-Type -Path $uiaActivationAssemblyPath + $uiaActivationType = "GraphCode.Windows.UiaWindowActivation" -as [type] +} +$uiaActivationAssemblyPath = $uiaActivationType.Assembly.Location +if (-not [IO.Path]::IsPathFullyQualified($uiaActivationAssemblyPath) -or + -not [IO.File]::Exists($uiaActivationAssemblyPath)) { + throw "UIA activation component has no file-backed assembly reference" +} Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; @@ -151,8 +168,6 @@ public static class GraphCodeUiaGateState { [DllImport("user32.dll")] private static extern IntPtr SetActiveWindow(IntPtr window); [DllImport("user32.dll")] - private static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo); - [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window); [DllImport("user32.dll")] private static extern bool IsWindowEnabled(IntPtr window); @@ -285,16 +300,20 @@ public static class GraphCodeUiaGateState { if (menu == IntPtr.Zero || !GetMenuItemRect(owner, menu, (uint)position, out item)) return false; int x = item.Right - Math.Min(12, (item.Right - item.Left) / 2); int y = (item.Top + item.Bottom) / 2; + uint ownerProcess = WindowProcessId(owner); + IntPtr popup = FindPopupForMenu(ownerProcess, menu); + if (popup == IntPtr.Zero) return false; + RequireOwnedPopupForeground(popup, owner, ownerProcess); + IntPtr hit = WindowFromPoint(new ScreenPoint { X = x, Y = y }); + if (GetAncestor(hit, 2) != popup || WindowProcessId(hit) != ownerProcess) + throw new InvalidOperationException("Submenu hover point is covered; no cursor movement was injected"); if (!SetCursorPos(x, y)) throw new InvalidOperationException(String.Format("SetCursorPos for submenu failed: Win32Error={0}", Marshal.GetLastWin32Error())); ScreenPoint at; if (!GetCursorPos(out at)) return false; - uint ownerProcess; - GetWindowThreadProcessId(owner, out ownerProcess); - IntPtr popup = FindPopupForMenu(ownerProcess, menu); - if (popup == IntPtr.Zero) return false; var client = new ScreenPoint { X = x, Y = y }; if (!ScreenToClient(popup, ref client)) return false; + RequireOwnedPopupForeground(popup, owner, ownerProcess); SendMessage(popup, 0x0200, UIntPtr.Zero, MouseLParam(client.X, client.Y)); bool hilite = false; for (int attempt = 0; attempt < 50 && !hilite; attempt++) { @@ -417,31 +436,84 @@ public static class GraphCodeUiaGateState { } public static bool FocusControl(IntPtr parent, IntPtr control) { if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; - ActivateWindow(parent); + uint expectedProcessId = WindowProcessId(parent); + RequireOwnedInputWindow(parent, expectedProcessId); + if (WindowProcessId(control) != expectedProcessId || GetAncestor(control, 2) != parent || + !ActivateWindow(parent)) return false; uint ignoredProcessId; uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); uint currentThread = GetCurrentThreadId(); bool attached = currentThread != parentThread && AttachThreadInput(currentThread, parentThread, true); try { + RequireOwnedForeground(parent, expectedProcessId); SetFocus(control); - return IsForegroundWindow(parent) && GetFocus() == control; + return IsForegroundWindow(parent) && FocusedControlInDialog(parent) == control; } finally { if (attached) AttachThreadInput(currentThread, parentThread, false); } } + [StructLayout(LayoutKind.Sequential)] + private struct GuiThreadInfo { + public int Size; + public uint Flags; + public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret; + public RECT CaretRectangle; + } + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetGUIThreadInfo(uint thread, ref GuiThreadInfo info); public static IntPtr FocusedControlInDialog(IntPtr parent) { - if (parent == IntPtr.Zero) return IntPtr.Zero; - uint ignoredProcessId; - uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); - uint currentThread = GetCurrentThreadId(); - bool attached = currentThread != parentThread && - AttachThreadInput(currentThread, parentThread, true); - try { - return GetFocus(); - } finally { - if (attached) AttachThreadInput(currentThread, parentThread, false); - } + uint processId; + uint thread = GetWindowThreadProcessId(parent, out processId); + var info = new GuiThreadInfo { Size = Marshal.SizeOf(typeof(GuiThreadInfo)) }; + if (thread == 0 || !GetGUIThreadInfo(thread, ref info)) + throw new InvalidOperationException("Owned focus could not be observed through GetGUIThreadInfo"); + return info.Focus; + } + private static readonly System.Collections.Generic.Dictionary + RetainedProcesses = new System.Collections.Generic.Dictionary(); + public static void RetainProcess(System.Diagnostics.Process process) { + if (process == null || process.Handle == IntPtr.Zero) + throw new InvalidOperationException("UIA process has no retained launch handle"); + RetainedProcesses[checked((uint)process.Id)] = process; + } + private static bool IsRetainedProcessAlive(uint processId) { + System.Diagnostics.Process process; + if (!RetainedProcesses.TryGetValue(processId, out process)) return false; + try { return process.Handle != IntPtr.Zero && !process.HasExited; } + catch (ObjectDisposedException) { return false; } + catch (InvalidOperationException) { return false; } + } + private static void RequireOwnedInputWindow(IntPtr owner, uint expectedProcessId) { + if (owner == IntPtr.Zero || expectedProcessId == 0 || !IsRetainedProcessAlive(expectedProcessId) || + WindowProcessId(owner) != expectedProcessId || !WindowIsVisible(owner) || !WindowIsEnabled(owner)) + throw new InvalidOperationException("Input target lost retained process/window ownership; no input was injected"); + } + private static void RequireOwnedForeground(IntPtr owner, uint expectedProcessId) { + RequireOwnedInputWindow(owner, expectedProcessId); + IntPtr foreground = GetForegroundWindow(); + if (foreground != owner || WindowProcessId(foreground) != expectedProcessId) + throw new InvalidOperationException("Owned foreground changed; no input was injected"); + } + public static void RequireOwnedKeyboardInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus) { + RequireOwnedForeground(owner, expectedProcessId); + IntPtr focus = FocusedControlInDialog(owner); + if (focus == IntPtr.Zero || WindowProcessId(focus) != expectedProcessId || + GetAncestor(focus, 2) != owner || (expectedFocus != IntPtr.Zero && focus != expectedFocus)) + throw new InvalidOperationException("Owned keyboard focus changed; no key input was injected"); + RequireOwnedForeground(owner, expectedProcessId); + } + private static void RequireOwnedMouseInput( + IntPtr owner, uint expectedProcessId, ScreenPoint expected, IntPtr expectedControl + ) { + RequireOwnedForeground(owner, expectedProcessId); + ScreenPoint actual; + if (!GetCursorPos(out actual) || actual.X != expected.X || actual.Y != expected.Y) + throw new InvalidOperationException("Owned mouse cursor changed; no mouse input was injected"); + IntPtr hit = WindowFromPoint(actual); + if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner || WindowProcessId(hit) != expectedProcessId || + (expectedControl != IntPtr.Zero && !ResolvesToControl(expectedControl, owner, actual))) + throw new InvalidOperationException("Owned mouse hit changed; no mouse input was injected"); } public static bool IsControlOwnedBy(IntPtr parent, IntPtr control, int controlId) { return parent != IntPtr.Zero && control != IntPtr.Zero && @@ -456,45 +528,35 @@ public static class GraphCodeUiaGateState { GetWindowRect(window, out rect) && rect.Right > rect.Left && rect.Bottom > rect.Top; } public static string LastActivationDiagnostic = "not attempted"; - public static bool ActivateWindow(IntPtr window) { - if (window == IntPtr.Zero) { - LastActivationDiagnostic = "invalid target HWND"; - return false; + private sealed class WindowActivationOperations : GraphCode.Windows.IUiaWindowActivationOperations { + public uint WindowProcessId(IntPtr window) { + return GraphCodeUiaGateState.WindowProcessId(window); } - IntPtr foreground = GetForegroundWindow(); - uint ignoredForegroundProcessId; - uint foregroundThread = foreground == IntPtr.Zero ? 0 : - GetWindowThreadProcessId(foreground, out ignoredForegroundProcessId); - uint ignoredTargetProcessId; - uint targetThread = GetWindowThreadProcessId(window, out ignoredTargetProcessId); - uint currentThread = GetCurrentThreadId(); - bool needsForegroundAttach = foregroundThread != 0 && currentThread != foregroundThread; - bool attachForeground = !needsForegroundAttach || - AttachThreadInput(currentThread, foregroundThread, true); int foregroundAttachError = Marshal.GetLastWin32Error(); - bool needsTargetAttach = currentThread != targetThread; - bool attachTarget = !needsTargetAttach || - AttachThreadInput(currentThread, targetThread, true); int targetAttachError = Marshal.GetLastWin32Error(); - try { - ShowWindow(window, 9); - bool broughtToTop = BringWindowToTop(window); - int bringError = Marshal.GetLastWin32Error(); - keybd_event(0x12, 0, 0, UIntPtr.Zero); - keybd_event(0x12, 0, 0x0002, UIntPtr.Zero); - SetActiveWindow(window); - bool foregroundSet = SetForegroundWindow(window); int foregroundError = Marshal.GetLastWin32Error(); - bool observed = IsForegroundWindow(window); - LastActivationDiagnostic = String.Format( - "AttachThreadInput(foreground)={0} GetLastError={1} AttachThreadInput(target)={2} GetLastError={3} BringWindowToTop={4} GetLastError={5} SetForegroundWindow={6} GetLastError={7} (FALSE can leave no meaningful last error) observedForeground={8}", - attachForeground, needsForegroundAttach && !attachForeground ? foregroundAttachError : 0, - attachTarget, needsTargetAttach && !attachTarget ? targetAttachError : 0, - broughtToTop, broughtToTop ? 0 : bringError, - foregroundSet, foregroundSet ? 0 : foregroundError, observed - ); - return observed; - } finally { - if (needsTargetAttach && attachTarget) AttachThreadInput(currentThread, targetThread, false); - if (needsForegroundAttach && attachForeground) AttachThreadInput(currentThread, foregroundThread, false); + public bool IsRetainedProcessAlive(uint processId) { + return GraphCodeUiaGateState.IsRetainedProcessAlive(processId); + } + public void ShowWindow(IntPtr window, int command) { + GraphCodeUiaGateState.ShowWindow(window, command); + } + public GraphCode.Windows.UiaActivationCallResult BringWindowToTop(IntPtr window) { + bool succeeded = GraphCodeUiaGateState.BringWindowToTop(window); + int error = Marshal.GetLastWin32Error(); + return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); } + public GraphCode.Windows.UiaActivationCallResult SetForegroundWindow(IntPtr window) { + bool succeeded = GraphCodeUiaGateState.SetForegroundWindow(window); + int error = Marshal.GetLastWin32Error(); + return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); + } + public bool IsForegroundWindow(IntPtr window) { + return GraphCodeUiaGateState.IsForegroundWindow(window); + } + } + private static readonly GraphCode.Windows.IUiaWindowActivationOperations ActivationOperations = + new WindowActivationOperations(); + public static bool ActivateWindow(IntPtr window) { + return GraphCode.Windows.UiaWindowActivation.ActivateWindow( + ActivationOperations, window, out LastActivationDiagnostic); } public static bool IsForegroundWindow(IntPtr window) { return window != IntPtr.Zero && GetForegroundWindow() == window; @@ -521,17 +583,6 @@ public static class GraphCodeUiaGateState { GetWindowText(window, text, text.Capacity); return text.ToString(); } - public static void HideWindow(IntPtr window) { - if (window != IntPtr.Zero) ShowWindow(window, 0); - } - public static void HideProcessWindows(uint processId) { - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId) HideWindow(window); - return true; - }, IntPtr.Zero); - } // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren // exhaustion path): enumerates every top-level window currently owned by // processId with its class, visibility, and title, so a failure that @@ -653,6 +704,8 @@ public static class GraphCodeUiaGateState { PostMessage(window, 0x0205, UIntPtr.Zero, point); } public static int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { + uint expectedProcessId = WindowProcessId(owner); + RequireOwnedForeground(owner, expectedProcessId); if (!WindowIsVisible(owner) || right <= left || bottom <= top) throw new InvalidOperationException("Owned rectangle is not visibly hittable"); var point = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; @@ -668,6 +721,7 @@ public static class GraphCodeUiaGateState { new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0008u : 0x0002u } }, new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0010u : 0x0004u } } }; + RequireOwnedMouseInput(owner, expectedProcessId, point, IntPtr.Zero); uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); if (sent != inputs.Length) throw new InvalidOperationException(String.Format("Owned rectangle SendInput sent {0}/{1}", sent, inputs.Length)); @@ -686,6 +740,16 @@ public static class GraphCodeUiaGateState { return PostMessage(owner, 0x0201, (UIntPtr)0x0001, MouseLParam(point.X, point.Y)) && PostMessage(owner, 0x0202, UIntPtr.Zero, MouseLParam(point.X, point.Y)); } + public static bool IsOwnedPopupInput( + IntPtr owner, IntPtr popup, uint expectedProcessId, uint popupProcessId, + uint foregroundProcessId, IntPtr foregroundRoot, uint pointProcessId, + IntPtr pointRoot, bool popupVisible + ) { + return owner != IntPtr.Zero && popup != IntPtr.Zero && expectedProcessId != 0 && + popupProcessId == expectedProcessId && foregroundProcessId == expectedProcessId && + (foregroundRoot == owner || foregroundRoot == popup) && + pointProcessId == expectedProcessId && pointRoot == popup && popupVisible; + } public sealed class PopupItemHit { public int Position, ItemId, Left, Top, Right, Bottom; public int ScreenX, ScreenY, ClientX, ClientY, CursorBeforeX, CursorBeforeY; @@ -693,7 +757,7 @@ public static class GraphCodeUiaGateState { public bool Hilite, UsedKeyboardFallback, UsedSelectItemFallback; public bool HiliteAtEnterFallback, HiliteBeforeEnter; } - public static PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId) { + public static PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId) { if (popup == IntPtr.Zero || position < 0) return null; IntPtr menu = PopupMenuHandle(popup); RECT item; @@ -712,6 +776,7 @@ public static class GraphCodeUiaGateState { ScreenPoint before, at; if (!GetCursorPos(out before)) throw new InvalidOperationException(String.Format("GetCursorPos before popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + RequireOwnedPopupForeground(popup, owner, expectedProcessId); if (!SetCursorPos(screenX, screenY)) throw new InvalidOperationException(String.Format("SetCursorPos for popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); if (!GetCursorPos(out at)) @@ -739,6 +804,19 @@ public static class GraphCodeUiaGateState { new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } }; + ScreenPoint cursorBeforeInput; + if (!GetCursorPos(out cursorBeforeInput) || cursorBeforeInput.X != screenX || cursorBeforeInput.Y != screenY) + throw new InvalidOperationException("Popup input cursor changed; no mouse input was injected"); + uint inputOwnerProcessId = WindowProcessId(owner); + if (inputOwnerProcessId != expectedProcessId || !IsRetainedProcessAlive(expectedProcessId)) + throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); + IntPtr foregroundBeforeInput = GetForegroundWindow(); + IntPtr inputPointWindow = WindowFromPoint(cursorBeforeInput); + if (!IsOwnedPopupInput( + owner, popup, inputOwnerProcessId, WindowProcessId(popup), + WindowProcessId(foregroundBeforeInput), GetAncestor(foregroundBeforeInput, 2), + WindowProcessId(inputPointWindow), GetAncestor(inputPointWindow, 2), IsWindowVisible(popup))) + throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); if (sent != inputs.Length) throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} popup mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); @@ -759,6 +837,7 @@ public static class GraphCodeUiaGateState { if (IsWindowVisible(popup)) { if (!hiliteBeforeEnter) throw new InvalidOperationException(String.Format("Popup item {0} was not hilited before the Enter fallback (hilite at fallback={1})", commandId, hiliteAtEnterFallback)); + RequireOwnedPopupForeground(popup, owner, expectedProcessId); keyboardFallback = PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero) && PostMessage(popup, 0x0101, (UIntPtr)0x0D, IntPtr.Zero); @@ -777,6 +856,14 @@ public static class GraphCodeUiaGateState { HiliteBeforeEnter = hiliteBeforeEnter }; } + private static void RequireOwnedPopupForeground(IntPtr popup, IntPtr owner, uint expectedProcessId) { + IntPtr foreground = GetForegroundWindow(); + IntPtr foregroundRoot = GetAncestor(foreground, 2); + if (expectedProcessId == 0 || !IsRetainedProcessAlive(expectedProcessId) || WindowProcessId(owner) != expectedProcessId || + WindowProcessId(popup) != expectedProcessId || WindowProcessId(foreground) != expectedProcessId || + (foregroundRoot != owner && foregroundRoot != popup) || !IsWindowVisible(popup)) + throw new InvalidOperationException("Popup input ownership changed; no further input was injected"); + } // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with // no UIA identity of their own, so a genuine click requires the real live client // height rather than an assumed window size. @@ -1144,6 +1231,8 @@ public static class GraphCodeUiaGateState { hit.Samples = samples; } if (!hit.HitTarget) return hit; + uint expectedProcessId = WindowProcessId(dialog); + RequireOwnedForeground(dialog, expectedProcessId); ScreenPoint before, at; if (!GetCursorPos(out before)) throw new InvalidOperationException(String.Format("GetCursorPos before control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); @@ -1159,48 +1248,58 @@ public static class GraphCodeUiaGateState { new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } }; + RequireOwnedMouseInput(dialog, expectedProcessId, center, target); uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); if (sent != inputs.Length) throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} control mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); return hit; } - public static int SendKeyInput(ushort virtualKey, int count) { + public static int SendKeyInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, ushort virtualKey, int count) { if (count <= 0) return 0; var inputs = new KeyInputRecord[count * 2]; for (int index = 0; index < count; index++) { inputs[index * 2] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey } }; inputs[index * 2 + 1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey, Flags = 0x0002 } }; } + RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); uint sent = SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); if (sent != inputs.Length) throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} key events (cbSize={2}): Win32Error={3}", sent, inputs.Length, Marshal.SizeOf(typeof(KeyInputRecord)), Marshal.GetLastWin32Error())); return count; } + public static int[] SendUnicodeInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, string text) { + var records = new KeyInputRecord[text.Length * 2]; + int offset = 0; + foreach (char character in text) { + records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } }; + records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } }; + } + if (records.Length == 0) return new int[] { 0, 0 }; + RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); + uint sent = SendKeyInputs((uint)records.Length, records, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != records.Length) + throw new InvalidOperationException(String.Format("Owned Unicode SendInput injected {0}/{1}", sent, records.Length)); + return new int[] { checked((int)sent), records.Length }; + } public static bool TypeEditTextById(IntPtr parent, int controlId, string text) { LastEditClearExpected = LastEditClearSent = 0; LastEditTextExpected = LastEditTextSent = 0; LastEditUsedMessageFallback = false; IntPtr edit = ControlById(parent, controlId); if (edit == IntPtr.Zero || !FocusControl(parent, edit)) return false; + uint expectedProcessId = WindowProcessId(parent); SendMessage(edit, 0x00B1, UIntPtr.Zero, new IntPtr(-1)); var clear = new KeyInputRecord[2]; clear[0] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E } }; clear[1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E, Flags = 2 } }; LastEditClearExpected = (uint)clear.Length; + RequireOwnedKeyboardInput(parent, expectedProcessId, edit); LastEditClearSent = SendKeyInputs(LastEditClearExpected, clear, Marshal.SizeOf(typeof(KeyInputRecord))); if (LastEditClearSent != LastEditClearExpected) return false; bool cleared = String.IsNullOrEmpty(EditBufferText(edit)); - var records = new KeyInputRecord[text.Length * 2]; - int offset = 0; - foreach (char character in text) { - records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } }; - records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } }; - } - LastEditTextExpected = (uint)records.Length; - if (records.Length > 0) { - LastEditTextSent = SendKeyInputs(LastEditTextExpected, records, Marshal.SizeOf(typeof(KeyInputRecord))); - if (LastEditTextSent != LastEditTextExpected) return false; - } + int[] typed = SendUnicodeInput(parent, expectedProcessId, edit, text); + LastEditTextExpected = checked((uint)typed[1]); + LastEditTextSent = checked((uint)typed[0]); bool matched = cleared && String.Equals(EditTextById(parent, controlId), text, StringComparison.Ordinal); if (!matched) { SendMessageString(edit, 0x000C, UIntPtr.Zero, text); @@ -1253,7 +1352,11 @@ public static class GraphCodeUiaGateState { } "@ -ReferencedAssemblies @( [System.Windows.Automation.AutomationElement].Assembly.Location, - [System.Windows.Automation.AutomationEventArgs].Assembly.Location + [System.Windows.Automation.AutomationEventArgs].Assembly.Location, + [Diagnostics.Process].Assembly.Location, + "System.Collections", + "System.ComponentModel.Primitives", + $uiaActivationAssemblyPath ) Add-Type -TypeDefinition @" @@ -1529,6 +1632,61 @@ function Require([bool] $condition, [string] $message) { if (-not $condition) { throw $message } } +function Wait-UiaUnwiredRecoveryVisibility($root, $rawWalker, $process, $wire, $mark) { + $wireId = [string]$wire.Current.AutomationId + $markId = [string]$mark.Current.AutomationId + Require ($process.Id -gt 0 -and + $wire.Current.ProcessId -eq $process.Id -and $mark.Current.ProcessId -eq $process.Id -and + $wireId.StartsWith("wire-node-", [StringComparison]::Ordinal) -and + $markId.StartsWith("mark-entry-", [StringComparison]::Ordinal) -and + $wire.Current.Name -ceq "Wire it up" -and $mark.Current.Name -ceq "Mark as entry") ` + "Unwired recovery source controls did not belong to the owned graph" + function Test-RecoveryVisibility($wire, $mark, $graph, [int] $owner, [string] $wireId, [string] $markId) { + if ($null -eq $wire -or $null -eq $mark -or $null -eq $graph -or + $graph.Current.ProcessId -ne $owner -or $graph.Current.AutomationId -cne "graph" -or + $wire.Current.ProcessId -ne $owner -or $mark.Current.ProcessId -ne $owner -or + $wire.Current.AutomationId -cne $wireId -or $mark.Current.AutomationId -cne $markId -or + $wire.Current.Name -cne "Wire it up" -or $mark.Current.Name -cne "Mark as entry" -or + $graph.Current.IsOffscreen) { return $false } + $graphBounds = $graph.Current.BoundingRectangle + if ($graphBounds.IsEmpty -or $graphBounds.Width -le 0 -or $graphBounds.Height -le 0) { return $false } + foreach ($control in @($wire, $mark)) { + if ($control.Current.IsOffscreen) { return $false } + $bounds = $control.Current.BoundingRectangle + if ($bounds.IsEmpty -or $bounds.Width -le 0 -or $bounds.Height -le 0 -or + $bounds.Left -lt $graphBounds.Left -or $bounds.Top -lt $graphBounds.Top -or + $bounds.Right -gt $graphBounds.Right -or $bounds.Bottom -gt $graphBounds.Bottom) { return $false } + } + return $true + } + $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker + Require ($null -ne $graph -and $graph.Current.ProcessId -eq $process.Id -and + $graph.Current.AutomationId -ceq "graph") "Unwired recovery graph did not belong to the owned shell" + if (Test-RecoveryVisibility $wire $mark $graph $process.Id $wireId $markId) { + return [ordered]@{ root = $root; graph = $graph; wire = $wire; mark = $mark } + } + $fitRecovery = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require ($null -ne $fitRecovery -and $fitRecovery.Current.ProcessId -eq $process.Id -and + $fitRecovery.Current.AutomationId -ceq "fit-canvas" -and $fitRecovery.Current.Name -ceq "Fit canvas" -and + $fitRecovery.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and + $fitRecovery.Current.IsEnabled -and -not $fitRecovery.Current.IsOffscreen) ` + "Unwired recovery could not acquire the owned visible Fit canvas action" + $fitRecovery.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $root = Wait-ForRootReconnect $process @($rawWalker) + $graph = Find-FragmentById $root "graph" $rawWalker + $wire = Find-FragmentById $root $wireId $rawWalker + $mark = Find-FragmentById $root $markId $rawWalker + if (Test-RecoveryVisibility $wire $mark $graph $process.Id $wireId $markId) { + return [ordered]@{ root = $root; graph = $graph; wire = $wire; mark = $mark } + } + if ($attempt -lt 39) { Start-Sleep -Milliseconds 100 } + } + Require $false "Unwired recovery actions remained unavailable after Fit canvas" +} + function Test-MultiProjectOwnedSheetIdentity( $identity, [int] $expectedProcessId, @@ -1577,6 +1735,156 @@ function Test-UpdateBannerClickTarget( $shellStatus -ceq "Update offer deferred" } +function Get-DeleteTargetDiagnosticValue([hashtable] $budget, [scriptblock] $readValue) { + if ($budget.reads -ge 11 -or $budget.clock.ElapsedMilliseconds -ge 500) { + return [ordered]@{ state = "unavailable"; reason = "diagnostic-budget" } + } + $budget.reads++ + try { + $value = & $readValue + if ($budget.clock.ElapsedMilliseconds -ge 500) { + return [ordered]@{ state = "unavailable"; reason = "diagnostic-budget" } + } + if ($null -eq $value) { return [ordered]@{ state = "unavailable" } } + return [ordered]@{ state = "available"; value = $value } + } catch { + return [ordered]@{ state = "unavailable"; hresult = $_.Exception.HResult } + } +} + +function Get-DeleteTargetIdentityHash([string] $identity) { + if ([string]::IsNullOrEmpty($identity) -or $identity.Length -gt 4096) { + throw [ArgumentException]::new("Diagnostic identity is absent or exceeds its bound") + } + return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($identity))) +} + +function Write-DeleteTargetSelectFailure( + $target, $selection, [string] $expectedIdentity, [int] $expectedProcessId, + [IntPtr] $expectedWindow, [Management.Automation.ErrorRecord] $failure, + [string] $receiptPath +) { + $budget = @{ reads = 0; clock = [Diagnostics.Stopwatch]::StartNew() } + $untrusted = [ordered]@{ state = "unavailable"; reason = "untrusted-identity" } + $processId = Get-DeleteTargetDiagnosticValue $budget { + $value = $target.Current.ProcessId + if ($value -isnot [int]) { throw "Process identity is unavailable" } + $value + } + $owned = $processId.state -ceq "available" -and $expectedProcessId -gt 0 -and $processId.value -eq $expectedProcessId + $identity = if ($owned) { + Get-DeleteTargetDiagnosticValue $budget { Get-DeleteTargetIdentityHash $target.Current.AutomationId } + } else { $untrusted } + $expectedHash = try { + [ordered]@{ state = "available"; value = Get-DeleteTargetIdentityHash $expectedIdentity } + } catch { [ordered]@{ state = "unavailable" } } + $matches = $identity.state -ceq "available" -and $expectedHash.state -ceq "available" -and + $identity.value -ceq $expectedHash.value + $record = [ordered]@{ + expectedProcessId = $expectedProcessId; expectedWindow = $expectedWindow.ToInt64() + automationIdHash = $identity; expectedAutomationIdHash = $expectedHash + identityMatches = if ($identity.state -ceq "available" -and $expectedHash.state -ceq "available") { + $identity.value -ceq $expectedHash.value + } else { $null } + processId = $processId + ownedProcess = if ($processId.state -ceq "available") { $processId.value -eq $expectedProcessId } else { $null } + runtimeIdHash = $untrusted; runtimeComparison = "unavailable-no-preselect-snapshot" + bounds = $untrusted; offscreen = $untrusted; enabled = $untrusted; selected = $untrusted + parent = $untrusted + retired = @{ state = "unavailable"; reason = "not-exposed" } + active = @{ state = "unavailable"; reason = "not-exposed" } + invokable = @{ state = "unavailable"; reason = "not-exposed" } + providerBranch = @{ state = "unavailable"; reason = "not-exposed" } + exceptionHResults = @(); exceptionChainTruncated = $false + } + if ($owned -and $matches) { + $record.runtimeIdHash = Get-DeleteTargetDiagnosticValue $budget { + $runtime = @($target.GetRuntimeId()) + if ($runtime.Count -eq 0 -or $runtime.Count -gt 64) { throw "Runtime identity exceeds diagnostic bound" } + foreach ($part in $runtime) { + if ($part -isnot [int]) { throw "Runtime identity is not an integer vector" } + } + Get-DeleteTargetIdentityHash ($runtime -join ",") + } + $record.bounds = Get-DeleteTargetDiagnosticValue $budget { + $bounds = $target.Current.BoundingRectangle + $values = @($bounds.Left, $bounds.Top, $bounds.Width, $bounds.Height) + foreach ($value in $values) { + if ($value -isnot [double] -or [double]::IsNaN($value) -or [double]::IsInfinity($value)) { + throw "Bounds are unavailable or nonfinite" + } + } + [ordered]@{ left = [double]$values[0]; top = [double]$values[1]; width = [double]$values[2]; height = [double]$values[3] } + } + foreach ($field in @("offscreen", "enabled", "selected")) { + $record[$field] = Get-DeleteTargetDiagnosticValue $budget { + $value = switch ($field) { + "offscreen" { $target.Current.IsOffscreen } + "enabled" { $target.Current.IsEnabled } + "selected" { $selection.Current.IsSelected } + } + if ($value -isnot [bool]) { throw "Boolean property is unavailable" } + $value + } + } + # This provider returns row.parent through the held selection pattern. + $container = Get-DeleteTargetDiagnosticValue $budget { $selection.Current.SelectionContainer } + if ($container.state -ceq "available") { + $record.parent = [ordered]@{ + state = "available"; classification = "selection-container" + processId = Get-DeleteTargetDiagnosticValue $budget { + $value = $container.value.Current.ProcessId + if ($value -isnot [int]) { throw "Parent process identity is unavailable" } + $value + } + controlTypeId = $untrusted; classNameHash = $untrusted + } + if ($record.parent.processId.state -ceq "available" -and + $record.parent.processId.value -eq $expectedProcessId) { + $record.parent.controlTypeId = Get-DeleteTargetDiagnosticValue $budget { + $value = $container.value.Current.ControlType.Id + if ($value -isnot [int]) { throw "Parent role is unavailable" } + $value + } + $record.parent.classNameHash = Get-DeleteTargetDiagnosticValue $budget { + Get-DeleteTargetIdentityHash $container.value.Current.ClassName + } + } + } else { + $record.parent = [ordered]@{ state = "unavailable"; reason = "selection-container-unavailable" } + } + } + $exception = $failure.Exception + for ($index = 0; $null -ne $exception -and $index -lt 4; $index++) { + $record.exceptionHResults += [int]$exception.HResult + $exception = $exception.InnerException + } + $record.exceptionChainTruncated = $null -ne $exception + $record.readCount = $budget.reads + $record.readLimit = 11 + $record.budgetMilliseconds = 500 + $record.elapsedMilliseconds = $budget.clock.ElapsedMilliseconds + $json = $record | ConvertTo-Json -Depth 6 -Compress + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json) + if ($bytes.Length -gt 8192) { throw "Delete Select receipt exceeds 8192 bytes" } + Write-Host ("UIA_DELETE_TARGET_SELECT_FAILURE=" + $json) + if ([string]::IsNullOrWhiteSpace($receiptPath)) { + Write-Host "UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=receipt-path-unconfigured" + return + } + $stream = $null + try { + $stream = [IO.FileStream]::new($receiptPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + Write-Host ("UIA_DELETE_TARGET_SELECT_RECEIPT=flushed bytes={0} sha256={1}" -f + $bytes.Length, [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes))) +} + function Get-UiaOwnedSheetIdentity($element) { if ($null -eq $element) { return $null } $handle = [IntPtr]$element.Current.NativeWindowHandle @@ -1868,6 +2176,43 @@ function ConvertTo-PopupMenuEvidence($items) { }) } +function Open-CanvasCardContextMenu( + $process, + $root, + [IntPtr] $ownerWindow, + [string] $automationId, + $walker, + [string] $label +) { + Require (Ensure-ShellForeground $ownerWindow "$label context menu") ` + "GraphCode shell did not reacquire foreground before the $label context menu" + $card = Find-FragmentByIdWithRetry $root $automationId $walker + Require ($null -ne $card) "$label context menu lost its stable card" + Require ($card.Current.AutomationId -ceq $automationId -and + $card.Current.ProcessId -eq $process.Id -and -not $card.Current.IsOffscreen) ` + "$label context menu card identity, process or visibility changed" + $canvas = Find-FragmentByIdWithRetry $root "graph" $walker + Require ($null -ne $canvas) "$label context menu lost its canvas" + $bounds = $card.Current.BoundingRectangle + $canvasBounds = $canvas.Current.BoundingRectangle + $cardRect = @($bounds.Left, $bounds.Top, $bounds.Right, $bounds.Bottom) + $canvasRect = @($canvasBounds.Left, $canvasBounds.Top, $canvasBounds.Right, $canvasBounds.Bottom) + Require (@(@($cardRect) + @($canvasRect) | Where-Object { + [double]::IsNaN($_) -or [double]::IsInfinity($_) + }).Count -eq 0) "$label context menu requires finite measured bounds" + $visible = Get-MultiProjectClippedRectangle $cardRect $canvasRect + $x = [int](($visible[0] + $visible[2]) / 2) + $y = [int](($visible[1] + $visible[3]) / 2) + Require ($x -ge $visible[0] -and $x -lt $visible[2] -and + $y -ge $visible[1] -and $y -lt $visible[3]) ` + "$label context menu card has no interior screen point" + Require ([GraphCodeUiaGateState]::PostOwnedScreenPoint($ownerWindow, $x, $y, $true)) ` + "$label context menu stable-card right-click was rejected" + $popup = Wait-ForPopupMenu $process $ownerWindow $label + Require ($popup -ne [IntPtr]::Zero) "$label context menu did not open" + return $popup +} + function Read-CanvasContextMenu( [System.Diagnostics.Process] $process, [IntPtr] $ownerWindow, @@ -2024,25 +2369,6 @@ function Wait-ForDesktopElementGone( return $null -eq $element } -function Hide-TestProviderZmxWindows { - if (-not $env:GRAPHCODE_ZMX) { return } - $providerZmx = [IO.Path]::GetFullPath($env:GRAPHCODE_ZMX) - foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -match "(?i)^zmx(?:\.exe)?$" -and - (([string]$_.ExecutablePath) -eq $providerZmx -or - ([string]$_.CommandLine) -like "*$providerZmx*") - })) { - [GraphCodeUiaGateState]::HideProcessWindows([uint32]$process.ProcessId) - } - $foreground = [GraphCodeUiaGateState]::CurrentForegroundWindow() - $foregroundTitle = [GraphCodeUiaGateState]::WindowTitle($foreground) - if ($foregroundTitle -eq $providerZmx -or - $foregroundTitle -like "*\zmx.exe") { - [GraphCodeUiaGateState]::HideWindow($foreground) - } -} - function Test-FocusedElementIdentity( [System.Windows.Automation.AutomationElement] $candidate, [System.Windows.Automation.AutomationElement] $expected, @@ -2072,11 +2398,9 @@ function Retain-FocusWithRetry( [int] $Attempts = 600 ) { $candidate = $null - Hide-TestProviderZmxWindows Start-Sleep -Milliseconds 250 Write-Host "UIA_FOCUS_DIAGNOSTICS phase=$label $(Get-FocusDiagnostics $window)" for ($index = 0; $index -lt $Attempts; $index++) { - Hide-TestProviderZmxWindows $activated = [GraphCodeUiaGateState]::ActivateWindow($window) Start-Sleep -Milliseconds 50 try { @@ -2113,12 +2437,8 @@ function Ensure-ShellForeground( # command/UIA path that depends on the shell being foreground, retrying # within a bounded deadline instead of assuming a prior activation holds. # - # Only fall back to ActivateWindow's invasive Alt-tap foreground-lock - # bypass (keybd_event + SetForegroundWindow) when the shell does not - # already hold true foreground ownership: that trick injects a global - # synthetic Alt key press/release, and issuing it when it is not needed - # (the shell is already foreground) races the very next native window - # this gate creates and can desynchronize its subsequent close handling. + # Activation never injects keys or attaches another foreground input queue. + # A denied foreground request remains an explicit bounded failure. if ([GraphCodeUiaGateState]::IsForegroundWindow($window)) { return $true } @@ -2126,7 +2446,6 @@ function Ensure-ShellForeground( $deadline = $startedUtc.AddMilliseconds($TimeoutMilliseconds) $acquired = $false do { - Hide-TestProviderZmxWindows $activated = [GraphCodeUiaGateState]::ActivateWindow($window) $acquired = $activated -and [GraphCodeUiaGateState]::IsForegroundWindow($window) if (-not $acquired) { @@ -3265,67 +3584,66 @@ function Get-UiaOwnedProcessDescendants([int[]] $rootProcessIds) { return @($descendants | Sort-Object Depth -Descending) } +function Invoke-UiaCleanupActions([scriptblock[]] $actions) { + $errors = [Collections.Generic.List[Exception]]::new() + foreach ($action in $actions) { + try { & $action } + catch { $errors.Add($_.Exception) } + } + if ($errors.Count) { + $failure = [AggregateException]::new("UIA cleanup actions failed", $errors.ToArray()) + if (@($errors | Where-Object { $_.Data["GraphCodeValidationRootRetentionRequired"] -eq $true }).Count) { + $tag = New-UiaOwnershipFailure $failure.Message + foreach ($key in $tag.Data.Keys) { $failure.Data[$key] = $tag.Data[$key] } + $failure.Data["GraphCodeValidationRetainedRoots"] = @( + @($tag.Data["GraphCodeValidationRetainedRoots"]) + + @($errors | ForEach-Object { $_.Data["GraphCodeValidationRetainedRoots"] }) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique + ) + } + throw $failure + } +} + function Stop-UiaOwnedProcessTrees([Diagnostics.Process[]] $rootProcesses) { $roots = @($rootProcesses | Where-Object { $null -ne $_ }) - if ($roots.Count -eq 0) { return } - $rootIds = @($roots | ForEach-Object { $_.Id }) - $descendants = @(Get-UiaOwnedProcessDescendants $rootIds) - Write-Host ("UIA_OWNED_PROCESS_TREE=" + (@($descendants | ForEach-Object { - [ordered]@{ - processId = $_.ProcessId - parentProcessId = $_.ParentProcessId - name = $_.Name - executablePath = Protect-UiaStartupDiagnosticText $_.ExecutablePath - depth = $_.Depth - } - } | ConvertTo-Json -Compress -Depth 4))) - foreach ($descendant in $descendants) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $($descendant.ProcessId)" -ErrorAction Stop - if ($null -eq $current) { continue } - if ([string]$current.Name -cne $descendant.Name -or - [string]$current.CreationDate -cne [string]$descendant.CreationDate) { - continue - } - $child = Get-Process -Id $descendant.ProcessId -ErrorAction SilentlyContinue - if ($child) { - try { - if (-not $child.HasExited) { $child.Kill() } - } catch [InvalidOperationException] { - if (-not $child.HasExited) { throw } - } - if (-not $child.WaitForExit(5000)) { - throw "Owned descendant process $($descendant.ProcessId) did not exit after termination" - } - $child.Dispose() - } + $captures = @($script:UiaGateOwnedCaptures | Where-Object { $null -ne $_ }) + if ($roots.Count -eq 0 -and $captures.Count -eq 0) { + Write-Host "UIA_PROCESS_TREE_CLEANUP=not_needed no owned launch was registered" + return } + $errors = [Collections.Generic.List[Exception]]::new() foreach ($root in $roots) { - try { - if (-not $root.HasExited) { $root.Kill() } - } catch [InvalidOperationException] { - if (-not $root.HasExited) { throw } - } - if (-not $root.HasExited) { - if (-not $root.WaitForExit(5000)) { - throw "Owned UIA shell process $($root.Id) did not exit after termination" - } + $owned = @($captures | Where-Object { [Object]::ReferenceEquals($_.process, $root) }) + if ($owned.Count -ne 1) { + $errors.Add((New-UiaOwnershipFailure ( + "UIA root $($root.Id) has no unique pre-launch owned-job lease. " + + "No PID/CIM-discovered descendant was terminated; complete tree cleanup cannot be verified."))) } } - $remaining = @() - foreach ($descendant in $descendants) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $($descendant.ProcessId)" -ErrorAction Stop - if ($null -ne $current -and - [string]$current.Name -ceq $descendant.Name -and - [string]$current.CreationDate -ceq [string]$descendant.CreationDate) { - $remaining += $descendant.ProcessId - } + foreach ($capture in $captures) { + if ($capture.cleaned) { continue } + try { $null = Complete-MultiProjectCapture $capture $capture.logDirectory } + catch { $errors.Add($_.Exception) } } - if ($remaining.Count -gt 0) { - throw "Owned UIA descendants remained after teardown: $($remaining -join ',')" + if ($errors.Count) { + $failure = [AggregateException]::new("UIA owned-job cleanup failed", $errors.ToArray()) + if (@($errors | Where-Object { $_.Data["GraphCodeValidationRootRetentionRequired"] -eq $true }).Count) { + $tag = New-UiaOwnershipFailure $failure.Message + foreach ($key in $tag.Data.Keys) { $failure.Data[$key] = $tag.Data[$key] } + } + throw $failure } - Write-Host "UIA_PROCESS_TREE_CLEANUP=verified roots=$($rootIds -join ',') descendants=$($descendants.Count)" + Write-Host "UIA_PROCESS_TREE_CLEANUP=verified ownedJobs=$($captures.Count)" +} + +function New-UiaOwnershipFailure([string] $message) { + $failure = [InvalidOperationException]::new($message) + $root = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", "Process") + $roots = @( + if (-not [string]::IsNullOrWhiteSpace($root)) { $root } + ) + return (ShellProcessOwnership\Set-ShellOwnershipUnproven -Exception $failure -RetainedRoots $roots) } function Stop-UiaOwnedProviderProcesses( @@ -3337,7 +3655,7 @@ function Stop-UiaOwnedProviderProcesses( return } $expected = [IO.Path]::GetFullPath($providerPath) - $owned = @( + $observed = @( Get-CimInstance Win32_Process -Filter "Name = 'zmx.exe'" -ErrorAction Stop | Where-Object { $_.ExecutablePath -and @@ -3345,36 +3663,12 @@ function Stop-UiaOwnedProviderProcesses( -not $baseline.Contains("$([int]$_.ProcessId)|$([string]$_.CreationDate)") } ) - foreach ($candidate in $owned) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$candidate.ProcessId)" -ErrorAction Stop - if ($null -eq $current -or - [string]$current.CreationDate -cne [string]$candidate.CreationDate -or - -not $current.ExecutablePath -or - [IO.Path]::GetFullPath([string]$current.ExecutablePath) -ine $expected) { - continue - } - $process = Get-Process -Id ([int]$candidate.ProcessId) -ErrorAction SilentlyContinue - if ($process) { - if (-not $process.HasExited) { $process.Kill() } - if (-not $process.WaitForExit(5000)) { - throw "Owned provider process $($candidate.ProcessId) did not exit after termination" - } - $process.Dispose() - } + if ($observed.Count -gt 0) { + throw (New-UiaOwnershipFailure ( + "UIA provider ownership is unproven for observed PIDs $($observed.ProcessId -join ','); " + + "path, creation time, and baseline absence are advisory only. No discovered process was terminated.")) } - $remaining = @( - Get-CimInstance Win32_Process -Filter "Name = 'zmx.exe'" -ErrorAction Stop | - Where-Object { - $_.ExecutablePath -and - [IO.Path]::GetFullPath([string]$_.ExecutablePath) -ieq $expected -and - -not $baseline.Contains("$([int]$_.ProcessId)|$([string]$_.CreationDate)") - } - ) - if ($remaining.Count -gt 0) { - throw "Owned provider processes remained after teardown: $($remaining.ProcessId -join ',')" - } - Write-Host "UIA_PROVIDER_PROCESS_CLEANUP=verified terminated=$($owned.Count)" + Write-Host "UIA_PROVIDER_PROCESS_CLEANUP=no_candidates_observed terminated=0" } function Get-UiaStartupImageHash([string] $path, [scriptblock] $openRead) { @@ -4469,13 +4763,10 @@ using System.Diagnostics; using System.ComponentModel; using System.Runtime.InteropServices; public static class GraphCodeMultiProjectEditNative { - [StructLayout(LayoutKind.Sequential)] private struct Key { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } - [StructLayout(LayoutKind.Sequential)] private struct Input { public uint Type; public Key Key; public uint Pad0, Pad1; } [StructLayout(LayoutKind.Sequential)] private struct Rect {public int Left,Top,Right,Bottom;} [StructLayout(LayoutKind.Sequential)] private struct GuiInfo { public int Size; public uint Flags; public IntPtr Active,Focus,Capture,MenuOwner,MoveSize,Caret; public Rect CaretRect; } - [DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, Input[] inputs, int size); [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", SetLastError=true)] private static extern IntPtr Message(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam, uint flags, uint timeout, out UIntPtr result); [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] @@ -4526,15 +4817,6 @@ public static class GraphCodeMultiProjectEditNative { if(thread==0 || !GetGUIThreadInfo(thread,ref info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e GetGUIThreadInfo"); return info.Focus; } - public static int[] Unicode(string text) { - var inputs=new Input[text.Length*2]; int index=0; - foreach(char value in text){ - inputs[index++]=new Input{Type=1,Key=new Key{Scan=value,Flags=4}}; - inputs[index++]=new Input{Type=1,Key=new Key{Scan=value,Flags=6}}; - } - uint sent=SendInput((uint)inputs.Length,inputs,Marshal.SizeOf(typeof(Input))); - return new[]{checked((int)sent),inputs.Length}; - } } '@ } @@ -4597,8 +4879,14 @@ public static class GraphCodeMultiProjectEditNative { $api | Add-Member ScriptMethod Selection {param($edit,$remaining) [GraphCodeMultiProjectEditNative]::Selection($edit,$remaining)} $api | Add-Member ScriptMethod SelectAll {param($edit,$remaining) [GraphCodeMultiProjectEditNative]::SelectAll($edit,$remaining)} $api | Add-Member ScriptMethod SetText {param($edit,$text,$remaining) [GraphCodeMultiProjectEditNative]::SetText($edit,$text,$remaining)} - $api | Add-Member ScriptMethod Delete {return ,@(([GraphCodeUiaGateState]::SendKeyInput(0x2E,1)*2),2)} - $api | Add-Member ScriptMethod Unicode {param($text) return ,@([GraphCodeMultiProjectEditNative]::Unicode($text))} + $api | Add-Member ScriptMethod Delete { + param($modal,$edit,$expectedProcessId) + return ,@(([GraphCodeUiaGateState]::SendKeyInput($modal,$expectedProcessId,$edit,0x2E,1)*2),2) + } + $api | Add-Member ScriptMethod Unicode { + param($text,$modal,$edit,$expectedProcessId) + return ,@([GraphCodeUiaGateState]::SendUnicodeInput($modal,$expectedProcessId,$edit,$text)) + } $api | Add-Member ScriptMethod Observe {param($remaining) Start-Sleep -Milliseconds ([Math]::Min(25,$remaining))} return $api } @@ -4731,7 +5019,7 @@ function Invoke-MultiProjectSequencedEdit( $null=Assert-MultiProjectEditOwned $api $modal $processId $started $field -Focused $null=Get-MultiProjectEditRemaining $api $started $record.phase="clear";$record.clearBatches++ - $record.clear=@($api.Delete()) + $record.clear=@($api.Delete($modal,$edit,$processId)) if($record.clear.Count -ne 2 -or $record.clear[0] -isnot [int] -or $record.clear[1] -isnot [int] -or $record.clear[0] -ne 2 -or $record.clear[1] -ne 2){throw "MULTIPROJECT_EDIT_COUNTS: clear insertion receipt not2/2"} if($null -ne $api.PSObject.Methods["SetText"]){ @@ -4765,7 +5053,7 @@ function Invoke-MultiProjectSequencedEdit( $null=Assert-MultiProjectEditOwned $api $modal $processId $started $field -Focused $null=Get-MultiProjectEditRemaining $api $started $record.phase="unicode";$record.unicodeBatches++ - $record.typed=@($api.Unicode($text)) + $record.typed=@($api.Unicode($text,$modal,$edit,$processId)) if($record.typed.Count -ne 2 -or $record.typed[0] -isnot [int] -or $record.typed[1] -isnot [int] -or $record.typed[0] -ne 2*$text.Length -or $record.typed[1] -ne 2*$text.Length){ throw "MULTIPROJECT_EDIT_COUNTS: single Unicode insertion receipt incomplete" @@ -5463,46 +5751,56 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t [StandaloneProcessJob]::WaitForEmpty($capture.job, $remaining) $empty = [StandaloneProcessJob]::Active($capture.job) -eq 0 if (-not $empty) { throw "owned job census is not empty" } - } elseif ($capture.rootProcess -and -not $capture.rootProcess.HasExited) { - if ($capture.rootProcess.StartTime.ToUniversalTime().Ticks -ne $capture.rootStartUtcTicks) { - throw "unassigned root identity changed" - } - Stop-Process -Id $capture.rootPID -Force + } else { + # The assignment barrier was never released; only the retained launcher exists. + if ($capture.rootProcess -and -not $capture.rootProcess.HasExited) { $capture.rootProcess.Kill() } + $empty = $true } $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - $exited = $capture.rootProcess.WaitForExit($remaining) + $exited = -not $capture.rootProcess -or $capture.rootProcess.WaitForExit($remaining) if (-not $exited) { throw "root did not exit within shared teardown deadline" } if ($capture.process -and $capture.process -ne $capture.rootProcess) { $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - if (-not $capture.process.WaitForExit($remaining)) { throw "retained target did not exit within shared teardown deadline" } + if (-not $capture.process.WaitForExit($remaining)) { + $exited = $false + throw "retained target did not exit within shared teardown deadline" + } } } catch { $errors.Add("owned exit/quiescence: $($_.Exception.Message)") } try { - if ($capture.stdout -and $capture.stderr) { + $readers = @(@($capture.stdout, $capture.stderr) | Where-Object { $null -ne $_ }) + if ($readers.Count) { $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - $drained = [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($capture.stdout, $capture.stderr), $remaining) + $drained = [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]$readers, $remaining) if (-not $drained) { throw "stdout=$($capture.stdout.Status) stderr=$($capture.stderr.Status) shared pipe-drain deadline exceeded" } foreach ($stream in @("stdout", "stderr")) { $task = $capture[$stream] + if ($null -eq $task) { continue } if ($task.Status -ne [Threading.Tasks.TaskStatus]::RanToCompletion) { throw "$stream reader did not successfully complete" } try { - $task.GetAwaiter().GetResult() | Set-Content -LiteralPath (Join-Path $logDirectory ($capture.stem + "-$stream.log")) + if (-not $capture.streamingLogs) { + $task.GetAwaiter().GetResult() | Set-Content -LiteralPath (Join-Path $logDirectory ($capture.stem + "-$stream.log")) + } } catch { $errors.Add("$stream capture persistence: $($_.Exception.Message)") } } - } + } else { $drained = $true } } catch { $errors.Add("redirected pipe drain: $($_.Exception.Message)") } - foreach ($stream in @("StandardOutput", "StandardError")) { - try { if ($capture.rootProcess) { $capture.rootProcess.$stream.Dispose() } } - catch { $errors.Add("disposing $stream reader: $($_.Exception.Message)") } + $release = $exited -and $empty -and $drained + if ($release) { + foreach ($stream in @("StandardOutput", "StandardError")) { + try { if ($capture.rootProcess) { $capture.rootProcess.$stream.Dispose() } } + catch { $errors.Add("disposing $stream reader: $($_.Exception.Message)") } + } + try { + if ($capture.job -ne [IntPtr]::Zero) { [StandaloneProcessJob]::Close($capture.job) } + } catch { $errors.Add("closing owned job: $($_.Exception.Message)") } + try { + if ($capture.process -and $capture.process -ne $capture.rootProcess) { $capture.process.Dispose() } + } catch { $errors.Add("disposing retained target: $($_.Exception.Message)") } + try { if ($capture.rootProcess) { $capture.rootProcess.Dispose() } } + catch { $errors.Add("disposing retained root: $($_.Exception.Message)") } + $capture.cleaned = $true } - try { - if ($capture.job -ne [IntPtr]::Zero) { [StandaloneProcessJob]::Close($capture.job) } - } catch { $errors.Add("closing owned job: $($_.Exception.Message)") } - try { - if ($capture.process -and $capture.process -ne $capture.rootProcess) { $capture.process.Dispose() } - } catch { $errors.Add("disposing retained target: $($_.Exception.Message)") } - try { if ($capture.rootProcess) { $capture.rootProcess.Dispose() } } - catch { $errors.Add("disposing retained root: $($_.Exception.Message)") } $diagnostic = [ordered]@{ identity = $identity; rootExited = $exited; ownedJobEmpty = $empty redirectedReadersDrained = $drained; deadlineMilliseconds = $timeoutMilliseconds @@ -5514,23 +5812,86 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t Write-Host ("MULTIPROJECT_CAPTURE_TEARDOWN=" + ($diagnostic | ConvertTo-Json -Depth 8 -Compress)) if ($errors.Count -gt 0) { $message = "MULTIPROJECT_TEARDOWN: $identity; $($errors -join '; ')" + $failure = if (-not $release) { New-UiaOwnershipFailure $message } else { [InvalidOperationException]::new($message) } + $failure.Data["UiaRetainedCapture"] = $capture if ($primaryError) { + foreach ($key in $failure.Data.Keys) { $primaryError.Exception.Data[$key] = $failure.Data[$key] } $primaryError.Exception.Data["MultiProjectTeardown:$($capture.stem)"] = $message Write-Warning $message -WarningAction Continue - } else { throw $message } + } else { throw $failure } } return $diagnostic } +function Initialize-UiaOwnedStreamCapture { + if ("UiaOwnedStreamCapture" -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class UiaOwnedStreamCapture { + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool contained); + public static void RequireJobMember(IntPtr process, IntPtr job) { + bool contained; + if (!IsProcessInJob(process, job, out contained)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (!contained) throw new InvalidOperationException("Published UIA target is not a member of its owned launch job"); + } + public static Task Read(StreamReader reader, string path) { + var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite); + return Capture(reader, file); + } + private static async Task Capture(StreamReader reader, FileStream file) { + using (var writer = new StreamWriter(file, new UTF8Encoding(false))) { + var text = new StringBuilder(); + var buffer = new char[4096]; + int count; + while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) { + text.Append(buffer, 0, count); + await writer.WriteAsync(buffer, 0, count).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + } + return text.ToString(); + } + } +} +'@ +} + +function Start-UiaOwnedProcess( + [string] $file, [string[]] $arguments, [string] $standardOutputPath, + [string] $standardErrorPath, [switch] $Hidden +) { + $environment = @{} + foreach ($entry in [Environment]::GetEnvironmentVariables("Process").GetEnumerator()) { + $environment[$entry.Key] = $entry.Value + } + $directory = Split-Path -Parent $standardErrorPath + $stem = [IO.Path]::GetFileNameWithoutExtension($standardErrorPath) -replace "-stderr$", "" + if (-not $standardOutputPath) { $standardOutputPath = Join-Path $directory "$stem-stdout.log" } + $capture = Start-MultiProjectOwnedProcess -file $file -arguments $arguments -environment $environment ` + -stem $stem -Visible:(-not $Hidden) -RegisterForUia -standardOutputPath $standardOutputPath ` + -standardErrorPath $standardErrorPath -logDirectory $directory -targetWorkingDirectory (Get-Location).ProviderPath + return $capture.process +} + function Start-MultiProjectOwnedProcess( [string] $file, [string[]] $arguments, [hashtable] $environment, [string] $stem, [string] $jobSourcePath = (Join-Path $PSScriptRoot "Tests\Packaging.Standalone.Tests.ps1"), - [switch] $Visible + [switch] $Visible, [switch] $RegisterForUia, + [string] $standardOutputPath, [string] $standardErrorPath, + [string] $logDirectory = $environment.TEMP, + [string] $targetWorkingDirectory = $environment.TEMP ) { Initialize-MultiProjectProcessJob $jobSourcePath $gate = Join-Path $environment.TEMP ("mp-start-" + [guid]::NewGuid().ToString("N")) $identityPath = $gate + ".identity" - $launch = @{ gate = $gate; identity = $identityPath; file = $file; arguments = @($arguments); visible = [bool]$Visible } | + $launch = @{ gate = $gate; identity = $identityPath; file = $file; arguments = @($arguments) + visible = [bool]$Visible; workingDirectory = $targetWorkingDirectory + lifetimeMilliseconds = $(if ($RegisterForUia) { -1 } else { 600000 }) } | ConvertTo-Json -Depth 8 -Compress $payload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($launch)) $command = @' @@ -5544,6 +5905,7 @@ while (-not [IO.File]::Exists($launch.gate)) { $start = [Diagnostics.ProcessStartInfo]::new($launch.file) $start.UseShellExecute = $false $start.CreateNoWindow = -not $launch.visible +$start.WorkingDirectory = $launch.workingDirectory foreach ($argument in $launch.arguments) { $start.ArgumentList.Add([string]$argument) } $target = [Diagnostics.Process]::Start($start) try { @@ -5552,7 +5914,7 @@ try { @{ pid = $target.Id; startUtcTicks = $target.StartTime.ToUniversalTime().Ticks } | ConvertTo-Json -Compress | Set-Content -LiteralPath $temporaryIdentity [IO.File]::Move($temporaryIdentity, $launch.identity) - if (-not $target.WaitForExit(600000)) { throw "Owned target exceeded ten-minute launch lifetime" } + if (-not $target.WaitForExit($launch.lifetimeMilliseconds)) { throw "Owned target exceeded its launch lifetime" } exit $target.ExitCode } finally { $target.Dispose() } '@ @@ -5573,15 +5935,27 @@ try { [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)))) { $start.ArgumentList.Add($argument) } $capture = [ordered]@{ process = $null; rootProcess = $null; rootPID = $null; rootStartUtcTicks = $null targetPID = $null; targetStartUtcTicks = $null; job = [StandaloneProcessJob]::Create() - assigned = $false; stdout = $null; stderr = $null; stem = $stem } + assigned = $false; stdout = $null; stderr = $null; stem = $stem + cleaned = $false; streamingLogs = [bool]$standardOutputPath; logDirectory = $logDirectory } + if ($RegisterForUia) { + if ($null -eq $script:UiaGateOwnedCaptures) { $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() } + $script:UiaGateOwnedCaptures.Add($capture) + } try { $root = [Diagnostics.Process]::Start($start) $capture.rootProcess = $root $capture.rootPID = $root.Id $capture.rootStartUtcTicks = $root.StartTime.ToUniversalTime().Ticks $handle = $root.Handle - $capture.stdout = $root.StandardOutput.ReadToEndAsync() - $capture.stderr = $root.StandardError.ReadToEndAsync() + if ($standardOutputPath) { + Initialize-UiaOwnedStreamCapture + $capture.stdout = [UiaOwnedStreamCapture]::Read($root.StandardOutput, $standardOutputPath) + $capture.stderr = [UiaOwnedStreamCapture]::Read($root.StandardError, $standardErrorPath) + if ($capture.stdout.IsFaulted -or $capture.stderr.IsFaulted) { throw "UIA owned stream could not open its exact log paths" } + } else { + $capture.stdout = $root.StandardOutput.ReadToEndAsync() + $capture.stderr = $root.StandardError.ReadToEndAsync() + } [StandaloneProcessJob]::Assign($capture.job, $handle) $capture.assigned = $true [IO.File]::WriteAllText($gate, "assigned") @@ -5602,10 +5976,13 @@ try { $capture.process = $target $capture.targetPID = $target.Id $capture.targetStartUtcTicks = $identity.startUtcTicks + Initialize-UiaOwnedStreamCapture + [UiaOwnedStreamCapture]::RequireJobMember($target.Handle, $capture.job) + if ("GraphCodeUiaGateState" -as [type]) { [GraphCodeUiaGateState]::RetainProcess($target) } return $capture } catch { $primary = $_ - $null = Complete-MultiProjectCapture $capture $environment.TEMP 5000 $primary + $null = Complete-MultiProjectCapture $capture $logDirectory 5000 $primary throw } } @@ -6064,7 +6441,7 @@ function Invoke-MultiProjectRenamePhase { $rename = @(Get-PopupMenuItems $popup | Where-Object { $_.Text -like "Rename...*" -and $_.Enabled }) } Require ($rename.Count -eq 1) "multi-project node popup lacks one enabled Rename" - $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $multiWindow, [int]$rename[0].Position, $rename[0].Id) + $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $multiWindow, [int]$rename[0].Position, $rename[0].Id, [uint32]$multiProcess.Id) Require ($null -ne $click) "multi-project native Rename item was not hit" $renameProcess = $multiProcess $renameShellWindow = $multiWindow @@ -6224,7 +6601,7 @@ function Invoke-MultiProjectRenamePhase { $primaryError = $_ throw } finally { - $cleanupFailures = [Collections.Generic.List[string]]::new() + $cleanupFailures = [Collections.Generic.List[Exception]]::new() $failureChildren = [Collections.Generic.List[object]]::new() foreach ($child in @($peerChild, $shellChild)) { if ($null -ne $child) { @@ -6245,7 +6622,7 @@ function Invoke-MultiProjectRenamePhase { $failureChildren.Add($observed) } try { $null = Complete-MultiProjectCapture $child $logDirectory 5000 $primaryError } - catch { $cleanupFailures.Add($_.Exception.Message) } + catch { $cleanupFailures.Add($_.Exception) } } } if ($primaryError) { @@ -6253,11 +6630,20 @@ function Invoke-MultiProjectRenamePhase { $failureChildren.ToArray() $phaseStartedUtc $phaseClock.ElapsedMilliseconds } if ($cleanupFailures.Count -gt 0) { - $message = "MULTIPROJECT_PHASE_TEARDOWN: $($cleanupFailures -join '; ')" - if ($primaryError) { - $primaryError.Exception.Data["MultiProjectPhaseTeardown"] = $message - Write-Warning $message -WarningAction Continue - } else { throw $message } + $message = "MULTIPROJECT_PHASE_TEARDOWN: $($cleanupFailures.Message -join '; ')" + $actions = @($cleanupFailures | ForEach-Object { + $cleanupFailure = $_ + { throw $cleanupFailure }.GetNewClosure() + }) + try { Invoke-UiaCleanupActions $actions } + catch { + if ($primaryError) { + $primaryError.Exception.Data["MultiProjectPhaseTeardown"] = $message + $primaryError.Exception.Data["MultiProjectCleanupFailure"] = $_.Exception + foreach ($key in $_.Exception.Data.Keys) { $primaryError.Exception.Data[$key] = $_.Exception.Data[$key] } + Write-Warning $message -WarningAction Continue + } else { throw } + } } } } @@ -6402,13 +6788,7 @@ try { [Text.UTF8Encoding]::new($false) ) Write-Host ("UIA_PRELAUNCH_DIAGNOSTICS=" + $prelaunchJson) - if ($ArgumentList.Count -gt 0) { - $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal ` - -RedirectStandardOutput $shellOutputPath -RedirectStandardError $shellErrorPath - } else { - $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardOutput $shellOutputPath -RedirectStandardError $shellErrorPath - } + $process = Start-UiaOwnedProcess $Shell $ArgumentList $shellOutputPath $shellErrorPath $root = $null for ($i = 0; $i -lt 160; $i++) { @@ -9062,22 +9442,81 @@ try { Write-Host "UIA_RENAME_OUTCOME graph='$renameGraphTitle' sidebar='$renameSidebarTitle' expected='UIA renamed loop' reason=gate-forces-daemon-connection-failure" $deleteLoopTitle = 'Delete “UIA loop A”?' + $fitDeleteCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require (($null -ne $fitDeleteCanvas) -and + ($fitDeleteCanvas.Current.ProcessId -eq $process.Id) -and + ($fitDeleteCanvas.Current.Name -ceq "Fit canvas")) ` + "Delete Loop visibility normalization omitted the exact owned Fit Canvas action" + $fitDeleteCanvas.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $deleteTargetVisible = $false + $deleteTargetBounds = $null + $deleteTargetOffscreen = $true + for ($deleteVisibilityAttempt = 0; $deleteVisibilityAttempt -lt 40; $deleteVisibilityAttempt++) { + $deleteTargetVisible = $false + $deleteCandidate = Find-FragmentById $root $renameGraphIdentity $rawWalker + $deleteGraph = Find-FragmentById $root "graph" $rawWalker + if (($null -ne $deleteCandidate) -and ($null -ne $deleteGraph) -and + ($deleteCandidate.Current.ProcessId -eq $process.Id) -and + ($deleteCandidate.Current.AutomationId -ceq $renameGraphIdentity) -and + ($deleteGraph.Current.ProcessId -eq $process.Id)) { + $deleteTargetOffscreen = $deleteCandidate.Current.IsOffscreen + if (-not $deleteTargetOffscreen) { + $deleteTargetBounds = $deleteCandidate.Current.BoundingRectangle + $deleteGraphBounds = $deleteGraph.Current.BoundingRectangle + $deleteTargetVisible = -not $deleteTargetOffscreen -and + -not $deleteTargetBounds.IsEmpty -and + $deleteTargetBounds.Width -gt 0 -and $deleteTargetBounds.Height -gt 0 -and + -not $deleteGraphBounds.IsEmpty -and + $deleteTargetBounds.Left -ge $deleteGraphBounds.Left -and + $deleteTargetBounds.Top -ge $deleteGraphBounds.Top -and + $deleteTargetBounds.Right -le $deleteGraphBounds.Right -and + $deleteTargetBounds.Bottom -le $deleteGraphBounds.Bottom + if ($deleteTargetVisible) { break } + } + } + Start-Sleep -Milliseconds 100 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + } + Require ($deleteTargetVisible) ` + "Delete Loop same-identity target did not become visible with positive bounds inside the owned graph after 40 bounded Fit Canvas observations" + Write-Host ("UIA_DELETE_TARGET_VISIBLE=" + ([ordered]@{ + attempts = $deleteVisibilityAttempt + 1 + bounds = @( + [double]$deleteTargetBounds.Left, + [double]$deleteTargetBounds.Top, + [double]$deleteTargetBounds.Right, + [double]$deleteTargetBounds.Bottom + ) + offscreen = $deleteTargetOffscreen + } | ConvertTo-Json -Compress)) $deleteTarget = Find-FragmentByIdWithRetry $root $renameGraphIdentity $rawWalker Require ($null -ne $deleteTarget) "Delete Loop could not reacquire the renamed fixture loop identity" $deleteTargetSelection = $deleteTarget.GetCurrentPattern( [System.Windows.Automation.SelectionItemPattern]::Pattern ) - $deleteTargetSelection.Select() + try { + $deleteTargetSelection.Select() + } catch { + try { + try { + Write-DeleteTargetSelectFailure $deleteTarget $deleteTargetSelection $renameGraphIdentity ` + $process.Id $shellWindow $_ $DeleteSelectFailureReceipt + } catch { + Write-Host ("UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=hresult:{0}" -f $_.Exception.HResult) + } + } finally { + throw + } + } Require $deleteTargetSelection.Current.IsSelected ` "Delete Loop could not select the fixture loop before invoking the selection-dependent command" Write-Host ("UIA_DELETE_LOOP_TARGET automationId='$($deleteTarget.Current.AutomationId)' " + "name='$($deleteTarget.Current.Name)' selected=$($deleteTargetSelection.Current.IsSelected)") - Require (Ensure-ShellForeground $shellWindow "Delete Loop context menu") ` - "GraphCode shell did not reacquire foreground before the Delete Loop context menu" - Require ([GraphCodeUiaGateState]::PostContextMenu($shellWindow, 3)) ` - "Delete Loop context menu request was rejected" - $deleteLoopPopup = Wait-ForPopupMenu $process $shellWindow "Delete Loop" - Require ($deleteLoopPopup -ne [IntPtr]::Zero) "Delete Loop context menu did not open" + $deleteLoopPopup = Open-CanvasCardContextMenu $process $root $shellWindow ` + $renameGraphIdentity $rawWalker "Delete Loop" $deleteLoopItems = @(Get-PopupMenuItems $deleteLoopPopup) $deleteLoopItem = @($deleteLoopItems | Where-Object { $_.Id -eq 5103 -and $_.Enabled @@ -9085,7 +9524,7 @@ try { Require ($null -ne $deleteLoopItem) ` "Delete Loop context menu omitted its enabled Delete action: $(Format-PopupMenuItems $deleteLoopItems)" $deleteLoopClick = [GraphCodeUiaGateState]::ClickPopupMenuItem( - $deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id + $deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id, [uint32]$process.Id ) Require ($null -ne $deleteLoopClick) "Delete Loop context-menu action could not be clicked" Write-Host ("UIA_DELETE_LOOP_MENU command=$($deleteLoopItem.Id) " + @@ -9840,6 +10279,11 @@ try { "unwired canvas card did not expose exactly one stable Wire it up control" Require ($markEntryControls.Count -eq 1) ` "unwired canvas card did not expose exactly one stable Mark as entry control" + $recoveryVisibility = Wait-UiaUnwiredRecoveryVisibility $root $rawWalker $process $wireControls[0] $markEntryControls[0] + $root = $recoveryVisibility.root + $graph = $recoveryVisibility.graph + $wireControls = @($recoveryVisibility.wire) + $markEntryControls = @($recoveryVisibility.mark) foreach ($control in @($wireControls[0], $markEntryControls[0])) { Require (($control.Current.BoundingRectangle.Width -gt 0) -and ($control.Current.BoundingRectangle.Height -gt 0)) ` @@ -10057,7 +10501,7 @@ try { Require ($null -ne $editEdgeMenuItem) ` "canvas edge action menu omitted Edit Edge: $(Format-PopupMenuItems $edgeActionItems)" $editEdgeClick = [GraphCodeUiaGateState]::ClickPopupMenuItem( - $edgeActionPopup, $shellWindow, [int]$editEdgeMenuItem.Position, [int]$editEdgeMenuItem.Id + $edgeActionPopup, $shellWindow, [int]$editEdgeMenuItem.Position, [int]$editEdgeMenuItem.Id, [uint32]$process.Id ) Require ($null -ne $editEdgeClick) "canvas edge Edit Edge item could not be clicked" $editEdgeClickEvidence = [ordered]@{ @@ -10454,13 +10898,7 @@ try { } Require $retainedProviderSafe "retained status provider was unsafe after teardown" $env:GRAPHCODE_UIA_RESET_SIDEBAR = "0" - if ($ArgumentList.Count -gt 0) { - $settingsProcess = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru ` - -WindowStyle Normal -RedirectStandardError $settingsErrorPath - } else { - $settingsProcess = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardError $settingsErrorPath - } + $settingsProcess = Start-UiaOwnedProcess -file $Shell -arguments $ArgumentList -standardErrorPath $settingsErrorPath for ($index = 0; $index -lt 160 -and $settingsProcess.MainWindowHandle -eq 0; $index++) { Start-Sleep -Milliseconds 250 $settingsProcess.Refresh() @@ -10747,22 +11185,19 @@ try { $renameCommandLogPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "rename-daemon-command.json") $renameShellErrorPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "rename-shell-stderr.log") $renameShellOutputPath = Assert-UiaSandboxPath $sandboxPath (Join-Path $logDirectory "rename-shell-stdout.log") - # Start-Process joins ArgumentList with spaces and quotes nothing, so any value - # that contains a space has to carry its own quotes or pwsh binds the remainder - # to the next positional parameter. - $renameStubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru ` - -RedirectStandardError $renameStubErrorPath -ArgumentList @( + $renameStubProcess = Start-UiaOwnedProcess -file (Get-Command pwsh).Source -Hidden ` + -standardErrorPath $renameStubErrorPath -arguments @( "-NoProfile", "-File", - ('"' + (Join-Path $PSScriptRoot "Stub-Daemon.ps1") + '"'), + (Join-Path $PSScriptRoot "Stub-Daemon.ps1"), "-PipeName", - ('"' + $renamePipeName + '"'), + $renamePipeName, "-ResultPath", - ('"' + $renameStubResultPath + '"'), + $renameStubResultPath, "-NodeAId", - ('"' + $renameNodeId + '"'), + $renameNodeId, "-NodeATitle", - ('"' + $renameInitialTitle + '"'), + $renameInitialTitle, "-ApplyGraphCommands", "-SeedSketches" ) @@ -10796,14 +11231,7 @@ try { Remove-Item Env:GRAPHCODE_UIA_FIXTURE_ROWS -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_UIA_UPDATE_AVAILABLE -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_UIA_SHOW_UPDATE -ErrorAction SilentlyContinue - if ($ArgumentList.Count -gt 0) { - $renameProcess = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru ` - -WindowStyle Normal -RedirectStandardOutput $renameShellOutputPath ` - -RedirectStandardError $renameShellErrorPath - } else { - $renameProcess = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` - -RedirectStandardOutput $renameShellOutputPath -RedirectStandardError $renameShellErrorPath - } + $renameProcess = Start-UiaOwnedProcess $Shell $ArgumentList $renameShellOutputPath $renameShellErrorPath $renameRoot = $null for ($index = 0; $index -lt 160 -and $null -eq $renameRoot; $index++) { Start-Sleep -Milliseconds 250 @@ -11152,7 +11580,7 @@ try { Start-Sleep -Milliseconds 100 } Require ($null -ne $item) "edge workflow menu lacks enabled $command after republish: $lastMenu; stub=$(Read-UiaTextFile $renameStubResultPath)" - $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $renameShellWindow, [int]$item.Position, $command) + $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($popup, $renameShellWindow, [int]$item.Position, $command, [uint32]$renameProcess.Id) if ($null -ne $click) { Write-Host "UIA_EDGE_MENU_CLICK command=$command hilite=$($click.Hilite) selectItemFallback=$($click.UsedSelectItemFallback) enterFallback=$($click.UsedKeyboardFallback) hiliteAtEnterFallback=$($click.HiliteAtEnterFallback) hiliteBeforeEnter=$($click.HiliteBeforeEnter) cursor=$($click.CursorAtX),$($click.CursorAtY) item=$($click.Left),$($click.Top),$($click.Right),$($click.Bottom)" } @@ -11255,6 +11683,7 @@ try { $delta = $index - [int]($after.Split("|")[0]) if ($delta -ne 0) { $keys = [GraphCodeUiaGateState]::SendKeyInput( + $edgeWorkflowWindow, [uint32]$renameProcess.Id, $control, [uint16]$(if ($delta -gt 0) { 0x28 } else { 0x26 }), [Math]::Abs($delta)) Require ($keys -eq [Math]::Abs($delta)) "edge workflow combo $id keyboard injection failed" } @@ -11405,7 +11834,7 @@ try { $edit = [GraphCodeUiaGateState]::ControlById($edgeWorkflowWindow, 9105) Require ($edit -ne [IntPtr]::Zero -and [GraphCodeUiaGateState]::FocusControl($edgeWorkflowWindow, $edit) -and - [GraphCodeUiaGateState]::SendKeyInput(0x0D, 1) -eq 1) ` + [GraphCodeUiaGateState]::SendKeyInput($edgeWorkflowWindow, [uint32]$renameProcess.Id, $edit, 0x0D, 1) -eq 1) ` "edge workflow $title measured-occluded keyboard submit failed" $method = "keyboardEnter" } @@ -11764,7 +12193,7 @@ try { Require ($goalEdit -ne [IntPtr]::Zero -and [GraphCodeUiaGateState]::FocusControl($nodeSheetWindow, $goalEdit)) ` "node creation sheet $label could not focus the Goal field before Enter" - Require ([GraphCodeUiaGateState]::SendKeyInput(0x0D, 1) -eq 1) ` + Require ([GraphCodeUiaGateState]::SendKeyInput($nodeSheetWindow, [uint32]$renameProcess.Id, $goalEdit, 0x0D, 1) -eq 1) ` "node creation sheet $label could not send a native Enter key" $method = "keyboardEnter" $mouseSubmitUnavailable = $true @@ -11989,7 +12418,8 @@ try { $delta = $comboCase.Index - $currentIndex $keys = 0 if ($delta -ne 0) { - $keys = [GraphCodeUiaGateState]::SendKeyInput([uint16]$(if ($delta -gt 0) { 0x28 } else { 0x26 }), [Math]::Abs($delta)) + $keys = [GraphCodeUiaGateState]::SendKeyInput($nodeSheetWindow, [uint32]$renameProcess.Id, $combo, + [uint16]$(if ($delta -gt 0) { 0x28 } else { 0x26 }), [Math]::Abs($delta)) } $after = $before for ($attempt = 0; $attempt -lt 40 -and $after -ne "$($comboCase.Index)|$($comboCase.Text)"; $attempt++) { @@ -12319,7 +12749,7 @@ try { $focus = Assert-SketchControl $focusId Require ([GraphCodeUiaGateState]::WindowIsVisible($script:edgeWorkflowWindow) -and [GraphCodeUiaGateState]::FocusControl($script:edgeWorkflowWindow, $focus) -and - [GraphCodeUiaGateState]::SendKeyInput(0x0D, 1) -eq 1) ` + [GraphCodeUiaGateState]::SendKeyInput($script:edgeWorkflowWindow, [uint32]$renameProcess.Id, $focus, 0x0D, 1) -eq 1) ` "sketch/custody submit '$label' measured-occluded Enter injection failed" $method = "keyboardEnter" } @@ -12347,7 +12777,7 @@ try { function Sketch-Cancel([string] $label) { Require (Ensure-ShellForeground $script:edgeWorkflowWindow "$label cancel") ` "sketch/custody '$label' lost foreground before cancel" - Require ([GraphCodeUiaGateState]::SendKeyInput(0x1B, 1) -eq 1) ` + Require ([GraphCodeUiaGateState]::SendKeyInput($script:edgeWorkflowWindow, [uint32]$renameProcess.Id, [IntPtr]::Zero, 0x1B, 1) -eq 1) ` "sketch/custody '$label' native Escape injection failed" Start-Sleep -Milliseconds 200 $keyboardFallback = $false @@ -12602,7 +13032,7 @@ try { $item = @($menu.items | Where-Object { $_.Id -eq $id -and $_.Enabled }) Require ($item.Count -eq 1) "sketch/custody popup missing enabled ${id}: $(Format-PopupMenuItems $menu.items)" $click = [GraphCodeUiaGateState]::ClickPopupMenuItem($menu.popup, $renameShellWindow, - [int]$item[0].Position, $id) + [int]$item[0].Position, $id, [uint32]$renameProcess.Id) if ($null -ne $click) { Write-Host "UIA_SKETCH_MENU_CLICK command=$id hilite=$($click.Hilite) selectItemFallback=$($click.UsedSelectItemFallback) enterFallback=$($click.UsedKeyboardFallback) hiliteAtEnterFallback=$($click.HiliteAtEnterFallback) hiliteBeforeEnter=$($click.HiliteBeforeEnter)" } @@ -13253,26 +13683,26 @@ try { throw } finally { try { - if ($stressJob) { - Remove-Job -Job $stressJob -Force -ErrorAction SilentlyContinue - } - if ($propertyEventRegistered) { - [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler( - $status, $propertyHandler - ) - } - if ($liveEventRegistered) { - [System.Windows.Automation.Automation]::RemoveAutomationEventHandler( - $liveRegionEvent, $status, $eventHandler - ) - } - if ($focusEventRegistered) { - [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) - } - Stop-UiaOwnedProcessTrees @($process, $settingsProcess, $renameProcess, $renameStubProcess) - Stop-UiaOwnedProviderProcesses $providerZmxPath $providerZmxBaseline + Invoke-UiaCleanupActions @( + { if ($stressJob) { Remove-Job -Job $stressJob -Force -ErrorAction Stop } }, + { if ($propertyEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler($status, $propertyHandler) + } }, + { if ($liveEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($liveRegionEvent, $status, $eventHandler) + } }, + { if ($focusEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) + } }, + { Stop-UiaOwnedProcessTrees @($process, $settingsProcess, $renameProcess, $renameStubProcess) }, + { Stop-UiaOwnedProviderProcesses $providerZmxPath $providerZmxBaseline } + ) } catch { $sandboxCleanupError = $_ + if ($gateFailure) { + $gateFailure.Exception.Data["UiaCleanupFailure"] = $_.Exception + foreach ($key in $_.Exception.Data.Keys) { $gateFailure.Exception.Data[$key] = $_.Exception.Data[$key] } + } if ($sandboxCreated) { Write-Host "UIA_FAILED_SANDBOX_RETAINED=$sandboxPath" } Write-Host "UIA_CLEANUP_ERROR=$($_.Exception.Message)" } finally { diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index ac74fe6c..1a1db9cb 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -262,7 +262,23 @@ function Install-AtomicRuntimePackage([string] $sourceDirectory, [string] $desti function Invoke-Native([string] $description, [scriptblock] $command) { Write-Host "==> $description" - & $command + $ownershipUnproven = $false + try { + & $command 2>&1 | ForEach-Object { + if ($_ -is [string] -and $_ -ceq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") { + $ownershipUnproven = $true + } + $_ + } + } catch { + if ($ownershipUnproven) { $_.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true } + throw + } + if ($ownershipUnproven) { + $failure = [InvalidOperationException]::new("$description reported unproven process ownership (exit code $LASTEXITCODE).") + $failure.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $failure + } if ($LASTEXITCODE -ne 0) { throw "$description failed with exit code $LASTEXITCODE" } @@ -375,7 +391,10 @@ function Invoke-WindowsShellValidationIsolation( TMP = Join-Path $validationRoot "temp" } $savedEnvironment = @{} + $savedEnvironmentMembership = @{} + $processEnvironment = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process) foreach ($name in $ownedEnvironment.Keys) { + $savedEnvironmentMembership[$name] = $processEnvironment.Contains($name) $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable( $name, [EnvironmentVariableTarget]::Process @@ -383,6 +402,7 @@ function Invoke-WindowsShellValidationIsolation( } $primaryError = $null + $actionStarted = $false $cleanupErrors = [Collections.Generic.List[Exception]]::new() try { New-Item -ItemType Directory -Force -Path @( @@ -398,25 +418,87 @@ function Invoke-WindowsShellValidationIsolation( ) } Write-Host "WINDOWS_SHELL_VALIDATION_ISOLATION_ROOT=$validationRoot" + $actionStarted = $true & $Action } catch { $primaryError = $_ } finally { foreach ($entry in $savedEnvironment.GetEnumerator()) { try { - [Environment]::SetEnvironmentVariable( - $entry.Key, - $entry.Value, - [EnvironmentVariableTarget]::Process - ) + if ($savedEnvironmentMembership[$entry.Key]) { + [Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [EnvironmentVariableTarget]::Process + ) + } elseif (Test-Path -LiteralPath "Env:\$($entry.Key)") { + Remove-Item -LiteralPath "Env:\$($entry.Key)" -ErrorAction Stop + } } catch { $cleanupErrors.Add($_.Exception) } } + $retentionReceipt = Join-Path $validationRoot "ownership-unproven.json" + if ([IO.File]::Exists($retentionReceipt)) { + if ($null -eq $primaryError) { + $primaryError = [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new("A child reported unproven ownership despite workload completion."), + "WindowsShellOwnershipUnproven", [Management.Automation.ErrorCategory]::InvalidResult, $validationRoot) + } + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + $receiptStream = $null + try { + if ([IO.File]::GetAttributes($retentionReceipt) -band [IO.FileAttributes]::ReparsePoint) { + throw [IO.InvalidDataException]::new("Ownership retention receipt must not be a reparse point.") + } + $receiptStream = [IO.FileStream]::new($retentionReceipt, [IO.FileMode]::Open, + [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) + $bytes = [byte[]]::new(4097) + $count = 0 + while ($count -lt $bytes.Length) { + $read = $receiptStream.Read($bytes, $count, $bytes.Length - $count) + if ($read -eq 0) { break } + $count += $read + } + if ($count -gt 4096) { throw [IO.InvalidDataException]::new("Ownership retention receipt exceeds its 4096-byte limit.") } + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes, 0, $count) | + ConvertFrom-Json -Depth 4 -ErrorAction Stop + if ($receipt.schemaVersion -ne 1 -or $receipt.reason -cne "ownership_unproven" -or + $receipt.validationRoot -isnot [string] -or + [IO.Path]::GetFullPath($receipt.validationRoot) -ine [IO.Path]::GetFullPath($validationRoot)) { + throw [IO.InvalidDataException]::new("Ownership retention receipt schema, reason or validation root is invalid.") + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($receiptStream) { $receiptStream.Dispose() } + } + } + if ($actionStarted -and $null -ne $primaryError) { + if ($primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] -isnot [bool] -or + -not $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"]) { + $primaryError.Exception.Data["GraphCodeValidationRootRetentionReason"] = "workload_failed_without_cleanup_proof" + } + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + } + $retainRoot = $null -ne $primaryError -and + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] -is [bool] -and + $primaryError.Exception.Data["GraphCodeValidationRootRetentionRequired"] try { - Remove-Item -LiteralPath $validationRoot -Recurse -Force -ErrorAction Stop + if ($retainRoot) { + if (-not [IO.Directory]::Exists($validationRoot)) { + throw [IO.IOException]::new("Requested validation root retention failed: $validationRoot no longer exists.") + } + $retainedRoots = if ($primaryError.Exception.Data.Contains("GraphCodeValidationRetainedRoots")) { + @($primaryError.Exception.Data["GraphCodeValidationRetainedRoots"]) + } else { @() } + $primaryError.Exception.Data["GraphCodeValidationRetainedRoots"] = @($retainedRoots) + @($validationRoot) + Write-Host "WINDOWS_SHELL_VALIDATION_ISOLATION_ROOT_RETAINED=$validationRoot" + } else { + Remove-Item -LiteralPath $validationRoot -Recurse -Force -ErrorAction Stop + } } catch { - if (-not ( + if ($retainRoot -or -not ( $_.Exception -is [Management.Automation.ItemNotFoundException] -or $_.Exception -is [IO.DirectoryNotFoundException] )) { @@ -447,10 +529,15 @@ function Invoke-WindowsShellValidationIsolation( foreach ($cleanupError in $cleanupErrors) { $failures.Add($cleanupError) } - throw [AggregateException]::new( + $aggregate = [AggregateException]::new( "Windows shell validation failed and isolation cleanup detected additional errors.", $failures.ToArray() ) + if ($retainRoot) { + $aggregate.Data["GraphCodeValidationRootRetentionRequired"] = $true + $aggregate.Data["GraphCodeValidationRetainedRoots"] = $primaryError.Exception.Data["GraphCodeValidationRetainedRoots"] + } + throw $aggregate } if ($cleanupErrors.Count -eq 1) { throw $cleanupErrors[0] @@ -1253,43 +1340,21 @@ function Invoke-Task([string] $name) { (ConvertTo-Json -InputObject $preUiaSnapshot -Compress -Depth 4)) $repositoryPrefix = ([IO.Path]::GetFullPath($repoRoot)).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar - $ownedSurvivors = @($preUiaProcesses | Where-Object { + $unexpectedCandidates = @($preUiaProcesses | Where-Object { $identity = "$([int]$_.ProcessId)|$($_.CreationDate.ToUniversalTime().ToString('o'))" $_.ExecutablePath -and $_.ExecutablePath.StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase) -and -not $uiaProductProcessBaseline.Contains($identity) }) - foreach ($survivor in $ownedSurvivors) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop - if ($null -eq $current -or - [string]$current.CreationDate -cne [string]$survivor.CreationDate) { - continue - } - Stop-Process -Id ([int]$survivor.ProcessId) -Force -ErrorAction Stop - } - $remainingSurvivors = @() - foreach ($survivor in $ownedSurvivors) { - for ($attempt = 0; $attempt -lt 30; $attempt++) { - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop - if ($null -eq $current -or - [string]$current.CreationDate -cne [string]$survivor.CreationDate) { - break - } - Start-Sleep -Milliseconds 100 - } - $current = Get-CimInstance Win32_Process -Filter ` - "ProcessId = $([int]$survivor.ProcessId)" -ErrorAction Stop - if ($null -ne $current -and - [string]$current.CreationDate -ceq [string]$survivor.CreationDate) { - $remainingSurvivors += [int]$survivor.ProcessId - } - } - if ($remainingSurvivors.Count -gt 0) { - throw "Owned GraphCode processes survived pre-UIA cleanup: $($remainingSurvivors -join ',')" - } - Write-Host "WINDOWS_SHELL_PRE_UIA_CLEANUP=verified terminated=$($ownedSurvivors.Count)" + if ($unexpectedCandidates.Count -gt 0) { + $ownershipFailure = [InvalidOperationException]::new("Unexpected GraphCode process candidates before UIA: " + + (($unexpectedCandidates | ForEach-Object { [int]$_.ProcessId }) -join ',') + + ". These candidates are not attested as run-owned; no processes were terminated. " + + "Review the advisory snapshot and stop them through their owning session before rerunning validation.") + $ownershipFailure.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $ownershipFailure + } + Write-Host "WINDOWS_SHELL_PRE_UIA_CLEANUP=no_candidates_observed terminated=0" Invoke-Native "Native UI Automation live gate" { & (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") ` -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") ` diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index 7e7578fa..fdc4b883 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -17,6 +17,8 @@ param( ) $ErrorActionPreference = "Stop" +Import-Module (Join-Path $PSScriptRoot "ShellProcessOwnership.psm1") +$processScope = $null $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") $shellRoot = Join-Path $repoRoot "graphcode-windows" $packageManifest = Get-Content (Join-Path $shellRoot "build.zig.zon") -Raw @@ -58,6 +60,7 @@ $shellProcess = $null $handoffStage = $null $bareZmxStage = $null $bareZmxProject = $null +$bareProcessScope = $null $resourceRole = "graphcode-windows" $metricSequence = 0 @@ -75,6 +78,20 @@ function Assert-Equal([string] $actual, [string] $expected, [string] $label) { } } +function Start-SmokeProcess([string] $file, [string[]] $arguments, [string] $directory = "", + $scope = $processScope, [switch] $CaptureOutput, [switch] $CaptureError) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = (Get-Command $file -ErrorAction Stop).Source + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden + $start.WorkingDirectory = if ($directory) { $directory } else { [string]$repoRoot } + $start.RedirectStandardOutput = $CaptureOutput + $start.RedirectStandardError = $CaptureError + foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + Start-ShellOwnedProcess $scope $start +} + function New-SmokeZmxRoot([string] $Path) { if (Test-Path -LiteralPath $Path) { throw "Smoke zmx root already exists: $Path" } New-Item -ItemType Directory -Path $Path | Out-Null @@ -119,6 +136,7 @@ function Get-ZmxSessionRecords { function Record-TestOwnedSessions { $records = @(Get-ZmxSessionRecords) foreach ($record in $records) { + # These CIM observations are telemetry only, never cleanup authority. [void] $ownedSessionNames.Add($record.Name) [void] $ownedProcessIds.Add($record.Pid) } @@ -161,15 +179,18 @@ function Invoke-ZmxProbe([string[]] $Arguments) { $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } - $process = [Diagnostics.Process]::Start($start) - $output = $process.StandardOutput.ReadToEndAsync() - $errors = $process.StandardError.ReadToEndAsync() + $process = Start-ShellOwnedProcess $processScope $start try { if (-not $process.WaitForExit(5000)) { - $process.Kill() + Stop-ShellOwnedProcess $processScope $process throw "zmx probe timed out: $($Arguments -join ' ')" } - return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $output.Result; Error = $errors.Result } + Complete-ShellOwnedProcess $processScope $process + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = Read-ShellOwnedOutput $processScope $process + Error = Read-ShellOwnedOutput $processScope $process -StandardError + } } finally { $process.Dispose() } @@ -193,15 +214,15 @@ function Start-StubLoopSessions { $start.WorkingDirectory = [string] $repoRoot $start.RedirectStandardError = $true foreach ($argument in @("--daemon", $name)) { $start.ArgumentList.Add($argument) } - $hostProcess = [Diagnostics.Process]::Start($start) - $hostError = $hostProcess.StandardError.ReadToEndAsync() + $hostProcess = Start-ShellOwnedProcess $processScope $start $stubSessionHosts[$name] = $hostProcess [void] $ownedProcessIds.Add($hostProcess.Id) [void] $ownedSessionNames.Add($name) $deadline = [DateTime]::UtcNow.AddSeconds(10) do { if ($hostProcess.HasExited) { - throw "zmx stub host exited ($($hostProcess.ExitCode)): $name; $($hostError.Result)" + Complete-ShellOwnedProcess $processScope $hostProcess + throw "zmx stub host exited ($($hostProcess.ExitCode)): $name; $(Read-ShellOwnedOutput $processScope $hostProcess -StandardError)" } $listing = Invoke-ZmxProbe @("ls") if ($listing.ExitCode -eq 0 -and $listing.Output -match $livePattern) { break } @@ -217,14 +238,16 @@ function Start-StubLoopSessions { function Invoke-ShellProcess([string[]] $arguments, [string] $phase) { Start-StubLoopSessions - $script:shellProcess = Start-Process -FilePath $app -ArgumentList $arguments -PassThru -WindowStyle Hidden + $script:shellProcess = Start-SmokeProcess $app $arguments [void] $ownedProcessIds.Add($script:shellProcess.Id) Start-Sleep -Milliseconds 250 Record-TestOwnedSessions Write-OwnedResourceMetrics $phase $script:shellProcess.WaitForExit() + Record-TestOwnedSessions + Complete-ShellOwnedProcess $processScope $script:shellProcess if ($script:shellProcess.ExitCode -ne 0) { - throw "GraphCode Windows shell exited with code $($script:shellProcess.ExitCode)" + throw "GraphCode Windows shell exited with code $($script:shellProcess.ExitCode): $(Read-ShellOwnedOutput $processScope $script:shellProcess -StandardError)" } $script:shellProcess.Dispose() $script:shellProcess = $null @@ -246,6 +269,31 @@ function Get-StageZmxProcesses([string] $stageZmx) { # that directory off PATH, no GRAPHCODE_ZMX - and require the scripted workspace actions # (New Tab, Split Right, among others) to pass through the same smoke contract, with # attach children observed running the staged zmx. +function New-ShellCleanupFailure($PrimaryFailure, [Exception[]] $CleanupErrors, + [string[]] $RetainedRoots) { + $primary = if ($PrimaryFailure -is [Management.Automation.ErrorRecord]) { + $PrimaryFailure.Exception + } else { $PrimaryFailure } + if (-not $CleanupErrors.Count -and -not (Test-ShellOwnershipUnproven $primary)) { return $null } + $messages = @($CleanupErrors | ForEach-Object Message) + if (-not $messages.Count) { $messages = @("Earlier ownership refusal requires retained validation roots") } + $roots = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($root in $RetainedRoots) { if ($root) { [void] $roots.Add($root) } } + foreach ($exception in @($primary) + @($CleanupErrors)) { + while ($exception) { + foreach ($root in @($exception.Data["GraphCodeValidationRetainedRoots"])) { + if ($root) { [void] $roots.Add($root) } + } + $exception = $exception.InnerException + } + } + $message = $messages -join "; " + $failure = if ($primary) { + [InvalidOperationException]::new("Shell failed: $($primary.Message); cleanup unproved: $message", $primary) + } else { [InvalidOperationException]::new($message) } + Set-ShellOwnershipUnproven $failure @($roots) +} + function Invoke-BareNameZmxShell { $stage = Join-Path $shellRoot "bare-zmx-installed-$PID" # A real folder that is not the shell's directory: the loop's project. Terminals start @@ -253,23 +301,27 @@ function Invoke-BareNameZmxShell { $project = Join-Path $shellRoot "bare-zmx-project-$PID" $stageShell = Join-Path $stage "graphcode-windows.exe" $stageZmx = Join-Path $stage "zmx.exe" - $script:bareZmxStage = $stage - $script:bareZmxProject = $project foreach ($path in @($stage, $project)) { if (Test-Path -LiteralPath $path) { throw "Bare-name zmx fixture already exists: $path" } } - New-Item -ItemType Directory -Path $stage, $project | Out-Null + New-Item -ItemType Directory -Path $stage | Out-Null + $script:bareZmxStage = $stage + New-Item -ItemType Directory -Path $project | Out-Null + $script:bareZmxProject = $project $process = $null $stub = $null + $barePrimaryFailure = $null $bareStubResult = Join-Path $shellRoot "bare-zmx-stub-result-$PID.json" try { + $script:bareProcessScope = New-ShellProcessScope Copy-Item -LiteralPath $app -Destination $stageShell Copy-Item -LiteralPath $env:GRAPHCODE_ZMX -Destination $stageZmx Start-StubLoopSessions $barePipe = "graphcode-shell-barezmx-$PID" - $stub = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + $stub = Start-SmokeProcess "pwsh" @( "-NoProfile", "-File", (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1"), - "-PipeName", $barePipe, "-ResultPath", $bareStubResult, "-StubProjectPath", $project) + "-PipeName", $barePipe, "-ResultPath", $bareStubResult, "-StubProjectPath", $project) ` + -scope $bareProcessScope $start = [Diagnostics.ProcessStartInfo]::new($stageShell) $start.WorkingDirectory = $stage @@ -299,11 +351,8 @@ function Invoke-BareNameZmxShell { throw "Bare-name zmx shell has its own directory on PATH" } - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - [void] $process.Start() + $process = Start-ShellOwnedProcess $bareProcessScope $start [void] $ownedProcessIds.Add($process.Id) - $stderr = $process.StandardError.ReadToEndAsync() $attachSessions = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $shellSessions = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $stageProcessIds = [Collections.Generic.HashSet[int]]::new() @@ -323,7 +372,7 @@ function Invoke-BareNameZmxShell { $deadline = [DateTime]::UtcNow.AddSeconds(90) while (-not $process.HasExited) { if ([DateTime]::UtcNow -ge $deadline) { - Stop-Process -Id $process.Id -Force + Stop-ShellOwnedProcess $bareProcessScope $process throw "Bare-name zmx smoke did not finish within 90 seconds" } & $sample @@ -334,6 +383,8 @@ function Invoke-BareNameZmxShell { # daemon outlives the shell, so one last look settles what a sample could miss. & $sample Record-TestOwnedSessions + Complete-ShellOwnedProcess $bareProcessScope $process + $stderr = Read-ShellOwnedOutput $bareProcessScope $process -StandardError Write-Host ("BARE_NAME_ZMX_EVIDENCE_JSON=" + (@{ exitCode = $process.ExitCode stageProcessCount = $stageProcessIds.Count @@ -341,7 +392,7 @@ function Invoke-BareNameZmxShell { shellSessions = @($shellSessions | Sort-Object) } | ConvertTo-Json -Compress)) if ($process.ExitCode -ne 0) { - throw "Bare-name zmx shell smoke exited with code $($process.ExitCode): $($stderr.Result)" + throw "Bare-name zmx shell smoke exited with code $($process.ExitCode): $stderr" } if ($stageProcessIds.Count -eq 0) { throw "Bare-name zmx shell never ran the staged zmx.exe; the layout was not exercised" @@ -361,28 +412,36 @@ function Invoke-BareNameZmxShell { if (@($bareEvidence.commands) -notcontains "openProject" -or [bool] $bareEvidence.error) { throw "Bare-name zmx stub did not serve the folder project: $($bareEvidence.error)" } + } catch { + $barePrimaryFailure = $_ + throw } finally { - if ($process) { - if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } - $process.Dispose() + $bareCleanupErrors = [Collections.Generic.List[Exception]]::new() + if ($bareProcessScope) { + try { Close-ShellProcessScope $bareProcessScope } catch { $bareCleanupErrors.Add($_.Exception) } } - if ($stub -and -not $stub.HasExited) { Stop-Process -Id $stub.Id -Force -ErrorAction SilentlyContinue } + if (-not $bareCleanupErrors.Count -and -not (Test-ShellOwnershipUnproven $barePrimaryFailure)) { + try { Remove-BareNameZmxStage } catch { $bareCleanupErrors.Add($_.Exception) } + } + $bareCleanupFailure = New-ShellCleanupFailure $barePrimaryFailure $bareCleanupErrors.ToArray() @($bareZmxStage, $bareZmxProject) + if ($bareCleanupFailure) { throw $bareCleanupFailure } Remove-Item -LiteralPath $bareStubResult -Force -ErrorAction SilentlyContinue - Remove-BareNameZmxStage } $global:LASTEXITCODE = 0 } function Remove-BareNameZmxStage { $stage = $script:bareZmxStage - if ($script:bareZmxProject) { - Remove-Item -LiteralPath $script:bareZmxProject -Recurse -Force -ErrorAction SilentlyContinue - } if (-not $stage -or -not (Test-Path -LiteralPath $stage)) { return } $stageZmx = Join-Path $stage "zmx.exe" - Record-TestOwnedSessions - foreach ($zmxProcess in @(Get-StageZmxProcesses $stageZmx)) { - Stop-Process -Id ([int] $zmxProcess.ProcessId) -Force -ErrorAction SilentlyContinue + if ($bareProcessScope -and -not $bareProcessScope.Closed) { throw "Bare-name stage cleanup requires verified owned-job exit" } + $survivors = @(Get-StageZmxProcesses $stageZmx) + if ($survivors.Count) { + throw (Set-ShellOwnershipUnproven ([InvalidOperationException]::new( + "UNATTESTED_SURVIVORS: staged zmx paths observed after job cleanup; no termination attempted: $($survivors.ProcessId -join ',')")) @($stage, $bareZmxProject)) + } + if ($script:bareZmxProject) { + Remove-Item -LiteralPath $script:bareZmxProject -Recurse -Force -ErrorAction SilentlyContinue } for ($attempt = 0; $attempt -lt 20; $attempt++) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue @@ -429,19 +488,20 @@ function Assert-PinnedCleanWorktree( function Assert-NoOrphanShellProcesses { $processes = @( - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.CommandLine -and (Test-TestSessionProcess $_) } ) if ($processes.Count -ne 0) { - throw "Windows shell cleanup left orphan processes: $($processes.ProcessId -join ', ')" + throw "UNATTESTED_SURVIVORS: shell session observations after owned-job cleanup; no termination attempted: $($processes.ProcessId -join ', ')" } } Assert-PinnedCleanWorktree $WinghosttyRoot $pins.winghostty.sha "Winghostty" Assert-PinnedCleanWorktree $ZmxRoot $pins.zmx.sha "zmx" +$primaryFailure = $null try { if (-not $SkipBuild) { Invoke-Native "Pinned provider artifacts" { @@ -466,7 +526,14 @@ try { if (-not (Test-Path -LiteralPath $app)) { throw "GraphCode Windows shell executable is missing: $app" } - $reportedVersion = (& $app --version 2>$null | Select-Object -First 1).Trim() + $processScope = New-ShellProcessScope + $versionProcess = Start-SmokeProcess $app @("--version") -CaptureOutput -CaptureError + if (-not $versionProcess.WaitForExit(5000)) { + Stop-ShellOwnedProcess $processScope $versionProcess + throw "Shell version query timed out" + } + Complete-ShellOwnedProcess $processScope $versionProcess + $reportedVersion = ((Read-ShellOwnedOutput $processScope $versionProcess) -split "\r?\n" | Select-Object -First 1).Trim() if ($reportedVersion -ne $Version) { throw "GraphCode Windows shell version mismatch: expected $Version, executable reports $reportedVersion" } @@ -483,7 +550,7 @@ try { Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue $pipeName = "graphcode-shell-stub-$PID" $stubScript = Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1" - $stubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + $stubProcess = Start-SmokeProcess "pwsh" @( "-NoProfile", "-File", $stubScript, @@ -554,7 +621,7 @@ try { } Remove-Item -LiteralPath $busyResult,$busyError -Force -ErrorAction SilentlyContinue $busyPipeName = "graphcode-shell-busy-$PID" - $busyStubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + $busyStubProcess = Start-SmokeProcess "pwsh" @( "-NoProfile", "-File", $stubScript, @@ -566,21 +633,22 @@ try { ) $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$busyPipeName" $env:GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR = "1" - $busyApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` - -RedirectStandardError $busyError + $busyApp = Start-SmokeProcess $app @("--smoke") -CaptureError [void] $busyApp.Handle if (-not $busyApp.WaitForExit(8000)) { - Stop-Process -Id $busyApp.Id -Force + Stop-ShellOwnedProcess $processScope $busyApp throw "Busy daemon smoke blocked the UI beyond the bounded timeout" } $busyApp.WaitForExit() $busyApp.Refresh() Record-TestOwnedSessions + Complete-ShellOwnedProcess $processScope $busyApp $busyEvidence = Get-Content -LiteralPath $busyResult -Raw | ConvertFrom-Json if (-not [bool] $busyEvidence.busyObserved) { throw "Busy daemon smoke did not accept a non-reading connection" } - $busyErrorText = Get-Content -LiteralPath $busyError -Raw + $busyErrorText = Read-ShellOwnedOutput $processScope $busyApp -StandardError + [IO.File]::WriteAllText($busyError, $busyErrorText) if ($busyErrorText -notmatch "(?i)Smoke daemon status: .*daemon") { throw "Busy daemon smoke did not post a daemon transport error" } @@ -593,8 +661,7 @@ try { Remove-Item -LiteralPath $inputError -Force -ErrorAction SilentlyContinue Start-StubLoopSessions $inputDeadline = [DateTime]::UtcNow.AddSeconds(8) - $inputApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` - -RedirectStandardError $inputError + $inputApp = Start-SmokeProcess $app @("--smoke") -CaptureError [void] $inputApp.Handle [void] $ownedProcessIds.Add($inputApp.Id) $shellProcess = $inputApp @@ -619,15 +686,17 @@ try { $remainingMilliseconds = [Math]::Max( 0, [int]($inputDeadline - [DateTime]::UtcNow).TotalMilliseconds) if ([DateTime]::UtcNow -ge $inputDeadline -or -not $inputApp.WaitForExit($remainingMilliseconds)) { - if (-not $inputApp.HasExited) { Stop-Process -Id $inputApp.Id -Force } + if (-not $inputApp.HasExited) { Stop-ShellOwnedProcess $processScope $inputApp } throw "Large paste/non-reading attach smoke blocked the UI beyond the bounded timeout" } $inputApp.WaitForExit() $inputApp.Refresh() + Record-TestOwnedSessions + Complete-ShellOwnedProcess $processScope $inputApp $inputExitCode = $inputApp.ExitCode + [IO.File]::WriteAllText($inputError, (Read-ShellOwnedOutput $processScope $inputApp -StandardError)) $inputApp.Dispose() $shellProcess = $null - Record-TestOwnedSessions if ($null -eq $inputExitCode) { throw "Large paste/non-reading attach smoke completed without an observable exit code" } @@ -648,8 +717,10 @@ try { throw "Daemon handoff runtime is missing $name" } } - $handoffStage = Join-Path $shellRoot "daemon-handoff-live-$PID" - New-Item -ItemType Directory -Force -Path $handoffStage | Out-Null + $stage = Join-Path $shellRoot "daemon-handoff-live-$PID" + if (Test-Path -LiteralPath $stage) { throw "Handoff stage already exists: $stage" } + New-Item -ItemType Directory -Path $stage | Out-Null + $handoffStage = $stage Copy-Item -LiteralPath $app -Destination (Join-Path $handoffStage "graphcode-windows.exe") -Force Get-ChildItem -LiteralPath $runtime -File | Where-Object { $_.Name -in @("graphcoded.exe", "graphcode.exe") -or $_.Extension -ieq ".dll" } | @@ -660,38 +731,25 @@ try { } } Record-TestOwnedSessions - Write-Host "Windows shell smoke/stress: PASS" +} catch { + $primaryFailure = $_ + throw } finally { - if ($stubProcess -and -not $stubProcess.HasExited) { - Stop-Process -Id $stubProcess.Id -Force + $cleanupFailure = $null + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + foreach ($scope in @($bareProcessScope, $processScope)) { + if ($scope) { + try { Close-ShellProcessScope $scope } catch { $cleanupErrors.Add($_.Exception) } + } } - if ($busyStubProcess -and -not $busyStubProcess.HasExited) { - Stop-Process -Id $busyStubProcess.Id -Force + try { Assert-NoOrphanShellProcesses } catch { $cleanupErrors.Add($_.Exception) } + if (-not $cleanupErrors.Count -and -not (Test-ShellOwnershipUnproven $primaryFailure)) { + try { Remove-BareNameZmxStage } catch { $cleanupErrors.Add($_.Exception) } } - if ($env:GRAPHCODE_ZMX -and (Test-Path -LiteralPath $env:GRAPHCODE_ZMX)) { - $treeProcessIds = Get-ProcessTreeIds @($ownedProcessIds) - foreach ($processId in $treeProcessIds) { - [void] $ownedProcessIds.Add($processId) - } - foreach ($session in @($ownedSessionNames)) { - & $env:GRAPHCODE_ZMX kill --force $session *> $null - } - foreach ($processId in @($ownedProcessIds)) { - if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { - Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue - } - } - $orphanTestDaemons = @( - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -match "(?i)^zmx(?:\.exe)?$" -and - (Test-TestSessionProcess $_) - } - ) - foreach ($process in $orphanTestDaemons) { - Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue - } + $cleanupFailure = New-ShellCleanupFailure $primaryFailure $cleanupErrors.ToArray() @($smokeZmxDir, $bareZmxStage, $bareZmxProject, $handoffStage) + if ($cleanupFailure) { + Write-Host "SHELL_CLEANUP_UNPROVEN=$($cleanupFailure.Message); retained=$smokeZmxDir,$bareZmxStage,$handoffStage" } Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue @@ -701,7 +759,7 @@ finally { $env:ZMX_DIR = $oldZmxDir } foreach ($hostProcess in $stubSessionHosts.Values) { $hostProcess.Dispose() } - if (Test-Path -LiteralPath $smokeZmxDir) { + if (-not $cleanupFailure -and (Test-Path -LiteralPath $smokeZmxDir)) { Remove-Item -LiteralPath $smokeZmxDir -Recurse -Force } if ($null -eq $oldSessionPrefix) { @@ -749,16 +807,20 @@ finally { } else { $env:GRAPHCODE_WORKSPACE_LAYOUT = $oldWorkspaceLayout } - $layoutStem = [IO.Path]::GetFileNameWithoutExtension($workspaceLayoutBase) - Get-ChildItem -LiteralPath $shellRoot -Filter "$layoutStem*.json" -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $busyResult,$busyError,$inputError -Force -ErrorAction SilentlyContinue - if ($handoffStage) { - Remove-Item -LiteralPath $handoffStage -Recurse -Force -ErrorAction SilentlyContinue + if (-not $cleanupFailure) { + $layoutStem = [IO.Path]::GetFileNameWithoutExtension($workspaceLayoutBase) + Get-ChildItem -LiteralPath $shellRoot -Filter "$layoutStem*.json" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $busyResult,$busyError,$inputError -Force -ErrorAction SilentlyContinue + if ($handoffStage) { + Remove-Item -LiteralPath $handoffStage -Recurse -Force -ErrorAction SilentlyContinue + } + } + if ($cleanupFailure) { + throw $cleanupFailure } - Remove-BareNameZmxStage - Assert-NoOrphanShellProcesses } +Write-Host "Windows shell smoke/stress: PASS" exit 0 From 163bf519933ff9a866b840811cdf99639b81fefd Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:45:53 -0700 Subject: [PATCH 06/46] Migrate complete UI native helpers and case-level input contracts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ui.json | 884 ++++++++++++- .../Ui/Fixtures/InputFixture.cs | 67 + .../Ui/Fixtures/PopupEffectFixture.cs | 67 + .../Ui/Fixtures/PromptLookupFixture.cs | 49 + .../ValidationRunner/Ui/ManagedUiSuite.cs | 188 +++ .../ValidationRunner/Ui/NativeUiSuite.cs | 195 +++ .../GraphCodeMultiProjectRenameNative.cs | 77 ++ .../Ui/Production/GraphCodeUiaAttribution.cs | 78 ++ .../Ui/Production/GraphCodeUiaGateState.cs | 1094 +++++++++++++++++ .../Ui/Production/GraphCodeUiaHostInfo.cs | 46 + .../Ui/Production/UiaInputSafety.cs | 334 +++++ .../Ui/Production/UiaWindowActivation.cs | 46 - .../ValidationRunner/Ui/SuiteRegistration.cs | 7 +- .../ValidationRunner/Ui/UiSourceContracts.ps1 | 137 +++ .../ValidationRunner/Ui/source-case-map.json | 875 +++++++++++++ 15 files changed, 4082 insertions(+), 62 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs create mode 100644 Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs create mode 100644 Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs delete mode 100644 Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs create mode 100644 Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ui/source-case-map.json diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index 1d430de1..42f3e0a9 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,18 +1,874 @@ { "units": [ - { "id": "ui.activation.foreground-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, - { "id": "ui.activation.foreground-acquired", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, - { "id": "ui.activation.ownership-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, - { "id": "ui.activation.invalid-window", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, - { "id": "ui.activation.source-boundary", "owner": "Ui", "minimumCount": 6, "requiredGroups": [] }, - { "id": "ui.input.batch", "owner": "Ui", "minimumCount": 21, "requiredGroups": [] }, - { "id": "ui.popup.predicate", "owner": "Ui", "minimumCount": 10, "requiredGroups": [] }, - { "id": "ui.popup.source", "owner": "Ui", "minimumCount": 9, "requiredGroups": [] }, - { "id": "ui.popup.effect", "owner": "Ui", "minimumCount": 11, "requiredGroups": [] }, - { "id": "ui.native.lookup", "owner": "Ui", "minimumCount": 14, "requiredGroups": [] }, - { "id": "ui.native.hit", "owner": "Ui", "minimumCount": 6, "requiredGroups": [] }, - { "id": "ui.geometry", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, - { "id": "ui.host-info", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, - { "id": "ui.gate-state", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] } + { + "id": "ui.activation.foreground-refused", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.foreground-acquired", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.ownership-refused", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.invalid-window-refused", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-key", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.foreign-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.sibling-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.missing-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.dead-retained-process", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.hidden-window", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.disabled-window", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-unicode", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.unicode-foreign-focus", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-mouse", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.mouse-hit-loss", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.mouse-cursor-loss", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.mouse-foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-edit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.edit-focus-loss-after-select", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.edit-focus-loss-after-read", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.owned-submenu-hover", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.submenu-foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.submenu-unretained-process", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.input.submenu-covered-point", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.owned-shell", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.owned-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-foreground-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.underlying-shell-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.hidden-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.unknown-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.foreign-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.predicate.absent-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.owned", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.owned-enter", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-after-hover", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-before-enter", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-owner-all", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.foreign-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.underlying-shell-hit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.hidden-popup", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.cursor-changed", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.effect.cursor-unreadable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.geometry.seven-pixel-band", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.geometry.multiple-covers", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.gate-state.activation-reference", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.gate-state.input-reference", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.named-direct-viewport", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.hidden-write-read", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.missing-id", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.missing-write-read", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.direct-precedence", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.wrong-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.wrong-viewport", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.other-sibling", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.no-deeper-fallback", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.direct-viewport-required", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.native-form", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.workspace-manager", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.unknown-content", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.lookup.null-parent", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.nested-edit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.content-background", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.wrong-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.wrong-container", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.no-recursion", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.native.hit.direct-child", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.host-info.current-session", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.actual", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.unshared", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-initial-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-fresh-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-restore", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.activation.source.missing-native-error", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.actual", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.bypass-guard", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.stale-foreground", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.stale-cursor", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.missing-held-owner-check", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.missing-pre-cursor-guard", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.missing-pre-enter-guard", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.wrong-hit-root", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.popup.source.extra-input", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.absent", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.valued", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.failure-identity", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.mock.isolation.ordinary-diagnostic", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.initially-empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.initially-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.already-visible", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mark-only-empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.delayed-visible", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.outside-before-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-source-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-fit-owner", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.fit-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.fit-disabled", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.remains-empty", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.remains-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-id-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.wrong-owner-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.outside-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.missing-after-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-skip-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-wrong-identity-accepted", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.recovery.visibility.mutant-outside-bounds-accepted", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.actual", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-fit", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.wrong-identity", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.ungated-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.escaped-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.stale-visibility", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-containment", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.visibility.missing-require", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.rejection", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.success", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.unavailable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.foreign-pid", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.identity-drift", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.zero-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.offscreen", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.disabled", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.selected", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.runtime-unavailable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.parent-unavailable", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.telemetry-write-failure", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.receipt-exists", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.receipt-unconfigured", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.deadline", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.long-identity", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.long-runtime", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.invalid-boolean", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.nonfinite-bounds", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.exception-chain", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.delete.select.foreign-parent", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.host-info.diagnostics", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source-assertions", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.production.consumer-linkage", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + } ] } diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs new file mode 100644 index 00000000..d10d9f9c --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/InputFixture.cs @@ -0,0 +1,67 @@ +#nullable disable +using System; +using static GraphCode.Windows.UiaInputSafety; +using GraphCode.Windows; +namespace GraphCode.Validation.Ui.Fixtures { +public sealed class InputFixture : IUiaInputOperations { + + + + + + + public bool Alive, Foreground, Visible, Enabled, FocusOnSelect, FocusOnRead, HitLoss, CursorLoss, Covered, Hover; + public int Injected, HitQueries, CursorQueries, Moves; + public IntPtr Focus; + public ScreenPoint Cursor; + public void Reset() { + Alive = Foreground = Visible = Enabled = true; + FocusOnSelect = FocusOnRead = HitLoss = CursorLoss = Covered = Hover = false; + Injected = HitQueries = CursorQueries = Moves = 0; Focus = new IntPtr(20); Cursor = new ScreenPoint { X = 5, Y = 5 }; + } + public bool IsRetainedProcessAlive(uint pid) { return Alive && pid == 123; } + public uint WindowProcessId(IntPtr window) { + return window == new IntPtr(10) || window == new IntPtr(20) || window == new IntPtr(21) || window == new IntPtr(30) ? 123u : 999u; + } + public bool WindowIsVisible(IntPtr window) { return Visible; } + public bool WindowIsEnabled(IntPtr window) { return Enabled; } + public IntPtr GetForegroundWindow() { return new IntPtr(Foreground ? 10 : 99); } + public IntPtr FocusedControlInDialog(IntPtr window) { return Focus; } + public IntPtr GetAncestor(IntPtr window, uint flag) { + if (window == new IntPtr(30)) return window; + return window == new IntPtr(10) || window == new IntPtr(20) || window == new IntPtr(21) ? new IntPtr(10) : new IntPtr(99); + } + public bool GetCursorPos(out ScreenPoint point) { + CursorQueries++; point = Cursor; if (CursorLoss && CursorQueries > 1) point.X++; return true; + } + public bool SetCursorPos(int x, int y) { Moves++; Cursor = new ScreenPoint { X = x, Y = y }; return true; } + public IntPtr WindowFromPoint(ScreenPoint point) { + HitQueries++; return new IntPtr(Covered || (HitLoss && HitQueries > 1) ? 99 : (Hover ? 30 : 20)); + } + public bool IsWindowVisible(IntPtr window) { return Visible; } + public bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item) { + item = new RECT { Left = 0, Top = 0, Right = 10, Bottom = 10 }; return true; + } + public uint GetWindowThreadProcessId(IntPtr owner, out uint pid) { pid = WindowProcessId(owner); return 1; } + public IntPtr FindPopupForMenu(uint pid, IntPtr menu) { return new IntPtr(30); } + public bool ScreenToClient(IntPtr popup, ref ScreenPoint point) { return true; } + public IntPtr MouseLParam(int x, int y) { return IntPtr.Zero; } + public uint GetMenuState(IntPtr menu, uint position, uint flags) { return 0x80; } + public void Sleep(uint milliseconds) {} + public bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point) { return target == new IntPtr(20); } + public uint SendInput(uint count, Input[] inputs, int size) { Injected++; return count; } + public uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size) { Injected++; return count; } + public IntPtr ControlById(IntPtr owner, int id) { return new IntPtr(20); } + public bool FocusControl(IntPtr owner, IntPtr edit) { Focus = edit; return true; } + public IntPtr SendMessage(IntPtr edit, uint message, UIntPtr first, IntPtr last) { + if (FocusOnSelect) Focus = new IntPtr(99); return IntPtr.Zero; + } + public string EditBufferText(IntPtr edit) { if (FocusOnRead) Focus = new IntPtr(99); return ""; } + public string EditTextById(IntPtr owner, int id) { return "test"; } + public IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text) { return IntPtr.Zero; } + + public IntPtr PopupMenuHandle(IntPtr popup) => throw new InvalidOperationException("Input fixture does not test popup clicks"); + public uint PopupMenuItemId(IntPtr menu, int position) => throw new InvalidOperationException("Input fixture does not test popup clicks"); + public bool PostMessage(IntPtr window, uint message, UIntPtr first, IntPtr last) => throw new InvalidOperationException("Input fixture does not post keyboard input"); +} +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs new file mode 100644 index 00000000..1a4f0ce0 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs @@ -0,0 +1,67 @@ +#nullable disable +using static GraphCode.Windows.UiaInputSafety; +using GraphCode.Windows; +namespace GraphCode.Validation.Ui.Fixtures { +using System; +using System.Runtime.InteropServices; +public sealed class PopupEffectFixture : IUiaInputOperations { + public string Scenario = ""; + public int MouseCalls, KeyCalls, CursorMoves; + public bool Visible; + public int CursorX, CursorY, CursorReads, ForegroundReads; + public void Reset(string scenario) { + Scenario = scenario; MouseCalls = KeyCalls = CursorMoves = 0; + Visible = scenario != "hidden-popup"; CursorX = CursorY = CursorReads = ForegroundReads = 0; + } + + + + + public IntPtr PopupMenuHandle(IntPtr popup) { return (IntPtr)7; } + public bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item) { + item = new RECT { Left = 100, Top = 100, Right = 300, Bottom = 150 }; return true; + } + public uint PopupMenuItemId(IntPtr menu, int position) { return 5103; } + public bool ScreenToClient(IntPtr popup, ref ScreenPoint point) { point.X -= 100; point.Y -= 100; return true; } + public bool GetCursorPos(out ScreenPoint point) { + CursorReads++; + point = new ScreenPoint { X = CursorX + (Scenario == "cursor-changed" && CursorReads == 3 ? 1 : 0), Y = CursorY }; + return Scenario != "cursor-unreadable" || CursorReads != 3; + } + public bool SetCursorPos(int x, int y) { CursorMoves++; CursorX = x; CursorY = y; return true; } + public IntPtr SendMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam) { return IntPtr.Zero; } + public IntPtr MouseLParam(int x, int y) { return (IntPtr)((y << 16) | x); } + public uint GetMenuState(IntPtr menu, uint position, uint flags) { return 128; } + public void Sleep(uint milliseconds) { } + public uint SendInput(uint count, Input[] inputs, int size) { + MouseCalls++; Visible = Scenario == "foreign-before-enter" || Scenario == "owned-enter"; return count; + } + public bool IsWindowVisible(IntPtr popup) { return Visible; } + public bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam) { KeyCalls++; return true; } + public IntPtr GetForegroundWindow() { + ForegroundReads++; + return Scenario == "foreign-foreground" || + (Scenario == "foreign-after-hover" && ForegroundReads == 2) || + (Scenario == "foreign-before-enter" && ForegroundReads == 3) ? (IntPtr)99 : (IntPtr)42; + } + public IntPtr WindowFromPoint(ScreenPoint point) { return Scenario == "underlying-shell-hit" ? (IntPtr)42 : (IntPtr)44; } + public bool IsRetainedProcessAlive(uint processId) { return processId == 4242 && Scenario != "foreign-owner-all"; } + public uint WindowProcessId(IntPtr window) { + return Scenario == "foreign-owner-all" || window == (IntPtr)99 || + (window == (IntPtr)44 && Scenario == "foreign-hit") ? 7777u : 4242u; + } + public IntPtr GetAncestor(IntPtr window, uint flags) { return window == (IntPtr)44 ? (IntPtr)43 : window; } + public bool WindowIsVisible(IntPtr window) => IsWindowVisible(window); + public bool WindowIsEnabled(IntPtr window) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr FocusedControlInDialog(IntPtr window) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr FindPopupForMenu(uint processId, IntPtr menu) => throw new InvalidOperationException("Not a popup operation"); + public bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point) => throw new InvalidOperationException("Not a popup operation"); + public uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr ControlById(IntPtr owner, int id) => throw new InvalidOperationException("Not a popup operation"); + public bool FocusControl(IntPtr owner, IntPtr edit) => throw new InvalidOperationException("Not a popup operation"); + public string EditBufferText(IntPtr edit) => throw new InvalidOperationException("Not a popup operation"); + public string EditTextById(IntPtr owner, int id) => throw new InvalidOperationException("Not a popup operation"); + public IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text) => throw new InvalidOperationException("Not a popup operation"); + +} +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs new file mode 100644 index 00000000..068ea2e3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/PromptLookupFixture.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace GraphCode.Validation.Ui.Fixtures { +using System; +using System.Runtime.InteropServices; +public static class PromptLookupFixture { + private delegate IntPtr WindowProc(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + private static readonly WindowProc procedure = DefWindowProc; + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClass { + public uint Style; + public IntPtr Procedure; + public int ClassExtra, WindowExtra; + public IntPtr Instance, Icon, Cursor, Background; + public string Menu, Name; + } + [DllImport("user32.dll", EntryPoint = "DefWindowProcW")] + private static extern IntPtr DefWindowProc(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "RegisterClassW")] + private static extern ushort RegisterClass(ref WindowClass value); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string name); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateWindowExW")] + private static extern IntPtr CreateWindow(uint ex, string cls, string title, uint style, + int x, int y, int width, int height, IntPtr parent, IntPtr id, IntPtr instance, IntPtr parameter); + [DllImport("user32.dll")] + public static extern bool DestroyWindow(IntPtr window); + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr window, IntPtr after, int x, int y, int width, int height, uint flags); + public static void Place(IntPtr window, int x, int y, int width, int height) { + if (!SetWindowPos(window, IntPtr.Zero, x, y, width, height, 0x14)) + throw new InvalidOperationException("Hidden lookup fixture placement failed."); + } + public static void Register(string name) { + var value = new WindowClass { Name = name, Instance = GetModuleHandle(null), + Procedure = Marshal.GetFunctionPointerForDelegate(procedure) }; + if (RegisterClass(ref value) == 0) + throw new InvalidOperationException("Hidden lookup fixture class registration failed: " + Marshal.GetLastWin32Error()); + } + public static IntPtr Create(string cls, IntPtr parent, int id) { + IntPtr result = CreateWindow(0, cls, "lookup fixture", parent == IntPtr.Zero ? 0u : 0x50000000u, + 0, 0, 400, 300, parent, new IntPtr(id), GetModuleHandle(null), IntPtr.Zero); + if (result == IntPtr.Zero) + throw new InvalidOperationException("Hidden lookup fixture creation failed: " + Marshal.GetLastWin32Error()); + return result; + } +} +} diff --git a/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs b/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs new file mode 100644 index 00000000..4e812c23 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GraphCode.Validation.Ui.Fixtures; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui; + +internal sealed record UiCase(string Id, Action Run, string Source); + +public sealed class ManagedUiSuite : IValidationSuite +{ + private readonly IReadOnlyList cases = CreateCases(); + public string Id => "ui.managed"; + public IReadOnlyList UnitIds => cases.Select(test => test.Id).ToArray(); + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var results = new List(); + foreach (var test in cases) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + test.Run(); + results.Add(new UnitResult(test.Id, UnitStatus.Passed, 1, $"{test.Source}; nativeCalls=0")); + } + catch (Exception failure) + { + results.Add(new UnitResult(test.Id, UnitStatus.Failed, 1, failure.ToString())); + } + } + return Task.FromResult>(results); + } + + private sealed record InputCase(string Name, string Operation, int Expected, + Action? Configure = null, int? Moves = null, bool? Refuse = null); + + private static IReadOnlyList CreateCases() + { + var cases = new List(); + InputCase[] inputCases = + { + new("owned-key", "key", 1), + new("foreign-foreground", "key", 0, p => p.Foreground = false), + new("foreign-focus", "key", 0, p => p.Focus = new IntPtr(99)), + new("sibling-focus", "key", 0, p => p.Focus = new IntPtr(21)), + new("missing-focus", "key", 0, p => p.Focus = IntPtr.Zero), + new("dead-retained-process", "key", 0, p => p.Alive = false), + new("hidden-window", "key", 0, p => p.Visible = false), + new("disabled-window", "key", 0, p => p.Enabled = false), + new("owned-unicode", "unicode", 1), + new("unicode-foreign-focus", "unicode", 0, p => p.Focus = new IntPtr(99)), + new("owned-mouse", "mouse", 1), + new("mouse-hit-loss", "mouse", 0, p => p.HitLoss = true), + new("mouse-cursor-loss", "mouse", 0, p => p.CursorLoss = true), + new("mouse-foreign-foreground", "mouse", 0, p => p.Foreground = false), + new("owned-edit", "edit", 2), + new("edit-focus-loss-after-select", "edit", 0, p => p.FocusOnSelect = true), + new("edit-focus-loss-after-read", "edit", 1, p => p.FocusOnRead = true, Refuse: true), + new("owned-submenu-hover", "hover", 0, Moves: 1, Refuse: false), + new("submenu-foreign-foreground", "hover", 0, p => p.Foreground = false, Moves: 0), + new("submenu-unretained-process", "hover", 0, p => p.Alive = false, Moves: 0), + new("submenu-covered-point", "hover", 0, p => p.Covered = true, Moves: 0) + }; + ActivationCases.Require(inputCases.Length == 21, "UIA input fixtures require exactly twenty-one cases"); + foreach (var test in inputCases) + cases.Add(new UiCase($"ui.input.{test.Name}", () => RunInput(test), "ValidationRunner.Tests.ps1:758-890")); + + (string Name, long Owner, long Popup, uint Expected, uint PopupPid, uint ForegroundPid, + long ForegroundRoot, uint PointPid, long PointRoot, bool Visible, bool Accepted)[] predicates = + { + ("owned-shell", 42,43,4242,4242,4242,42,4242,43,true,true), + ("owned-popup", 42,43,4242,4242,4242,43,4242,43,true,true), + ("foreign-foreground", 42,43,4242,4242,7777,42,4242,43,true,false), + ("foreign-foreground-root", 42,43,4242,4242,4242,99,4242,43,true,false), + ("foreign-hit", 42,43,4242,4242,4242,42,7777,43,true,false), + ("underlying-shell-hit", 42,43,4242,4242,4242,42,4242,42,true,false), + ("hidden-popup", 42,43,4242,4242,4242,42,4242,43,false,false), + ("unknown-owner", 42,43,0,0,0,42,0,43,true,false), + ("foreign-popup", 42,43,4242,7777,4242,42,4242,43,true,false), + ("absent-popup", 42,0,4242,4242,4242,42,4242,0,true,false) + }; + ActivationCases.Require(predicates.Length == 10, "Popup ownership requires ten managed combinations"); + foreach (var test in predicates) + cases.Add(new UiCase($"ui.popup.predicate.{test.Name}", () => + { + bool accepted = UiaInputSafety.IsOwnedPopupInput(new IntPtr(test.Owner), new IntPtr(test.Popup), + test.Expected, test.PopupPid, test.ForegroundPid, new IntPtr(test.ForegroundRoot), + test.PointPid, new IntPtr(test.PointRoot), test.Visible); + ActivationCases.Require(accepted == test.Accepted, $"Popup ownership predicate: {test.Name} differs"); + }, "ValidationRunner.Tests.ps1:1221-1255")); + + string[] effects = { "owned", "owned-enter", "foreign-foreground", "foreign-after-hover", + "foreign-before-enter", "foreign-owner-all", "foreign-hit", "underlying-shell-hit", + "hidden-popup", "cursor-changed", "cursor-unreadable" }; + ActivationCases.Require(effects.Length == 11, "Popup effect contract requires eleven scenarios"); + foreach (string name in effects) + cases.Add(new UiCase($"ui.popup.effect.{name}", () => RunPopupEffect(name), + "ValidationRunner.Tests.ps1:1256-1328")); + + cases.Add(new UiCase("ui.geometry.seven-pixel-band", () => + { + int[][] remainder = GraphCodeUiaGateState.SubtractCoveredRectangles( + new[] { 667, 698, 763, 728 }, new[] { new[] { 84, 699, 763, 721 } }); + ActivationCases.Require(remainder.Length == 2 && remainder[0].SequenceEqual(new[] { 667, 721, 763, 728 }), + "RED: node sheet rectangle subtraction misses the real seven-pixel uncovered Create band"); + }, "ValidationRunner.Tests.ps1:7626-7634")); + cases.Add(new UiCase("ui.geometry.multiple-covers", () => + { + int[][] remainder = GraphCodeUiaGateState.SubtractCoveredRectangles( + new[] { 667, 698, 763, 728 }, new[] { new[] { 84, 699, 763, 721 }, new[] { 667, 721, 715, 728 } }); + ActivationCases.Require(remainder.Length == 2 && remainder[0].SequenceEqual(new[] { 715, 721, 763, 728 }), + "RED: node sheet rectangle subtraction does not handle more than one covering control"); + }, "ValidationRunner.Tests.ps1:7635-7643")); + cases.Add(new UiCase("ui.gate-state.activation-reference", () => + { + var field = typeof(GraphCodeUiaGateState).GetField("ActivationOperations", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + ActivationCases.Require(field is not null && + field.FieldType == typeof(IUiaWindowActivationOperations) && + field.FieldType.Assembly == typeof(UiaWindowActivation).Assembly, + "Full UIA gate helper does not use the activation contracts' production type identity"); + }, "ValidationRunner.Tests.ps1:7408-7430")); + cases.Add(new UiCase("ui.gate-state.input-reference", () => + { + var field = typeof(GraphCodeUiaGateState).GetField("InputSafety", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + ActivationCases.Require(field is not null && field.FieldType == typeof(UiaInputSafety), + "Full UIA gate helper must use the tested shared input safety component"); + }, "Compiled migration shared production linkage")); + return cases; + } + + private static void RunInput(InputCase test) + { + var probe = new InputFixture(); + probe.Reset(); + test.Configure?.Invoke(probe); + var input = new UiaInputSafety(probe); + Exception? failure = null; + try + { + switch (test.Operation) + { + case "key": input.SendKeyInput(new IntPtr(10), 123, new IntPtr(20), 46, 1); break; + case "unicode": input.SendUnicodeInput(new IntPtr(10), 123, new IntPtr(20), "test"); break; + case "mouse": input.ClickOwnedScreenRectangle(new IntPtr(10), 0, 0, 10, 10, false); break; + case "edit": input.TypeEditTextById(new IntPtr(10), 1, "test"); break; + case "hover": probe.Hover = true; input.HoverPopupMenuItem(new IntPtr(10), new IntPtr(11), 0); break; + default: throw new InvalidOperationException($"Unknown input operation {test.Operation}"); + } + } + catch (InvalidOperationException error) { failure = error; } + bool mustRefuse = test.Refuse ?? test.Expected == 0; + ActivationCases.Require(probe.Injected == test.Expected && (failure is not null) == mustRefuse && + (test.Moves is null || probe.Moves == test.Moves), + $"UIA_INPUT_RED: {test.Name} injected={probe.Injected} expected={test.Expected} refusal={failure is not null}; {failure?.Message}"); + } + + private static void RunPopupEffect(string name) + { + var probe = new PopupEffectFixture(); + probe.Reset(name); + var input = new UiaInputSafety(probe); + UiaInputSafety.PopupItemHit? hit = null; + Exception? failure = null; + try { hit = input.ClickPopupMenuItem(new IntPtr(43), new IntPtr(42), 0, 5103, 4242); } + catch (InvalidOperationException error) { failure = error; } + bool expected = name is "owned" or "owned-enter"; + bool passed = (hit is not null) == expected && + probe.MouseCalls == (expected || name == "foreign-before-enter" ? 1 : 0) && + probe.KeyCalls == (name == "owned-enter" ? 2 : 0) && + probe.CursorMoves == (name is "foreign-foreground" or "foreign-owner-all" or "hidden-popup" ? 0 : 1); + if (!expected) + { + string diagnostic = name is "cursor-changed" or "cursor-unreadable" + ? "Popup input cursor changed; no mouse input was injected" + : name is "foreign-foreground" or "foreign-before-enter" or "foreign-owner-all" or "hidden-popup" + ? "Popup input ownership changed; no further input was injected" + : "Popup input ownership changed; no mouse input was injected"; + passed &= failure is not null && failure.Message.Contains(diagnostic, StringComparison.Ordinal); + } + ActivationCases.Require(passed, $"Actual popup input effect contract: {name} differs; {failure?.Message}"); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs b/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs new file mode 100644 index 00000000..31cf22a7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/NativeUiSuite.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using GraphCode.Validation.Ui.Fixtures; + +namespace GraphCode.Validation.Ui; + +public sealed class NativeUiSuite : IValidationSuite +{ + public string Id => "ui.native"; + public IReadOnlyList UnitIds { get; } = new[] + { + "ui.native.lookup.named-direct-viewport", "ui.native.lookup.hidden-write-read", + "ui.native.lookup.missing-id", "ui.native.lookup.missing-write-read", + "ui.native.lookup.direct-precedence", "ui.native.lookup.wrong-root", + "ui.native.lookup.wrong-viewport", "ui.native.lookup.other-sibling", + "ui.native.lookup.no-deeper-fallback", "ui.native.lookup.direct-viewport-required", + "ui.native.lookup.native-form", "ui.native.lookup.workspace-manager", + "ui.native.lookup.unknown-content", "ui.native.lookup.null-parent", + "ui.native.hit.nested-edit", "ui.native.hit.content-background", + "ui.native.hit.wrong-root", "ui.native.hit.wrong-container", + "ui.native.hit.no-recursion", "ui.native.hit.direct-child", + "ui.host-info.current-session" + }; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var results = new List(); + var roots = new List(); + Exception? primary = null; + try + { + foreach (string name in new[] { "GraphCodeSheetHost", "GraphCodeSheetViewport", + "GraphCodeWindowsDialogContent", "GraphCodeNativeForm", "GraphCodeWorkspaceManager", + "GraphCodeUnknownSheetContent" }) + PromptLookupFixture.Register(name); + + IntPtr NewRoot(string cls) + { + IntPtr window = PromptLookupFixture.Create(cls, IntPtr.Zero, 0); + roots.Add(window); + return window; + } + void Check(string id, Action assertion) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + assertion(); + results.Add(new UnitResult(id, UnitStatus.Passed, 1, + "Actual compiled helper and hidden owned Win32 fixture; ValidationRunner.Tests.ps1:7431-7624")); + } + catch (InvalidOperationException failure) + { + results.Add(new UnitResult(id, UnitStatus.Failed, 1, failure.ToString())); + } + } + void Lookup(string id, IntPtr parent, int controlId, IntPtr expected) => + Check(id, () => ActivationCases.Require(GraphCodeUiaGateState.ControlById(parent, controlId) == expected, + $"RED: prompt lookup {id} differs")); + IntPtr Create(string cls, IntPtr parent, int id = 0) => PromptLookupFixture.Create(cls, parent, id); + + IntPtr prompt = NewRoot("GraphCodeSheetHost"); + IntPtr viewport = Create("GraphCodeSheetViewport", prompt); + IntPtr content = Create("GraphCodeWindowsDialogContent", viewport); + IntPtr edit = Create("EDIT", content, 9904); + Lookup("ui.native.lookup.named-direct-viewport", prompt, 9904, edit); + Check("ui.native.lookup.hidden-write-read", () => ActivationCases.Require( + !PromptLookupFixture.IsWindowVisible(prompt) && + GraphCodeUiaGateState.SetEditTextById(prompt, 9904, "owned fixture") && + GraphCodeUiaGateState.EditTextById(prompt, 9904) == "owned fixture", + "RED: prompt lookup hidden nested write/read did not use the same control")); + Lookup("ui.native.lookup.missing-id", prompt, 9905, IntPtr.Zero); + Check("ui.native.lookup.missing-write-read", () => ActivationCases.Require( + !GraphCodeUiaGateState.SetEditTextById(prompt, 9905, "wrong") && + GraphCodeUiaGateState.EditTextById(prompt, 9905) is null, + "RED: prompt lookup missing id returned successful text")); + IntPtr direct = Create("EDIT", prompt, 9904); + Lookup("ui.native.lookup.direct-precedence", prompt, 9904, direct); + + IntPtr wrongRoot = NewRoot("STATIC"); + IntPtr wrongRootViewport = Create("GraphCodeSheetViewport", wrongRoot); + IntPtr wrongRootContent = Create("GraphCodeWindowsDialogContent", wrongRootViewport); + Create("EDIT", wrongRootContent, 9904); + Lookup("ui.native.lookup.wrong-root", wrongRoot, 9904, IntPtr.Zero); + IntPtr wrongContainerRoot = NewRoot("GraphCodeSheetHost"); + IntPtr wrongContainer = Create("STATIC", wrongContainerRoot); + IntPtr wrongContent = Create("GraphCodeWindowsDialogContent", wrongContainer); + Create("EDIT", wrongContent, 9904); + Lookup("ui.native.lookup.wrong-viewport", wrongContainerRoot, 9904, IntPtr.Zero); + IntPtr namedViewport = Create("GraphCodeSheetViewport", wrongContainerRoot); + IntPtr namedContent = Create("GraphCodeWindowsDialogContent", namedViewport); + Lookup("ui.native.lookup.other-sibling", wrongContainerRoot, 9904, IntPtr.Zero); + IntPtr deeper = Create("STATIC", namedContent); + Create("EDIT", deeper, 9904); + Lookup("ui.native.lookup.no-deeper-fallback", wrongContainerRoot, 9904, IntPtr.Zero); + IntPtr deepRoot = NewRoot("GraphCodeSheetHost"); + IntPtr wrapper = Create("STATIC", deepRoot); + IntPtr deepViewport = Create("GraphCodeSheetViewport", wrapper); + IntPtr deepContent = Create("GraphCodeWindowsDialogContent", deepViewport); + Create("EDIT", deepContent, 9904); + Lookup("ui.native.lookup.direct-viewport-required", deepRoot, 9904, IntPtr.Zero); + IntPtr nativeRoot = NewRoot("GraphCodeSheetHost"); + IntPtr nativeViewport = Create("GraphCodeSheetViewport", nativeRoot); + IntPtr nativeContent = Create("GraphCodeNativeForm", nativeViewport); + IntPtr nativeEdit = Create("EDIT", nativeContent, 9100); + Lookup("ui.native.lookup.native-form", nativeRoot, 9100, nativeEdit); + IntPtr workspaceRoot = NewRoot("GraphCodeSheetHost"); + IntPtr workspaceViewport = Create("GraphCodeSheetViewport", workspaceRoot); + IntPtr workspaceContent = Create("GraphCodeWorkspaceManager", workspaceViewport); + IntPtr workspaceList = Create("LISTBOX", workspaceContent, 9721); + Lookup("ui.native.lookup.workspace-manager", workspaceRoot, 9721, workspaceList); + IntPtr unknownRoot = NewRoot("GraphCodeSheetHost"); + IntPtr unknownViewport = Create("GraphCodeSheetViewport", unknownRoot); + IntPtr unknownContent = Create("GraphCodeUnknownSheetContent", unknownViewport); + Create("EDIT", unknownContent, 9100); + Lookup("ui.native.lookup.unknown-content", unknownRoot, 9100, IntPtr.Zero); + Lookup("ui.native.lookup.null-parent", IntPtr.Zero, 9904, IntPtr.Zero); + ActivationCases.Require(results.Count == 14, "RED: prompt lookup case count was not 14"); + + IntPtr hitRoot = NewRoot("GraphCodeSheetHost"); + IntPtr hitViewport = Create("GraphCodeSheetViewport", hitRoot); + PromptLookupFixture.Place(hitViewport, 0, 0, 400, 300); + IntPtr hitContent = Create("GraphCodeWindowsDialogContent", hitViewport); + PromptLookupFixture.Place(hitContent, 15, 20, 350, 250); + IntPtr hitEdit = Create("EDIT", hitContent, 9904); + PromptLookupFixture.Place(hitEdit, 20, 25, 100, 24); + int[] editBounds = GraphCodeUiaGateState.WindowBounds(hitEdit); + Check("ui.native.hit.nested-edit", () => ActivationCases.Require( + !PromptLookupFixture.IsWindowVisible(hitRoot) && + GraphCodeMultiProjectRenameNative.ChildAt(hitRoot, (editBounds[0] + editBounds[2]) / 2, + (editBounds[1] + editBounds[3]) / 2) == hitEdit, + "RED: prompt hit did not resolve nested edit in hidden named viewport")); + int[] contentBounds = GraphCodeUiaGateState.WindowBounds(hitContent); + Check("ui.native.hit.content-background", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(hitRoot, contentBounds[2] - 10, contentBounds[3] - 10) == hitContent, + "RED: prompt viewport background became an edit hit")); + IntPtr plainRoot = NewRoot("STATIC"); + IntPtr plainViewport = Create("GraphCodeSheetViewport", plainRoot); + IntPtr plainContent = Create("GraphCodeWindowsDialogContent", plainViewport); + Create("EDIT", plainContent, 9904); + int[] plainBounds = GraphCodeUiaGateState.WindowBounds(plainContent); + Check("ui.native.hit.wrong-root", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(plainRoot, plainBounds[0] + 50, plainBounds[1] + 50) == plainViewport, + "RED: prompt hit descended through wrong root class")); + IntPtr otherRoot = NewRoot("GraphCodeSheetHost"); + IntPtr otherContent = Create("BUTTON", otherRoot); + Create("EDIT", otherContent, 9904); + int[] otherBounds = GraphCodeUiaGateState.WindowBounds(otherContent); + Check("ui.native.hit.wrong-container", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(otherRoot, otherBounds[0] + 50, otherBounds[1] + 50) == otherContent, + "RED: prompt hit descended through wrong container class")); + IntPtr deepHitRoot = NewRoot("GraphCodeSheetHost"); + IntPtr deepHitViewport = Create("GraphCodeSheetViewport", deepHitRoot); + PromptLookupFixture.Place(deepHitViewport, 0, 0, 400, 300); + IntPtr deepHitContent = Create("GraphCodeWindowsDialogContent", deepHitViewport); + IntPtr hitWrapper = Create("BUTTON", deepHitContent); + Create("EDIT", hitWrapper, 9904); + int[] deepBounds = GraphCodeUiaGateState.WindowBounds(hitWrapper); + Check("ui.native.hit.no-recursion", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(deepHitRoot, deepBounds[0] + 50, deepBounds[1] + 50) == hitWrapper, + "RED: prompt hit recursively searched deeper descendants")); + IntPtr directRoot = NewRoot("GraphCodeSheetHost"); + IntPtr directEdit = Create("EDIT", directRoot, 9904); + int[] directBounds = GraphCodeUiaGateState.WindowBounds(directEdit); + Check("ui.native.hit.direct-child", () => ActivationCases.Require( + GraphCodeMultiProjectRenameNative.ChildAt(directRoot, directBounds[0] + 50, directBounds[1] + 50) == directEdit, + "RED: prompt hit changed existing direct-child behavior")); + ActivationCases.Require(results.Count == 20, "RED: prompt hit case count was not 6"); + Check("ui.host-info.current-session", () => ActivationCases.Require( + GraphCodeUiaHostInfo.CurrentSessionId() == checked((uint)Process.GetCurrentProcess().SessionId), + "RED: UIA host context did not resolve the current Windows session")); + } + catch (Exception failure) + { + primary = failure; + throw; + } + finally + { + var cleanupFailures = new List(); + foreach (IntPtr window in roots) + if (!PromptLookupFixture.DestroyWindow(window)) + cleanupFailures.Add(new InvalidOperationException($"Prompt lookup fixture cleanup failed for {window}")); + if (cleanupFailures.Count != 0) + { + if (primary is not null) cleanupFailures.Insert(0, primary); + throw new AggregateException("Native UI fixture cleanup failed", cleanupFailures); + } + } + return Task.FromResult>(results); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs new file mode 100644 index 00000000..f422763e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeMultiProjectRenameNative.cs @@ -0,0 +1,77 @@ +#nullable disable +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class GraphCodeMultiProjectRenameNative { + [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } + [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] private struct MonitorInfo { + public int Size; public Rect Monitor, Work; public uint Flags; + } + [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr window, uint flags); + [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(Point point); + [DllImport("user32.dll")] private static extern IntPtr RealChildWindowFromPoint(IntPtr window, Point point); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] + private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string title); + [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="GetClassNameW")] + private static extern int GetClassName(IntPtr window, System.Text.StringBuilder name, int capacity); + [DllImport("user32.dll", SetLastError=true)] private static extern bool GetClientRect(IntPtr window, out Rect rect); + [DllImport("user32.dll", SetLastError=true)] private static extern bool ClientToScreen(IntPtr window, ref Point point); + [DllImport("user32.dll", SetLastError=true)] private static extern bool ScreenToClient(IntPtr window, ref Point point); + [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr window, uint flags); + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info); + public static int[] ClientBounds(IntPtr window) { + Rect rect; + if (!GetClientRect(window, out rect)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetClientRect"); + var first = new Point { X=rect.Left, Y=rect.Top }; + var last = new Point { X=rect.Right, Y=rect.Bottom }; + if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ClientToScreen"); + return new[] { first.X, first.Y, last.X, last.Y }; + } + public static int[] WorkBounds(IntPtr window) { + var info = new MonitorInfo { Size=Marshal.SizeOf(typeof(MonitorInfo)) }; + if (!GetMonitorInfo(MonitorFromWindow(window, 2), ref info)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetMonitorInfo"); + return new[] { info.Work.Left, info.Work.Top, info.Work.Right, info.Work.Bottom }; + } + private static bool ContainsScreenPoint(IntPtr window, int x, int y) { + Rect rect; + if (!GetClientRect(window, out rect)) return false; + var first = new Point { X=rect.Left, Y=rect.Top }; + var last = new Point { X=rect.Right, Y=rect.Bottom }; + if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) return false; + return x >= first.X && x < last.X && y >= first.Y && y < last.Y; + } + public static IntPtr ChildAt(IntPtr window, int x, int y) { + var point = new Point { X=x, Y=y }; + if (!ScreenToClient(window, ref point)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ScreenToClient"); + IntPtr direct = RealChildWindowFromPoint(window, point); + var rootClass = new System.Text.StringBuilder(128); + var childClass = new System.Text.StringBuilder(128); + GetClassName(window, rootClass, rootClass.Capacity); + if (direct == IntPtr.Zero && + String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal)) { + IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); + if (ContainsScreenPoint(viewport, x, y)) direct = viewport; + } + GetClassName(direct, childClass, childClass.Capacity); + if (!String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal) || + !String.Equals(childClass.ToString(), "GraphCodeSheetViewport", StringComparison.Ordinal)) + return direct; + IntPtr content = FindWindowEx(direct, IntPtr.Zero, "GraphCodeWindowsDialogContent", null); + if (content == IntPtr.Zero) return direct; + var viewportPoint = new Point { X=x, Y=y }; + if (!ScreenToClient(direct, ref viewportPoint)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename viewport ScreenToClient"); + IntPtr viewportChild = RealChildWindowFromPoint(direct, viewportPoint); + if (viewportChild != content && + !(viewportChild == IntPtr.Zero && ContainsScreenPoint(content, x, y))) return direct; + var contentPoint = new Point { X=x, Y=y }; + if (!ScreenToClient(content, ref contentPoint)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename content ScreenToClient"); + IntPtr nested = RealChildWindowFromPoint(content, contentPoint); + return nested == IntPtr.Zero ? content : nested; + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs new file mode 100644 index 00000000..5511b13a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaAttribution.cs @@ -0,0 +1,78 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public static class GraphCodeUiaAttribution { + private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsWindowEnabled(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsHungAppWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetWindow(IntPtr window, uint command); + [StructLayout(LayoutKind.Sequential)] + public struct Rect { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr window, out Rect rect); + [DllImport("user32.dll")] + public static extern bool PrintWindow(IntPtr window, IntPtr hdc, uint flags); + public sealed class WindowRecord { + public long Handle; public long Owner; public string ClassName; public string Title; + public bool Visible, Enabled, Hung; public int Left, Top, Right, Bottom; + } + private static string ClassOf(IntPtr window) { + var text = new StringBuilder(256); + GetClassName(window, text, text.Capacity); + return text.ToString(); + } + public static long ForegroundHandle() { return GetForegroundWindow().ToInt64(); } + public static uint ProcessOf(IntPtr window) { + if (window == IntPtr.Zero) return 0; + uint processId; + GetWindowThreadProcessId(window, out processId); + return processId; + } + public static string ClassName(IntPtr window) { + return window == IntPtr.Zero ? "" : ClassOf(window); + } + public static Rect Bounds(IntPtr window) { + Rect rect; + if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return new Rect(); + return rect; + } + // Titles are read only for windows owned by processId (the gate's own shell). + public static WindowRecord[] ProcessWindows(uint processId) { + var results = new List(); + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var title = new StringBuilder(512); + GetWindowText(window, title, title.Capacity); + Rect rect; + GetWindowRect(window, out rect); + results.Add(new WindowRecord { + Handle = window.ToInt64(), Owner = GetWindow(window, 4).ToInt64(), + ClassName = ClassOf(window), Title = title.ToString(), + Visible = IsWindowVisible(window), Enabled = IsWindowEnabled(window), + Hung = IsHungAppWindow(window), + Left = rect.Left, Top = rect.Top, Right = rect.Right, Bottom = rect.Bottom + }); + return true; + }, IntPtr.Zero); + return results.ToArray(); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs new file mode 100644 index 00000000..ee8952c9 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs @@ -0,0 +1,1094 @@ +#nullable disable +using GraphCode.Windows; +using static GraphCode.Windows.UiaInputSafety; +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Automation; +public static class GraphCodeUiaGateState { + private sealed class NativeInputOperations : IUiaInputOperations { + public bool IsRetainedProcessAlive(uint processId) { return GraphCodeUiaGateState.IsRetainedProcessAlive(processId); } + public uint WindowProcessId(IntPtr window) { return GraphCodeUiaGateState.WindowProcessId(window); } + public bool WindowIsVisible(IntPtr window) { return GraphCodeUiaGateState.WindowIsVisible(window); } + public bool WindowIsEnabled(IntPtr window) { return GraphCodeUiaGateState.WindowIsEnabled(window); } + public IntPtr GetForegroundWindow() { return GraphCodeUiaGateState.GetForegroundWindow(); } + public IntPtr FocusedControlInDialog(IntPtr window) { return GraphCodeUiaGateState.FocusedControlInDialog(window); } + public IntPtr GetAncestor(IntPtr window, uint flags) { return GraphCodeUiaGateState.GetAncestor(window, flags); } + public bool GetCursorPos(out ScreenPoint point) { return GraphCodeUiaGateState.GetCursorPos(out point); } + public bool SetCursorPos(int x, int y) { return GraphCodeUiaGateState.SetCursorPos(x, y); } + public IntPtr WindowFromPoint(ScreenPoint point) { return GraphCodeUiaGateState.WindowFromPoint(point); } + public bool IsWindowVisible(IntPtr window) { return GraphCodeUiaGateState.IsWindowVisible(window); } + public bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item) { return GraphCodeUiaGateState.GetMenuItemRect(owner, menu, position, out item); } + public IntPtr FindPopupForMenu(uint processId, IntPtr menu) { return GraphCodeUiaGateState.FindPopupForMenu(processId, menu); } + public bool ScreenToClient(IntPtr window, ref ScreenPoint point) { return GraphCodeUiaGateState.ScreenToClient(window, ref point); } + public IntPtr MouseLParam(int x, int y) { return GraphCodeUiaGateState.MouseLParam(x, y); } + public uint GetMenuState(IntPtr menu, uint position, uint flags) { return GraphCodeUiaGateState.GetMenuState(menu, position, flags); } + public void Sleep(uint milliseconds) { GraphCodeUiaGateState.Sleep(milliseconds); } + public bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point) { return GraphCodeUiaGateState.ResolvesToControl(target, owner, point); } + public uint SendInput(uint count, Input[] inputs, int size) { return GraphCodeUiaGateState.SendInput(count, inputs, size); } + public uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size) { return GraphCodeUiaGateState.SendKeyInputs(count, inputs, size); } + public IntPtr ControlById(IntPtr owner, int id) { return GraphCodeUiaGateState.ControlById(owner, id); } + public bool FocusControl(IntPtr owner, IntPtr edit) { return GraphCodeUiaGateState.FocusControl(owner, edit); } + public IntPtr SendMessage(IntPtr edit, uint message, UIntPtr first, IntPtr last) { return GraphCodeUiaGateState.SendMessage(edit, message, first, last); } + public string EditBufferText(IntPtr edit) { return GraphCodeUiaGateState.EditBufferText(edit); } + public string EditTextById(IntPtr owner, int id) { return GraphCodeUiaGateState.EditTextById(owner, id); } + public IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text) { return GraphCodeUiaGateState.SendMessageString(edit, message, first, text); } + public IntPtr PopupMenuHandle(IntPtr popup) { return GraphCodeUiaGateState.PopupMenuHandle(popup); } + public uint PopupMenuItemId(IntPtr menu, int position) { return GraphCodeUiaGateState.PopupMenuItemId(menu, position); } + public bool PostMessage(IntPtr window, uint message, UIntPtr first, IntPtr last) { return GraphCodeUiaGateState.PostMessage(window, message, first, last); } + } + private static readonly UiaInputSafety InputSafety = new UiaInputSafety(new NativeInputOperations()); + + public static volatile bool LiveObserved; + public static volatile bool NamePropertyObserved; + public static volatile bool FocusObserved; + public static int LiveEvents; + public static int NamePropertyEvents; + public static int SelectedEvents; + public static int AddedEvents; + public static int RemovedEvents; + public static int TogglePropertyEvents; + public static uint LastEditClearExpected { get => InputSafety.LastEditClearExpected; set => InputSafety.LastEditClearExpected = value; } + public static uint LastEditClearSent { get => InputSafety.LastEditClearSent; set => InputSafety.LastEditClearSent = value; } + public static uint LastEditTextExpected { get => InputSafety.LastEditTextExpected; set => InputSafety.LastEditTextExpected = value; } + public static uint LastEditTextSent { get => InputSafety.LastEditTextSent; set => InputSafety.LastEditTextSent = value; } + public static bool LastEditUsedMessageFallback { get => InputSafety.LastEditUsedMessageFallback; set => InputSafety.LastEditUsedMessageFallback = value; } + public static string LiveSourceAutomationId; + public static string LiveSourceName; + public static string LiveSourceRuntimeId; + public static string NamePropertySourceAutomationId; + public static string FocusSourceAutomationId; + public static string SelectionSourceAutomationId; + public static string SelectionSourceRuntimeId; + public static string TogglePropertySourceAutomationId; + public static readonly AutomationEventHandler LiveHandler = HandleLive; + public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; + public static readonly AutomationFocusChangedEventHandler FocusHandler = HandleFocus; + public static readonly AutomationEventHandler SelectedHandler = HandleSelected; + public static readonly AutomationEventHandler AddedHandler = HandleAdded; + public static readonly AutomationEventHandler RemovedHandler = HandleRemoved; + public static readonly AutomationPropertyChangedEventHandler TogglePropertyHandler = HandleToggleProperty; + private static void HandleLive(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element == null) return; + LiveSourceAutomationId = element.Current.AutomationId; + LiveSourceName = element.Current.Name; + LiveSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + LiveEvents++; + LiveObserved = true; + } + private static void HandleNameProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) NamePropertySourceAutomationId = element.Current.AutomationId; + NamePropertyEvents++; + NamePropertyObserved = true; + } + private static void HandleFocus(object sender, AutomationFocusChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) FocusSourceAutomationId = element.Current.AutomationId; + FocusObserved = true; + } + private static void HandleSelected(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } + SelectedEvents++; + } + private static void HandleAdded(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } + AddedEvents++; + } + private static void HandleRemoved(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } + RemovedEvents++; + } + private static void HandleToggleProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) TogglePropertySourceAutomationId = element.Current.AutomationId; + TogglePropertyEvents++; + } + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", EntryPoint = "SendMessageTimeoutW", SetLastError = true)] + private static extern IntPtr SendMessageTimeout( + IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, + uint flags, uint timeoutMilliseconds, out UIntPtr result + ); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] + private static extern IntPtr SendMessageString(IntPtr window, uint message, UIntPtr wParam, string lParam); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] + private static extern IntPtr SendMessageText( + IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam + ); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string windowName); + private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool SetWindowText(IntPtr window, string text); + [DllImport("user32.dll")] + private static extern int GetDlgCtrlID(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetDlgItem(IntPtr dialog, int controlId); + [DllImport("user32.dll")] + private static extern IntPtr SetFocus(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetFocus(); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetForegroundWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool BringWindowToTop(IntPtr window); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll")] + private static extern void Sleep(uint milliseconds); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowTextLength(IntPtr window); + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr window, int command); + [DllImport("user32.dll")] + private static extern IntPtr SetActiveWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + private static extern bool IsWindowEnabled(IntPtr window); + [DllImport("user32.dll")] + private static extern int GetMenuItemCount(IntPtr menu); + [DllImport("user32.dll")] + private static extern uint GetMenuItemID(IntPtr menu, int position); + [DllImport("user32.dll")] + private static extern uint GetMenuState(IntPtr menu, uint item, uint flags); + [DllImport("user32.dll")] + private static extern bool GetMenuItemRect(IntPtr window, IntPtr menu, uint position, out RECT rect); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetCursorPos(out ScreenPoint point); + + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint count, Input[] inputs, int size); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMenuStringW")] + private static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int max, uint flags); + // The File/Loop/Terminal/Workspace/View/Help bar is a real SetMenu menu bar, + // not a TrackPopupMenu popup, so (unlike the popup context menu below) its + // live enabled state is readable directly through the Win32 menu API against + // GetMenu(shell) -- no MN_GETHMENU / popup-window workaround required. + // MF_BYCOMMAND (0) searches the whole menu tree for the command identifier. + public static uint MenuCommandState(IntPtr menuBar, uint command) { + if (menuBar == IntPtr.Zero) return 0xFFFFFFFF; + return GetMenuState(menuBar, command, 0x0000); + } + public static IntPtr GetMenuBar(IntPtr window) { + return GetMenu(window); + } + public static IntPtr FindPopupMenuWindow(uint processId) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var actualClass = new StringBuilder(256); + GetClassName(window, actualClass, actualClass.Capacity); + if (!String.Equals(actualClass.ToString(), "#32768", StringComparison.Ordinal)) return true; + if (!IsWindowVisible(window)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static IntPtr FindVisibleOwnedSheetByContentClass( + uint processId, IntPtr expectedOwner, string contentClass) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + GetWindow(window, 4) == expectedOwner && + String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && + String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + public static int CountVisibleOwnedSheetsByContentClass( + uint processId, IntPtr expectedOwner, string contentClass) { + int count = 0; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + GetWindow(window, 4) == expectedOwner && + String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && + String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { + count++; + } + return true; + }, IntPtr.Zero); + return count; + } + public static IntPtr FindVisibleOwnedSheet(uint processId, string title) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && + String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + // MN_GETHMENU. The live UIA tree exposes a popup menu only as an empty Pane + // with no MenuItem children, so the menu itself is read through the Win32 + // menu API against the real HMENU the shell handed to TrackPopupMenu. + public static IntPtr PopupMenuHandle(IntPtr popup) { + if (popup == IntPtr.Zero) return IntPtr.Zero; + return SendMessage(popup, 0x01E1, UIntPtr.Zero, IntPtr.Zero); + } + public static IntPtr FindPopupForMenu(uint processId, IntPtr menu) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + String.Equals(ClassOf(window), "#32768", StringComparison.Ordinal) && + PopupMenuHandle(window) == menu) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + public static bool HoverPopupMenuItem(IntPtr owner, IntPtr menu, int position) { return InputSafety.HoverPopupMenuItem(owner, menu, position); } + // Keyboard reveal of a real submenu: hilite the parent item (MN_SELECTITEM) + // and post VK_RIGHT, the same navigation a keyboard user performs. Unlike a + // WM_COMMAND posted to the shell (which TrackPopupMenu/TPM_RETURNCMD menus + // never route), this opens the shell's own submenu popup. + public static bool RevealSubmenuByKeyboard(IntPtr popup, IntPtr menu, int position) { + if (popup == IntPtr.Zero || menu == IntPtr.Zero || !IsWindowVisible(popup)) return false; + SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); + if ((GetMenuState(menu, (uint)position, 0x0400) & 0x0080) == 0) return false; + return PostMessage(popup, 0x0100, (UIntPtr)0x27, IntPtr.Zero) && + PostMessage(popup, 0x0101, (UIntPtr)0x27, IntPtr.Zero); + } + public static int PopupMenuItemCount(IntPtr menu) { + if (menu == IntPtr.Zero) return -1; + return GetMenuItemCount(menu); + } + public static uint PopupMenuItemId(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return 0; + return GetMenuItemID(menu, position); + } + public static string PopupMenuItemText(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return ""; + var text = new StringBuilder(512); + GetMenuString(menu, (uint)position, text, text.Capacity, 0x0400); + return text.ToString(); + } + public static uint PopupMenuItemState(IntPtr menu, int position) { + if (menu == IntPtr.Zero) return 0xFFFFFFFF; + return GetMenuState(menu, (uint)position, 0x0400); + } + // MainWindow.wm_uia_context_menu (WM_APP + 44). + public static bool PostContextMenu(IntPtr window, uint target) { + return PostMessage(window, 0x802C, (UIntPtr)target, IntPtr.Zero); + } + // MainWindow.wm_uia_present_form (WM_APP + 45). + public static bool PostPresentForm(IntPtr window, uint form) { + return PostMessage(window, 0x802D, (UIntPtr)form, IntPtr.Zero); + } + public static bool DismissPopupMenu(IntPtr popup, IntPtr owner) { + bool posted = popup != IntPtr.Zero && + PostMessage(popup, 0x0100, (UIntPtr)0x1B, IntPtr.Zero); + if (!posted && owner != IntPtr.Zero) { + SendMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); + } + return posted; + } + public static void CancelPopupMenu(IntPtr owner) { + if (owner != IntPtr.Zero) PostMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); + } + public static IntPtr FindChild(IntPtr parent, string className) { + return FindWindowEx(parent, IntPtr.Zero, className, null); + } + public static IntPtr FindTopLevel(string className, uint processId) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var actualClass = new StringBuilder(256); + GetClassName(window, actualClass, actualClass.Capacity); + if (!String.Equals(actualClass.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static string[] GetListItems(IntPtr list) { + if (list == IntPtr.Zero) return new string[0]; + int count = (int)SendMessage(list, 0x018B, UIntPtr.Zero, IntPtr.Zero); + var result = new string[count]; + for (int index = 0; index < count; index++) { + int length = (int)SendMessage(list, 0x018A, (UIntPtr)index, IntPtr.Zero); + var text = new StringBuilder(length + 1); + SendMessageText(list, 0x0189, (UIntPtr)index, text); + result[index] = text.ToString(); + } + return result; + } + public static bool PostFixtureMutation(IntPtr window, uint mutation) { + return PostMessage(window, 0x802A, (UIntPtr)mutation, IntPtr.Zero); + } + public static bool PostPaletteRefresh(IntPtr window) { + return PostMessage(window, 0x802B, UIntPtr.Zero, IntPtr.Zero); + } + public static bool PostTaggedExitCollision(IntPtr window) { + return PostMessage(window, 0x0111, new UIntPtr(0x8000000000001008UL), IntPtr.Zero); + } + public static bool PostKeyboard(IntPtr window, uint key) { + return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)key, IntPtr.Zero); + } + public static bool SendReturn(IntPtr window) { + if (window == IntPtr.Zero) return false; + SendMessage(window, 0x0100, (UIntPtr)0x0D, IntPtr.Zero); + return true; + } + public static bool SendCommand(IntPtr window, uint command) { + if (window == IntPtr.Zero) return false; + UIntPtr result; + return SendMessageTimeout( + window, 0x0111, (UIntPtr)command, IntPtr.Zero, + 0x0001 | 0x0002, 5000, out result + ) != IntPtr.Zero; + } + public static bool ClickButton(IntPtr button) { + if (button == IntPtr.Zero) return false; + SendMessage(button, 0x00F5, UIntPtr.Zero, IntPtr.Zero); + return true; + } + public static int GetCheckState(IntPtr window) { + if (window == IntPtr.Zero) return -1; + return (int)SendMessage(window, 0x00F0, UIntPtr.Zero, IntPtr.Zero); + } + public static bool FocusControl(IntPtr parent, IntPtr control) { + if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; + uint expectedProcessId = WindowProcessId(parent); + RequireOwnedInputWindow(parent, expectedProcessId); + if (WindowProcessId(control) != expectedProcessId || GetAncestor(control, 2) != parent || + !ActivateWindow(parent)) return false; + uint ignoredProcessId; + uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); + uint currentThread = GetCurrentThreadId(); + bool attached = currentThread != parentThread && + AttachThreadInput(currentThread, parentThread, true); + try { + RequireOwnedForeground(parent, expectedProcessId); + SetFocus(control); + return IsForegroundWindow(parent) && FocusedControlInDialog(parent) == control; + } finally { + if (attached) AttachThreadInput(currentThread, parentThread, false); + } + } + [StructLayout(LayoutKind.Sequential)] + private struct GuiThreadInfo { + public int Size; + public uint Flags; + public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret; + public RECT CaretRectangle; + } + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetGUIThreadInfo(uint thread, ref GuiThreadInfo info); + public static IntPtr FocusedControlInDialog(IntPtr parent) { + uint processId; + uint thread = GetWindowThreadProcessId(parent, out processId); + var info = new GuiThreadInfo { Size = Marshal.SizeOf(typeof(GuiThreadInfo)) }; + if (thread == 0 || !GetGUIThreadInfo(thread, ref info)) + throw new InvalidOperationException("Owned focus could not be observed through GetGUIThreadInfo"); + return info.Focus; + } + private static readonly System.Collections.Generic.Dictionary + RetainedProcesses = new System.Collections.Generic.Dictionary(); + public static void RetainProcess(System.Diagnostics.Process process) { + if (process == null || process.Handle == IntPtr.Zero) + throw new InvalidOperationException("UIA process has no retained launch handle"); + RetainedProcesses[checked((uint)process.Id)] = process; + } + private static bool IsRetainedProcessAlive(uint processId) { + System.Diagnostics.Process process; + if (!RetainedProcesses.TryGetValue(processId, out process)) return false; + try { return process.Handle != IntPtr.Zero && !process.HasExited; } + catch (ObjectDisposedException) { return false; } + catch (InvalidOperationException) { return false; } + } + private static void RequireOwnedInputWindow(IntPtr owner, uint expectedProcessId) { InputSafety.RequireOwnedInputWindow(owner, expectedProcessId); } + private static void RequireOwnedForeground(IntPtr owner, uint expectedProcessId) { InputSafety.RequireOwnedForeground(owner, expectedProcessId); } + public static void RequireOwnedKeyboardInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus) { InputSafety.RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); } + private static void RequireOwnedMouseInput( + IntPtr owner, uint expectedProcessId, ScreenPoint expected, IntPtr expectedControl + ) { InputSafety.RequireOwnedMouseInput(owner, expectedProcessId, expected, expectedControl); } + public static bool IsControlOwnedBy(IntPtr parent, IntPtr control, int controlId) { + return parent != IntPtr.Zero && control != IntPtr.Zero && + GetDlgCtrlID(control) == controlId && GetAncestor(control, 2) == parent; + } + public static int ControlIdOf(IntPtr control) { + return control == IntPtr.Zero ? 0 : GetDlgCtrlID(control); + } + public static bool HasVisibleBounds(IntPtr window) { + RECT rect; + return window != IntPtr.Zero && IsWindowVisible(window) && + GetWindowRect(window, out rect) && rect.Right > rect.Left && rect.Bottom > rect.Top; + } + public static string LastActivationDiagnostic = "not attempted"; + private sealed class WindowActivationOperations : GraphCode.Windows.IUiaWindowActivationOperations { + public uint WindowProcessId(IntPtr window) { + return GraphCodeUiaGateState.WindowProcessId(window); + } + public bool IsRetainedProcessAlive(uint processId) { + return GraphCodeUiaGateState.IsRetainedProcessAlive(processId); + } + public void ShowWindow(IntPtr window, int command) { + GraphCodeUiaGateState.ShowWindow(window, command); + } + public GraphCode.Windows.UiaActivationCallResult BringWindowToTop(IntPtr window) { + bool succeeded = GraphCodeUiaGateState.BringWindowToTop(window); + int error = Marshal.GetLastWin32Error(); + return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); + } + public GraphCode.Windows.UiaActivationCallResult SetForegroundWindow(IntPtr window) { + bool succeeded = GraphCodeUiaGateState.SetForegroundWindow(window); + int error = Marshal.GetLastWin32Error(); + return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); + } + public bool IsForegroundWindow(IntPtr window) { + return GraphCodeUiaGateState.IsForegroundWindow(window); + } + } + private static readonly GraphCode.Windows.IUiaWindowActivationOperations ActivationOperations = + new WindowActivationOperations(); + public static bool ActivateWindow(IntPtr window) { + return GraphCode.Windows.UiaWindowActivation.ActivateWindow( + ActivationOperations, window, out LastActivationDiagnostic); + } + public static bool IsForegroundWindow(IntPtr window) { + return window != IntPtr.Zero && GetForegroundWindow() == window; + } + public static IntPtr CurrentForegroundWindow() { + return GetForegroundWindow(); + } + public static uint WindowProcessId(IntPtr window) { + if (window == IntPtr.Zero) return 0; + uint processId; + GetWindowThreadProcessId(window, out processId); + return processId; + } + public static string WindowClass(IntPtr window) { + if (window == IntPtr.Zero) return ""; + var text = new StringBuilder(256); + GetClassName(window, text, text.Capacity); + return text.ToString(); + } + public static string WindowTitle(IntPtr window) { + if (window == IntPtr.Zero) return ""; + int length = GetWindowTextLength(window); + var text = new StringBuilder(length + 1); + GetWindowText(window, text, text.Capacity); + return text.ToString(); + } + // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren + // exhaustion path): enumerates every top-level window currently owned by + // processId with its class, visibility, and title, so a failure that + // reports MainWindowHandle == 0 can be told apart as window-destroyed + // (empty result), window-hidden (a result exists but is not visible), or + // handle-churn (a visible, healthy-looking window exists under a handle + // Process.MainWindowHandle no longer reports). Does not filter or assert + // anything - it is read verbatim into a log line. + public static string[] DescribeTopLevelWindows(uint processId) { + var results = new System.Collections.ArrayList(); + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var classText = new StringBuilder(256); + GetClassName(window, classText, classText.Capacity); + int titleLength = GetWindowTextLength(window); + var titleText = new StringBuilder(titleLength + 1); + GetWindowText(window, titleText, titleText.Capacity); + bool visible = IsWindowVisible(window); + results.Add(window.ToInt64() + ":" + classText.ToString() + ":" + + (visible ? "visible" : "hidden") + ":" + titleText.ToString()); + return true; + }, IntPtr.Zero); + return (string[])results.ToArray(typeof(string)); + } + public static bool PostMouseClick(IntPtr window) { + return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); + } + // A modal disables its owner for as long as it is up. Win32 refuses a caption + // close against a WS_DISABLED window, so the owner must be observed enabled + // again before a close can be attributed to the shell rather than to a modal + // that has not finished tearing down. + public static bool WindowIsEnabled(IntPtr window) { + return IsWindowEnabled(window); + } + public static bool WindowIsVisible(IntPtr window) { + return window != IntPtr.Zero && IsWindowVisible(window); + } + public static IntPtr WindowOwner(IntPtr window) { + return window == IntPtr.Zero ? IntPtr.Zero : GetWindow(window, 4); + } + public static bool IsCaptionlessSheet(IntPtr window) { + if (window == IntPtr.Zero || + !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) + return false; + ulong style = unchecked((ulong)GetWindowLongPtr(window, -16).ToInt64()); + return (style & 0x80000000UL) != 0 && (style & 0x00C00000UL) == 0; + } + public static string SheetContentClass(IntPtr window) { + if (window == IntPtr.Zero || + !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) + return ""; + IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); + if (viewport == IntPtr.Zero) return ""; + string[] contentClasses = new[] { + "GraphCodeWindowsDialogContent", + "GraphCodeNativeForm", + "GraphCodeProductSettings", + "GraphCodeWorkspaceManager", + "GraphCodeRepositoryIngressDialog", + "GraphCodeRepositoryOperationDialog", + "GraphCodeCodespaceIngressDialog", + "GraphCodeUpdateOffer", + "GraphCodeUpdateInstall" + }; + foreach (string contentClass in contentClasses) + if (FindWindowEx(viewport, IntPtr.Zero, contentClass, null) != IntPtr.Zero) + return contentClass; + return ""; + } + public static IntPtr FindVisibleProcessWindow(uint processId, string title) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner == processId && IsWindowVisible(window) && + String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { + result = window; + return false; + } + return true; + }, IntPtr.Zero); + return result; + } + // Real client-coordinate mouse messages posted directly to the target window, + // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS + // delivers for genuine mouse input, without moving the shared desktop's real + // cursor (multiple fleet sessions share this desktop). PostMouseButtonAt sets + // the MK_LBUTTON flag in wParam, matching the wParam a real WM_LBUTTONDOWN/UP + // carries; ClickAt below (and its existing sidebar-update-banner caller) keep + // working unchanged since App.zig's click handling only decodes the lParam + // x/y, not the button-state bits in wParam. + private static IntPtr MouseLParam(int x, int y) { + return (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); + } + public static bool PostMouseButtonAt(IntPtr window, uint message, int x, int y) { + return PostMessage(window, message, (UIntPtr)0x0001, MouseLParam(x, y)); + } + public static bool ClickAt(IntPtr window, int x, int y) { + return PostMouseButtonAt(window, 0x0201, x, y) && PostMouseButtonAt(window, 0x0202, x, y); + } + public static bool PostMouseMoveAt(IntPtr window, int clientX, int clientY) { + return PostMessage(window, 0x0200, UIntPtr.Zero, MouseLParam(clientX, clientY)); + } + public static bool PostMouseDragMoveAt(IntPtr window, int clientX, int clientY) { + return PostMessage(window, 0x0200, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); + } + public static IntPtr SendMouseButtonAt(IntPtr window, uint message, int clientX, int clientY) { + return SendMessage(window, message, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); + } + public static bool PostMouseClickAt(IntPtr window, int clientX, int clientY) { + return PostMouseButtonAt(window, 0x0201, clientX, clientY) && + PostMouseButtonAt(window, 0x0202, clientX, clientY); + } + public static bool PostRightClickAt(IntPtr window, int clientX, int clientY) { + IntPtr point = MouseLParam(clientX, clientY); + return PostMessage(window, 0x0204, (UIntPtr)0x0002, point) && + PostMessage(window, 0x0205, UIntPtr.Zero, point); + } + public static int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { return InputSafety.ClickOwnedScreenRectangle(owner, left, top, right, bottom, rightClick); } + public static bool PostOwnedScreenPoint(IntPtr owner, int screenX, int screenY, bool rightClick) { + if (!WindowIsVisible(owner)) return false; + var point = new ScreenPoint { X = screenX, Y = screenY }; + IntPtr hit = WindowFromPoint(point); + if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner || !ScreenToClient(owner, ref point)) + return false; + if (rightClick) { + return PostMessage(owner, 0x0204, (UIntPtr)0x0002, MouseLParam(point.X, point.Y)) && + PostMessage(owner, 0x0205, UIntPtr.Zero, MouseLParam(point.X, point.Y)); + } + return PostMessage(owner, 0x0201, (UIntPtr)0x0001, MouseLParam(point.X, point.Y)) && + PostMessage(owner, 0x0202, UIntPtr.Zero, MouseLParam(point.X, point.Y)); + } + public static bool IsOwnedPopupInput( + IntPtr owner, IntPtr popup, uint expectedProcessId, uint popupProcessId, + uint foregroundProcessId, IntPtr foregroundRoot, uint pointProcessId, + IntPtr pointRoot, bool popupVisible + ) { return UiaInputSafety.IsOwnedPopupInput(owner, popup, expectedProcessId, popupProcessId, foregroundProcessId, foregroundRoot, pointProcessId, pointRoot, popupVisible); } + + public static PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId) { return InputSafety.ClickPopupMenuItem(popup, owner, position, commandId, expectedProcessId); } + private static void RequireOwnedPopupForeground(IntPtr popup, IntPtr owner, uint expectedProcessId) { InputSafety.RequireOwnedPopupForeground(popup, owner, expectedProcessId); } + // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with + // no UIA identity of their own, so a genuine click requires the real live client + // height rather than an assumed window size. + public static int ClientHeight(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; + return rect.Bottom - rect.Top; + } + // Posts the WM_MOUSEWHEEL the OS delivers to the window under the cursor. Its + // lParam is in screen coordinates; delta is in wheel units (negative scrolls + // content up, so the sidebar advances). + [DllImport("user32.dll")] + private static extern bool ClientToScreen(IntPtr window, ref ScreenPoint point); + public static bool WheelAt(IntPtr window, int clientX, int clientY, int delta) { + var point = new ScreenPoint { X = clientX, Y = clientY }; + if (window == IntPtr.Zero || !ClientToScreen(window, ref point)) return false; + return PostMessage(window, 0x020A, new UIntPtr((uint)((delta & 0xFFFF) << 16)), MouseLParam(point.X, point.Y)); + } + + [DllImport("user32.dll")] + private static extern bool GetClientRect(IntPtr window, out RECT rect); + [DllImport("user32.dll")] + private static extern IntPtr GetMenu(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetSubMenu(IntPtr menu, int position); + public static IntPtr NativeMenu(IntPtr window) { + return window == IntPtr.Zero ? IntPtr.Zero : GetMenu(window); + } + public static IntPtr NativeSubMenu(IntPtr menu, int position) { + return menu == IntPtr.Zero ? IntPtr.Zero : GetSubMenu(menu, position); + } + // App.zig only rebuilds recent_folders (and the rest of the native chrome) + // from the live GraphModel on WM_INITMENUPOPUP - the same message real + // Windows sends right before a menu bar popup is displayed. Reading the + // HMENU without first sending this leaves it holding whatever was current + // the last time a menu was actually opened (or, at startup, an empty + // "No recent folders" placeholder installed before the fixture loaded). + // Sending it here reproduces the exact real trigger, not a shortcut. + public static void RefreshNativeMenuFromLiveModel(IntPtr window, IntPtr menu) { + SendMessage(window, 0x0117, (UIntPtr)(ulong)menu.ToInt64(), IntPtr.Zero); + } + + [DllImport("user32.dll")] + private static extern bool ScreenToClient(IntPtr window, ref ScreenPoint point); + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr window); + public static bool IsWindowMinimized(IntPtr window) { return IsIconic(window); } + public static bool ScreenToClientPoint(IntPtr window, int screenX, int screenY, out int clientX, out int clientY) { + var point = new ScreenPoint { X = screenX, Y = screenY }; + bool ok = ScreenToClient(window, ref point); + clientX = point.X; + clientY = point.Y; + return ok; + } + public static int[] ClientScreenBounds(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return null; + var topLeft = new ScreenPoint { X = rect.Left, Y = rect.Top }; + var bottomRight = new ScreenPoint { X = rect.Right, Y = rect.Bottom }; + if (!ClientToScreen(window, ref topLeft) || !ClientToScreen(window, ref bottomRight)) + return null; + return new[] { topLeft.X, topLeft.Y, bottomRight.X, bottomRight.Y }; + } + public static bool PostCommand(IntPtr window, uint command) { + return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); + } + public static bool PostClose(IntPtr window) { + return PostMessage(window, 0x0010, UIntPtr.Zero, IntPtr.Zero); + } + public static bool SetFirstEditText(IntPtr parent, string text) { + var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); + if (edit == IntPtr.Zero || !SetWindowText(edit, text)) return false; + ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); + SendMessage(parent, 0x0111, (UIntPtr)command, edit); + return true; + } + public static bool SetEditTextById(IntPtr parent, int controlId, string text) { + var edit = ControlById(parent, controlId); + // Cross-process SetWindowText only updates the stored caption; WM_SETTEXT reaches the EDIT buffer. + if (edit == IntPtr.Zero || SendMessageString(edit, 0x000C, UIntPtr.Zero, text) == IntPtr.Zero) return false; + ulong command = ((ulong)0x0300 << 16) | (uint)controlId; + SendMessage(parent, 0x0111, (UIntPtr)command, edit); + return true; + } + public static string EditTextById(IntPtr parent, int controlId) { + var edit = ControlById(parent, controlId); + if (edit == IntPtr.Zero) return null; + return EditBufferText(edit); + } + private static string EditBufferText(IntPtr edit) { + // Cross-process GetWindowText returns the stored caption; WM_GETTEXT reads the EDIT buffer. + int length = (int)SendMessage(edit, 0x000E, UIntPtr.Zero, IntPtr.Zero); + var text = new StringBuilder(length + 1); + SendMessageText(edit, 0x000D, (UIntPtr)text.Capacity, text); + return text.ToString(); + } + public static string FirstEditText(IntPtr parent) { + var edit = ControlById(parent, 9100); + if (edit == IntPtr.Zero) edit = ControlById(parent, 9904); + if (edit == IntPtr.Zero) edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); + if (edit == IntPtr.Zero) return null; + return EditBufferText(edit); + } + // Node creation sheet helpers. Every click below is a real cursor move plus + // SendInput at the live window rectangle of the target control, and the + // window under that point is checked first so a covered or mis-scaled target + // fails instead of silently clicking something else. + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr window, out RECT rect); + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(ScreenPoint point); + private delegate bool EnumChildProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumChildWindows(IntPtr parent, EnumChildProc callback, IntPtr parameter); + + // INPUT is a union sized by MOUSEINPUT; the trailing padding keeps this + // keyboard record the same size so SendInput accepts cbSize. + + [DllImport("user32.dll", SetLastError = true, EntryPoint = "SendInput")] + private static extern uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); + [DllImport("user32.dll", SetLastError = true, EntryPoint = "SystemParametersInfoW")] + private static extern bool SystemParametersInfoRect(uint action, uint param, out RECT rect, uint winIni); + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr window, uint flags); + [DllImport("user32.dll")] + private static extern IntPtr RealChildWindowFromPoint(IntPtr parent, ScreenPoint clientPoint); + [DllImport("user32.dll")] + private static extern IntPtr GetWindow(IntPtr window, uint command); + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static extern IntPtr GetWindowLongPtr(IntPtr window, int index); + public sealed class ControlClick { + public int ControlId, Left, Top, Right, Bottom, ScreenX, ScreenY; + public int CursorBeforeX, CursorBeforeY, CursorAtX, CursorAtY; + public bool HitTarget; + public int WindowAtPointId; + public string WindowAtPointClass, WindowAtPointRootClass, WindowAtPointText; + public int WindowAtPointProcessId; + public int RealChildId; + public string RealChildClass, RealChildText; + public bool SameTopLevel; + public bool VisibleEmpty; + public int WorkLeft, WorkTop, WorkRight, WorkBottom; + public bool OutsideWorkArea; + public int CenterX, CenterY; + public bool CenterHitTarget; + public string WindowAtCenterClass, WindowAtCenterRootClass; + public bool ScanUsed; + public int ScannedPoints, CoveredPoints; + public int CoveringId, CoveringLeft, CoveringTop, CoveringRight, CoveringBottom; + public string CoveringClass, CoveringText; + public string[] Samples; + public string[] CoveringWindows; + public int[][] UncoveredRectangles; + public int[] ChosenUncoveredRectangle; + public bool NarrowUncovered; + } + public static int[][] SubtractCoveredRectangles(int[] visible, int[][] covers) { + var remaining = new System.Collections.ArrayList(); + if (visible[2] > visible[0] && visible[3] > visible[1]) remaining.Add(visible); + foreach (int[] cover in covers) { + var next = new System.Collections.ArrayList(); + foreach (int[] piece in remaining) { + int left = Math.Max(piece[0], cover[0]), top = Math.Max(piece[1], cover[1]); + int right = Math.Min(piece[2], cover[2]), bottom = Math.Min(piece[3], cover[3]); + if (left >= right || top >= bottom) { next.Add(piece); continue; } + if (piece[0] < left) next.Add(new[] { piece[0], piece[1], left, piece[3] }); + if (right < piece[2]) next.Add(new[] { right, piece[1], piece[2], piece[3] }); + if (piece[1] < top) next.Add(new[] { left, piece[1], right, top }); + if (bottom < piece[3]) next.Add(new[] { left, bottom, right, piece[3] }); + } + remaining = next; + } + var result = new int[remaining.Count][]; + remaining.CopyTo(result); + Array.Sort(result, delegate(int[] a, int[] b) { + long areaA = (long)(a[2] - a[0]) * (a[3] - a[1]); + long areaB = (long)(b[2] - b[0]) * (b[3] - b[1]); + int byArea = areaB.CompareTo(areaA); + if (byArea != 0) return byArea; + int byTop = a[1].CompareTo(b[1]); + return byTop != 0 ? byTop : a[0].CompareTo(b[0]); + }); + return result; + } + private static IntPtr ResolveChild(IntPtr dialog, ScreenPoint point) { + var clientPoint = point; + if (!ScreenToClient(dialog, ref clientPoint)) return IntPtr.Zero; + return RealChildWindowFromPoint(dialog, clientPoint); + } + private static bool ResolvesToControl(IntPtr target, IntPtr dialog, ScreenPoint point) { + IntPtr atPoint = WindowFromPoint(point); + if (atPoint == IntPtr.Zero || GetAncestor(atPoint, 2) != GetAncestor(target, 2)) return false; + var clientPoint = point; + if (!ScreenToClient(dialog, ref clientPoint)) return false; + return RealChildWindowFromPoint(dialog, clientPoint) == target; + } + private static string ClassOf(IntPtr window) { + if (window == IntPtr.Zero) return ""; + var name = new StringBuilder(128); + GetClassName(window, name, name.Capacity); + return name.ToString(); + } + public static int[] WindowBounds(IntPtr window) { + RECT rect; + if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return null; + return new[] { rect.Left, rect.Top, rect.Right, rect.Bottom }; + } + public static IntPtr ControlById(IntPtr parent, int controlId) { + if (parent == IntPtr.Zero) return IntPtr.Zero; + IntPtr direct = GetDlgItem(parent, controlId); + if (direct != IntPtr.Zero) return direct; + if (!String.Equals(ClassOf(parent), "GraphCodeSheetHost", StringComparison.Ordinal)) + return IntPtr.Zero; + IntPtr viewport = FindWindowEx(parent, IntPtr.Zero, "GraphCodeSheetViewport", null); + if (viewport == IntPtr.Zero) return IntPtr.Zero; + string[] contentClasses = new[] { + "GraphCodeWindowsDialogContent", + "GraphCodeNativeForm", + "GraphCodeProductSettings", + "GraphCodeWorkspaceManager", + "GraphCodeRepositoryIngressDialog", + "GraphCodeRepositoryOperationDialog", + "GraphCodeCodespaceIngressDialog", + "GraphCodeUpdateOffer", + "GraphCodeUpdateInstall" + }; + foreach (string contentClass in contentClasses) { + IntPtr content = FindWindowEx(viewport, IntPtr.Zero, contentClass, null); + if (content == IntPtr.Zero) continue; + IntPtr nested = GetDlgItem(content, controlId); + if (nested != IntPtr.Zero) return nested; + } + return IntPtr.Zero; + } + public static ControlClick ClickScreenPoint(IntPtr target) { + RECT rect; + if (target == IntPtr.Zero || !GetWindowRect(target, out rect) || + rect.Right <= rect.Left || rect.Bottom <= rect.Top) return null; + RECT work; + if (!SystemParametersInfoRect(0x0030, 0, out work, 0)) + throw new InvalidOperationException(String.Format("SPI_GETWORKAREA failed: Win32Error={0}", Marshal.GetLastWin32Error())); + var rectCenter = new ScreenPoint { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }; + IntPtr atRectCenter = WindowFromPoint(rectCenter); + // A user clicks the part of a control they can see. When the control lies + // inside the work area this is exactly its centre; only when it crosses the + // work-area edge does the point move into the visible portion. A control with + // no visible portion is reported as VisibleEmpty and never clicked. + int visibleLeft = Math.Max(rect.Left, work.Left), visibleTop = Math.Max(rect.Top, work.Top); + int visibleRight = Math.Min(rect.Right, work.Right), visibleBottom = Math.Min(rect.Bottom, work.Bottom); + var center = new ScreenPoint { X = (visibleLeft + visibleRight) / 2, Y = (visibleTop + visibleBottom) / 2 }; + bool visibleEmpty = visibleRight <= visibleLeft || visibleBottom <= visibleTop; + IntPtr atPoint = visibleEmpty ? IntPtr.Zero : WindowFromPoint(center); + IntPtr rootAtPoint = atPoint == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atPoint, 2); + uint ownerProcess = 0; + if (rootAtPoint != IntPtr.Zero) GetWindowThreadProcessId(rootAtPoint, out ownerProcess); + // Real mouse input skips HTTRANSPARENT children such as plain static text; + // cross-process WindowFromPoint does not. RealChildWindowFromPoint applies + // the same rule within the dialog, and the top-level check still rejects + // any other window (for example the taskbar) covering the point. + IntPtr dialog = GetAncestor(target, 1); + IntPtr realChild = IntPtr.Zero; + if (!visibleEmpty && dialog != IntPtr.Zero) { + var clientPoint = center; + if (ScreenToClient(dialog, ref clientPoint)) realChild = RealChildWindowFromPoint(dialog, clientPoint); + } + bool sameTopLevel = rootAtPoint != IntPtr.Zero && rootAtPoint == GetAncestor(target, 2); + var hit = new ControlClick { + ControlId = GetDlgCtrlID(target), Left = rect.Left, Top = rect.Top, Right = rect.Right, + Bottom = rect.Bottom, ScreenX = center.X, ScreenY = center.Y, + HitTarget = !visibleEmpty && sameTopLevel && realChild == target, + VisibleEmpty = visibleEmpty, + WindowAtPointId = atPoint == IntPtr.Zero ? 0 : GetDlgCtrlID(atPoint), + WindowAtPointClass = ClassOf(atPoint), + WindowAtPointText = atPoint == IntPtr.Zero ? "" : EditBufferText(atPoint), + WindowAtPointRootClass = ClassOf(rootAtPoint), + WindowAtPointProcessId = (int)ownerProcess, + RealChildId = realChild == IntPtr.Zero ? 0 : GetDlgCtrlID(realChild), + RealChildClass = ClassOf(realChild), + RealChildText = realChild == IntPtr.Zero ? "" : EditBufferText(realChild), + SameTopLevel = sameTopLevel, + WorkLeft = work.Left, WorkTop = work.Top, WorkRight = work.Right, WorkBottom = work.Bottom, + OutsideWorkArea = rect.Left < work.Left || rect.Top < work.Top || rect.Right > work.Right || rect.Bottom > work.Bottom, + CenterX = rectCenter.X, CenterY = rectCenter.Y, + CenterHitTarget = atRectCenter == target, + WindowAtCenterClass = ClassOf(atRectCenter), + WindowAtCenterRootClass = ClassOf(atRectCenter == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atRectCenter, 2)) + }; + // Subtract visible siblings above the target in Z order. A narrow uncovered + // strip can fall between grid rows, so try the largest residual rectangle + // before scanning the fixed grid as a diagnostic fallback. + if (!hit.HitTarget && !visibleEmpty && dialog != IntPtr.Zero) { + IntPtr covering = realChild != IntPtr.Zero && realChild != target && realChild != dialog ? realChild : atPoint; + RECT coveringRect; + if (covering != IntPtr.Zero && GetWindowRect(covering, out coveringRect)) { + hit.CoveringLeft = coveringRect.Left; hit.CoveringTop = coveringRect.Top; + hit.CoveringRight = coveringRect.Right; hit.CoveringBottom = coveringRect.Bottom; + } + hit.CoveringId = covering == IntPtr.Zero ? 0 : GetDlgCtrlID(covering); + hit.CoveringClass = ClassOf(covering); + hit.CoveringText = covering == IntPtr.Zero ? "" : EditBufferText(covering); + var covers = new System.Collections.ArrayList(); + var coveringWindows = new System.Collections.ArrayList(); + for (IntPtr sibling = GetWindow(target, 3); sibling != IntPtr.Zero; sibling = GetWindow(sibling, 3)) { + RECT siblingRect; + if (!IsWindowVisible(sibling) || !GetWindowRect(sibling, out siblingRect)) continue; + int left = Math.Max(visibleLeft, siblingRect.Left), top = Math.Max(visibleTop, siblingRect.Top); + int right = Math.Min(visibleRight, siblingRect.Right), bottom = Math.Min(visibleBottom, siblingRect.Bottom); + if (left >= right || top >= bottom) continue; + var overlapCenter = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; + if (ResolveChild(dialog, overlapCenter) != sibling) continue; + covers.Add(new[] { siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom }); + coveringWindows.Add(String.Format("{0}|{1}|{2}|{3},{4},{5},{6}", GetDlgCtrlID(sibling), + ClassOf(sibling), EditBufferText(sibling), siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom)); + } + hit.CoveringWindows = (string[])coveringWindows.ToArray(typeof(string)); + hit.UncoveredRectangles = SubtractCoveredRectangles(new[] { visibleLeft, visibleTop, visibleRight, visibleBottom }, + (int[][])covers.ToArray(typeof(int[]))); + foreach (int[] piece in hit.UncoveredRectangles) { + var candidate = new ScreenPoint { X = (piece[0] + piece[2]) / 2, Y = (piece[1] + piece[3]) / 2 }; + if (!ResolvesToControl(target, dialog, candidate)) continue; + if (piece[2] - piece[0] < 3 || piece[3] - piece[1] < 3) { + hit.NarrowUncovered = true; + continue; + } + center = candidate; + hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; + hit.HitTarget = true; + hit.ScanUsed = true; + hit.ChosenUncoveredRectangle = piece; + break; + } + int width = visibleRight - visibleLeft, height = visibleBottom - visibleTop; + var samples = new string[15]; + for (int row = 1; row <= 3; row++) { + for (int column = 1; column <= 5; column++) { + var candidate = new ScreenPoint { X = visibleLeft + column * width / 6, Y = visibleTop + row * height / 4 }; + IntPtr resolved = ResolveChild(dialog, candidate); + bool uncovered = ResolvesToControl(target, dialog, candidate); + if (uncovered && hit.NarrowUncovered) uncovered = false; + samples[hit.ScannedPoints++] = String.Format("{0},{1}|{2}|{3}|{4}|{5}", candidate.X, candidate.Y, + resolved == IntPtr.Zero ? 0 : GetDlgCtrlID(resolved), ClassOf(resolved), uncovered ? "control" : "covered", + resolved == IntPtr.Zero ? "" : EditBufferText(resolved)); + if (!uncovered) { + hit.CoveredPoints++; + } else if (!hit.HitTarget) { + center = candidate; + hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; + hit.HitTarget = true; + hit.ScanUsed = true; + } + } + } + hit.Samples = samples; + } + if (!hit.HitTarget) return hit; + uint expectedProcessId = WindowProcessId(dialog); + RequireOwnedForeground(dialog, expectedProcessId); + ScreenPoint before, at; + if (!GetCursorPos(out before)) + throw new InvalidOperationException(String.Format("GetCursorPos before control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (!SetCursorPos(center.X, center.Y)) + throw new InvalidOperationException(String.Format("SetCursorPos for control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (!GetCursorPos(out at)) + throw new InvalidOperationException(String.Format("GetCursorPos after control move failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (at.X != center.X || at.Y != center.Y) + throw new InvalidOperationException(String.Format("Control cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, center.X, center.Y)); + hit.CursorBeforeX = before.X; hit.CursorBeforeY = before.Y; + hit.CursorAtX = at.X; hit.CursorAtY = at.Y; + var inputs = new[] { + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } + }; + RequireOwnedMouseInput(dialog, expectedProcessId, center, target); + uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} control mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); + return hit; + } + public static int SendKeyInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, ushort virtualKey, int count) { return InputSafety.SendKeyInput(owner, expectedProcessId, expectedFocus, virtualKey, count); } + public static int[] SendUnicodeInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, string text) { return InputSafety.SendUnicodeInput(owner, expectedProcessId, expectedFocus, text); } + public static bool TypeEditTextById(IntPtr parent, int controlId, string text) { return InputSafety.TypeEditTextById(parent, controlId, text); } + public static int[] VisibleChildIds(IntPtr parent, int first, int last) { + var buffer = new int[Math.Max(0, last - first + 1)]; + int found = 0; + for (int id = first; id <= last; id++) { + IntPtr child = ControlById(parent, id); + if (child != IntPtr.Zero && IsWindowVisible(child)) buffer[found++] = id; + } + var result = new int[found]; + Array.Copy(buffer, result, found); + return result; + } + public static string[] VisibleStaticTexts(IntPtr parent) { + var buffer = new string[256]; + int found = 0; + if (parent == IntPtr.Zero) return new string[0]; + EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) { + var actualClass = new StringBuilder(64); + GetClassName(window, actualClass, actualClass.Capacity); + if (String.Equals(actualClass.ToString(), "Static", StringComparison.OrdinalIgnoreCase) && + IsWindowVisible(window) && found < buffer.Length) { + buffer[found++] = EditBufferText(window); + } + return true; + }, IntPtr.Zero); + var result = new string[found]; + Array.Copy(buffer, result, found); + return result; + } + public static string WindowTextOf(IntPtr window) { + return window == IntPtr.Zero ? null : EditBufferText(window); + } + public static string ComboSelection(IntPtr parent, int controlId) { + IntPtr combo = ControlById(parent, controlId); + if (combo == IntPtr.Zero) return null; + int index = (int)SendMessage(combo, 0x0147, UIntPtr.Zero, IntPtr.Zero); + if (index < 0) return index + "|"; + int length = (int)SendMessage(combo, 0x0149, (UIntPtr)index, IntPtr.Zero); + var text = new StringBuilder(Math.Max(length, 0) + 1); + SendMessageText(combo, 0x0148, (UIntPtr)index, text); + return index + "|" + text.ToString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs new file mode 100644 index 00000000..a3f0a6be --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaHostInfo.cs @@ -0,0 +1,46 @@ +#nullable disable +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +public static class GraphCodeUiaHostInfo { + private const int UOI_NAME = 2; + [DllImport("user32.dll")] + private static extern IntPtr GetProcessWindowStation(); + [DllImport("user32.dll")] + private static extern IntPtr GetThreadDesktop(uint threadId); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetUserObjectInformation( + IntPtr handle, int index, StringBuilder info, uint length, out uint needed + ); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ProcessIdToSessionId(uint processId, out uint sessionId); + [DllImport("user32.dll")] + private static extern uint GetGuiResources(IntPtr process, uint flags); + private static string ObjectName(IntPtr handle) { + if (handle == IntPtr.Zero) return null; + var name = new StringBuilder(256); + uint needed; + if (!GetUserObjectInformation(handle, UOI_NAME, name, (uint)(name.Capacity * 2), out needed)) + return null; + return name.ToString(); + } + public static string CurrentWindowStationName() { + return ObjectName(GetProcessWindowStation()); + } + public static string CurrentDesktopName() { + return ObjectName(GetThreadDesktop(GetCurrentThreadId())); + } + public static uint CurrentSessionId() { + uint sessionId; + if (!ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out sessionId)) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + return sessionId; + } + public static uint GuiResourceCount(int processId, uint flags) { + using (var process = Process.GetProcessById(processId)) + return GetGuiResources(process.Handle, flags); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs b/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs new file mode 100644 index 00000000..e4895b7e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs @@ -0,0 +1,334 @@ +#nullable disable +using System; +using System.Runtime.InteropServices; +using static GraphCode.Windows.UiaInputSafety; + +namespace GraphCode.Windows { +public interface IUiaInputOperations { + bool IsRetainedProcessAlive(uint processId); + uint WindowProcessId(IntPtr window); + bool WindowIsVisible(IntPtr window); + bool WindowIsEnabled(IntPtr window); + IntPtr GetForegroundWindow(); + IntPtr FocusedControlInDialog(IntPtr window); + IntPtr GetAncestor(IntPtr window, uint flags); + bool GetCursorPos(out ScreenPoint point); + bool SetCursorPos(int x, int y); + IntPtr WindowFromPoint(ScreenPoint point); + bool IsWindowVisible(IntPtr window); + bool GetMenuItemRect(IntPtr owner, IntPtr menu, uint position, out RECT item); + IntPtr FindPopupForMenu(uint processId, IntPtr menu); + bool ScreenToClient(IntPtr window, ref ScreenPoint point); + IntPtr MouseLParam(int x, int y); + uint GetMenuState(IntPtr menu, uint position, uint flags); + void Sleep(uint milliseconds); + bool ResolvesToControl(IntPtr target, IntPtr owner, ScreenPoint point); + uint SendInput(uint count, Input[] inputs, int size); + uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); + IntPtr ControlById(IntPtr owner, int id); + bool FocusControl(IntPtr owner, IntPtr edit); + IntPtr SendMessage(IntPtr edit, uint message, UIntPtr first, IntPtr last); + string EditBufferText(IntPtr edit); + string EditTextById(IntPtr owner, int id); + IntPtr SendMessageString(IntPtr edit, uint message, UIntPtr first, string text); + IntPtr PopupMenuHandle(IntPtr popup); + uint PopupMenuItemId(IntPtr menu, int position); + bool PostMessage(IntPtr window, uint message, UIntPtr first, IntPtr last); +} +public sealed class UiaInputSafety { + private readonly IUiaInputOperations operations; + public UiaInputSafety(IUiaInputOperations operations) { this.operations = operations ?? throw new ArgumentNullException(nameof(operations)); } +[StructLayout(LayoutKind.Sequential)] +public struct MouseInput { + public int X, Y; + public uint MouseData, Flags, Time; + public UIntPtr ExtraInfo; + } +[StructLayout(LayoutKind.Sequential)] +public struct Input { + public uint Type; + public MouseInput Mouse; + } +[StructLayout(LayoutKind.Sequential)] +public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } +[StructLayout(LayoutKind.Sequential)] +public struct ScreenPoint { public int X; public int Y; } +[StructLayout(LayoutKind.Sequential)] +public struct KeybdInput { + public ushort VirtualKey, ScanCode; + public uint Flags, Time; + public UIntPtr ExtraInfo; + } +[StructLayout(LayoutKind.Sequential)] +public struct KeyInputRecord { + public uint Type; + public KeybdInput Key; + public uint Pad0, Pad1; + } +public sealed class PopupItemHit { + public int Position, ItemId, Left, Top, Right, Bottom; + public int ScreenX, ScreenY, ClientX, ClientY, CursorBeforeX, CursorBeforeY; + public int CursorAtX, CursorAtY; + public bool Hilite, UsedKeyboardFallback, UsedSelectItemFallback; + public bool HiliteAtEnterFallback, HiliteBeforeEnter; + } +public uint LastEditClearExpected; +public uint LastEditClearSent; +public uint LastEditTextExpected; +public uint LastEditTextSent; +public bool LastEditUsedMessageFallback; +public void RequireOwnedInputWindow(IntPtr owner, uint expectedProcessId) { + if (owner == IntPtr.Zero || expectedProcessId == 0 || !operations.IsRetainedProcessAlive(expectedProcessId) || + operations.WindowProcessId(owner) != expectedProcessId || !operations.WindowIsVisible(owner) || !operations.WindowIsEnabled(owner)) + throw new InvalidOperationException("Input target lost retained process/window ownership; no input was injected"); + } +public void RequireOwnedForeground(IntPtr owner, uint expectedProcessId) { + RequireOwnedInputWindow(owner, expectedProcessId); + IntPtr foreground = operations.GetForegroundWindow(); + if (foreground != owner || operations.WindowProcessId(foreground) != expectedProcessId) + throw new InvalidOperationException("Owned foreground changed; no input was injected"); + } +public void RequireOwnedKeyboardInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus) { + RequireOwnedForeground(owner, expectedProcessId); + IntPtr focus = operations.FocusedControlInDialog(owner); + if (focus == IntPtr.Zero || operations.WindowProcessId(focus) != expectedProcessId || + operations.GetAncestor(focus, 2) != owner || (expectedFocus != IntPtr.Zero && focus != expectedFocus)) + throw new InvalidOperationException("Owned keyboard focus changed; no key input was injected"); + RequireOwnedForeground(owner, expectedProcessId); + } +public void RequireOwnedMouseInput( + IntPtr owner, uint expectedProcessId, ScreenPoint expected, IntPtr expectedControl + ) { + RequireOwnedForeground(owner, expectedProcessId); + ScreenPoint actual; + if (!operations.GetCursorPos(out actual) || actual.X != expected.X || actual.Y != expected.Y) + throw new InvalidOperationException("Owned mouse cursor changed; no mouse input was injected"); + IntPtr hit = operations.WindowFromPoint(actual); + if (hit == IntPtr.Zero || operations.GetAncestor(hit, 2) != owner || operations.WindowProcessId(hit) != expectedProcessId || + (expectedControl != IntPtr.Zero && !operations.ResolvesToControl(expectedControl, owner, actual))) + throw new InvalidOperationException("Owned mouse hit changed; no mouse input was injected"); + } +public int SendKeyInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, ushort virtualKey, int count) { + if (count <= 0) return 0; + var inputs = new KeyInputRecord[count * 2]; + for (int index = 0; index < count; index++) { + inputs[index * 2] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey } }; + inputs[index * 2 + 1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey, Flags = 0x0002 } }; + } + RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); + uint sent = operations.SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} key events (cbSize={2}): Win32Error={3}", sent, inputs.Length, Marshal.SizeOf(typeof(KeyInputRecord)), Marshal.GetLastWin32Error())); + return count; + } +public int[] SendUnicodeInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, string text) { + var records = new KeyInputRecord[text.Length * 2]; + int offset = 0; + foreach (char character in text) { + records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } }; + records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } }; + } + if (records.Length == 0) return new int[] { 0, 0 }; + RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); + uint sent = operations.SendKeyInputs((uint)records.Length, records, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != records.Length) + throw new InvalidOperationException(String.Format("Owned Unicode SendInput injected {0}/{1}", sent, records.Length)); + return new int[] { checked((int)sent), records.Length }; + } +public bool TypeEditTextById(IntPtr parent, int controlId, string text) { + LastEditClearExpected = LastEditClearSent = 0; + LastEditTextExpected = LastEditTextSent = 0; + LastEditUsedMessageFallback = false; + IntPtr edit = operations.ControlById(parent, controlId); + if (edit == IntPtr.Zero || !operations.FocusControl(parent, edit)) return false; + uint expectedProcessId = operations.WindowProcessId(parent); + operations.SendMessage(edit, 0x00B1, UIntPtr.Zero, new IntPtr(-1)); + var clear = new KeyInputRecord[2]; + clear[0] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E } }; + clear[1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E, Flags = 2 } }; + LastEditClearExpected = (uint)clear.Length; + RequireOwnedKeyboardInput(parent, expectedProcessId, edit); + LastEditClearSent = operations.SendKeyInputs(LastEditClearExpected, clear, Marshal.SizeOf(typeof(KeyInputRecord))); + if (LastEditClearSent != LastEditClearExpected) return false; + bool cleared = String.IsNullOrEmpty(operations.EditBufferText(edit)); + int[] typed = SendUnicodeInput(parent, expectedProcessId, edit, text); + LastEditTextExpected = checked((uint)typed[1]); + LastEditTextSent = checked((uint)typed[0]); + bool matched = cleared && String.Equals(operations.EditTextById(parent, controlId), text, StringComparison.Ordinal); + if (!matched) { + operations.SendMessageString(edit, 0x000C, UIntPtr.Zero, text); + LastEditUsedMessageFallback = true; + matched = String.Equals(operations.EditTextById(parent, controlId), text, StringComparison.Ordinal); + } + return matched; + } +public int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { + uint expectedProcessId = operations.WindowProcessId(owner); + RequireOwnedForeground(owner, expectedProcessId); + if (!operations.WindowIsVisible(owner) || right <= left || bottom <= top) + throw new InvalidOperationException("Owned rectangle is not visibly hittable"); + var point = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; + IntPtr hit = operations.WindowFromPoint(point); + if (hit == IntPtr.Zero || operations.GetAncestor(hit, 2) != owner) + throw new InvalidOperationException("Live rectangle is covered by another top-level window"); + if (!operations.SetCursorPos(point.X, point.Y)) + throw new InvalidOperationException("Unable to move cursor into owned live rectangle"); + ScreenPoint actual; + if (!operations.GetCursorPos(out actual) || actual.X != point.X || actual.Y != point.Y) + throw new InvalidOperationException("Owned live-rectangle cursor position differs"); + var inputs = new Input[] { + new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0008u : 0x0002u } }, + new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0010u : 0x0004u } } + }; + RequireOwnedMouseInput(owner, expectedProcessId, point, IntPtr.Zero); + uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("Owned rectangle SendInput sent {0}/{1}", sent, inputs.Length)); + return new int[] { left, top, right, bottom, actual.X, actual.Y, (int)sent, inputs.Length }; + } +public void RequireOwnedPopupForeground(IntPtr popup, IntPtr owner, uint expectedProcessId) { + IntPtr foreground = operations.GetForegroundWindow(); + IntPtr foregroundRoot = operations.GetAncestor(foreground, 2); + if (expectedProcessId == 0 || !operations.IsRetainedProcessAlive(expectedProcessId) || operations.WindowProcessId(owner) != expectedProcessId || + operations.WindowProcessId(popup) != expectedProcessId || operations.WindowProcessId(foreground) != expectedProcessId || + (foregroundRoot != owner && foregroundRoot != popup) || !operations.IsWindowVisible(popup)) + throw new InvalidOperationException("Popup input ownership changed; no further input was injected"); + } +public bool HoverPopupMenuItem(IntPtr owner, IntPtr menu, int position) { + RECT item; + if (menu == IntPtr.Zero || !operations.GetMenuItemRect(owner, menu, (uint)position, out item)) return false; + int x = item.Right - Math.Min(12, (item.Right - item.Left) / 2); + int y = (item.Top + item.Bottom) / 2; + uint ownerProcess = operations.WindowProcessId(owner); + IntPtr popup = operations.FindPopupForMenu(ownerProcess, menu); + if (popup == IntPtr.Zero) return false; + RequireOwnedPopupForeground(popup, owner, ownerProcess); + IntPtr hit = operations.WindowFromPoint(new ScreenPoint { X = x, Y = y }); + if (operations.GetAncestor(hit, 2) != popup || operations.WindowProcessId(hit) != ownerProcess) + throw new InvalidOperationException("Submenu hover point is covered; no cursor movement was injected"); + if (!operations.SetCursorPos(x, y)) + throw new InvalidOperationException(String.Format("SetCursorPos for submenu failed: Win32Error={0}", Marshal.GetLastWin32Error())); + ScreenPoint at; + if (!operations.GetCursorPos(out at)) return false; + var client = new ScreenPoint { X = x, Y = y }; + if (!operations.ScreenToClient(popup, ref client)) return false; + RequireOwnedPopupForeground(popup, owner, ownerProcess); + operations.SendMessage(popup, 0x0200, UIntPtr.Zero, operations.MouseLParam(client.X, client.Y)); + bool hilite = false; + for (int attempt = 0; attempt < 50 && !hilite; attempt++) { + hilite = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + if (!hilite) operations.Sleep(10); + } + return at.X == x && at.Y == y && hilite; + } +public static bool IsOwnedPopupInput( + IntPtr owner, IntPtr popup, uint expectedProcessId, uint popupProcessId, + uint foregroundProcessId, IntPtr foregroundRoot, uint pointProcessId, + IntPtr pointRoot, bool popupVisible + ) { + return owner != IntPtr.Zero && popup != IntPtr.Zero && expectedProcessId != 0 && + popupProcessId == expectedProcessId && foregroundProcessId == expectedProcessId && + (foregroundRoot == owner || foregroundRoot == popup) && + pointProcessId == expectedProcessId && pointRoot == popup && popupVisible; + } +public PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId) { + if (popup == IntPtr.Zero || position < 0) return null; + IntPtr menu = operations.PopupMenuHandle(popup); + RECT item; + if (menu == IntPtr.Zero || !operations.GetMenuItemRect(owner, menu, (uint)position, out item) || + item.Right <= item.Left || item.Bottom <= item.Top || + operations.PopupMenuItemId(menu, position) != (uint)commandId) return null; + int screenX = (item.Left + item.Right) / 2; + int screenY = (item.Top + item.Bottom) / 2; + if (screenX < item.Left || screenX >= item.Right || + screenY < item.Top || screenY >= item.Bottom) return null; + var point = new ScreenPoint { + X = screenX, + Y = screenY + }; + if (!operations.ScreenToClient(popup, ref point)) return null; + ScreenPoint before, at; + if (!operations.GetCursorPos(out before)) + throw new InvalidOperationException(String.Format("GetCursorPos before popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + RequireOwnedPopupForeground(popup, owner, expectedProcessId); + if (!operations.SetCursorPos(screenX, screenY)) + throw new InvalidOperationException(String.Format("SetCursorPos for popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (!operations.GetCursorPos(out at)) + throw new InvalidOperationException(String.Format("GetCursorPos after popup move failed: Win32Error={0}", Marshal.GetLastWin32Error())); + if (at.X != screenX || at.Y != screenY) + throw new InvalidOperationException(String.Format("Popup cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, screenX, screenY)); + operations.SendMessage(popup, 0x0200, UIntPtr.Zero, operations.MouseLParam(point.X, point.Y)); + bool hilite = false; + for (int attempt = 0; attempt < 50 && !hilite; attempt++) { + hilite = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + if (!hilite) operations.Sleep(10); + } + bool selectItemFallback = false; + if (!hilite) { + operations.SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); + selectItemFallback = true; + for (int attempt = 0; attempt < 50 && !hilite; attempt++) { + hilite = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + if (!hilite) operations.Sleep(10); + } + } + if (!hilite) + throw new InvalidOperationException("Popup item did not acknowledge hover before click"); + var inputs = new[] { + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, + new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } + }; + ScreenPoint cursorBeforeInput; + if (!operations.GetCursorPos(out cursorBeforeInput) || cursorBeforeInput.X != screenX || cursorBeforeInput.Y != screenY) + throw new InvalidOperationException("Popup input cursor changed; no mouse input was injected"); + uint inputOwnerProcessId = operations.WindowProcessId(owner); + if (inputOwnerProcessId != expectedProcessId || !operations.IsRetainedProcessAlive(expectedProcessId)) + throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); + IntPtr foregroundBeforeInput = operations.GetForegroundWindow(); + IntPtr inputPointWindow = operations.WindowFromPoint(cursorBeforeInput); + if (!IsOwnedPopupInput( + owner, popup, inputOwnerProcessId, operations.WindowProcessId(popup), + operations.WindowProcessId(foregroundBeforeInput), operations.GetAncestor(foregroundBeforeInput, 2), + operations.WindowProcessId(inputPointWindow), operations.GetAncestor(inputPointWindow, 2), operations.IsWindowVisible(popup))) + throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); + uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + if (sent != inputs.Length) + throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} popup mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); + for (int attempt = 0; attempt < 20 && operations.IsWindowVisible(popup); attempt++) operations.Sleep(10); + bool keyboardFallback = false; + bool hiliteAtEnterFallback = false; + bool hiliteBeforeEnter = false; + if (operations.IsWindowVisible(popup)) { + // Injected clicks can be dropped (or held) by the desktop's input stack, + // and the menu can lose its hover hilite meanwhile. Enter with no hilited + // item ends TrackPopupMenu with command 0: the popup closes exactly as if + // the item had been chosen, but no command reaches the shell. Re-assert + // the intended item and prove its hilite immediately before Enter. + hiliteAtEnterFallback = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + operations.SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); + hiliteBeforeEnter = (operations.GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; + } + if (operations.IsWindowVisible(popup)) { + if (!hiliteBeforeEnter) + throw new InvalidOperationException(String.Format("Popup item {0} was not hilited before the Enter fallback (hilite at fallback={1})", commandId, hiliteAtEnterFallback)); + RequireOwnedPopupForeground(popup, owner, expectedProcessId); + keyboardFallback = + operations.PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero) && + operations.PostMessage(popup, 0x0101, (UIntPtr)0x0D, IntPtr.Zero); + if (!keyboardFallback) + throw new InvalidOperationException(String.Format("Popup Enter fallback was rejected: Win32Error={0}", Marshal.GetLastWin32Error())); + } + return new PopupItemHit { + Position = position, ItemId = commandId, Left = item.Left, Top = item.Top, + Right = item.Right, Bottom = item.Bottom, + ScreenX = screenX, ScreenY = screenY, ClientX = point.X, ClientY = point.Y, + CursorBeforeX = before.X, CursorBeforeY = before.Y, + CursorAtX = at.X, CursorAtY = at.Y, Hilite = hilite, + UsedKeyboardFallback = keyboardFallback, + UsedSelectItemFallback = selectItemFallback, + HiliteAtEnterFallback = hiliteAtEnterFallback, + HiliteBeforeEnter = hiliteBeforeEnter + }; + } +} +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs b/Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs deleted file mode 100644 index 24718782..00000000 --- a/Tools/windows/ValidationRunner/Ui/Production/UiaWindowActivation.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; - -namespace GraphCode.Windows { - public struct UiaActivationCallResult { - public readonly bool Succeeded; - public readonly int Error; - - public UiaActivationCallResult(bool succeeded, int error) { - Succeeded = succeeded; - Error = error; - } - } - - public interface IUiaWindowActivationOperations { - uint WindowProcessId(IntPtr window); - bool IsRetainedProcessAlive(uint processId); - void ShowWindow(IntPtr window, int command); - UiaActivationCallResult BringWindowToTop(IntPtr window); - UiaActivationCallResult SetForegroundWindow(IntPtr window); - bool IsForegroundWindow(IntPtr window); - } - - public static class UiaWindowActivation { - public static bool ActivateWindow( - IUiaWindowActivationOperations operations, IntPtr window, out string diagnostic - ) { - if (operations == null) throw new ArgumentNullException(nameof(operations)); - uint expectedProcessId = operations.WindowProcessId(window); - if (window == IntPtr.Zero || !operations.IsRetainedProcessAlive(expectedProcessId)) { - diagnostic = "invalid target HWND or no retained live launch identity"; - return false; - } - operations.ShowWindow(window, 9); - UiaActivationCallResult broughtToTop = operations.BringWindowToTop(window); - UiaActivationCallResult foregroundSet = operations.SetForegroundWindow(window); - bool observed = operations.IsForegroundWindow(window) && - operations.WindowProcessId(window) == expectedProcessId && - operations.IsRetainedProcessAlive(expectedProcessId); - diagnostic = String.Format( - "BringWindowToTop={0} GetLastError={1} SetForegroundWindow={2} GetLastError={3} observedOwnedForeground={4}; no synthetic keys or input-queue attachment", - broughtToTop.Succeeded, broughtToTop.Succeeded ? 0 : broughtToTop.Error, - foregroundSet.Succeeded, foregroundSet.Succeeded ? 0 : foregroundSet.Error, observed); - return observed; - } - } -} diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index c23148d2..7d4b34b0 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -7,7 +7,10 @@ namespace GraphCode.Validation.Ui; public static class SuiteRegistration { - public static IReadOnlyList CreateSuites() => new IValidationSuite[] { new ActivationSuite() }; + public static IReadOnlyList CreateSuites() => new IValidationSuite[] + { + new ActivationSuite(), new ManagedUiSuite(), new NativeUiSuite() + }; } public sealed class ActivationSuite : IValidationSuite @@ -18,7 +21,7 @@ public sealed class ActivationSuite : IValidationSuite "ui.activation.foreground-refused", "ui.activation.foreground-acquired", "ui.activation.ownership-refused", - "ui.activation.invalid-window" + "ui.activation.invalid-window-refused" }; public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) diff --git a/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 new file mode 100644 index 00000000..41a907b3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 @@ -0,0 +1,137 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$uiRoot = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui' +$activation = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\UiaWindowActivation.cs')) +$gate = [IO.File]::ReadAllText((Join-Path $uiRoot 'Production\GraphCodeUiaGateState.cs')) +$inputSource = [IO.File]::ReadAllText((Join-Path $uiRoot 'Production\UiaInputSafety.cs')) +$results = [Collections.Generic.List[object]]::new() + +function Assert-UiContract([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-SourceCase([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Actual ordinary build-time production source; nativeCalls=0' }) + }) +} +function Read-CSharpMethod([string] $Source, [string] $Signature) { + $start = $Source.IndexOf($Signature, [StringComparison]::Ordinal) + if ($start -lt 0) { throw "Production method missing: $Signature" } + $brace = $Source.IndexOf('{', $start) + $depth = 1 + $end = $brace + 1 + while ($end -lt $Source.Length -and $depth -gt 0) { + if ($Source[$end] -eq '{') { $depth++ } + if ($Source[$end] -eq '}') { $depth-- } + $end++ + } + if ($depth -ne 0) { throw "Production method has no closing brace: $Signature" } + return $Source.Substring($start, $end - $start) +} +function Confirm-ActivationSource([string] $Gate, [string] $Component) { + $gateText = [regex]::Replace($Gate, '\s+', ' ') + $componentText = [regex]::Replace($Component, '\s+', ' ') + $requiredComponent = @( + 'public interface IUiaWindowActivationOperations', + 'uint expectedProcessId = operations.WindowProcessId(window);', + 'if (window == IntPtr.Zero || !operations.IsRetainedProcessAlive(expectedProcessId))', + 'operations.ShowWindow(window, 9);', + 'operations.IsForegroundWindow(window) && operations.WindowProcessId(window) == expectedProcessId && operations.IsRetainedProcessAlive(expectedProcessId)', + 'broughtToTop.Succeeded, broughtToTop.Succeeded ? 0 : broughtToTop.Error', + 'foregroundSet.Succeeded, foregroundSet.Succeeded ? 0 : foregroundSet.Error, observed', + 'observedOwnedForeground={4}; no synthetic keys or input-queue attachment' + ) + foreach ($needle in $requiredComponent) { if (-not $componentText.Contains($needle)) { return $false } } + if ($componentText -match '\b(DllImport|LibraryImport|SendInput|keybd_event|AttachThreadInput|Marshal)\b') { return $false } + foreach ($needle in @( + 'private sealed class WindowActivationOperations : GraphCode.Windows.IUiaWindowActivationOperations', + 'return GraphCodeUiaGateState.WindowProcessId(window);', + 'return GraphCodeUiaGateState.IsRetainedProcessAlive(processId);', + 'GraphCodeUiaGateState.ShowWindow(window, command);', + 'return GraphCodeUiaGateState.IsForegroundWindow(window);', + 'bool succeeded = GraphCodeUiaGateState.BringWindowToTop(window); int error = Marshal.GetLastWin32Error(); return new GraphCode.Windows.UiaActivationCallResult(succeeded, error);', + 'bool succeeded = GraphCodeUiaGateState.SetForegroundWindow(window); int error = Marshal.GetLastWin32Error(); return new GraphCode.Windows.UiaActivationCallResult(succeeded, error);', + 'return GraphCode.Windows.UiaWindowActivation.ActivateWindow( ActivationOperations, window, out LastActivationDiagnostic);' + )) { if (-not $gateText.Contains($needle)) { return $false } } + return $true +} +$activationCases = @( + @{ name = 'actual'; gate = $gate; component = $activation; expected = $true }, + @{ name = 'unshared'; gate = $gate.Replace('GraphCode.Windows.UiaWindowActivation.ActivateWindow(', 'UnsharedActivation('); component = $activation; expected = $false }, + @{ name = 'missing-initial-owner'; gate = $gate; component = $activation.Replace('!operations.IsRetainedProcessAlive(expectedProcessId)', 'false'); expected = $false }, + @{ name = 'missing-fresh-owner'; gate = $gate; component = $activation.Replace('operations.IsRetainedProcessAlive(expectedProcessId);', 'true;'); expected = $false }, + @{ name = 'missing-restore'; gate = $gate; component = $activation.Replace('operations.ShowWindow(window, 9);', ''); expected = $false }, + @{ name = 'missing-native-error'; gate = $gate.Replace('int error = Marshal.GetLastWin32Error();', 'int error = 0;'); component = $activation; expected = $false } +) +foreach ($case in $activationCases) { + Invoke-SourceCase "ui.activation.source.$($case.name)" { + Assert-UiContract ((Confirm-ActivationSource $case.gate $case.component) -eq $case.expected) ` + 'UIA activation source contract accepted an unshared, unowned or undiagnosed activation mutant' + } +} + +function Confirm-PopupSource([string] $Source) { + try { $click = Read-CSharpMethod $Source 'public PopupItemHit ClickPopupMenuItem(' } catch { return $false } + if ([regex]::Matches($click, '\boperations\.SendInput\(').Count -ne 1 -or + [regex]::Matches($click, 'RequireOwnedPopupForeground\(popup, owner, expectedProcessId\);').Count -ne 2 -or + -not $click.Contains('int commandId, uint expectedProcessId)') -or + $click -match '\b(SetForegroundWindow|SetFocus|BringWindowToTop)\(') { return $false } + $offset = 0 + foreach ($needle in @( + 'if (!operations.GetCursorPos(out before))', + 'RequireOwnedPopupForeground(popup, owner, expectedProcessId);', + 'if (!operations.SetCursorPos(screenX, screenY))', + 'var inputs = new[]', + 'ScreenPoint cursorBeforeInput;', + 'if (!operations.GetCursorPos(out cursorBeforeInput) || cursorBeforeInput.X != screenX || cursorBeforeInput.Y != screenY)', + 'throw new InvalidOperationException("Popup input cursor changed; no mouse input was injected");', + 'uint inputOwnerProcessId = operations.WindowProcessId(owner);', + 'if (inputOwnerProcessId != expectedProcessId || !operations.IsRetainedProcessAlive(expectedProcessId))', + 'IntPtr foregroundBeforeInput = operations.GetForegroundWindow();', + 'IntPtr inputPointWindow = operations.WindowFromPoint(cursorBeforeInput);', + 'if (!IsOwnedPopupInput(', + 'operations.WindowProcessId(foregroundBeforeInput), operations.GetAncestor(foregroundBeforeInput, 2),', + 'operations.WindowProcessId(inputPointWindow), operations.GetAncestor(inputPointWindow, 2), operations.IsWindowVisible(popup)))', + 'throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected");', + 'uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));', + 'RequireOwnedPopupForeground(popup, owner, expectedProcessId);', + 'keyboardFallback =', + 'operations.PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero)' + )) { + $next = $click.IndexOf($needle, $offset, [StringComparison]::Ordinal) + if ($next -lt 0) { return $false } + $offset = $next + $needle.Length + } + return $true +} +$popupCases = @( + @{ name = 'actual'; source = $inputSource; expected = $true }, + @{ name = 'bypass-guard'; source = $inputSource.Replace('if (!IsOwnedPopupInput(', 'if (false && !IsOwnedPopupInput('); expected = $false }, + @{ name = 'stale-foreground'; source = $inputSource.Replace('IntPtr foregroundBeforeInput = operations.GetForegroundWindow();', 'IntPtr foregroundBeforeInput = owner;'); expected = $false }, + @{ name = 'stale-cursor'; source = $inputSource.Replace('operations.WindowFromPoint(cursorBeforeInput)', 'operations.WindowFromPoint(new ScreenPoint { X = screenX, Y = screenY })'); expected = $false }, + @{ name = 'missing-held-owner-check'; source = $inputSource.Replace('if (inputOwnerProcessId != expectedProcessId || !operations.IsRetainedProcessAlive(expectedProcessId))', 'if (false)'); expected = $false }, + @{ name = 'missing-pre-cursor-guard'; source = [regex]::Replace($inputSource, '(?m)^ RequireOwnedPopupForeground\(popup, owner, expectedProcessId\);', ' ;'); expected = $false }, + @{ name = 'missing-pre-enter-guard'; source = $inputSource.Replace(' RequireOwnedPopupForeground(popup, owner, expectedProcessId);', ' ;'); expected = $false }, + @{ name = 'wrong-hit-root'; source = $inputSource.Replace('operations.GetAncestor(inputPointWindow, 2)', 'operations.GetAncestor(owner, 2)'); expected = $false }, + @{ name = 'extra-input'; source = $inputSource.Replace('uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));', 'operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));'); expected = $false } +) +foreach ($case in $popupCases) { + Invoke-SourceCase "ui.popup.source.$($case.name)" { + Assert-UiContract ((Confirm-PopupSource $case.source) -eq $case.expected) ` + "Popup input safety contract: $($case.name) must fail closed before real input on foreign foreground or hit point" + } +} +Assert-UiContract ($activationCases.Count -eq 6 -and $popupCases.Count -eq 9 -and $results.Count -eq 15) ` + 'Source guard case count changed' +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +$report = Join-Path $ArtifactRoot 'ui-source-results.json' +[IO.File]::WriteAllText($report, ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/source-case-map.json b/Tools/windows/ValidationRunner/Ui/source-case-map.json new file mode 100644 index 00000000..79af22f7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/source-case-map.json @@ -0,0 +1,875 @@ +{ + "snapshotManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "units": [ + { + "id": "ui.activation.foreground-refused", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented" + }, + { + "id": "ui.activation.foreground-acquired", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented" + }, + { + "id": "ui.activation.ownership-refused", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented" + }, + { + "id": "ui.activation.invalid-window-refused", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "195-290", + "state": "Implemented" + }, + { + "id": "ui.input.owned-key", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.foreign-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.sibling-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.missing-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.dead-retained-process", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.hidden-window", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.disabled-window", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.owned-unicode", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.unicode-foreign-focus", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.owned-mouse", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.mouse-hit-loss", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.mouse-cursor-loss", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.mouse-foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.owned-edit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.edit-focus-loss-after-select", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.edit-focus-loss-after-read", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.owned-submenu-hover", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.submenu-foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.submenu-unretained-process", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.input.submenu-covered-point", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "758-890", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.owned-shell", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.owned-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.foreign-foreground-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.foreign-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.underlying-shell-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.hidden-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.unknown-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.foreign-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.predicate.absent-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1221-1255", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.owned", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.owned-enter", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.foreign-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.foreign-after-hover", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.foreign-before-enter", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.foreign-owner-all", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.foreign-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.underlying-shell-hit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.hidden-popup", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.cursor-changed", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.popup.effect.cursor-unreadable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1256-1328", + "state": "Implemented" + }, + { + "id": "ui.geometry.seven-pixel-band", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7626-7643", + "state": "Implemented" + }, + { + "id": "ui.geometry.multiple-covers", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7626-7643", + "state": "Implemented" + }, + { + "id": "ui.gate-state.activation-reference", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7408-7430", + "state": "Implemented" + }, + { + "id": "ui.gate-state.input-reference", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7408-7430", + "state": "Implemented" + }, + { + "id": "ui.native", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5896-5924", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.named-direct-viewport", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.hidden-write-read", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.missing-id", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.missing-write-read", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.direct-precedence", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.wrong-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.wrong-viewport", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.other-sibling", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.no-deeper-fallback", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.direct-viewport-required", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.native-form", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.workspace-manager", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.unknown-content", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.lookup.null-parent", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.hit.nested-edit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.hit.content-background", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.hit.wrong-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.hit.wrong-container", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.hit.no-recursion", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.native.hit.direct-child", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7431-7624", + "state": "Implemented" + }, + { + "id": "ui.host-info.current-session", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5896-5924", + "state": "Implemented" + }, + { + "id": "ui.activation.source.actual", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented" + }, + { + "id": "ui.activation.source.unshared", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented" + }, + { + "id": "ui.activation.source.missing-initial-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented" + }, + { + "id": "ui.activation.source.missing-fresh-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented" + }, + { + "id": "ui.activation.source.missing-restore", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented" + }, + { + "id": "ui.activation.source.missing-native-error", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-223", + "state": "Implemented" + }, + { + "id": "ui.popup.source.actual", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.bypass-guard", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.stale-foreground", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.stale-cursor", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.missing-held-owner-check", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.missing-pre-cursor-guard", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.missing-pre-enter-guard", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.wrong-hit-root", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.popup.source.extra-input", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1164-1220", + "state": "Implemented" + }, + { + "id": "ui.mock.isolation.absent", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Incomplete" + }, + { + "id": "ui.mock.isolation.empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Incomplete" + }, + { + "id": "ui.mock.isolation.valued", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Incomplete" + }, + { + "id": "ui.mock.isolation.failure-identity", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Incomplete" + }, + { + "id": "ui.mock.isolation.ordinary-diagnostic", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "19-88", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.initially-empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.initially-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.already-visible", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.mark-only-empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.delayed-visible", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.outside-before-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.wrong-source-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.wrong-fit-owner", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.fit-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.fit-disabled", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.remains-empty", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.remains-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.wrong-id-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.wrong-owner-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.outside-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.missing-after-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.mutant-skip-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.mutant-wrong-identity-accepted", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.recovery.visibility.mutant-outside-bounds-accepted", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "989-1162", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.actual", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.missing-fit", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.wrong-identity", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.missing-offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.ungated-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.escaped-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.stale-visibility", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.missing-containment", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.visibility.missing-require", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1330-1424", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.rejection", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.success", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.unavailable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.foreign-pid", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.identity-drift", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.zero-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.offscreen", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.disabled", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.selected", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.runtime-unavailable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.parent-unavailable", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.telemetry-write-failure", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.receipt-exists", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.receipt-unconfigured", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.deadline", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.long-identity", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.long-runtime", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.invalid-boolean", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.nonfinite-bounds", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.exception-chain", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.delete.select.foreign-parent", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "1426-1670", + "state": "Incomplete" + }, + { + "id": "ui.host-info.diagnostics", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5896-5924", + "state": "Incomplete" + }, + { + "id": "ui.root.source-assertions", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5342-7403", + "state": "Incomplete" + }, + { + "id": "ui.production.consumer-linkage", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "169-194;6751-6778;7408-7430", + "state": "Incomplete" + } + ] +} From 4481af523952d1a6b575241cb2bfee7a6537eee2 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:46:00 -0700 Subject: [PATCH 07/46] Add held-job child supervision and compiled ownership cases Preserve monotonic exit/drain deadlines, independent stream faults, primary errors and UNKNOWN retention. Add strict typed multiproject protocol and controlled native fixtures. Keep twelve legacy and production-linkage obligations explicitly incomplete. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ownership.json | 83 +++ .../ValidationRunner/Ownership/Deadline.cs | 29 + .../Ownership/MultiProjectJson.cs | 101 ++++ .../Ownership/MultiProjectPeer.cs | 199 +++++++ .../Ownership/OwnedChildSupervisor.cs | 386 +++++++++++++ .../Ownership/OwnedFixture.cs | 146 +++++ .../Ownership/OwnershipRetention.cs | 67 +++ .../ValidationRunner/Ownership/PipeFrames.cs | 74 +++ .../ValidationRunner/Ownership/README.md | 51 ++ .../Ownership/StandaloneProcessJob.cs | 185 +++++++ .../Ownership/SuiteRegistration.cs | 517 ++++++++++++++++++ .../Ownership/UiaOwnedStreamCapture.cs | 37 ++ 12 files changed, 1875 insertions(+) create mode 100644 Tools/windows/ValidationRunner/Manifests/ownership.json create mode 100644 Tools/windows/ValidationRunner/Ownership/Deadline.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/PipeFrames.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/README.md create mode 100644 Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json new file mode 100644 index 00000000..74c863cc --- /dev/null +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -0,0 +1,83 @@ +{ + "units": [ + { "id": "ownership.managed.quote-empty", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.quote-trailing-slash", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.quote-embedded", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.quote-unicode", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.quote-nul", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.stream-raw", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.stream-surrogate-boundary", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.stream-no-overwrite", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.stream-independent-faults", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.stream-shared-deadline", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-valid", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-existing", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-missing", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-file", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-absent", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-duplicate-json", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-foreign-root", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.receipt-over-limit", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.proof-success", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.proof-root-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.proof-job-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.proof-stdout-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.proof-stderr-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.json-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.json-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.json-nested-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.json-array", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.json-case-sensitive", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-fragmented-unicode", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-clean-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-short-header", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-short-body", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-zero", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-over-limit", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.frame-invalid-utf8", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-positive-rename", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-resume-unchanged", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-foreign-owner", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-foreign-node", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-missing-title", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-extra-field", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-title-type", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-version-string", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-version-bool", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-request-id", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-blank-title", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-request-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-unanswered-publication", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-response-unknown", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-response-extra", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-response-type", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-response-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-positive", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-owner", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-selection", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-extra", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-type", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.peer-control-second", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.normal", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.abrupt", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.inherited", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.inherited-timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.arguments-environment", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.real-pipe", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.legacy.ownership-receipts", "owner": "Ownership", "minimumCount": 7, "exactCount": 7 }, + { "id": "ownership.legacy.retention-boundary", "owner": "Ownership", "minimumCount": 6, "exactCount": 6 }, + { "id": "ownership.legacy.capture-ownership", "owner": "Ownership", "minimumCount": 14, "exactCount": 14 }, + { "id": "ownership.legacy.tree-ownership", "owner": "Ownership", "minimumCount": 4, "exactCount": 4 }, + { "id": "ownership.legacy.provider-ownership", "owner": "Ownership", "minimumCount": 4, "exactCount": 4 }, + { "id": "ownership.legacy.pre-uia-isolation", "owner": "Ownership", "minimumCount": 10, "exactCount": 10 }, + { "id": "ownership.legacy.mixed-multiproject", "owner": "Ownership", "minimumCount": 1, + "requiredGroups": ["mixed-ui-policy", "request-response-publication", "receipt-negative-controls", "owned-real-pipes", "bounded-capture-eight-modes", "primary-and-cleanup-errors"] }, + { "id": "ownership.legacy.peer-emission", "owner": "Ownership", "minimumCount": 1 }, + { "id": "ownership.legacy.rename-submit", "owner": "Ownership", "minimumCount": 1 }, + { "id": "ownership.legacy.fresh-capture", "owner": "Ownership", "minimumCount": 1 }, + { "id": "ownership.legacy.packaging-shared-linkage", "owner": "Ownership", "minimumCount": 1 }, + { "id": "ownership.legacy.uia-shared-linkage", "owner": "Ownership", "minimumCount": 1 } + ] +} diff --git a/Tools/windows/ValidationRunner/Ownership/Deadline.cs b/Tools/windows/ValidationRunner/Ownership/Deadline.cs new file mode 100644 index 00000000..79d8e14c --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Deadline.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics; + +namespace GraphCode.Validation.Ownership; + +public sealed class Deadline +{ + private readonly long started = Stopwatch.GetTimestamp(); + private readonly TimeSpan budget; + + public Deadline(TimeSpan budget) + { + if (budget <= TimeSpan.Zero || budget.TotalMilliseconds > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(budget)); + this.budget = budget; + } + + public TimeSpan Remaining + { + get + { + TimeSpan remaining = budget - Stopwatch.GetElapsedTime(started); + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + } + + public int RemainingMilliseconds => (int)Math.Ceiling(Remaining.TotalMilliseconds); + public bool Expired => Remaining == TimeSpan.Zero; +} diff --git a/Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs b/Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs new file mode 100644 index 00000000..c22797f5 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/MultiProjectJson.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Ownership; + +public static partial class MultiProjectJson +{ + [GeneratedRegex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", RegexOptions.CultureInvariant)] + private static partial Regex UuidPattern(); + + public static JsonDocument Parse(string json) + { + JsonDocument document; + try { document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 }); } + catch (JsonException failure) { throw new InvalidDataException("MULTIPROJECT_JSON: malformed JSON", failure); } + try + { + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("MULTIPROJECT_JSON: root must be an object"); + CheckProperties(document.RootElement); + return document; + } + catch { document.Dispose(); throw; } + } + + private static void CheckProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) throw new InvalidDataException("MULTIPROJECT_JSON: duplicate field " + property.Name); + CheckProperties(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + foreach (JsonElement value in element.EnumerateArray()) CheckProperties(value); + } + + public static JsonObject Object(string json) + { + using var document = Parse(json); + return JsonNode.Parse(document.RootElement.GetRawText())!.AsObject(); + } + + public static JsonObject Shape(JsonNode? value, string label, params string[] keys) + { + if (value is not JsonObject result || result.Count != keys.Length || + result.Any(pair => !keys.Contains(pair.Key, StringComparer.Ordinal))) + throw new InvalidDataException("MULTIPROJECT_SCHEMA: " + label + " has the wrong object shape"); + return result; + } + + public static string String(JsonNode? value, string label) + { + if (value is not JsonValue literal || literal.GetValueKind() != JsonValueKind.String) + throw new InvalidDataException("MULTIPROJECT_TYPE: " + label + " must be a string"); + return literal.GetValue(); + } + + public static long Integer(JsonNode? value, string label) + { + if (value is not JsonValue literal || literal.GetValueKind() != JsonValueKind.Number || + !long.TryParse(literal.ToJsonString(), System.Globalization.NumberStyles.AllowLeadingSign, + System.Globalization.CultureInfo.InvariantCulture, out long result)) + throw new InvalidDataException("MULTIPROJECT_TYPE: " + label + " must be an integer"); + return result; + } + + public static string Uuid(JsonNode? value, string label) + { + string text = String(value, label); + if (!UuidPattern().IsMatch(text)) + throw new InvalidDataException("MULTIPROJECT_UUID: " + label + " must be a lowercase version-4 UUID"); + return text; + } + + public static string Title(JsonNode? value) + { + string title = String(value, "title"); + if (string.IsNullOrWhiteSpace(title)) throw new InvalidDataException("MULTIPROJECT_TITLE: title cannot be blank"); + return title; + } + + public static string Canonical(JsonNode? value) + { + if (value == null) return "null"; + if (value is JsonObject obj) + return "{" + string.Join(",", obj.OrderBy(p => p.Key, StringComparer.Ordinal) + .Select(p => JsonSerializer.Serialize(p.Key) + ":" + Canonical(p.Value))) + "}"; + if (value is JsonArray array) return "[" + string.Join(",", array.Select(Canonical)) + "]"; + return value.ToJsonString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs b/Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs new file mode 100644 index 00000000..e2485654 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/MultiProjectPeer.cs @@ -0,0 +1,199 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace GraphCode.Validation.Ownership; + +public sealed record MultiProjectReply(JsonObject? Response, string? PublishPath); + +// The compiled controlled peer uses this same policy; suites never substitute a model. +public sealed class MultiProjectPeer +{ + public const string AlphaNodeId = "11111111-1111-4111-8111-111111111111"; + public const string BetaNodeId = "22222222-2222-4222-8222-222222222222"; + private readonly JsonArray graphs; + private readonly JsonArray received = [], applied = [], answered = [], publications = [], controls = []; + private long sequence; + + public MultiProjectPeer(string alphaPath, string betaPath) + { + foreach (string path in new[] { alphaPath, betaPath }) + { + if (!Path.IsPathFullyQualified(path) || Path.GetFullPath(path) != path || + !Directory.Exists(path) || File.Exists(Path.Combine(path, ".git")) || Directory.Exists(Path.Combine(path, ".git"))) + throw new InvalidDataException("MULTIPROJECT_FIXTURE: require exact existing ordinary-folder paths"); + } + if (string.Equals(alphaPath, betaPath, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("MULTIPROJECT_FIXTURE: owners must be different folders"); + graphs = + [ + Graph("aaaaaaaa-1111-4111-8111-111111111111", alphaPath, "Alpha", AlphaNodeId), + Graph("bbbbbbbb-2222-4222-8222-222222222222", betaPath, "Beta", BetaNodeId) + ]; + } + + private static JsonObject Graph(string id, string path, string name, string nodeId) => new() + { + ["id"] = id, ["project"] = new JsonObject { ["path"] = path, ["name"] = name, ["remote"] = false }, + ["nodes"] = new JsonArray(new JsonObject + { + ["id"] = nodeId, ["title"] = name + " loop", ["loopType"] = "turnBased", ["state"] = "idle", ["activity"] = "stub", + ["presence"] = new JsonObject { ["presence"] = "idle", ["confidence"] = "reported" } + }), + ["edges"] = new JsonArray() + }; + + private static string Text(JsonNode? value) => MultiProjectJson.String(value, "value"); + private static JsonObject Clone(JsonObject value) => (JsonObject)value.DeepClone(); + private static string OwnerPath(JsonNode graph) => Text(graph["project"]!["path"]); + private static JsonNode Node(JsonNode graph) => graph["nodes"]![0]!; + private JsonObject Find(JsonNode? path, string? node = null) + { + string exactPath = MultiProjectJson.String(path, "projectPath"); + JsonObject[] matches = graphs.OfType().Where(g => OwnerPath(g) == exactPath).ToArray(); + if (matches.Length != 1 || (node != null && Text(Node(matches[0])["id"]) != node)) + throw new InvalidDataException("MULTIPROJECT_OWNER: wrong exact project/node owner"); + return matches[0]; + } + + public MultiProjectReply Request(JsonObject frame) + { + MultiProjectJson.Shape(frame, "request", "version", "kind", "requestID", "command"); + long version = MultiProjectJson.Integer(frame["version"], "version"); + string kind = MultiProjectJson.String(frame["kind"], "kind"); + if (version != 2 || kind != "request") throw new InvalidDataException("MULTIPROJECT_SCHEMA: expected v2 request"); + string id = MultiProjectJson.Uuid(frame["requestID"], "requestID"); + if (received.Any(r => Text(r!["requestID"]) == id)) + throw new InvalidDataException("MULTIPROJECT_DUPLICATE: requestID already received"); + if (frame["command"] is not JsonObject command || command.Count != 1) + throw new InvalidDataException("MULTIPROJECT_SCHEMA: expected one command"); + string verb = command.First().Key; + JsonObject? response = null, owner = null, rename = null; + if (verb == "graphCommand") + { + JsonObject graphCommand = MultiProjectJson.Shape(command[verb], "graphCommand", "projectPath", "command"); + if (graphCommand["command"] is JsonObject inner && inner.ContainsKey("resumeSession")) + { + MultiProjectJson.Shape(inner, "graphCommand.command", "resumeSession"); + JsonObject resume = MultiProjectJson.Shape(inner["resumeSession"], "resumeSession", "_0"); + owner = Find(graphCommand["projectPath"], MultiProjectJson.Uuid(resume["_0"], "nodeID")); + } + else + { + JsonObject innerCommand = MultiProjectJson.Shape(graphCommand["command"], "graphCommand.command", "renameNode"); + rename = MultiProjectJson.Shape(innerCommand["renameNode"], "renameNode", "_0", "title"); + string nodeId = MultiProjectJson.Uuid(rename["_0"], "nodeID"); + MultiProjectJson.Title(rename["title"]); + owner = Find(graphCommand["projectPath"], nodeId); + } + response = new JsonObject { ["version"] = 2, ["kind"] = "response", ["requestID"] = id, ["success"] = true }; + } + else if (verb == "listRecentProjects") + { + MultiProjectJson.Shape(command[verb], verb); + response = new JsonObject + { + ["version"] = 2, ["kind"] = "response", ["requestID"] = id, + ["event"] = new JsonObject { ["recentProjectsListed"] = new JsonArray(graphs.Select(g => g!["project"]!.DeepClone()).ToArray()) } + }; + } + else if (verb is "listQuickChats" or "restoreOpenProjects" or "openGlobalGraph") + MultiProjectJson.Shape(command[verb], verb); + else if (verb == "openProject") + { + JsonObject open = MultiProjectJson.Shape(command[verb], verb, "path"); + Find(open["path"]); + } + else throw new InvalidDataException("MULTIPROJECT_SCHEMA: unsupported opt-in command " + verb); + received.Add(new JsonObject { ["requestID"] = id, ["frame"] = frame.DeepClone(), ["expectedResponse"] = response?.DeepClone() }); + if (rename != null && owner != null) + { + applied.Add(new JsonObject + { + ["requestID"] = id, ["projectPath"] = OwnerPath(owner), ["nodeID"] = Text(rename["_0"]), + ["beforeTitle"] = Text(Node(owner)["title"]), ["title"] = Text(rename["title"]) + }); + Node(owner)["title"] = Text(rename["title"]); + } + return new MultiProjectReply(response, rename == null ? null : OwnerPath(owner!)); + } + + public void CompleteResponse(JsonObject response) + { + MultiProjectJson.Shape(response, "response", "version", "kind", "requestID", response.ContainsKey("event") ? "event" : "success"); + if (MultiProjectJson.Integer(response["version"], "version") != 2 || Text(response["kind"]) != "response" || + (response.ContainsKey("success") && response["success"]?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False))) + throw new InvalidDataException("MULTIPROJECT_TYPE: response version/kind/success types differ"); + string id = MultiProjectJson.Uuid(response["requestID"], "response.requestID"); + JsonNode[] matches = received.Where(r => Text(r!["requestID"]) == id).Select(r => r!).ToArray(); + if (matches.Length != 1) throw new InvalidDataException("MULTIPROJECT_CORRELATION: response has no unique request"); + if (answered.Any(r => Text(r!["requestID"]) == id)) throw new InvalidDataException("MULTIPROJECT_DUPLICATE: request already answered"); + if (matches[0]["expectedResponse"] != null && + MultiProjectJson.Canonical(matches[0]["expectedResponse"]) != MultiProjectJson.Canonical(response)) + throw new InvalidDataException("MULTIPROJECT_CORRELATION: reply differs from expected typed response"); + answered.Add(new JsonObject { ["requestID"] = id, ["response"] = response.DeepClone() }); + } + + public JsonObject Publication(string path) + { + JsonObject graph = Find(JsonValue.Create(path)); + return new JsonObject + { + ["version"] = 2, ["kind"] = "event", ["sequence"] = sequence + 1, + ["event"] = new JsonObject { ["graphChanged"] = graph.DeepClone() } + }; + } + + public void CompletePublication(JsonObject frame, string cause, string correlationId) + { + string path = Text(frame["event"]?["graphChanged"]?["project"]?["path"]); + JsonObject expected = Publication(path); + if (MultiProjectJson.Canonical(expected) != MultiProjectJson.Canonical(frame)) + throw new InvalidDataException("MULTIPROJECT_SEQUENCE: publication differs from exact next graph frame"); + if (cause != "control" && answered.Count(r => Text(r!["requestID"]) == correlationId) != 1) + throw new InvalidDataException("MULTIPROJECT_UNANSWERED: publication precedes correlated response"); + sequence = MultiProjectJson.Integer(frame["sequence"], "sequence"); + publications.Add(new JsonObject { ["cause"] = cause, ["correlationID"] = correlationId, ["frame"] = frame.DeepClone() }); + } + + public JsonObject Control(JsonObject control) + { + MultiProjectJson.Shape(control, "publication control", "token", "projectPath", "nodeID", "title", "selection"); + string token = MultiProjectJson.Uuid(control["token"], "control.token"); + string node = MultiProjectJson.Uuid(control["nodeID"], "control.nodeID"); + if (controls.Any(c => Text(c!["token"]) == token)) throw new InvalidDataException("MULTIPROJECT_DUPLICATE: control token already used"); + if (controls.Count != 0) throw new InvalidDataException("MULTIPROJECT_ONE_SHOT: Alpha control was already applied"); + JsonObject graph = Find(control["projectPath"], node); + if (OwnerPath(graph) != OwnerPath(graphs[0]!)) throw new InvalidDataException("MULTIPROJECT_OWNER: control must target Alpha"); + string title = MultiProjectJson.Title(control["title"]); + JsonObject selection = MultiProjectJson.Shape(control["selection"], "selected Beta", "projectPath", "nodeID", "source"); + if (Text(selection["projectPath"]) != OwnerPath(graphs[1]!) || + Text(selection["nodeID"]) != BetaNodeId || Text(selection["source"]) is not ("live-uia" or "synthetic-client")) + throw new InvalidDataException("MULTIPROJECT_SELECTION: expected exact observed Beta identity"); + if (received.Count <= 0 || answered.Count != received.Count || publications.Count < 2) + throw new InvalidDataException("MULTIPROJECT_UNANSWERED: require positive settled request/publication baseline"); + foreach (JsonNode? owner in graphs) + if (!publications.Any(p => Text(p!["cause"]) == "initial" && + Text(p["frame"]!["event"]!["graphChanged"]!["project"]!["path"]) == OwnerPath(owner!))) + throw new InvalidDataException("MULTIPROJECT_PREREQUISITE: both owners must be published before interleaving"); + controls.Add(new JsonObject + { + ["token"] = token, ["projectPath"] = OwnerPath(graph), ["nodeID"] = node, + ["beforeTitle"] = Text(Node(graph)["title"]), ["title"] = title, ["selection"] = selection.DeepClone() + }); + Node(graph)["title"] = title; + return Publication(OwnerPath(graph)); + } + + public JsonObject Snapshot() => new() + { + ["receivedCount"] = received.Count, ["requestCount"] = received.Count, ["responseCount"] = answered.Count, + ["appliedCount"] = applied.Count, ["publicationCount"] = publications.Count, ["graphSequence"] = sequence, + ["received"] = received.DeepClone(), ["applied"] = applied.DeepClone(), ["answered"] = answered.DeepClone(), + ["publications"] = publications.DeepClone(), ["controls"] = controls.DeepClone(), ["graphs"] = graphs.DeepClone(), + ["unansweredRequests"] = new JsonArray(received.Where(r => !answered.Any(a => Text(a!["requestID"]) == Text(r!["requestID"]))) + .Select(r => (JsonNode?)JsonValue.Create(Text(r!["requestID"]))).ToArray()) + }; +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs new file mode 100644 index 00000000..6e76c357 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +namespace GraphCode.Validation.Ownership; + +public sealed record OwnedCleanupProof(bool RootExited, bool JobEmpty, bool StdoutEof, bool StderrEof, bool ResourcesClosed) +{ + public bool CanRelease => RootExited && JobEmpty && StdoutEof && StderrEof; +} +public sealed record OwnedChildResult(int ExitCode, string StandardOutput, string StandardError, OwnedCleanupProof Cleanup); +public sealed record OwnedChildObservation(bool RootExited, uint ActiveJobProcesses, bool StdoutCompleted, bool StderrCompleted); + +public sealed class OwnedChildSupervisor +{ + [StructLayout(LayoutKind.Sequential)] + private struct Security { public int Length; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct Startup + { + public int Size; + public string? Reserved, Desktop, Title; + public int X, Y, Width, Height, XChars, YChars, Fill, Flags; + public short Show, ReservedSize; + public IntPtr ReservedBytes, Input, Output, Error, Attributes; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref Security security, int size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateFileW(string name, uint access, uint share, ref Security security, uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcessW(string file, StringBuilder command, IntPtr processSecurity, IntPtr threadSecurity, + bool inherit, uint flags, IntPtr environment, string? directory, ref Startup startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessTimes(IntPtr process, out long created, out long exited, out long kernel, out long user); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetProcessId(IntPtr process); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + // Unknown leases stay reachable: a finalizer must not close a kill-on-close job. + private static readonly ConcurrentBag retained = new(); + private readonly Deadline deadline; + private readonly string artifactRoot; + private IntPtr root, thread, job; + private bool assigned, resumed, completed; + private StreamReader? stdoutReader, stderrReader; + private Task? stdout, stderr; + private long creationFileTime; + public uint ProcessId { get; private set; } + public long CreationFileTime => creationFileTime; + public Task StandardOutput => stdout ?? throw new InvalidOperationException("stdout capture unavailable."); + public Task StandardError => stderr ?? throw new InvalidOperationException("stderr capture unavailable."); + public static int RetainedLeaseCount => retained.Count; + + public OwnedChildObservation Observe() => new(RootExited(), StandaloneProcessJob.Active(job), + StandardOutput.IsCompleted, StandardError.IsCompleted); + + private OwnedChildSupervisor(TimeSpan timeout, string artifactRoot) + { + deadline = new Deadline(timeout); + this.artifactRoot = Path.GetFullPath(artifactRoot); + if (!Directory.Exists(this.artifactRoot)) throw new DirectoryNotFoundException(this.artifactRoot); + } + + public static OwnedChildSupervisor Start(ProcessStartInfo start, TimeSpan timeout, string artifactRoot) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Owned native jobs require Windows."); + if (start.UseShellExecute || !string.IsNullOrEmpty(start.Arguments) || start.RedirectStandardInput) + throw new ArgumentException("Owned launch requires ArgumentList, UseShellExecute=false and closed stdin.", nameof(start)); + if (!Path.IsPathFullyQualified(start.FileName) || !File.Exists(start.FileName)) + throw new ArgumentException("Owned executable must be an existing absolute path.", nameof(start)); + var run = new OwnedChildSupervisor(timeout, artifactRoot); + try + { + run.Launch(start); + return run; + } + catch (Exception primary) + { + // Synchronous launch has no consumer yet, but uses the same bounded cleanup. + run.CleanupAsync(primary).GetAwaiter().GetResult(); + throw; + } + } + + public static async Task RunAsync(ProcessStartInfo start, TimeSpan timeout, string artifactRoot, + CancellationToken cancellationToken = default) + { + var child = Start(start, timeout, artifactRoot); + return await child.CompleteAsync(cancellationToken).ConfigureAwait(false); + } + + private void Launch(ProcessStartInfo info) + { + var security = new Security { Length = Marshal.SizeOf(), Inherit = 1 }; + IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero, errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + IntPtr input = IntPtr.Zero, attributes = IntPtr.Zero, handles = IntPtr.Zero, environment = IntPtr.Zero; + bool initialized = false; + Exception? primary = null; + try + { + job = StandaloneProcessJob.Create(); + if (!CreatePipe(out outRead, out outWrite, ref security, 0) || + !CreatePipe(out errRead, out errWrite, ref security, 0) || + !SetHandleInformation(outRead, 1, 0) || !SetHandleInformation(errRead, 1, 0)) + throw new Win32Exception(); + input = CreateFileW("NUL", 0x80000000, 3, ref security, 3, 0, IntPtr.Zero); + if (input == new IntPtr(-1)) throw new Win32Exception(); + IntPtr size = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size); + attributes = Marshal.AllocHGlobal(size); + if (!InitializeProcThreadAttributeList(attributes, 1, 0, ref size)) throw new Win32Exception(); + initialized = true; + handles = Marshal.AllocHGlobal(IntPtr.Size * 3); + Marshal.WriteIntPtr(handles, 0, input); + Marshal.WriteIntPtr(handles, IntPtr.Size, outWrite); + Marshal.WriteIntPtr(handles, IntPtr.Size * 2, errWrite); + if (!UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x20002), handles, + new IntPtr(IntPtr.Size * 3), IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(); + var block = new StringBuilder(); + foreach (var pair in info.Environment.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + block.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); + // Even an empty environment requires two terminators. + if (block.Length == 0) block.Append('\0'); + block.Append('\0'); + environment = Marshal.StringToHGlobalUni(block.ToString()); + var command = new StringBuilder(QuoteArgument(info.FileName)); + foreach (string argument in info.ArgumentList) command.Append(' ').Append(QuoteArgument(argument)); + var startup = new Startup + { + Size = Marshal.SizeOf(), Flags = 0x101, Input = input, Output = outWrite, Error = errWrite, + Show = (short)(info.WindowStyle == ProcessWindowStyle.Hidden ? 0 : + info.WindowStyle == ProcessWindowStyle.Minimized ? 2 : info.WindowStyle == ProcessWindowStyle.Maximized ? 3 : 1), + Attributes = attributes + }; + uint flags = 0x4 | 0x400 | 0x80000; + if (info.CreateNoWindow) flags |= 0x08000000; + if (!CreateProcessW(info.FileName, command, IntPtr.Zero, IntPtr.Zero, true, flags, environment, + string.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory, ref startup, out ProcessInfo created)) + throw new Win32Exception(); + root = created.Process; + thread = created.Thread; + ProcessId = created.Pid; + if (!GetProcessTimes(root, out creationFileTime, out _, out _, out _)) throw new Win32Exception(); + VerifyRoot(); + StandaloneProcessJob.Assign(job, root); + assigned = true; + UiaOwnedStreamCapture.RequireJobMember(root, job); + stdoutReader = MakeReader(ref outRead, info.StandardOutputEncoding); + stderrReader = MakeReader(ref errRead, info.StandardErrorEncoding); + string stem = $"owned-{ProcessId}-{creationFileTime}"; + stdout = UiaOwnedStreamCapture.Read(stdoutReader, Path.Combine(artifactRoot, stem + "-stdout.log")); + stderr = UiaOwnedStreamCapture.Read(stderrReader, Path.Combine(artifactRoot, stem + "-stderr.log")); + if (deadline.Expired) throw new TimeoutException("Owned launch original deadline exceeded before resume."); + uint previous = ResumeThread(thread); + if (previous == uint.MaxValue) throw new Win32Exception(); + if (previous != 1) throw new InvalidOperationException("Owned launch suspension count changed."); + resumed = true; + Close(ref thread); + } + catch (Exception failure) + { + primary = failure; + throw; + } + finally + { + var failures = new List(); + foreach (IntPtr handle in new[] { outRead, outWrite, errRead, errWrite, input }) + if (handle != IntPtr.Zero && handle != new IntPtr(-1) && !CloseHandle(handle)) + failures.Add(new Win32Exception()); + if (initialized) DeleteProcThreadAttributeList(attributes); + foreach (IntPtr allocation in new[] { attributes, handles, environment }) + if (allocation != IntPtr.Zero) Marshal.FreeHGlobal(allocation); + if (failures.Count != 0) + { + var cleanup = new AggregateException("Owned launch temporary handle cleanup failed.", failures); + if (primary != null) primary.Data["OwnedLaunchTemporaryCleanup"] = cleanup; + else throw cleanup; + } + } + } + + private static StreamReader MakeReader(ref IntPtr handle, Encoding? encoding) + { + var safe = new SafeFileHandle(handle, true); + handle = IntPtr.Zero; + try { return new StreamReader(new FileStream(safe, FileAccess.Read), encoding ?? new UTF8Encoding(false, true), true); } + catch { safe.Dispose(); throw; } + } + + public static string QuoteArgument(string value) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Contains('\0')) throw new ArgumentException("Command argument contains NUL.", nameof(value)); + var result = new StringBuilder("\""); + int slashes = 0; + foreach (char c in value) + { + if (c == '\\') { slashes++; continue; } + result.Append('\\', c == '"' ? slashes * 2 + 1 : slashes).Append(c); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + private void VerifyRoot() + { + if (root == IntPtr.Zero || GetProcessId(root) != ProcessId || + !GetProcessTimes(root, out long observed, out _, out _, out _) || observed != creationFileTime) + throw new InvalidOperationException("Held root identity could not be verified."); + } + + private bool RootExited() + { + VerifyRoot(); + uint state = WaitForSingleObject(root, 0); + if (state == 0) return true; + if (state == 258) return false; + throw new Win32Exception(); + } + + public async Task CompleteAsync(CancellationToken cancellationToken = default) + { + if (completed) throw new InvalidOperationException("Owned child completion already consumed."); + completed = true; + Exception? primary = null; + int exitCode = -1; + string output = "", error = ""; + try + { + while (!RootExited()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (deadline.Expired) throw new TimeoutException("OWNED_CAPTURE_EXIT: original deadline exceeded."); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds), cancellationToken).ConfigureAwait(false); + } + await RequireDrainAsync([StandardOutput, StandardError], deadline, cancellationToken).ConfigureAwait(false); + if (!GetExitCodeProcess(root, out uint code)) throw new Win32Exception(); + exitCode = unchecked((int)code); + output = await StandardOutput.ConfigureAwait(false); + error = await StandardError.ConfigureAwait(false); + } + catch (Exception failure) { primary = failure; } + OwnedCleanupProof proof = await CleanupAsync(primary).ConfigureAwait(false); + if (primary != null) ExceptionDispatchInfo.Capture(primary).Throw(); + return new OwnedChildResult(exitCode, output, error, proof); + } + + public Task StopAsync(Exception? primary = null) + { + if (completed) throw new InvalidOperationException("Owned child completion already consumed."); + completed = true; + return CleanupAsync(primary); + } + + public static async Task RequireDrainAsync(IReadOnlyList> streams, Deadline limit, + CancellationToken cancellationToken = default) + { + var failures = new List(); + foreach (Task stream in streams) + { + try + { + await stream.WaitAsync(limit.Remaining, cancellationToken).ConfigureAwait(false); + } + catch (Exception failure) { failures.Add(failure); } + } + if (failures.Count != 0) + throw new AggregateException("OWNED_CAPTURE_DRAIN: independent readers did not all reach successful EOF.", failures); + } + + private async Task CleanupAsync(Exception? primary) + { + var limit = new Deadline(TimeSpan.FromSeconds(5)); + var failures = new List(); + bool rootExited = root == IntPtr.Zero, empty = job == IntPtr.Zero; + bool outEof = stdout == null, errEof = stderr == null, closed = false; + try + { + if (assigned) StandaloneProcessJob.Terminate(job); + else if (root != IntPtr.Zero) + { + // The retained CreateProcess handle is still suspended behind the unreleased barrier. + if (resumed) throw new InvalidOperationException("Unassigned root was unexpectedly resumed."); + if (WaitForSingleObject(root, 0) != 0 && !TerminateProcess(root, 124)) + throw new Win32Exception(); + } + } + catch (Exception failure) { failures.Add(failure); } + try + { + if (job != IntPtr.Zero) + { + StandaloneProcessJob.WaitForEmpty(job, limit); + empty = StandaloneProcessJob.Active(job) == 0; + } + } + catch (Exception failure) { failures.Add(failure); } + try + { + while (root != IntPtr.Zero && !RootExited()) + { + if (limit.Expired) throw new TimeoutException("Held root exit unproved."); + await Task.Delay(Math.Min(10, limit.RemainingMilliseconds)).ConfigureAwait(false); + } + rootExited = true; + } + catch (Exception failure) { failures.Add(failure); } + // Do not let a faulted stdout hide a pending/faulted stderr, or grant it a new timeout. + foreach (var stream in new[] { (Task: stdout, Name: "stdout"), (Task: stderr, Name: "stderr") }) + { + if (stream.Task == null) continue; + try + { + await stream.Task.WaitAsync(limit.Remaining).ConfigureAwait(false); + if (stream.Name == "stdout") outEof = true; else errEof = true; + } + catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } + } + if (new OwnedCleanupProof(rootExited, empty, outEof, errEof, false).CanRelease) + { + foreach (Action close in new Action[] + { + () => stdoutReader?.Dispose(), () => stderrReader?.Dispose(), + () => Close(ref thread), + () => { if (job != IntPtr.Zero) { StandaloneProcessJob.Close(job); job = IntPtr.Zero; } }, + () => Close(ref root) + }) + { + try { close(); } + catch (Exception failure) { failures.Add(failure); } + } + closed = failures.Count == 0; + } + var proof = new OwnedCleanupProof(rootExited, empty, outEof, errEof, closed); + if (primary != null) primary.Data["OwnedCleanupProof"] = proof; + if (!closed || failures.Count != 0) + { + retained.Add(this); + var cleanup = new AggregateException($"OWNED_CLEANUP_UNPROVEN: pid={ProcessId}; creation={creationFileTime}; job={job}; proof={proof}", failures); + OwnershipRetention.Tag(primary ?? cleanup, artifactRoot); + (primary ?? cleanup).Data["OwnedCleanupFailure"] = cleanup; + (primary ?? cleanup).Data["OwnedRetainedLease"] = this; + if (primary == null) throw cleanup; + } + return proof; + } + + private static void Close(ref IntPtr handle) + { + if (handle == IntPtr.Zero) return; + if (!CloseHandle(handle)) throw new Win32Exception(); + handle = IntPtr.Zero; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs new file mode 100644 index 00000000..dec74dca --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs @@ -0,0 +1,146 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class OwnedFixture +{ + public static ProcessStartInfo Command(string mode, string root, params string[] arguments) + { + string host = Environment.ProcessPath ?? throw new InvalidOperationException("Current process executable unavailable."); + var start = new ProcessStartInfo(host) + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = root, + StandardOutputEncoding = new UTF8Encoding(false, true), + StandardErrorEncoding = new UTF8Encoding(false, true) + }; + if (Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + start.ArgumentList.Add(Assembly.GetEntryAssembly()!.Location); + foreach (string value in new[] { "--ownership-fixture", mode, root }) start.ArgumentList.Add(value); + foreach (string argument in arguments) start.ArgumentList.Add(argument); + start.Environment["TEMP"] = root; + start.Environment["TMP"] = root; + return start; + } + + public static async Task TryRunAsync(string[] args) + { + if (args.Length == 0 || args[0] != "--ownership-fixture") return null; + Console.OutputEncoding = new UTF8Encoding(false); + if (args.Length < 3) throw new ArgumentException("Owned fixture requires a mode and existing owned root."); + string mode = args[1], root = Path.GetFullPath(args[2]); + if (!Directory.Exists(root)) throw new DirectoryNotFoundException(root); + if (mode == "protocol") + { + if (args.Length != 6) throw new ArgumentException("Protocol fixture requires pipe name and both owner paths."); + await ServeProtocolAsync(root, args[3], args[4], args[5]).ConfigureAwait(false); + return 0; + } + if (mode == "descendant") + { + File.WriteAllText(Path.Combine(root, "descendant-ready"), "ready"); + await Task.Delay(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + return 0; + } + if (mode == "echo") + { + Console.Out.Write(JsonSerializer.Serialize(new + { + arguments = args[3..], present = Environment.GetEnvironmentVariables().Contains("OWNERSHIP_EMPTY"), + value = Environment.GetEnvironmentVariable("OWNERSHIP_EMPTY"), + absent = !Environment.GetEnvironmentVariables().Contains("OWNERSHIP_ABSENT"), + cwd = Environment.CurrentDirectory + })); + return 0; + } + if (mode is not ("normal" or "abrupt" or "timeout" or "inherited" or "inherited-timeout")) + throw new ArgumentException("Unknown owned fixture mode: " + mode); + Console.Out.WriteLine("owned-capture-stdout \uD83D\uDE80"); + Console.Error.WriteLine("owned-capture-stderr \u03BB"); + if (mode is "inherited" or "inherited-timeout") + { + using Process child = Process.Start(CommandForInheritedChild(root)) + ?? throw new InvalidOperationException("Controlled descendant did not start."); + File.WriteAllText(Path.Combine(root, "descendant.json"), + JsonSerializer.Serialize(new { pid = child.Id, startUtcTicks = child.StartTime.ToUniversalTime().Ticks })); + var readyLimit = new Deadline(TimeSpan.FromSeconds(5)); + while (!File.Exists(Path.Combine(root, "descendant-ready"))) + { + if (readyLimit.Expired) throw new TimeoutException("Controlled descendant not ready."); + await Task.Delay(10).ConfigureAwait(false); + } + } + File.WriteAllText(Path.Combine(root, "ready"), "ready"); + if (mode is "timeout" or "inherited-timeout") await Task.Delay(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + return mode == "abrupt" ? 23 : 0; + } + + private static ProcessStartInfo CommandForInheritedChild(string root) + { + ProcessStartInfo start = Command("descendant", root); + start.RedirectStandardOutput = false; + start.RedirectStandardError = false; + start.StandardOutputEncoding = null; + start.StandardErrorEncoding = null; + return start; + } + + private static async Task ServeProtocolAsync(string root, string pipeName, string alpha, string beta) + { + var peer = new MultiProjectPeer(alpha, beta); + await using var server = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + var deadline = new Deadline(TimeSpan.FromSeconds(10)); + using var connect = new CancellationTokenSource(deadline.Remaining); + await server.WaitForConnectionAsync(connect.Token).ConfigureAwait(false); + using (JsonDocument? hello = await PipeFrames.ReadAsync(server, deadline).ConfigureAwait(false)) + { + JsonObject frame = MultiProjectJson.Object(hello?.RootElement.GetRawText() ?? throw new EndOfStreamException()); + MultiProjectJson.Shape(frame, "hello", "version", "kind", "supportedVersions"); + if (MultiProjectJson.Integer(frame["version"], "version") != 2 || MultiProjectJson.String(frame["kind"], "kind") != "hello" || + frame["supportedVersions"]?.ToJsonString() != "[1,2]") + throw new InvalidDataException("Owned protocol hello differs."); + } + await PipeFrames.WriteAsync(server, "{\"version\":2,\"kind\":\"hello\",\"selectedVersion\":2}", deadline, 7).ConfigureAwait(false); + while (true) + { + using JsonDocument? document = await PipeFrames.ReadAsync(server, deadline).ConfigureAwait(false); + if (document == null) break; + JsonObject frame = MultiProjectJson.Object(document.RootElement.GetRawText()); + if (frame.ContainsKey("token")) + { + JsonObject publication = peer.Control(frame); + await PipeFrames.WriteAsync(server, publication.ToJsonString(), deadline, 7).ConfigureAwait(false); + peer.CompletePublication(publication, "control", frame["token"]!.GetValue()); + continue; + } + MultiProjectReply reply = peer.Request(frame); + if (reply.Response != null) + { + await PipeFrames.WriteAsync(server, reply.Response.ToJsonString(), deadline, 7).ConfigureAwait(false); + peer.CompleteResponse(reply.Response); + } + string id = frame["requestID"]!.GetValue(); + string[] owners = frame["command"]!["listRecentProjects"] != null ? [alpha, beta] : + reply.PublishPath != null ? [reply.PublishPath] : []; + foreach (string path in owners) + { + JsonObject publication = peer.Publication(path); + await PipeFrames.WriteAsync(server, publication.ToJsonString(), deadline, 7).ConfigureAwait(false); + peer.CompletePublication(publication, reply.PublishPath == null ? "initial" : "rename", id); + } + } + using var receipt = new FileStream(Path.Combine(root, "peer.json"), FileMode.CreateNew, FileAccess.Write, FileShare.Read); + await JsonSerializer.SerializeAsync(receipt, peer.Snapshot()).ConfigureAwait(false); + receipt.Flush(true); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs b/Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs new file mode 100644 index 00000000..ad9a509e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnershipRetention.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace GraphCode.Validation.Ownership; + +public static class OwnershipRetention +{ + public const string RequiredKey = "GraphCodeValidationRootRetentionRequired"; + public const string RootsKey = "GraphCodeValidationRetainedRoots"; + public const string Sentinel = "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven"; + + public static void Tag(Exception failure, string? root) + { + failure.Data[RequiredKey] = true; + if (string.IsNullOrEmpty(root)) return; + try + { + string full = Path.GetFullPath(root); + if (!Directory.Exists(full)) throw new DirectoryNotFoundException(full); + failure.Data[RootsKey] = new[] { full }; + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(new + { + schemaVersion = 1, + validationRoot = full, + reason = "ownership_unproven" + }); + if (bytes.Length > 4096) throw new InvalidOperationException("Ownership receipt exceeds 4096 bytes."); + using var file = new FileStream(Path.Combine(full, "ownership-unproven.json"), + FileMode.CreateNew, FileAccess.Write, FileShare.Read); + file.Write(bytes); + file.Flush(true); + } + catch (Exception receiptFailure) when (receiptFailure is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException) + { + failure.Data["GraphCodeValidationRetentionReceiptFailure"] = receiptFailure.ToString(); + } + } + + public static bool ReceiptRequiresRetention(string root) + { + string path = Path.Combine(root, "ownership-unproven.json"); + if (!File.Exists(path)) return false; + var info = new FileInfo(path); + if (info.Length is <= 0 or > 4096) throw new InvalidDataException("Invalid ownership receipt length."); + byte[] bytes = File.ReadAllBytes(path); + string json = new UTF8Encoding(false, true).GetString(bytes); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement receipt = document.RootElement; + if (receipt.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Ownership receipt must be an object."); + var keys = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in receipt.EnumerateObject()) + if (!keys.Add(property.Name)) throw new InvalidDataException("Duplicate receipt field."); + if (keys.Count != 3 || !keys.SetEquals(["schemaVersion", "validationRoot", "reason"]) || + receipt.GetProperty("schemaVersion").ValueKind != JsonValueKind.Number || + receipt.GetProperty("schemaVersion").GetRawText() != "1" || + receipt.GetProperty("validationRoot").ValueKind != JsonValueKind.String || + !string.Equals(receipt.GetProperty("validationRoot").GetString(), Path.GetFullPath(root), StringComparison.Ordinal) || + receipt.GetProperty("reason").ValueKind != JsonValueKind.String || + receipt.GetProperty("reason").GetString() != "ownership_unproven") + throw new InvalidDataException("Ownership receipt schema/root/reason mismatch."); + return true; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/PipeFrames.cs b/Tools/windows/ValidationRunner/Ownership/PipeFrames.cs new file mode 100644 index 00000000..2f368f92 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/PipeFrames.cs @@ -0,0 +1,74 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class PipeFrames +{ + public const int MaximumPayloadBytes = 2 * 1024 * 1024; + private static readonly UTF8Encoding utf8 = new(false, true); + + public static async Task ReadAsync(Stream stream, Deadline deadline, + CancellationToken cancellationToken = default) + { + var header = new byte[4]; + if (!await ReadExactAsync(stream, header, deadline, true, cancellationToken).ConfigureAwait(false)) + return null; + uint length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length is 0 or > MaximumPayloadBytes) throw new InvalidDataException("Owned pipe invalid frame length."); + var body = new byte[(int)length]; + await ReadExactAsync(stream, body, deadline, false, cancellationToken).ConfigureAwait(false); + return MultiProjectJson.Parse(utf8.GetString(body)); + } + + public static async Task WriteAsync(Stream stream, string json, Deadline deadline, + int fragmentBytes = 0, CancellationToken cancellationToken = default) + { + byte[] body = utf8.GetBytes(json); + if (body.Length is 0 or > MaximumPayloadBytes) throw new InvalidDataException("Owned pipe invalid frame length."); + var header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, (uint)body.Length); + await BoundedWriteAsync(stream, header, deadline, cancellationToken).ConfigureAwait(false); + int chunkSize = fragmentBytes <= 0 ? body.Length : fragmentBytes; + for (int offset = 0; offset < body.Length; offset += Math.Min(chunkSize, body.Length - offset)) + await BoundedWriteAsync(stream, body.AsMemory(offset, Math.Min(chunkSize, body.Length - offset)), + deadline, cancellationToken).ConfigureAwait(false); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(deadline.Remaining); + await stream.FlushAsync(limit.Token).ConfigureAwait(false); + } + + private static async Task ReadExactAsync(Stream stream, Memory buffer, Deadline deadline, + bool cleanEofAllowed, CancellationToken cancellationToken) + { + int offset = 0; + while (offset != buffer.Length) + { + if (deadline.Expired) throw new TimeoutException("Owned pipe original read deadline exceeded."); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(deadline.Remaining); + int count = await stream.ReadAsync(buffer[offset..], limit.Token).ConfigureAwait(false); + if (count == 0) + { + if (offset == 0 && cleanEofAllowed) return false; + throw new EndOfStreamException("Owned pipe ended within a frame."); + } + offset += count; + } + return true; + } + + private static async Task BoundedWriteAsync(Stream stream, ReadOnlyMemory bytes, + Deadline deadline, CancellationToken cancellationToken) + { + if (deadline.Expired) throw new TimeoutException("Owned pipe original write deadline exceeded."); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + limit.CancelAfter(deadline.Remaining); + await stream.WriteAsync(bytes, limit.Token).ConfigureAwait(false); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md new file mode 100644 index 00000000..64d6b23e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -0,0 +1,51 @@ +# Compiled ownership contracts + +`OwnedChildSupervisor.RunAsync(ProcessStartInfo, TimeSpan, string, +CancellationToken)` is the shared child executor. `ArgumentList` is mandatory; +the executable must be an existing absolute path. The artifact directory must +already exist. The supervisor creates a suspended root with an explicit standard +handle list, assigns the held root to a non-breakaway job, verifies membership, +starts independent readers, and only then resumes execution. + +The original monotonic deadline covers launch, root exit and both readers. +Teardown has one separate five-second budget shared by the root, job and both +readers. `StopAsync` supports early controlled teardown. A failed cleanup tags +the original exception, writes a create-new bounded receipt, and retains the +lease rather than releasing an unproved kill-on-close job or reader. Neither +CIM observations nor reopening a PID supplies termination authority. + +`StandaloneProcessJob` is the complete native job implementation, including +membership, accounting, bounded census and packaging diagnostics. +`UiaOwnedStreamCapture` uses it for native membership. Both must be linked by +the host-owned packaging and UIA adapters; merely compiling these files does +not establish that integration. Streaming uses `AutoFlush`, which flushes bytes +without prematurely flushing an encoder containing half a surrogate pair. + +`OwnedFixture.TryRunAsync(args)` must run before ordinary host argument parsing. +It recognizes only `--ownership-fixture`. The compiled fixture tests native +root/job/descendant/stream behavior, real fragmented named pipes, and exact +argv/empty-versus-absent environment/Unicode/cwd. These are controlled fixtures, +not evidence of an actual GraphCode product walkthrough. + +## Coverage correspondence + +The source oracle is the immutable Cards export with manifest SHA-256 +`CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F` +and root-runner SHA-256 +`202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869`. +The old runner is never executed, embedded or extracted for execution. + +The 59 managed and seven controlled-native cases are individually registered +with exact-count manifest guards. The typed peer implements the complete +request/response/publication/control core from `Stub-Daemon.ps1:29-294`; it is +the implementation used by the compiled real-pipe fixture, not a passing fake. +Its presence does not claim that the old PowerShell stub has been rewired. + +Twelve separate mandatory units retain the original seven ownership/mixed root +families, three mixed source-boundary assertions, and two shared-production +linkage obligations. Until explicit host-owned PowerShell integration adapters +preserve the actual scope, ScriptMethod, ErrorRecord identity, streams and +mocking assertions, those units report `Incomplete`, never a synthetic pass. +The six-case retention boundary and mixed eight-mode capture/cleanup negative +controls remain mandatory. Source occurrences of `Assert-MultiCase` and +`Invoke-MultiCase` are not runtime counts. diff --git a/Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs b/Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs new file mode 100644 index 00000000..005ecfc8 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/StandaloneProcessJob.cs @@ -0,0 +1,185 @@ +using System; +using System.ComponentModel; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace GraphCode.Validation.Ownership; + +// Shared by the compiled supervisor and the PowerShell packaging/UIA adapters. +public static class StandaloneProcessJob +{ + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimits + { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr Min, Max; + public uint Active; + public UIntPtr Affinity; + public uint Priority, Scheduling; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; } + [StructLayout(LayoutKind.Sequential)] + private struct Limits + { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [StructLayout(LayoutKind.Sequential)] + private struct Accounting + { + public long User, Kernel, PeriodUser, PeriodKernel; + public uint Faults, Total, Active, Terminated; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateJobObjectW(IntPtr attributes, IntPtr name); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject(IntPtr job, int info, ref Limits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject(IntPtr job, int info, out Accounting value, uint size, IntPtr length); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject(IntPtr job, int info, IntPtr buffer, uint size, IntPtr length); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessTimes(IntPtr process, out long creation, out long exit, out long kernel, out long user); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, StringBuilder name, ref uint size); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + public static IntPtr Create() + { + IntPtr job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero); + if (job == IntPtr.Zero) throw new Win32Exception(); + var limits = new Limits(); + limits.Basic.Flags = 0x2000; + if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf())) + { + var primary = new Win32Exception(); + if (!CloseHandle(job)) + primary.Data["JobCloseFailure"] = new Win32Exception(); + throw primary; + } + return job; + } + + public static bool Contains(IntPtr job, IntPtr process) + { + if (job == IntPtr.Zero || process == IntPtr.Zero) + throw new ArgumentException("Held process and job handles are required."); + if (!IsProcessInJob(process, job, out bool result)) throw new Win32Exception(); + return result; + } + + public static void Assign(IntPtr job, IntPtr process) + { + if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); + if (!Contains(job, process)) throw new InvalidOperationException("Assigned root is not in the held job."); + } + + public static uint Active(IntPtr job) + { + if (!QueryInformationJobObject(job, 1, out Accounting value, (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw new Win32Exception(); + return value.Active; + } + + public static string Summary(IntPtr job) + { + if (!QueryInformationJobObject(job, 1, out Accounting value, (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw new Win32Exception(); + return $"total={value.Total}; active={value.Active}; terminated={value.Terminated}; user100ns={value.User}; kernel100ns={value.Kernel}"; + } + + public static int[] Members(IntPtr job) + { + const int capacity = 4096; + int size = 8 + capacity * IntPtr.Size; + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + if (!QueryInformationJobObject(job, 3, buffer, (uint)size, IntPtr.Zero)) + throw new Win32Exception(); + int assigned = Marshal.ReadInt32(buffer, 0), count = Marshal.ReadInt32(buffer, 4); + if (count < 0 || count > capacity || assigned != count) + throw new InvalidOperationException("Incomplete owned job member census."); + var result = new int[count]; + for (int i = 0; i < count; i++) + result[i] = checked((int)Marshal.ReadIntPtr(buffer, 8 + i * IntPtr.Size).ToInt64()); + return result; + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public static string Processes(IntPtr job) + { + var identities = new List(); + foreach (int pid in Members(job)) + { + IntPtr process = OpenProcess(0x1000, false, (uint)pid); + if (process == IntPtr.Zero) + { + identities.Add($"pid={pid}; unavailable-win32={Marshal.GetLastWin32Error()}"); + continue; + } + try + { + if (!Contains(job, process)) + { + identities.Add($"pid={pid}; no-longer-owned"); + continue; + } + uint length = 32768; + var name = new StringBuilder((int)length); + if (!GetProcessTimes(process, out long creation, out _, out _, out _) || + !QueryFullProcessImageNameW(process, 0, name, ref length)) + { + identities.Add($"pid={pid}; identity-unavailable-win32={Marshal.GetLastWin32Error()}"); + continue; + } + identities.Add($"pid={pid}; creationUtcTicks={DateTime.FromFileTimeUtc(creation).Ticks}; name={System.IO.Path.GetFileNameWithoutExtension(name.ToString())}"); + } + finally { if (!CloseHandle(process)) throw new Win32Exception(); } + } + return string.Join(" | ", identities); + } + + public static void Terminate(IntPtr job) + { + if (!TerminateJobObject(job, 124)) throw new Win32Exception(); + } + + public static void WaitForEmpty(IntPtr job, int milliseconds) + { + if (milliseconds < 0) throw new ArgumentOutOfRangeException(nameof(milliseconds)); + WaitForEmpty(job, new Deadline(TimeSpan.FromMilliseconds(Math.Max(1, milliseconds)))); + } + + public static void WaitForEmpty(IntPtr job, Deadline deadline) + { + while (Active(job) != 0) + { + if (deadline.Expired) throw new TimeoutException("Owned job still has active processes."); + Thread.Sleep(Math.Min(10, deadline.RemainingMilliseconds)); + } + } + + public static void Close(IntPtr job) + { + if (Active(job) != 0) throw new InvalidOperationException("Cannot release a nonempty or unknown owned job."); + if (!CloseHandle(job)) throw new Win32Exception(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs new file mode 100644 index 00000000..b5b3eddd --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -0,0 +1,517 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class SuiteRegistration +{ + public static IReadOnlyList CreateSuites() => [new OwnershipSuite()]; +} + +public sealed class OwnershipSuite : IValidationSuite +{ + private const string ListId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + private const string RenameId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + private const string Token = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + private static readonly string[] managedIds = + [ + "quote-empty", "quote-trailing-slash", "quote-embedded", "quote-unicode", "quote-nul", + "stream-raw", "stream-surrogate-boundary", "stream-no-overwrite", "stream-independent-faults", "stream-shared-deadline", + "receipt-valid", "receipt-existing", "receipt-missing", "receipt-file", "receipt-absent", + "receipt-duplicate-json", "receipt-foreign-root", "receipt-over-limit", + "proof-success", "proof-root-survivor", "proof-job-survivor", "proof-stdout-fault", "proof-stderr-fault", + "json-malformed", "json-duplicate", "json-nested-duplicate", "json-array", "json-case-sensitive", + "frame-fragmented-unicode", "frame-clean-eof", "frame-short-header", "frame-short-body", "frame-zero", "frame-over-limit", "frame-invalid-utf8", + "peer-positive-rename", "peer-resume-unchanged", "peer-foreign-owner", "peer-foreign-node", + "peer-missing-title", "peer-extra-field", "peer-title-type", "peer-version-string", "peer-version-bool", + "peer-request-id", "peer-blank-title", "peer-request-duplicate", "peer-unanswered-publication", + "peer-response-unknown", "peer-response-extra", "peer-response-type", "peer-response-duplicate", + "peer-control-positive", "peer-control-owner", "peer-control-selection", "peer-control-extra", "peer-control-type", + "peer-control-duplicate", "peer-control-second" + ]; + private static readonly string[] nativeIds = ["normal", "abrupt", "timeout", "inherited", "inherited-timeout", "arguments-environment", "real-pipe"]; + private static readonly (string Id, string Evidence)[] unported = + [ + ("legacy.ownership-receipts", "Actual PS producer/stream/scope assertions: Test-UiaOwnershipReceiptContracts, source 90-168; exactly seven cases."), + ("legacy.retention-boundary", "Actual PS producer/Invoke-Native/isolation boundary: Test-UiaRetentionBoundaryContracts, source 292-429; exactly six cases including receipt-only and sentinel-only exit0/17 obligations."), + ("legacy.capture-ownership", "Actual ScriptMethod/mock/ErrorRecord assertions: Test-UiaCaptureOwnershipContracts, source 430-596; exactly fourteen cases; native helper linkage requires host adapters."), + ("legacy.tree-ownership", "Actual PS lease registry and continuation assertions: Test-UiaTreeOwnershipContracts, source 597-673; exactly four cases."), + ("legacy.provider-ownership", "Actual PS advisory-CIM refusal/removed hiding assertions: Test-UiaProviderOwnershipContracts, source 674-757; exactly four cases."), + ("legacy.pre-uia-isolation", "Actual runner PS region and environment/scope assertions: Test-PreUiaProcessIsolationContracts, source 892-988; exactly ten cases."), + ("legacy.mixed-multiproject", "Complete mixed source 1672-4245 remains mandatory: UI predicates, native/fresh snapshot policies, receipts, strict protocol, owned real pipes, eight bounded-capture and primary/cleanup negative controls. Source invocation inventories are not executed counts."), + ("legacy.peer-emission", "Assert-MultiProjectPeerEmissionContract source 4247-4265 requires actual PS AST source correspondence."), + ("legacy.rename-submit", "Assert-MultiProjectRenameSubmitContract source 4266-4289 requires actual caller/production Zig route correspondence."), + ("legacy.fresh-capture", "Assert-MultiProjectFreshCaptureContract source 4290-4315 requires actual ownership/cache/observer correspondence."), + ("legacy.packaging-shared-linkage", "Packaging.Standalone.Tests.ps1 must load this complete StandaloneProcessJob implementation, not its embedded duplicate."), + ("legacy.uia-shared-linkage", "uia-live-gate.ps1 must load these complete UiaOwnedStreamCapture/StandaloneProcessJob implementations, including actual native membership.") + ]; + + public string Id => "ownership"; + public IReadOnlyList UnitIds { get; } = managedIds.Select(id => "ownership.managed." + id) + .Concat(nativeIds.Select(id => "ownership.native." + id)).Concat(unported.Select(p => "ownership." + p.Id)).ToArray(); + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + string root = Path.Combine(context.ArtifactRoot, "ownership-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string alpha = Path.Combine(root, "Alpha"), beta = Path.Combine(root, "Beta"); + Directory.CreateDirectory(alpha); + Directory.CreateDirectory(beta); + var results = new List(); + foreach (string id in managedIds) + { + cancellationToken.ThrowIfCancellationRequested(); + await ExecuteAsync("ownership.managed." + id, () => ManagedAsync(id, root, alpha, beta), results, "managed").ConfigureAwait(false); + } + foreach (string id in nativeIds) + { + cancellationToken.ThrowIfCancellationRequested(); + await ExecuteAsync("ownership.native." + id, () => NativeAsync(id, root, alpha, beta, cancellationToken), results, "controlled-native").ConfigureAwait(false); + } + results.AddRange(unported.Select(p => new UnitResult("ownership." + p.Id, UnitStatus.Incomplete, 0, + p.Evidence + " Not replaced by the separately counted compiled cases."))); + return results; + } + + private static async Task ExecuteAsync(string id, Func action, List results, string group) + { + try + { + await action().ConfigureAwait(false); + results.Add(new UnitResult(id, UnitStatus.Passed, 1, "Exact registered case completed; evidence kind=" + group, [group])); + } + catch (Exception failure) + { + results.Add(new UnitResult(id, UnitStatus.Failed, 1, failure.ToString(), [group])); + if (failure.Data[OwnershipRetention.RequiredKey] is true) + throw; + } + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private static T Reject(Action action, string? prefix = null) where T : Exception + { + try { action(); } + catch (T failure) + { + Require(prefix == null || failure.Message.StartsWith(prefix, StringComparison.Ordinal), "Wrong rejection: " + failure); + return failure; + } + throw new InvalidOperationException("Expected rejection: " + typeof(T).Name); + } + + private static async Task RejectAsync(Func action) where T : Exception + { + try { await action().ConfigureAwait(false); } + catch (T failure) { return failure; } + throw new InvalidOperationException("Expected rejection: " + typeof(T).Name); + } + + private static JsonObject Request(string alpha, string title = "Alpha renamed") => new() + { + ["version"] = 2, ["kind"] = "request", ["requestID"] = RenameId, + ["command"] = new JsonObject { ["graphCommand"] = new JsonObject + { + ["projectPath"] = alpha, + ["command"] = new JsonObject { ["renameNode"] = new JsonObject { ["_0"] = MultiProjectPeer.AlphaNodeId, ["title"] = title } } + } } + }; + + private static JsonObject ListRequest() => MultiProjectJson.Object( + "{\"version\":2,\"kind\":\"request\",\"requestID\":\"" + ListId + "\",\"command\":{\"listRecentProjects\":{}}}"); + + private static MultiProjectPeer Baseline(string alpha, string beta) + { + var peer = new MultiProjectPeer(alpha, beta); + MultiProjectReply reply = peer.Request(ListRequest()); + peer.CompleteResponse(reply.Response!); + foreach (string owner in new[] { alpha, beta }) peer.CompletePublication(peer.Publication(owner), "initial", ListId); + return peer; + } + + private static JsonObject Control(string alpha, string beta) => new() + { + ["token"] = Token, ["projectPath"] = alpha, ["nodeID"] = MultiProjectPeer.AlphaNodeId, ["title"] = "Alpha interleaved", + ["selection"] = new JsonObject { ["projectPath"] = beta, ["nodeID"] = MultiProjectPeer.BetaNodeId, ["source"] = "synthetic-client" } + }; + + private static async Task ManagedAsync(string id, string root, string alpha, string beta) + { + if (id.StartsWith("quote-", StringComparison.Ordinal)) + { + switch (id) + { + case "quote-empty": Require(OwnedChildSupervisor.QuoteArgument("") == "\"\"", "Empty argv was lost."); break; + case "quote-trailing-slash": Require(OwnedChildSupervisor.QuoteArgument("a\\") == "\"a\\\\\"", "Trailing slash not doubled."); break; + case "quote-embedded": Require(OwnedChildSupervisor.QuoteArgument("a\"b") == "\"a\\\"b\"", "Embedded quote not escaped."); break; + case "quote-unicode": Require(OwnedChildSupervisor.QuoteArgument("\u03BB \uD83D\uDE80") == "\"\u03BB \uD83D\uDE80\"", "Unicode argv changed."); break; + case "quote-nul": Reject(() => OwnedChildSupervisor.QuoteArgument("a\0b")); break; + } + return; + } + if (id.StartsWith("stream-", StringComparison.Ordinal)) + { + if (id == "stream-independent-faults") + { + var first = new IOException("stdout original"); + var second = new IOException("stderr original"); + var failure = await RejectAsync(() => OwnedChildSupervisor.RequireDrainAsync( + [Task.FromException(first), Task.FromException(second)], new Deadline(TimeSpan.FromSeconds(1)))).ConfigureAwait(false); + Require(failure.InnerExceptions.Count == 2 && ReferenceEquals(failure.InnerExceptions[0], first) && + ReferenceEquals(failure.InnerExceptions[1], second), "Independent original stream errors were replaced or omitted."); + return; + } + if (id == "stream-shared-deadline") + { + var pending = new TaskCompletionSource(); + var original = new IOException("stderr fails independently"); + var clock = Stopwatch.StartNew(); + var limit = new Deadline(TimeSpan.FromMilliseconds(100)); + await Task.Delay(60).ConfigureAwait(false); + var failure = await RejectAsync(() => OwnedChildSupervisor.RequireDrainAsync( + [pending.Task, Task.FromException(original)], limit)).ConfigureAwait(false); + Require(failure.InnerExceptions.Count == 2 && ReferenceEquals(failure.InnerExceptions[1], original) && + clock.Elapsed < TimeSpan.FromSeconds(1), "Original shared deadline or independent stderr fault was lost."); + return; + } + string text = id == "stream-surrogate-boundary" ? new string('a', 4095) + "\uD83D\uDE80tail" : "first\nsecond \u03BB \uD83D\uDE80\r\n"; + string path = Path.Combine(root, id + ".log"); + using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(text)), new UTF8Encoding(false, true)); + string captured = await UiaOwnedStreamCapture.Read(reader, path).ConfigureAwait(false); + Require(captured == text && File.ReadAllText(path, new UTF8Encoding(false, true)) == text && + File.ReadAllBytes(path).SequenceEqual(Encoding.UTF8.GetBytes(text)), "Raw UTF-8 bytes or streaming Unicode changed."); + if (id == "stream-no-overwrite") + { + Reject(() => UiaOwnedStreamCapture.Read(reader, path)); + Require(File.ReadAllText(path) == text, "Existing capture evidence overwritten."); + } + return; + } + if (id.StartsWith("receipt-", StringComparison.Ordinal)) + { + string receiptRoot = Path.Combine(root, id); + if (id is not ("receipt-missing" or "receipt-file" or "receipt-absent")) Directory.CreateDirectory(receiptRoot); + if (id == "receipt-file") File.WriteAllText(receiptRoot, "not a directory"); + string path = Path.Combine(receiptRoot, "ownership-unproven.json"); + if (id == "receipt-existing") File.WriteAllText(path, "preserve-original"); + if (id is "receipt-duplicate-json" or "receipt-foreign-root" or "receipt-over-limit") + { + string json = id == "receipt-duplicate-json" ? "{\"schemaVersion\":1,\"schemaVersion\":1}" : + id == "receipt-over-limit" ? new string(' ', 4097) : + JsonSerializer.Serialize(new { schemaVersion = 1, validationRoot = root, reason = "ownership_unproven" }); + File.WriteAllText(path, json); + Reject(() => OwnershipRetention.ReceiptRequiresRetention(receiptRoot)); + return; + } + var failure = new IOException("original primary"); + OwnershipRetention.Tag(failure, id == "receipt-absent" ? null : receiptRoot); + Require(failure.Data[OwnershipRetention.RequiredKey] is true && failure.Message == "original primary", "Boolean retention intent or original failure changed."); + if (id == "receipt-valid") Require(OwnershipRetention.ReceiptRequiresRetention(receiptRoot), "Receipt not consumed."); + else if (id == "receipt-existing") + Require(File.ReadAllText(path) == "preserve-original" && failure.Data["GraphCodeValidationRetentionReceiptFailure"] != null, "Receipt overwrite or missing write-failure diagnostics."); + else if (id != "receipt-absent") Require(failure.Data["GraphCodeValidationRetentionReceiptFailure"] != null, "Unwritable receipt silently accepted."); + return; + } + if (id.StartsWith("proof-", StringComparison.Ordinal)) + { + var proof = new OwnedCleanupProof(id != "proof-root-survivor", id != "proof-job-survivor", + id != "proof-stdout-fault", id != "proof-stderr-fault", false); + Require(proof.CanRelease == (id == "proof-success"), "UNKNOWN proof allowed resource release."); + return; + } + if (id.StartsWith("json-", StringComparison.Ordinal)) + { + string text = id switch + { + "json-malformed" => "{", "json-duplicate" => "{\"x\":1,\"x\":2}", + "json-nested-duplicate" => "{\"a\":[{\"x\":1,\"x\":2}]}", + "json-array" => "[]", _ => "{\"x\":1,\"X\":2}" + }; + if (id == "json-case-sensitive") + { + using var document = MultiProjectJson.Parse(text); + Require(document.RootElement.EnumerateObject().Count() == 2, "Ordinal JSON property names collapsed."); + } + else Reject(() => MultiProjectJson.Parse(text), "MULTIPROJECT_JSON"); + return; + } + if (id.StartsWith("frame-", StringComparison.Ordinal)) + { + if (id == "frame-fragmented-unicode") + { + using var memory = new MemoryStream(); + await PipeFrames.WriteAsync(memory, "{\"text\":\"\u03BB \uD83D\uDE80\"}", new Deadline(TimeSpan.FromSeconds(1)), 7).ConfigureAwait(false); + memory.Position = 0; + using var fragmented = new FragmentedReadStream(memory, 1); + using var doc = await PipeFrames.ReadAsync(fragmented, new Deadline(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Require(doc!.RootElement.GetProperty("text").GetString() == "\u03BB \uD83D\uDE80", "UTF-8 framing text changed."); + return; + } + byte[] bytes = id switch + { + "frame-clean-eof" => [], "frame-short-header" => [0, 0], "frame-short-body" => [0, 0, 0, 2, 123], + "frame-zero" => [0, 0, 0, 0], "frame-over-limit" => [0, 32, 0, 1], + "frame-invalid-utf8" => [0, 0, 0, 2, 0xc3, 0x28], _ => throw new InvalidOperationException(id) + }; + using var source = new MemoryStream(bytes); + if (id == "frame-clean-eof") Require(await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))) == null, "Clean EOF not distinct."); + else if (id is "frame-short-header" or "frame-short-body") + await RejectAsync(async () => { using var doc = await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))); }).ConfigureAwait(false); + else if (id == "frame-invalid-utf8") + await RejectAsync(async () => { using var doc = await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))); }).ConfigureAwait(false); + else await RejectAsync(async () => { using var doc = await PipeFrames.ReadAsync(source, new Deadline(TimeSpan.FromSeconds(1))); }).ConfigureAwait(false); + return; + } + PeerCase(id, alpha, beta); + } + + private static void PeerCase(string id, string alpha, string beta) + { + var peer = Baseline(alpha, beta); + JsonObject request = Request(alpha); + if (id == "peer-positive-rename") + { + MultiProjectReply reply = peer.Request(request); + peer.CompleteResponse(reply.Response!); + JsonObject publication = peer.Publication(alpha); + peer.CompletePublication(publication, "rename", RenameId); + JsonObject snapshot = peer.Snapshot(); + Require(snapshot["appliedCount"]!.GetValue() == 1 && snapshot["graphSequence"]!.GetValue() == 3 && + snapshot["graphs"]![0]!["nodes"]![0]!["title"]!.GetValue() == "Alpha renamed" && + snapshot["graphs"]![1]!["nodes"]![0]!["title"]!.GetValue() == "Beta loop", "Rename crossed owner or sequence."); + return; + } + if (id == "peer-resume-unchanged") + { + request["command"]!["graphCommand"]!["command"] = new JsonObject { ["resumeSession"] = new JsonObject { ["_0"] = MultiProjectPeer.AlphaNodeId } }; + string before = MultiProjectJson.Canonical(peer.Snapshot()["graphs"]); + MultiProjectReply reply = peer.Request(request); + Require(reply.PublishPath == null && MultiProjectJson.Canonical(peer.Snapshot()["graphs"]) == before && + peer.Snapshot()["appliedCount"]!.GetValue() == 0, "Resume acknowledgement mutated graph."); + return; + } + if (id.StartsWith("peer-control-", StringComparison.Ordinal)) + { + JsonObject control = Control(alpha, beta); + string prefix = "MULTIPROJECT_SCHEMA"; + switch (id) + { + case "peer-control-positive": + JsonObject frame = peer.Control(control); + peer.CompletePublication(frame, "control", Token); + Require(peer.Snapshot()["controls"]!.AsArray().Count == 1 && frame["sequence"]!.GetValue() == 3, "Interleaved control not applied exactly once."); + return; + case "peer-control-owner": control["projectPath"] = beta; control["nodeID"] = MultiProjectPeer.BetaNodeId; prefix = "MULTIPROJECT_OWNER"; break; + case "peer-control-selection": control["selection"]!["nodeID"] = MultiProjectPeer.AlphaNodeId; prefix = "MULTIPROJECT_SELECTION"; break; + case "peer-control-extra": control["extra"] = 1; break; + case "peer-control-type": control["title"] = 7; prefix = "MULTIPROJECT_TYPE"; break; + case "peer-control-duplicate": peer.Control(control); prefix = "MULTIPROJECT_DUPLICATE"; break; + case "peer-control-second": peer.Control(control); control["token"] = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; prefix = "MULTIPROJECT_ONE_SHOT"; break; + } + string before = MultiProjectJson.Canonical(peer.Snapshot()); + Reject(() => peer.Control(control), prefix); + Require(MultiProjectJson.Canonical(peer.Snapshot()) == before, "Rejected control mutated peer."); + return; + } + if (id.StartsWith("peer-response-", StringComparison.Ordinal)) + { + JsonObject response = peer.Request(request).Response!; + string prefix = "MULTIPROJECT_CORRELATION"; + switch (id) + { + case "peer-response-unknown": response["requestID"] = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; break; + case "peer-response-extra": response["extra"] = 1; prefix = "MULTIPROJECT_SCHEMA"; break; + case "peer-response-type": response["success"] = "true"; prefix = "MULTIPROJECT_TYPE"; break; + case "peer-response-duplicate": peer.CompleteResponse(response); prefix = "MULTIPROJECT_DUPLICATE"; break; + } + Reject(() => peer.CompleteResponse(response), prefix); + return; + } + if (id == "peer-unanswered-publication") + { + peer.Request(request); + Reject(() => peer.CompletePublication(peer.Publication(alpha), "rename", RenameId), "MULTIPROJECT_UNANSWERED"); + return; + } + string expected = "MULTIPROJECT_SCHEMA"; + JsonNode rename = request["command"]!["graphCommand"]!["command"]!["renameNode"]!; + switch (id) + { + case "peer-foreign-owner": request["command"]!["graphCommand"]!["projectPath"] = beta; expected = "MULTIPROJECT_OWNER"; break; + case "peer-foreign-node": rename["_0"] = MultiProjectPeer.BetaNodeId; expected = "MULTIPROJECT_OWNER"; break; + case "peer-missing-title": rename.AsObject().Remove("title"); break; + case "peer-extra-field": rename["extra"] = 1; break; + case "peer-title-type": rename["title"] = 7; expected = "MULTIPROJECT_TYPE"; break; + case "peer-version-string": request["version"] = "2"; expected = "MULTIPROJECT_TYPE"; break; + case "peer-version-bool": request["version"] = true; expected = "MULTIPROJECT_TYPE"; break; + case "peer-request-id": request["requestID"] = "bad"; expected = "MULTIPROJECT_UUID"; break; + case "peer-blank-title": rename["title"] = " "; expected = "MULTIPROJECT_TITLE"; break; + case "peer-request-duplicate": peer.Request(request); expected = "MULTIPROJECT_DUPLICATE"; break; + default: throw new InvalidOperationException("Unimplemented managed case: " + id); + } + string original = MultiProjectJson.Canonical(peer.Snapshot()); + Reject(() => peer.Request(request), expected); + Require(MultiProjectJson.Canonical(peer.Snapshot()) == original, "Rejected request mutated peer."); + } + + private static async Task NativeAsync(string id, string root, string alpha, string beta, CancellationToken cancellationToken) + { + string childRoot = Path.Combine(root, "native-" + id); + Directory.CreateDirectory(childRoot); + if (id == "real-pipe") + { + await RealPipeAsync(childRoot, alpha, beta, cancellationToken).ConfigureAwait(false); + return; + } + string mode = id == "arguments-environment" ? "echo" : id; + string[] arguments = ["space argument", "", "\"literal\"", "trailing\\", "\u03BB \uD83D\uDE80"]; + var start = OwnedFixture.Command(mode, childRoot, id == "arguments-environment" ? arguments : []); + if (id == "arguments-environment") + { + start.Environment["OWNERSHIP_EMPTY"] = ""; + start.Environment.Remove("OWNERSHIP_ABSENT"); + } + TimeSpan budget = TimeSpan.FromMilliseconds(id.Contains("timeout", StringComparison.Ordinal) ? 1500 : 5000); + OwnedChildSupervisor child = OwnedChildSupervisor.Start(start, budget, childRoot); + bool completionAttempted = false; + try + { + if (id is "timeout" or "inherited-timeout" or "inherited") + { + var readyLimit = new Deadline(TimeSpan.FromSeconds(3)); + while (!File.Exists(Path.Combine(childRoot, "ready"))) + { + if (readyLimit.Expired) throw new TimeoutException("Controlled root not ready."); + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + } + } + if (id.Contains("timeout", StringComparison.Ordinal)) + { + completionAttempted = true; + Exception failure = await RejectAsync(async () => { await child.CompleteAsync(cancellationToken); }).ConfigureAwait(false); + Require(failure is TimeoutException or AggregateException && failure.Data["OwnedCleanupProof"] is OwnedCleanupProof { CanRelease: true, ResourcesClosed: true }, + "Timeout cleanup did not prove exact held root, empty job and both EOFs: " + failure); + return; + } + if (id == "inherited") + { + var exitLimit = new Deadline(TimeSpan.FromSeconds(2)); + OwnedChildObservation observation = child.Observe(); + while (!observation.RootExited) + { + if (exitLimit.Expired) throw new TimeoutException("Controlled root exit not observed."); + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + observation = child.Observe(); + } + Require(observation.ActiveJobProcesses > 0 && !observation.StdoutCompleted && !observation.StderrCompleted, + "Actual inherited-open-pipe/job condition was not exercised."); + completionAttempted = true; + OwnedCleanupProof proof = await child.StopAsync().ConfigureAwait(false); + Require(proof is { CanRelease: true, ResourcesClosed: true }, "Inherited descendant cleanup unproved."); + return; + } + completionAttempted = true; + OwnedChildResult result = await child.CompleteAsync(cancellationToken).ConfigureAwait(false); + Require(result.Cleanup is { CanRelease: true, ResourcesClosed: true }, "Native cleanup proof incomplete."); + Require(result.ExitCode == (id == "abrupt" ? 23 : 0), "Controlled exit code changed."); + if (id == "arguments-environment") + { + using var document = JsonDocument.Parse(result.StandardOutput); + JsonElement record = document.RootElement; + Require(record.GetProperty("arguments").EnumerateArray().Select(p => p.GetString()).SequenceEqual(arguments) && + record.GetProperty("present").GetBoolean() && record.GetProperty("value").GetString() == "" && + record.GetProperty("absent").GetBoolean() && record.GetProperty("cwd").GetString() == childRoot, + "Native argv/empty-versus-absent environment/Unicode/cwd changed."); + } + else Require(result.StandardOutput.Contains("owned-capture-stdout \uD83D\uDE80", StringComparison.Ordinal) && + result.StandardError.Contains("owned-capture-stderr \u03BB", StringComparison.Ordinal), "Independent native stream content lost."); + } + catch (Exception primary) + { + if (!completionAttempted) await child.StopAsync(primary).ConfigureAwait(false); + throw; + } + } + + private static async Task RealPipeAsync(string root, string alpha, string beta, CancellationToken cancellationToken) + { + string name = "graphcode-compiled-owned-" + Guid.NewGuid().ToString("N"); + var child = OwnedChildSupervisor.Start(OwnedFixture.Command("protocol", root, name, alpha, beta), TimeSpan.FromSeconds(10), root); + Exception? primary = null; + try + { + await using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + var deadline = new Deadline(TimeSpan.FromSeconds(5)); + await client.ConnectAsync(deadline.RemainingMilliseconds, cancellationToken).ConfigureAwait(false); + await PipeFrames.WriteAsync(client, "{\"version\":2,\"kind\":\"hello\",\"supportedVersions\":[1,2]}", deadline, 7, cancellationToken).ConfigureAwait(false); + using var hello = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(hello!.RootElement.GetProperty("selectedVersion").GetInt32() == 2, "Real pipe v2 negotiation absent."); + await PipeFrames.WriteAsync(client, ListRequest().ToJsonString(), deadline, 7, cancellationToken).ConfigureAwait(false); + using var response = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + using var first = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + using var second = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(response!.RootElement.GetProperty("requestID").GetString() == ListId && + first!.RootElement.GetProperty("sequence").GetInt32() == 1 && second!.RootElement.GetProperty("sequence").GetInt32() == 2 && + first.RootElement.GetProperty("event").GetProperty("graphChanged").GetProperty("project").GetProperty("path").GetString() == alpha && + second.RootElement.GetProperty("event").GetProperty("graphChanged").GetProperty("project").GetProperty("path").GetString() == beta, + "Real fragmented two-owner publication differed."); + await PipeFrames.WriteAsync(client, Request(alpha).ToJsonString(), deadline, 7, cancellationToken).ConfigureAwait(false); + using var renamed = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + using var publication = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(renamed!.RootElement.GetProperty("requestID").GetString() == RenameId && + publication!.RootElement.GetProperty("sequence").GetInt32() == 3, "Real rename correlation or sequence lost."); + await PipeFrames.WriteAsync(client, Control(alpha, beta).ToJsonString(), deadline, 7, cancellationToken).ConfigureAwait(false); + using var interleaved = await PipeFrames.ReadAsync(client, deadline, cancellationToken).ConfigureAwait(false); + Require(interleaved!.RootElement.GetProperty("sequence").GetInt32() == 4 && + interleaved.RootElement.GetProperty("event").GetProperty("graphChanged").GetProperty("nodes")[0].GetProperty("title").GetString() == "Alpha interleaved", + "Real interleaved Alpha publication missing."); + } + catch (Exception failure) { primary = failure; } + try + { + OwnedChildResult result = await child.CompleteAsync(cancellationToken).ConfigureAwait(false); + Require(result.ExitCode == 0 && result.Cleanup.ResourcesClosed, "Real peer cleanup failed: " + result.StandardError); + } + catch (Exception cleanup) + { + if (primary == null) throw; + primary.Data["ProtocolCleanupFailure"] = cleanup; + if (cleanup.Data[OwnershipRetention.RequiredKey] is true) OwnershipRetention.Tag(primary, root); + } + if (primary != null) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + using var receipt = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "peer.json"))); + Require(receipt.RootElement.GetProperty("requestCount").GetInt32() == 2 && + receipt.RootElement.GetProperty("responseCount").GetInt32() == 2 && + receipt.RootElement.GetProperty("publicationCount").GetInt32() == 4 && + receipt.RootElement.GetProperty("unansweredRequests").GetArrayLength() == 0, "Real peer receipt is incomplete."); + } + + private sealed class FragmentedReadStream(Stream source, int maximum) : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => source.Read(buffer, offset, Math.Min(count, maximum)); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + source.ReadAsync(buffer[..Math.Min(maximum, buffer.Length)], cancellationToken); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs b/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs new file mode 100644 index 00000000..bb6b3690 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class UiaOwnedStreamCapture +{ + public static void RequireJobMember(IntPtr process, IntPtr job) + { + if (!StandaloneProcessJob.Contains(job, process)) + throw new InvalidOperationException("Published UIA target is not a member of its owned launch job."); + } + + public static Task Read(StreamReader reader, string path) + { + ArgumentNullException.ThrowIfNull(reader); + var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite); + return Capture(reader, file); + } + + private static async Task Capture(StreamReader reader, FileStream file) + { + // AutoFlush preserves encoder state across a split surrogate; FlushAsync does not. + using var writer = new StreamWriter(file, new UTF8Encoding(false, true)) { AutoFlush = true }; + var text = new StringBuilder(); + var buffer = new char[4096]; + int count; + while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) + { + text.Append(buffer, 0, count); + await writer.WriteAsync(buffer, 0, count).ConfigureAwait(false); + } + return text.ToString(); + } +} From 4ceadefc6307c1617b11f41979a848c855d9f221 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:50:38 -0700 Subject: [PATCH 08/46] Add protected suite adapters and compiled fixture ABI Keep incomplete coverage nonzero; preserve owned supervision, suite provenance, and root console marker isolation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Build-CompiledValidationRunner.ps1 | 47 ++++++++ .../Import-CompiledValidationAssembly.ps1 | 42 +++++++ .../ValidationFixtureDirectory.Tests.ps1 | 9 +- .../Tests/ValidationFixtureDirectory.ps1 | 25 +---- .../ValidationFixtureDirectoryNative.cs | 21 ++++ .../Adapters/EvidenceParser.cs | 88 +++++++++++++++ .../ValidationRunner/Adapters/HostTasks.ps1 | 102 +++++++++++++++++ .../Adapters/PowerShellPayload.cs | 60 ++++++++++ .../Adapters/StandaloneSuites.cs | 104 ++++++++++++++++++ .../Bindings/ValidationFixtureBindings.csproj | 13 +++ .../ValidationRunner/Host/ConsoleReport.cs | 14 +++ .../windows/ValidationRunner/Host/Coverage.cs | 15 ++- .../ValidationRunner/Host/HostSelfTests.cs | 55 +++++++++ .../ValidationRunner/Manifests/host.json | 4 +- Tools/windows/ValidationRunner/Program.cs | 23 +++- .../ValidationRunner/ValidationRunner.csproj | 5 + 16 files changed, 596 insertions(+), 31 deletions(-) create mode 100644 Tools/windows/Build-CompiledValidationRunner.ps1 create mode 100644 Tools/windows/Import-CompiledValidationAssembly.ps1 create mode 100644 Tools/windows/ValidationFixtureDirectoryNative.cs create mode 100644 Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs create mode 100644 Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 create mode 100644 Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs create mode 100644 Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs create mode 100644 Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj create mode 100644 Tools/windows/ValidationRunner/Host/ConsoleReport.cs diff --git a/Tools/windows/Build-CompiledValidationRunner.ps1 b/Tools/windows/Build-CompiledValidationRunner.ps1 new file mode 100644 index 00000000..0510b82c --- /dev/null +++ b/Tools/windows/Build-CompiledValidationRunner.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")][string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$projectRoot = Join-Path $PSScriptRoot "ValidationRunner" +$local = Join-Path $projectRoot ".local" +$names = @("DOTNET_CLI_HOME", "NUGET_PACKAGES", "TEMP", "TMP", "DOTNET_CLI_TELEMETRY_OPTOUT", + "DOTNET_GENERATE_ASPNET_CERTIFICATE", "DOTNET_SKIP_FIRST_TIME_EXPERIENCE") +$before = @{} +$environment = [Environment]::GetEnvironmentVariables("Process") +foreach ($name in $names) { + $before[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } +} +try { + $env:DOTNET_CLI_HOME = Join-Path $local "dotnet" + $env:NUGET_PACKAGES = Join-Path $local "nuget" + $env:TEMP = Join-Path $local "temp" + $env:TMP = $env:TEMP + $env:DOTNET_CLI_TELEMETRY_OPTOUT = "1" + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" + [void][IO.Directory]::CreateDirectory($env:TEMP) + Push-Location $projectRoot + try { + $dotnet = (Get-Command dotnet -CommandType Application -ErrorAction Stop).Source + $version = & $dotnet --version + if ($LASTEXITCODE -ne 0 -or $version -cne "10.0.403") { + throw "Compiled validation requires the pinned .NET SDK 10.0.403; install it before building" + } + & $dotnet build ValidationRunner.csproj --configuration $Configuration --nologo --verbosity quiet | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + } finally { Pop-Location } + $assembly = Join-Path $projectRoot "bin\$Configuration\net10.0-windows\ValidationRunner.dll" + if (-not (Test-Path -LiteralPath $assembly -PathType Leaf)) { throw "Compiled validation build produced no assembly" } + Write-Output $assembly +} finally { + foreach ($name in $names) { + if ($before[$name].present) { + [Environment]::SetEnvironmentVariable($name, $before[$name].value, "Process") + } else { + [Environment]::SetEnvironmentVariable($name, $null, "Process") + } + } +} diff --git a/Tools/windows/Import-CompiledValidationAssembly.ps1 b/Tools/windows/Import-CompiledValidationAssembly.ps1 new file mode 100644 index 00000000..cfa4268c --- /dev/null +++ b/Tools/windows/Import-CompiledValidationAssembly.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $AssemblyPath, + [Parameter(Mandatory)][string] $RequiredType +) + +$ErrorActionPreference = "Stop" +if ($RequiredType -cne "GraphCode.ValidationFixtureDirectoryNative" -and [Environment]::Version.Major -ne 10) { + throw "Compiled validation bindings require PowerShell on .NET 10 (PowerShell 7.6); observed $([Environment]::Version)" +} +if (-not [IO.Path]::IsPathFullyQualified($AssemblyPath)) { + throw "Compiled validation assembly must be an explicit absolute path" +} +$selected = (Resolve-Path -LiteralPath $AssemblyPath -ErrorAction Stop).Path +$projectRoot = Join-Path $PSScriptRoot "ValidationRunner" +$fileName = if ($RequiredType -ceq "GraphCode.ValidationFixtureDirectoryNative") { + "ValidationFixtureBindings.dll" +} else { "ValidationRunner.dll" } +$expected = @("Debug", "Release") | ForEach-Object { + [IO.Path]::GetFullPath((Join-Path $projectRoot "bin\$_\net10.0-windows\$fileName")) +} +if ($expected -cnotcontains $selected) { + throw "Compiled validation assembly is not a build from this worktree: $selected" +} +$alreadyLoaded = $RequiredType -as [type] +if ($null -ne $alreadyLoaded -and $alreadyLoaded.Assembly.Location -cne $selected) { + throw "Required validation type is already loaded from a different assembly: $RequiredType" +} +$assembly = [Reflection.Assembly]::LoadFrom($selected) +$type = $assembly.GetType($RequiredType, $false) +if ($null -eq $type -or $type.Assembly.Location -cne $selected) { + throw "Expected file-backed validation type was not loaded: $RequiredType" +} +$sourceRoots = @($assembly.GetCustomAttributesData() | Where-Object { + $_.AttributeType.FullName -ceq "System.Reflection.AssemblyMetadataAttribute" -and + $_.ConstructorArguments.Count -eq 2 -and + $_.ConstructorArguments[0].Value -ceq "GraphCode.Validation.SourceRoot" + }) +if ($sourceRoots.Count -ne 1 -or $sourceRoots[0].ConstructorArguments[1].Value -cne $projectRoot) { + throw "Compiled validation assembly has no matching worktree build provenance" +} +Write-Output $assembly diff --git a/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 b/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 index 623d850e..f8386157 100644 --- a/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 +++ b/Tools/windows/Tests/ValidationFixtureDirectory.Tests.ps1 @@ -5,6 +5,9 @@ param( ) $ErrorActionPreference = "Stop" +if (-not $env:GRAPHCODE_VALIDATION_ASSEMBLY) { + $env:GRAPHCODE_VALIDATION_ASSEMBLY = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") +} . (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") $results = [Collections.Generic.List[object]]::new() @@ -275,8 +278,8 @@ Invoke-FixtureCase "cleanup refuses a replaced owned file and preserves its cont Assert-Fixture ([IO.File]::ReadAllText($file) -ceq "replacement file") "Replacement file contents changed" } -# Read the blocked runner only as data; never import or execute its contents. -$runnerSource = [IO.File]::ReadAllText((Join-Path $PSScriptRoot "ValidationRunner.Tests.ps1")) +# Check the actual compiled entry's focused fixture consumer. +$runnerSource = [IO.File]::ReadAllText((Join-Path $PSScriptRoot "..\ValidationRunner\Adapters\HostTasks.ps1")) foreach ($binding in @( @{ name = "untracked"; path = "untrackedDirectory"; fixture = "untrackedFixture"; failure = "untrackedFailure" }, @{ name = "sentinel"; path = "foreignJunction"; fixture = "foreignFixture"; failure = "foreignFailure" } @@ -295,7 +298,7 @@ foreach ($binding in @( Assert-Fixture ($runnerSource -notmatch ( 'Remove-Item -LiteralPath \$' + $binding.path)) "Runner still removes the unowned fixed path" Assert-Fixture ($runnerSource.Contains( - '. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1")')) "Runner does not import fixture helper" + '. (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1")')) "Runner does not import fixture helper" if ($binding.name -eq "untracked") { Assert-Fixture ($runnerSource.Contains( 'New-ValidationFixtureFile -Fixture $untrackedFixture -Name "Unformatted.swift" -Content "let value=1"')) ` diff --git a/Tools/windows/Tests/ValidationFixtureDirectory.ps1 b/Tools/windows/Tests/ValidationFixtureDirectory.ps1 index 6f378475..13b568cc 100644 --- a/Tools/windows/Tests/ValidationFixtureDirectory.ps1 +++ b/Tools/windows/Tests/ValidationFixtureDirectory.ps1 @@ -2,27 +2,12 @@ function Invoke-ValidationFixtureDirectoryCreate { [CmdletBinding()] param([Parameter(Mandatory)][string] $Path) - if (-not ("GraphCode.ValidationFixtureDirectoryNative" -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -namespace GraphCode { - public static class ValidationFixtureDirectoryNative { - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); - public static void Create(string path) { - if (!CreateDirectoryW(path, IntPtr.Zero)) { - int error = Marshal.GetLastWin32Error(); - throw new Win32Exception(error, - "Exclusive validation fixture create failed (Win32 " + error + "): " + path); - } - } - } -} -'@ + if (-not $env:GRAPHCODE_VALIDATION_ASSEMBLY) { + throw "Build with Tools\windows\Build-CompiledValidationRunner.ps1 and set GRAPHCODE_VALIDATION_ASSEMBLY to its output" } + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath (Join-Path ([IO.Path]::GetDirectoryName($env:GRAPHCODE_VALIDATION_ASSEMBLY)) "ValidationFixtureBindings.dll") ` + -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null [GraphCode.ValidationFixtureDirectoryNative]::Create($Path) } diff --git a/Tools/windows/ValidationFixtureDirectoryNative.cs b/Tools/windows/ValidationFixtureDirectoryNative.cs new file mode 100644 index 00000000..8f44ead8 --- /dev/null +++ b/Tools/windows/ValidationFixtureDirectoryNative.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace GraphCode; + +public static class ValidationFixtureDirectoryNative +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); + + public static void Create(string path) + { + if (!CreateDirectoryW(path, IntPtr.Zero)) + { + var error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, $"Exclusive validation fixture create failed (Win32 {error}): {path}"); + } + } +} diff --git a/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs b/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs new file mode 100644 index 00000000..d9b9c067 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.IO; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Adapters; + +public static class EvidenceParser +{ + public static UnitResult Structured( + string unitId, string suite, string output, int minimumCount, int? exactCount = null, + bool requireSkipped = true) + { + var matches = new List(); + foreach (var line in Lines(output)) + { + if (!line.StartsWith("{", StringComparison.Ordinal)) continue; + using var document = JsonDocument.Parse(line); + if (document.RootElement.TryGetProperty("suite", out var name) && + name.ValueKind == JsonValueKind.String && name.GetString() == suite) + matches.Add(document.RootElement.Clone()); + } + if (matches.Count != 1) throw new InvalidDataException($"{suite}: expected one structured summary; observed {matches.Count}"); + var summary = matches[0]; + var executed = Integer(summary, "executed"); + if (executed < minimumCount || (exactCount.HasValue && executed != exactCount.Value) || + Integer(summary, "passed") != executed || Integer(summary, "failed") != 0 || + (requireSkipped && Integer(summary, "skipped") != 0) || + (summary.TryGetProperty("notRun", out _) && Integer(summary, "notRun") != 0)) + throw new InvalidDataException($"{suite}: incomplete or failed suite summary"); + return new UnitResult(unitId, UnitStatus.Passed, executed, $"{suite}: executed={executed}; passed={executed}; failed=0; skipped=0"); + } + + public static UnitResult PesterVersion(string output) + { + const string marker = "PesterVersion.Tests.ps1: PASS; executed=14"; + var lines = Lines(output); + var cases = lines.Where(line => line.StartsWith("PASS: ", StringComparison.Ordinal)).ToArray(); + if (lines.Count(line => line == marker) != 1 || cases.Length != 14 || + cases.Distinct(StringComparer.Ordinal).Count() != 14) + throw new InvalidDataException("PesterVersion: expected 14 distinct executed cases and exactly one summary"); + return new UnitResult("ps.pester-version", UnitStatus.Passed, cases.Length, marker); + } + + public static UnitResult IsolatedEnvironment(string output) + { + var summaries = Lines(output).Where(line => + Regex.IsMatch(line, "^Isolated process environment contracts: [0-9]+ cases; phase=REGRESSION$", + RegexOptions.CultureInvariant)).ToArray(); + if (summaries.Length != 1) throw new InvalidDataException("Isolated environment: missing or duplicate regression count"); + var match = Regex.Match(summaries[0], "contracts: ([0-9]+) cases", RegexOptions.CultureInvariant); + var count = int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + if (count != 14) throw new InvalidDataException($"Isolated environment: expected all 14 cases; observed {count}"); + return new UnitResult("ps.isolated-process-environment", UnitStatus.Passed, count, summaries[0]); + } + + public static UnitResult AgentCommand(string output) + { + const string marker = "Invoke-AgentCommand.Tests.ps1: PASS; executed=6; cases=argument-stream-marker,zero-arguments,existing-console,existing-marker,parse-failure,child-nonzero"; + var lines = Lines(output); + if (lines.Count != 1 || lines[0] != marker) + throw new InvalidDataException("Agent command: expected the exact six-case production suite summary"); + return new UnitResult("ps.agent-command", UnitStatus.Passed, 6, marker); + } + + public static IReadOnlyList CaptureGroups(string reportPath, IReadOnlyList required) + { + using var report = JsonDocument.Parse(File.ReadAllText(reportPath)); + var cases = report.RootElement.GetProperty("cases").EnumerateArray().ToArray(); + foreach (var name in required) + { + var matches = cases.Where(row => row.GetProperty("name").GetString() == name).ToArray(); + if (matches.Length != 1 || matches[0].GetProperty("passed").ValueKind != JsonValueKind.True) + throw new InvalidDataException($"Capture group did not pass exactly once: {name}"); + } + return required.ToArray(); + } + + private static int Integer(JsonElement element, string property) + { + if (!element.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Number || + !value.TryGetInt32(out var count)) + throw new InvalidDataException($"Suite summary requires integer {property}"); + return count; + } + + private static IReadOnlyList Lines(string text) => + text.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); +} diff --git a/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 b/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 new file mode 100644 index 00000000..7be97661 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 @@ -0,0 +1,102 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet("inventory", "dry-run", "unknown", "untracked-format", "foreign-fixture")][string] $Contract +) + +$ErrorActionPreference = "Stop" +$runner = Join-Path $RepositoryRoot "Tools\windows\validate.ps1" +$executed = 0 + +function Assert-HostTask([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw "Compiled host task contract: $Message" } +} + +switch ($Contract) { + "inventory" { + $expected = @("swift-portable", "swift-contracts", "swift-production", "swift-paths", + "swift-process", "swift-named-pipe", "remote-bridge", "remote-e2e", "swift-format", + "visual-baseline", "tdd-evidence", "privacy", "terminal-gate", "windows-shell", + "packaging", "hardening", "provider-build") + $actual = @(& $runner -List) + Assert-HostTask ($LASTEXITCODE -eq 0) "task listing failed" + Assert-HostTask ($actual.Count -eq $expected.Count) "task count changed" + foreach ($task in $expected) { + Assert-HostTask (@($actual | Where-Object { $_ -ceq $task }).Count -eq 1) "missing or duplicated task $task" + $executed++ + } + } + "dry-run" { + $single = @(& $runner -Task swift-paths -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $single.Count -eq 1 -and $single[0] -ceq "task=swift-paths") ` + "single-task dry run did not select exactly swift-paths" + $executed++ + $provider = @(& $runner -Task provider-build -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $provider.Count -eq 1 -and $provider[0] -ceq "task=provider-build") ` + "build-only dry run changed" + $executed++ + $all = @(& $runner -Task all -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $all.Count -eq 16 -and + $all -cnotcontains "task=provider-build" -and $all -ccontains "task=terminal-gate") "all-task selection changed" + $executed++ + $skipped = @(& $runner -Task all -SkipTask swift-paths -DryRun) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $skipped.Count -eq 15 -and + $skipped -cnotcontains "task=swift-paths") "skip-task selection changed" + $executed++ + } + "unknown" { + $failure = $null + try { & $runner -Task not-a-task | Out-Null } catch { $failure = $_ } + Assert-HostTask ($null -ne $failure -and + $failure.Exception -is [Management.Automation.ParameterBindingException]) "unknown task did not fail parameter validation" + $executed++ + } + "untracked-format" { + . (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1") + $untrackedDirectory = Join-Path $RepositoryRoot "investigation\spikes\validation-runner-untracked" + $untrackedFailure = $null + $untrackedFixture = New-ValidationFixtureDirectory -Path $untrackedDirectory + try { + New-ValidationFixtureFile -Fixture $untrackedFixture -Name "Unformatted.swift" -Content "let value=1" + $failure = $null + try { & $runner -Task swift-format 2>&1 | ForEach-Object { Write-Host $_ } } catch { $failure = $_ } + $expectedPrefix = "Swift formatting: " + (Join-Path $untrackedDirectory "Unformatted.swift") + " failed with exit code " + Assert-HostTask ($null -ne $failure -and + $failure.Exception.Message.StartsWith($expectedPrefix, [StringComparison]::Ordinal)) ` + "untracked formatter did not report its actual formatting failure" + $executed++ + } catch { + $untrackedFailure = $_ + throw + } finally { + Remove-ValidationFixtureDirectory -Fixture $untrackedFixture -PrimaryError $untrackedFailure + } + } + "foreign-fixture" { + . (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1") + $foreignJunction = Join-Path $RepositoryRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" + $foreignFailure = $null + $foreignFixture = New-ValidationFixtureDirectory -Path $foreignJunction + try { + & $runner -Task swift-format -DryRun | Out-Null + Assert-HostTask ($LASTEXITCODE -eq 0) "foreign-fixture dry run failed" + Assert-HostTask (Test-Path -LiteralPath $foreignJunction) "validation removed another task's resource" + $executed++ + } catch { + $foreignFailure = $_ + throw + } finally { + Remove-ValidationFixtureDirectory -Fixture $foreignFixture -PrimaryError $foreignFailure + } + } +} + +Assert-HostTask ($executed -gt 0) "no cases ran" +[ordered]@{ + suite = "HostTasks.$Contract" + executed = $executed + passed = $executed + failed = 0 + skipped = 0 +} | ConvertTo-Json -Compress | Write-Output diff --git a/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs new file mode 100644 index 00000000..b2346b45 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; +using GraphCode.Validation.Ownership; + +namespace GraphCode.Validation.Adapters; + +public static class PowerShellPayload +{ + public static async Task RunAsync( + ValidationContext context, string id, string relativeScript, IReadOnlyList arguments, + CancellationToken cancellationToken) + { + var executable = Environment.GetEnvironmentVariable("GRAPHCODE_VALIDATION_PWSH") ?? + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); + if (!Path.IsPathFullyQualified(executable) || !File.Exists(executable)) + throw new FileNotFoundException("Set GRAPHCODE_VALIDATION_PWSH to the supported PowerShell executable", executable); + var script = Path.GetFullPath(Path.Combine(context.RepositoryRoot, relativeScript)); + if (!script.StartsWith(Path.TrimEndingDirectorySeparator(context.RepositoryRoot) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) || !File.Exists(script)) + throw new FileNotFoundException("Validation payload must exist in the current repository", script); + if (Path.GetFileName(script).Equals("ValidationRunner.Tests.ps1", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The legacy monolithic runner is not an allowed payload"); + var artifacts = Path.Combine(context.ArtifactRoot, id + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(artifacts); + var tempParent = Path.Combine(Path.GetPathRoot(context.RepositoryRoot) ?? + throw new InvalidDataException("Repository has no volume root"), "gc-validation"); + Directory.CreateDirectory(tempParent); + var temp = Path.Combine(tempParent, Guid.NewGuid().ToString("N")); + GraphCode.ValidationFixtureDirectoryNative.Create(temp); + await File.WriteAllTextAsync(Path.Combine(artifacts, "scratch-root.txt"), temp, cancellationToken); + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = context.RepositoryRoot, + StandardOutputEncoding = new UTF8Encoding(false, true), + StandardErrorEncoding = new UTF8Encoding(false, true) + }; + foreach (var argument in new[] { "-NoLogo", "-NoProfile", "-NonInteractive", "-File", script }.Concat(arguments)) + start.ArgumentList.Add(argument); + start.Environment["TEMP"] = temp; + start.Environment["TMP"] = temp; + start.Environment["GRAPHCODE_VALIDATION_ASSEMBLY"] = Assembly.GetExecutingAssembly().Location; + var result = await OwnedChildSupervisor.RunAsync(start, TimeSpan.FromMinutes(12), artifacts, cancellationToken); + if (!result.Cleanup.CanRelease || !result.Cleanup.ResourcesClosed) + throw new InvalidOperationException($"{id}: owned child cleanup is UNKNOWN; artifacts retained: {artifacts}"); + if (result.StandardOutput.Contains("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven", StringComparison.Ordinal)) + { + var failure = new InvalidOperationException($"{id}: descendant reported UNKNOWN ownership; artifacts retained: {artifacts}"); + failure.Data["GraphCodeValidationRootRetentionRequired"] = true; + throw failure; + } + if (result.ExitCode != 0 || !string.IsNullOrWhiteSpace(result.StandardError)) + throw new InvalidOperationException($"{id}: protected payload failed (exit={result.ExitCode}); " + + $"stdout/stderr and scratch receipt retained: {artifacts}"); + return result.StandardOutput; + } +} diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs new file mode 100644 index 00000000..ede33435 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -0,0 +1,104 @@ +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using GraphCode.Validation.Host; + +namespace GraphCode.Validation.Adapters; + +public static class StandaloneSuites +{ + public static IReadOnlyList Create(IReadOnlyList required) => + [ + new ScriptSuite("ps.pester-version", "PesterVersion.Tests.ps1", EvidenceParser.PesterVersion), + new ScriptSuite("ps.isolated-process-environment", "IsolatedProcessEnvironment.Contracts.Tests.ps1", EvidenceParser.IsolatedEnvironment), + new ScriptSuite("ps.fixture-directory", "ValidationFixtureDirectory.Tests.ps1", + output => EvidenceParser.Structured("ps.fixture-directory", "ValidationFixtureDirectory", output, 28, 28)), + new ScriptSuite("ps.ui-scenario", "UiScenario.Tests.ps1", + output => EvidenceParser.Structured("ps.ui-scenario", "UiScenario", output, 1, requireSkipped: false)), + new CaptureSuite(required.Single(unit => unit.Id == "ps.ui-capture")), + new ScriptSuite("ps.action-worker", "ActionWorkerImport.Tests.ps1", + output => EvidenceParser.Structured("ps.action-worker", "ActionWorkerImport", output, 18, 18)), + new ScriptSuite("ps.action-identity", "ActionWindowIdentity.Tests.ps1", + output => EvidenceParser.Structured("ps.action-identity", "ActionWindowIdentity", output, 62, 62)), + new ScriptSuite("ps.action-topology", "ActionTopologyImport.Tests.ps1", + output => EvidenceParser.Structured("ps.action-topology", "ActionTopologyImport", output, 16, 16)), + new ScriptSuite("ps.wire-targeting", "WireTargeting.Tests.ps1", + output => EvidenceParser.Structured("ps.wire-targeting", "WireTargeting", output, 56, 56)), + new ScriptSuite("ps.agent-command", "Invoke-AgentCommand.Tests.ps1", EvidenceParser.AgentCommand), + new HostTaskSuite("host.task-inventory", "inventory", 17), + new HostTaskSuite("host.task-dry-run", "dry-run", 4), + new HostTaskSuite("host.unknown-task", "unknown", 1), + new HostTaskSuite("host.foreign-fixture", "foreign-fixture", 1), + new HostTaskSuite("host.untracked-format", "untracked-format", 1), + new CaseScriptSuite("ui.source-contracts", "Tools\\windows\\ValidationRunner\\Ui\\UiSourceContracts.ps1", + "UI_CASE_RESULT=", required.Where(unit => unit.Id.StartsWith("ui.activation.source.", StringComparison.Ordinal) || + unit.Id.StartsWith("ui.popup.source.", StringComparison.Ordinal)).Select(unit => unit.Id).ToArray()) + ]; + + private sealed class ScriptSuite(string id, string script, Func parse) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, "Tools\\windows\\Tests\\" + script, [], cancellationToken); + return [parse(output)]; + } + } + + private sealed class HostTaskSuite(string id, string contract, int count) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, + "Tools\\windows\\ValidationRunner\\Adapters\\HostTasks.ps1", + ["-RepositoryRoot", context.RepositoryRoot, "-Contract", contract], cancellationToken); + return [EvidenceParser.Structured(id, "HostTasks." + contract, output, count, count)]; + } + } + + private sealed class CaptureSuite(RequiredUnit required) : IValidationSuite + { + public string Id => "ps.ui-capture"; + public IReadOnlyList UnitIds => [Id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + const string script = "Tools\\windows\\Tests\\UiCaptureDriver.Tests.ps1"; + var captureRoot = Path.Combine(context.ArtifactRoot, "capture-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(captureRoot); + var prebuilt = Path.Combine(captureRoot, "prebuilt"); + await PowerShellPayload.RunAsync(context, Id + ".prepare", script, + ["-PrepareFixturesOnly", "-ScratchRoot", prebuilt], cancellationToken); + var manifest = Path.Combine(prebuilt, "fixtures.json"); + var hash = Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(manifest, cancellationToken))); + var suiteRoot = Path.Combine(captureRoot, "suite"); + var output = await PowerShellPayload.RunAsync(context, Id, script, + ["-ScratchRoot", suiteRoot, "-PrebuiltFixtureManifest", manifest, "-PrebuiltFixtureManifestSha256", hash], + cancellationToken); + var result = EvidenceParser.Structured(Id, "UiCaptureDriver", output, 182, 182); + return [result with { Groups = EvidenceParser.CaptureGroups(Path.Combine(suiteRoot, "report.json"), + required.RequiredGroups ?? []) }]; + } + } + + public sealed class CaseScriptSuite(string id, string script, string prefix, IReadOnlyList unitIds) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => unitIds; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, script, + ["-RepositoryRoot", context.RepositoryRoot, "-ArtifactRoot", context.ArtifactRoot, + "-AssemblyPath", Assembly.GetExecutingAssembly().Location], cancellationToken); + var rows = output.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith(prefix, StringComparison.Ordinal)).Select(line => + JsonSerializer.Deserialize(line[prefix.Length..], Coverage.JsonOptions) ?? + throw new InvalidDataException($"{id}: null case evidence")).ToArray(); + if (rows.Length == 0) throw new InvalidDataException($"{id}: no case evidence"); + return rows; + } + } +} diff --git a/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj b/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj new file mode 100644 index 00000000..042fee71 --- /dev/null +++ b/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + latest + enable + true + false + + + + + + diff --git a/Tools/windows/ValidationRunner/Host/ConsoleReport.cs b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs new file mode 100644 index 00000000..9f5e4538 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Host; + +public static class ConsoleReport +{ + public static bool ValidId(string id) => + Regex.IsMatch(id, "^[a-z0-9][a-z0-9.-]{0,160}$", RegexOptions.CultureInvariant); + + public static string Unit(UnitResult result) => + $"{result.Status}: {(ValidId(result.Id) ? result.Id : "[invalid-id]")} executed={result.ExecutedCount}"; + + public static string Error(int index) => $"ERROR {index}: details retained in coverage.json"; +} diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs index 367dec7b..fc74ee35 100644 --- a/Tools/windows/ValidationRunner/Host/Coverage.cs +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.IO; using System.Text.Json.Serialization; namespace GraphCode.Validation.Host; @@ -55,7 +56,7 @@ public static CoverageReport Evaluate( if (required.Count == 0) errors.Add("Required-unit manifest is empty"); foreach (var unit in required) { - if (string.IsNullOrWhiteSpace(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || + if (!ConsoleReport.ValidId(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) errors.Add($"Invalid required unit: {unit.Id}"); if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); @@ -118,7 +119,11 @@ public static CoverageReport Evaluate( if (problems.Count != 0) { errors.Add($"{id}: {string.Join("; ", problems)}"); - ledger.Add(result with { Status = UnitStatus.Failed, Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" }); + ledger.Add(result with + { + Status = result.Status is UnitStatus.Incomplete or UnitStatus.Skipped ? result.Status : UnitStatus.Failed, + Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" + }); } else ledger.Add(result); } @@ -126,4 +131,10 @@ public static CoverageReport Evaluate( return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && passed == required.Count, required.Count, passed, ledger, errors); } + + public static void ValidateProvenance(IValidationSuite suite, IReadOnlyList results) + { + if (results.Any(result => !suite.UnitIds.Contains(result.Id, StringComparer.Ordinal))) + throw new InvalidDataException("A suite returned evidence for a unit it does not own"); + } } diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index f08c7cbb..62490758 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -1,3 +1,6 @@ +using GraphCode.Validation.Adapters; +using System.IO; + namespace GraphCode.Validation.Host; public static class HostSelfTests @@ -33,6 +36,8 @@ CoverageReport Evaluate(IReadOnlyList? units = null, Check("zero-run", () => Assert(!Evaluate(results: [passed with { ExecutedCount = 0 }]).Complete, "zero run admitted")); Check("skipped", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Skipped }]).Complete, "skip admitted")); Check("incomplete", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]).Complete, "incomplete admitted")); + Check("incomplete-status", () => Assert(Evaluate(results: [passed with { Status = UnitStatus.Incomplete }]) + .Units.Single().Status == UnitStatus.Incomplete, "incomplete status was disguised as a behavioral failure")); Check("failed", () => Assert(!Evaluate(results: [passed with { Status = UnitStatus.Failed }]).Complete, "failure admitted")); Check("unknown-result", () => Assert(!Evaluate(results: [passed, passed with { Id = "foreign" }]).Complete, "unknown result admitted")); Check("unknown-executor", () => Assert(!Evaluate(registry: [suite, new StaticSuite("foreign", ["foreign"])]).Complete, "unknown executor admitted")); @@ -53,6 +58,56 @@ CoverageReport Evaluate(IReadOnlyList? units = null, catch (DirectoryNotFoundException) { return; } throw new InvalidOperationException("missing manifests admitted"); }); + const string summary = """{"suite":"Contract","executed":2,"passed":2,"failed":0,"skipped":0}"""; + void RejectSummary(string value) + { + try { EvidenceParser.Structured("ps.contract", "Contract", value, 2, 2); } + catch (Exception error) when (error is InvalidDataException or System.Text.Json.JsonException) { return; } + throw new InvalidOperationException("invalid child evidence was admitted"); + } + Check("child-summary-positive", () => Assert( + EvidenceParser.Structured("ps.contract", "Contract", summary, 2, 2).ExecutedCount == 2, "wrong executed count")); + Check("child-summary-missing", () => RejectSummary("")); + Check("child-summary-duplicate", () => RejectSummary(summary + "\n" + summary)); + Check("child-summary-zero", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":0"))); + Check("child-summary-skipped", () => RejectSummary(summary.Replace("\"skipped\":0", "\"skipped\":1"))); + Check("child-summary-string-count", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":\"2\""))); + Check("child-summary-boolean-count", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":true"))); + Check("child-summary-partial", () => RejectSummary(summary.Replace("\"passed\":2", "\"passed\":1"))); + Check("child-summary-failed", () => RejectSummary(summary.Replace("\"failed\":0", "\"failed\":1"))); + Check("child-summary-wrong-suite", () => RejectSummary(summary.Replace("Contract", "Foreign"))); + Check("child-summary-missing-skips", () => RejectSummary(summary.Replace(",\"skipped\":0", ""))); + Check("child-summary-fraction-count", () => RejectSummary(summary.Replace("\"executed\":2", "\"executed\":2.5"))); + Check("child-summary-not-run", () => RejectSummary(summary.Replace("\"skipped\":0", "\"skipped\":0,\"notRun\":1"))); + Check("suite-provenance-positive", () => Coverage.ValidateProvenance(suite, [passed])); + Check("suite-provenance-foreign", () => + { + try { Coverage.ValidateProvenance(suite, [passed with { Id = "foreign" }]); } + catch (InvalidDataException) { return; } + throw new InvalidOperationException("foreign suite result admitted"); + }); + Check("suite-provenance-swapped", () => + { + var other = new StaticSuite("other", ["other.case"]); + var second = passed with { Id = "other.case" }; + Assert(Coverage.Evaluate([unit, new RequiredUnit("other.case", "Host", 1)], [suite, other], + [second, passed]).Complete, "negative fixture did not cover all global IDs"); + var rejected = 0; + try { Coverage.ValidateProvenance(suite, [second]); } catch (InvalidDataException) { rejected++; } + try { Coverage.ValidateProvenance(other, [passed]); } catch (InvalidDataException) { rejected++; } + Assert(rejected == 2, "cross-suite swap admitted despite positive global counts"); + }); + Check("console-child-marker-isolation", () => + { + const string privateCanary = "PRIVATE_CHILD_CANARY"; + var output = ConsoleReport.Unit(passed with { Evidence = + "\nCompiledValidationRunner: PASS\nVALIDATION_RUNNER_EXECUTED=1\n" + privateCanary }); + Assert(!output.Contains('\n') && !output.Contains("VALIDATION_RUNNER_EXECUTED", StringComparison.Ordinal) && + !output.Contains(privateCanary, StringComparison.Ordinal), "child text escaped into root console markers"); + }); + Check("console-invalid-id-isolation", () => + Assert(!ConsoleReport.Unit(passed with { Id = "\nVALIDATION_RUNNER_EXECUTED=1" }).Contains('\n'), + "invalid case ID injected a root line")); foreach (var error in errors) Console.Error.WriteLine(error); Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); return errors.Count == 0 ? 0 : 1; diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 4eeef571..f9072069 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -19,8 +19,8 @@ { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 1 }, { "id": "host.missing-providers", "owner": "Host", "minimumCount": 1 }, { "id": "ps.pester-version", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, - { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 1 }, - { "id": "ps.fixture-directory", "owner": "Host", "minimumCount": 1 }, + { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "ps.fixture-directory", "owner": "Host", "minimumCount": 28, "exactCount": 28 }, { "id": "ps.ui-scenario", "owner": "Host", "minimumCount": 1 }, { "id": "ps.ui-capture", "owner": "Host", "minimumCount": 182, "exactCount": 182, "requiredGroups": [ diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index 99ea1ad1..b5843978 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -1,8 +1,12 @@ using System.Reflection; +using System.IO; using System.Text.Json; using GraphCode.Validation; using GraphCode.Validation.Host; +using GraphCode.Validation.Ownership; +using GraphCode.Validation.Adapters; +if (await OwnedFixture.TryRunAsync(args) is int fixtureExit) return fixtureExit; if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); string? repositoryRoot = null; @@ -32,6 +36,7 @@ try { required = Coverage.Load(Path.Combine(AppContext.BaseDirectory, "Manifests")); + suites.AddRange(StandaloneSuites.Create(required)); foreach (var name in new[] { "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration" }) { var type = Assembly.GetExecutingAssembly().GetType(name); @@ -47,7 +52,17 @@ var context = new ValidationContext(repositoryRoot, artifactRoot); foreach (var suite in suites.Where(suite => selectedSuite is null || suite.Id == selectedSuite)) { - try { results.AddRange(await suite.RunAsync(context, CancellationToken.None)); } + try + { + var observations = await suite.RunAsync(context, CancellationToken.None); + Coverage.ValidateProvenance(suite, observations); + results.AddRange(observations); + if (observations.Any(result => result.Status == UnitStatus.Failed)) + { + errors.Add($"{suite.Id}: a contract failed; subsequent suites were not executed"); + break; + } + } catch (Exception error) { errors.Add($"{suite.Id}: {error}"); @@ -60,9 +75,9 @@ var reportPath = Path.Combine(artifactRoot, "coverage.json"); await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); foreach (var unit in report.Units) - Console.WriteLine($"{unit.Status}: {unit.Id} executed={unit.ExecutedCount} {unit.Evidence}"); -foreach (var error in report.Errors) Console.Error.WriteLine(error); -Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} complete={report.Complete} report={reportPath}"); + Console.WriteLine(ConsoleReport.Unit(unit)); +for (var index = 0; index < report.Errors.Count; index++) Console.Error.WriteLine(ConsoleReport.Error(index + 1)); +Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} complete={report.Complete} report={JsonSerializer.Serialize(reportPath)}"); if (!report.Complete) return 1; Console.WriteLine("CompiledValidationRunner: PASS"); Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); diff --git a/Tools/windows/ValidationRunner/ValidationRunner.csproj b/Tools/windows/ValidationRunner/ValidationRunner.csproj index bcbdd702..84b45f03 100644 --- a/Tools/windows/ValidationRunner/ValidationRunner.csproj +++ b/Tools/windows/ValidationRunner/ValidationRunner.csproj @@ -3,12 +3,17 @@ Exe net10.0-windows true + true enable enable true false + + + + From ef6b73da13c1ae12ef688d6e77e4d20dda5c2d9b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:49:45 -0700 Subject: [PATCH 09/46] Exercise shared owned cleanup authority with negative controls Run real supervisor teardown through the same coordinator as controlled fake authority cases. Preserve original primary objects, independently observe root/job/stream proofs, share one cleanup deadline, and continue later close actions after failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ownership.json | 9 ++ .../Ownership/OwnedChildSupervisor.cs | 97 +++++++------------ .../Ownership/OwnedCleanupCoordinator.cs | 80 +++++++++++++++ .../ValidationRunner/Ownership/README.md | 2 +- .../Ownership/SuiteRegistration.cs | 84 ++++++++++++++++ 5 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json index 74c863cc..a1a52fc9 100644 --- a/Tools/windows/ValidationRunner/Manifests/ownership.json +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -23,6 +23,15 @@ { "id": "ownership.managed.proof-job-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.proof-stdout-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.proof-stderr-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-success", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-root-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-job-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-stdout-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-both-stream-faults", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-primary-error", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-only-error", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-original-deadline", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-later-actions", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-nested-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs index 6e76c357..4badcb4f 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -300,81 +300,56 @@ public static async Task RequireDrainAsync(IReadOnlyList> streams, throw new AggregateException("OWNED_CAPTURE_DRAIN: independent readers did not all reach successful EOF.", failures); } - private async Task CleanupAsync(Exception? primary) + private Task CleanupAsync(Exception? primary) => + OwnedCleanupCoordinator.CompleteAsync(new NativeCleanupAuthority(this), primary, TimeSpan.FromSeconds(5)); + + private sealed class NativeCleanupAuthority(OwnedChildSupervisor owner) : IOwnedCleanupAuthority { - var limit = new Deadline(TimeSpan.FromSeconds(5)); - var failures = new List(); - bool rootExited = root == IntPtr.Zero, empty = job == IntPtr.Zero; - bool outEof = stdout == null, errEof = stderr == null, closed = false; - try + public string Identity => $"pid={owner.ProcessId}; creation={owner.creationFileTime}; job={owner.job}"; + public Task? StandardOutput => owner.stdout; + public Task? StandardError => owner.stderr; + public IReadOnlyList CloseActions => + [ + () => owner.stdoutReader?.Dispose(), () => owner.stderrReader?.Dispose(), + () => Close(ref owner.thread), + () => { if (owner.job != IntPtr.Zero) { StandaloneProcessJob.Close(owner.job); owner.job = IntPtr.Zero; } }, + () => Close(ref owner.root) + ]; + + public void Terminate() { - if (assigned) StandaloneProcessJob.Terminate(job); - else if (root != IntPtr.Zero) + if (owner.assigned) StandaloneProcessJob.Terminate(owner.job); + else if (owner.root != IntPtr.Zero) { - // The retained CreateProcess handle is still suspended behind the unreleased barrier. - if (resumed) throw new InvalidOperationException("Unassigned root was unexpectedly resumed."); - if (WaitForSingleObject(root, 0) != 0 && !TerminateProcess(root, 124)) + if (owner.resumed) throw new InvalidOperationException("Unassigned root was unexpectedly resumed."); + if (WaitForSingleObject(owner.root, 0) != 0 && !TerminateProcess(owner.root, 124)) throw new Win32Exception(); } } - catch (Exception failure) { failures.Add(failure); } - try - { - if (job != IntPtr.Zero) - { - StandaloneProcessJob.WaitForEmpty(job, limit); - empty = StandaloneProcessJob.Active(job) == 0; - } - } - catch (Exception failure) { failures.Add(failure); } - try - { - while (root != IntPtr.Zero && !RootExited()) - { - if (limit.Expired) throw new TimeoutException("Held root exit unproved."); - await Task.Delay(Math.Min(10, limit.RemainingMilliseconds)).ConfigureAwait(false); - } - rootExited = true; - } - catch (Exception failure) { failures.Add(failure); } - // Do not let a faulted stdout hide a pending/faulted stderr, or grant it a new timeout. - foreach (var stream in new[] { (Task: stdout, Name: "stdout"), (Task: stderr, Name: "stderr") }) + + public Task WaitForJobEmptyAsync(Deadline deadline) { - if (stream.Task == null) continue; - try - { - await stream.Task.WaitAsync(limit.Remaining).ConfigureAwait(false); - if (stream.Name == "stdout") outEof = true; else errEof = true; - } - catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } + if (owner.job == IntPtr.Zero) return Task.FromResult(true); + StandaloneProcessJob.WaitForEmpty(owner.job, deadline); + return Task.FromResult(StandaloneProcessJob.Active(owner.job) == 0); } - if (new OwnedCleanupProof(rootExited, empty, outEof, errEof, false).CanRelease) + + public async Task WaitForRootExitAsync(Deadline deadline) { - foreach (Action close in new Action[] + while (owner.root != IntPtr.Zero && !owner.RootExited()) { - () => stdoutReader?.Dispose(), () => stderrReader?.Dispose(), - () => Close(ref thread), - () => { if (job != IntPtr.Zero) { StandaloneProcessJob.Close(job); job = IntPtr.Zero; } }, - () => Close(ref root) - }) - { - try { close(); } - catch (Exception failure) { failures.Add(failure); } + if (deadline.Expired) throw new TimeoutException("Held root exit unproved."); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds)).ConfigureAwait(false); } - closed = failures.Count == 0; + return true; } - var proof = new OwnedCleanupProof(rootExited, empty, outEof, errEof, closed); - if (primary != null) primary.Data["OwnedCleanupProof"] = proof; - if (!closed || failures.Count != 0) + + public void Retain(Exception failure) { - retained.Add(this); - var cleanup = new AggregateException($"OWNED_CLEANUP_UNPROVEN: pid={ProcessId}; creation={creationFileTime}; job={job}; proof={proof}", failures); - OwnershipRetention.Tag(primary ?? cleanup, artifactRoot); - (primary ?? cleanup).Data["OwnedCleanupFailure"] = cleanup; - (primary ?? cleanup).Data["OwnedRetainedLease"] = this; - if (primary == null) throw cleanup; + retained.Add(owner); + OwnershipRetention.Tag(failure, owner.artifactRoot); + failure.Data["OwnedRetainedLease"] = owner; } - return proof; } private static void Close(ref IntPtr handle) diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs new file mode 100644 index 00000000..762794e1 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public interface IOwnedCleanupAuthority +{ + string Identity { get; } + Task? StandardOutput { get; } + Task? StandardError { get; } + void Terminate(); + Task WaitForJobEmptyAsync(Deadline deadline); + Task WaitForRootExitAsync(Deadline deadline); + IReadOnlyList CloseActions { get; } + void Retain(Exception failure); +} + +public static class OwnedCleanupCoordinator +{ + public static async Task CompleteAsync(IOwnedCleanupAuthority authority, Exception? primary, + TimeSpan timeout) + { + var deadline = new Deadline(timeout); + var failures = new List(); + bool rootExited = false, jobEmpty = false; + bool outEof = authority.StandardOutput == null, errEof = authority.StandardError == null; + try { authority.Terminate(); } + catch (Exception failure) { failures.Add(failure); } + try + { + jobEmpty = await authority.WaitForJobEmptyAsync(deadline).ConfigureAwait(false); + if (!jobEmpty) failures.Add(new InvalidOperationException("Owned job quiescence unproved.")); + } + catch (Exception failure) { failures.Add(failure); } + try + { + rootExited = await authority.WaitForRootExitAsync(deadline).ConfigureAwait(false); + if (!rootExited) failures.Add(new InvalidOperationException("Held root exit unproved.")); + } + catch (Exception failure) { failures.Add(failure); } + foreach (var stream in new[] + { + (Task: authority.StandardOutput, Name: "stdout"), + (Task: authority.StandardError, Name: "stderr") + }) + { + if (stream.Task == null) continue; + try + { + await stream.Task.WaitAsync(deadline.Remaining).ConfigureAwait(false); + if (stream.Name == "stdout") outEof = true; else errEof = true; + } + catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } + } + bool closed = false; + if (new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, false).CanRelease) + { + closed = true; + foreach (Action close in authority.CloseActions) + { + try { close(); } + catch (Exception failure) { failures.Add(failure); closed = false; } + } + } + var proof = new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, closed); + if (primary != null) primary.Data["OwnedCleanupProof"] = proof; + if (!closed || failures.Count != 0) + { + var cleanup = new AggregateException($"OWNED_CLEANUP_UNPROVEN: {authority.Identity}; proof={proof}", failures); + Exception reported = primary ?? cleanup; + reported.Data["OwnedCleanupProof"] = proof; + reported.Data["OwnedCleanupFailure"] = cleanup; + if (!closed) authority.Retain(reported); + if (primary == null) throw cleanup; + } + return proof; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md index 64d6b23e..3f648838 100644 --- a/Tools/windows/ValidationRunner/Ownership/README.md +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -35,7 +35,7 @@ and root-runner SHA-256 `202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869`. The old runner is never executed, embedded or extracted for execution. -The 59 managed and seven controlled-native cases are individually registered +The 68 managed and seven controlled-native cases are individually registered with exact-count manifest guards. The typed peer implements the complete request/response/publication/control core from `Stub-Daemon.ps1:29-294`; it is the implementation used by the compiled real-pipe fixture, not a passing fake. diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs index b5b3eddd..d6dfe2d5 100644 --- a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -29,6 +29,8 @@ public sealed class OwnershipSuite : IValidationSuite "receipt-valid", "receipt-existing", "receipt-missing", "receipt-file", "receipt-absent", "receipt-duplicate-json", "receipt-foreign-root", "receipt-over-limit", "proof-success", "proof-root-survivor", "proof-job-survivor", "proof-stdout-fault", "proof-stderr-fault", + "cleanup-success", "cleanup-root-survivor", "cleanup-job-survivor", "cleanup-stdout-fault", + "cleanup-both-stream-faults", "cleanup-primary-error", "cleanup-only-error", "cleanup-original-deadline", "cleanup-later-actions", "json-malformed", "json-duplicate", "json-nested-duplicate", "json-array", "json-case-sensitive", "frame-fragmented-unicode", "frame-clean-eof", "frame-short-header", "frame-short-body", "frame-zero", "frame-over-limit", "frame-invalid-utf8", "peer-positive-rename", "peer-resume-unchanged", "peer-foreign-owner", "peer-foreign-node", @@ -232,6 +234,51 @@ private static async Task ManagedAsync(string id, string root, string alpha, str Require(proof.CanRelease == (id == "proof-success"), "UNKNOWN proof allowed resource release."); return; } + if (id.StartsWith("cleanup-", StringComparison.Ordinal)) + { + var authority = new FakeCleanupAuthority + { + RootExited = id is not ("cleanup-root-survivor" or "cleanup-primary-error"), + JobEmpty = id != "cleanup-job-survivor", + CloseFails = id is "cleanup-only-error" or "cleanup-later-actions", + ConsumeDeadline = id == "cleanup-original-deadline" + }; + if (id is "cleanup-stdout-fault" or "cleanup-both-stream-faults") + authority.StandardOutput = Task.FromException(new IOException("original stdout")); + if (id == "cleanup-both-stream-faults") + authority.StandardError = Task.FromException(new IOException("original stderr")); + if (id == "cleanup-original-deadline") + authority.StandardOutput = new TaskCompletionSource().Task; + var primary = id == "cleanup-primary-error" ? new IOException("original primary") : null; + OwnedCleanupProof? proof = null; + if (id == "cleanup-success") + proof = await OwnedCleanupCoordinator.CompleteAsync(authority, null, TimeSpan.FromSeconds(1)).ConfigureAwait(false); + else if (primary != null) + { + proof = await OwnedCleanupCoordinator.CompleteAsync(authority, primary, TimeSpan.FromSeconds(1)).ConfigureAwait(false); + Require(ReferenceEquals(authority.RetainedError, primary) && primary.Message == "original primary" && + primary.Data["OwnedCleanupFailure"] is AggregateException, "Primary identity/secondary cleanup diagnostic changed."); + } + else + { + var failure = await RejectAsync(() => OwnedCleanupCoordinator.CompleteAsync(authority, null, + TimeSpan.FromMilliseconds(id == "cleanup-original-deadline" ? 100 : 1000))).ConfigureAwait(false); + proof = failure.Data["OwnedCleanupProof"] as OwnedCleanupProof; + Require(ReferenceEquals(authority.RetainedError, failure), "Cleanup-only failure not retained by exact identity."); + if (id == "cleanup-both-stream-faults") + Require(failure.InnerExceptions.Count == 2 && failure.InnerExceptions.All(e => e.InnerException is IOException), + "A failed stdout hid the independent stderr failure."); + } + Require(authority.Terminations == 1 && authority.JobWaits == 1 && authority.RootWaits == 1 && + ReferenceEquals(authority.JobDeadline, authority.RootDeadline), "Cleanup skipped a proof or refreshed its deadline."); + bool closable = id is "cleanup-success" or "cleanup-only-error" or "cleanup-later-actions"; + Require(authority.Closes == (closable ? 2 : 0), "UNKNOWN allowed close or a close error skipped a later action."); + Require(proof != null && proof.ResourcesClosed == (id == "cleanup-success") && + authority.Retained == (id != "cleanup-success"), "Cleanup resource/retention disposition changed."); + if (id == "cleanup-original-deadline") + Require(authority.RootRemainingMilliseconds < 50, "Root wait received a refreshed deadline after job wait."); + return; + } if (id.StartsWith("json-", StringComparison.Ordinal)) { string text = id switch @@ -514,4 +561,41 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } + + private sealed class FakeCleanupAuthority : IOwnedCleanupAuthority + { + public string Identity => "controlled-fake-held-authority; nativeCalls=0"; + public Task? StandardOutput { get; set; } = Task.FromResult("stdout"); + public Task? StandardError { get; set; } = Task.FromResult("stderr"); + public bool RootExited = true, JobEmpty = true, CloseFails, ConsumeDeadline, Retained; + public int Terminations, JobWaits, RootWaits, Closes, RootRemainingMilliseconds; + public Deadline? JobDeadline, RootDeadline; + public Exception? RetainedError; + public void Terminate() => Terminations++; + public async Task WaitForJobEmptyAsync(Deadline deadline) + { + JobWaits++; + JobDeadline = deadline; + if (ConsumeDeadline) await Task.Delay(75).ConfigureAwait(false); + return JobEmpty; + } + public Task WaitForRootExitAsync(Deadline deadline) + { + RootWaits++; + RootDeadline = deadline; + RootRemainingMilliseconds = deadline.RemainingMilliseconds; + return Task.FromResult(RootExited); + } + public IReadOnlyList CloseActions => + [ + () => { Closes++; if (CloseFails) throw new IOException("original first close failure"); }, + () => Closes++ + ]; + public void Retain(Exception failure) + { + Retained = true; + RetainedError = failure; + failure.Data[OwnershipRetention.RequiredKey] = true; + } + } } From 92280420853ebe8c96898ae9ef48efe275319a96 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:49:53 -0700 Subject: [PATCH 10/46] Normalize shared cleanup coordinator to LF Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Ownership/OwnedCleanupCoordinator.cs | 160 +++++++++--------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs index 762794e1..380e0bda 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs @@ -1,80 +1,80 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - -namespace GraphCode.Validation.Ownership; - -public interface IOwnedCleanupAuthority -{ - string Identity { get; } - Task? StandardOutput { get; } - Task? StandardError { get; } - void Terminate(); - Task WaitForJobEmptyAsync(Deadline deadline); - Task WaitForRootExitAsync(Deadline deadline); - IReadOnlyList CloseActions { get; } - void Retain(Exception failure); -} - -public static class OwnedCleanupCoordinator -{ - public static async Task CompleteAsync(IOwnedCleanupAuthority authority, Exception? primary, - TimeSpan timeout) - { - var deadline = new Deadline(timeout); - var failures = new List(); - bool rootExited = false, jobEmpty = false; - bool outEof = authority.StandardOutput == null, errEof = authority.StandardError == null; - try { authority.Terminate(); } - catch (Exception failure) { failures.Add(failure); } - try - { - jobEmpty = await authority.WaitForJobEmptyAsync(deadline).ConfigureAwait(false); - if (!jobEmpty) failures.Add(new InvalidOperationException("Owned job quiescence unproved.")); - } - catch (Exception failure) { failures.Add(failure); } - try - { - rootExited = await authority.WaitForRootExitAsync(deadline).ConfigureAwait(false); - if (!rootExited) failures.Add(new InvalidOperationException("Held root exit unproved.")); - } - catch (Exception failure) { failures.Add(failure); } - foreach (var stream in new[] - { - (Task: authority.StandardOutput, Name: "stdout"), - (Task: authority.StandardError, Name: "stderr") - }) - { - if (stream.Task == null) continue; - try - { - await stream.Task.WaitAsync(deadline.Remaining).ConfigureAwait(false); - if (stream.Name == "stdout") outEof = true; else errEof = true; - } - catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } - } - bool closed = false; - if (new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, false).CanRelease) - { - closed = true; - foreach (Action close in authority.CloseActions) - { - try { close(); } - catch (Exception failure) { failures.Add(failure); closed = false; } - } - } - var proof = new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, closed); - if (primary != null) primary.Data["OwnedCleanupProof"] = proof; - if (!closed || failures.Count != 0) - { - var cleanup = new AggregateException($"OWNED_CLEANUP_UNPROVEN: {authority.Identity}; proof={proof}", failures); - Exception reported = primary ?? cleanup; - reported.Data["OwnedCleanupProof"] = proof; - reported.Data["OwnedCleanupFailure"] = cleanup; - if (!closed) authority.Retain(reported); - if (primary == null) throw cleanup; - } - return proof; - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public interface IOwnedCleanupAuthority +{ + string Identity { get; } + Task? StandardOutput { get; } + Task? StandardError { get; } + void Terminate(); + Task WaitForJobEmptyAsync(Deadline deadline); + Task WaitForRootExitAsync(Deadline deadline); + IReadOnlyList CloseActions { get; } + void Retain(Exception failure); +} + +public static class OwnedCleanupCoordinator +{ + public static async Task CompleteAsync(IOwnedCleanupAuthority authority, Exception? primary, + TimeSpan timeout) + { + var deadline = new Deadline(timeout); + var failures = new List(); + bool rootExited = false, jobEmpty = false; + bool outEof = authority.StandardOutput == null, errEof = authority.StandardError == null; + try { authority.Terminate(); } + catch (Exception failure) { failures.Add(failure); } + try + { + jobEmpty = await authority.WaitForJobEmptyAsync(deadline).ConfigureAwait(false); + if (!jobEmpty) failures.Add(new InvalidOperationException("Owned job quiescence unproved.")); + } + catch (Exception failure) { failures.Add(failure); } + try + { + rootExited = await authority.WaitForRootExitAsync(deadline).ConfigureAwait(false); + if (!rootExited) failures.Add(new InvalidOperationException("Held root exit unproved.")); + } + catch (Exception failure) { failures.Add(failure); } + foreach (var stream in new[] + { + (Task: authority.StandardOutput, Name: "stdout"), + (Task: authority.StandardError, Name: "stderr") + }) + { + if (stream.Task == null) continue; + try + { + await stream.Task.WaitAsync(deadline.Remaining).ConfigureAwait(false); + if (stream.Name == "stdout") outEof = true; else errEof = true; + } + catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } + } + bool closed = false; + if (new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, false).CanRelease) + { + closed = true; + foreach (Action close in authority.CloseActions) + { + try { close(); } + catch (Exception failure) { failures.Add(failure); closed = false; } + } + } + var proof = new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, closed); + if (primary != null) primary.Data["OwnedCleanupProof"] = proof; + if (!closed || failures.Count != 0) + { + var cleanup = new AggregateException($"OWNED_CLEANUP_UNPROVEN: {authority.Identity}; proof={proof}", failures); + Exception reported = primary ?? cleanup; + reported.Data["OwnedCleanupProof"] = proof; + reported.Data["OwnedCleanupFailure"] = cleanup; + if (!closed) authority.Retain(reported); + if (primary == null) throw cleanup; + } + return proof; + } +} From 896bdbfd9a7c96276514819898fad26317279e02 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:52:58 -0700 Subject: [PATCH 11/46] Add supervised PowerShell UI contracts and bounded source assertion oracle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ui.json | 850 +- .../ValidationRunner/Ui/SuiteRegistration.cs | 3 +- .../ValidationRunner/Ui/UiAdapterCases.cs | 144 + .../ValidationRunner/Ui/UiDeleteContracts.ps1 | 289 + .../Ui/UiPowerShellContracts.ps1 | 239 + .../Ui/UiRootSourceContracts.ps1 | 147 + .../Ui/pending-source-assertions.json | 590 ++ .../Ui/source-assertions.json | 8951 +++++++++++++++++ .../ValidationRunner/Ui/source-case-map.json | 1489 ++- 9 files changed, 12539 insertions(+), 163 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs create mode 100644 Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ui/pending-source-assertions.json create mode 100644 Tools/windows/ValidationRunner/Ui/source-assertions.json diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index 42f3e0a9..a2ae751c 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -300,12 +300,6 @@ "minimumCount": 1, "requiredGroups": [] }, - { - "id": "ui.native", - "owner": "Ui", - "minimumCount": 1, - "requiredGroups": [] - }, { "id": "ui.native.lookup.named-direct-viewport", "owner": "Ui", @@ -859,13 +853,853 @@ "requiredGroups": [] }, { - "id": "ui.root.source-assertions", + "id": "ui.production.consumer-linkage", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { - "id": "ui.production.consumer-linkage", + "id": "ui.root.source.line-5342", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5350", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5369", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5528", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5545", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5572", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5584", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5973", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5979", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5987", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6062", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6097", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6100", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6165", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6171", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6199", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6210", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6215", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6221", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6226", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6231", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6246", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6250", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6257", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6278", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6285", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6339", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6345", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6355", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6417", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6426", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6430", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6436", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6440", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6446", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6454", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6462", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6472", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6514", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6520", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6526", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6542", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6551", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6677", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6680", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6773", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6788", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6801", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6909", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6974", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7042", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7348", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7351", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7355", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7365", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7381", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7387", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7396", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.unknown-operator", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.unknown-resource", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.missing-file", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.malformed-tree", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.actual-predicate-false", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.wrong-production-text", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.depth-boundary", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.evaluator.depth-overflow", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5366", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5380", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5392", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5396", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5399", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5407", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5410", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5432", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5449", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5457", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5462", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5470", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5477", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5485", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5501", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5514", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5524", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5537", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5553", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5561", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5569", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5580", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5632", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5659", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5683", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5696", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5713", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5724", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5739", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5752", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5761", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5773", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5783", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5792", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5807", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5812", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5897", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5905", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5914", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-5918", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6057", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6093", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6157", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6182", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6276", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6292", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6298", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6305", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6314", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6326", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6352", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6412", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6467", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6591", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6654", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6754", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6770", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6853", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6864", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6866", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6870", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6877", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6892", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6905", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6921", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6925", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6939", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6958", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-6967", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7027", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7136", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7235", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7326", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7337", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.root.source.line-7344", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index 7d4b34b0..21ae6744 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -10,7 +11,7 @@ public static class SuiteRegistration public static IReadOnlyList CreateSuites() => new IValidationSuite[] { new ActivationSuite(), new ManagedUiSuite(), new NativeUiSuite() - }; + }.Concat(UiAdapterCases.CreateSuites()).ToArray(); } public sealed class ActivationSuite : IValidationSuite diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs new file mode 100644 index 00000000..0d2eab77 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using GraphCode.Validation.Adapters; + +namespace GraphCode.Validation.Ui; + +internal static class UiAdapterCases +{ + internal static readonly string[] PowerShellIds = + { + "ui.mock.isolation.absent", + "ui.mock.isolation.empty", + "ui.mock.isolation.valued", + "ui.mock.isolation.failure-identity", + "ui.mock.isolation.ordinary-diagnostic", + "ui.recovery.visibility.initially-empty", + "ui.recovery.visibility.initially-offscreen", + "ui.recovery.visibility.already-visible", + "ui.recovery.visibility.mark-only-empty", + "ui.recovery.visibility.delayed-visible", + "ui.recovery.visibility.outside-before-fit", + "ui.recovery.visibility.wrong-source-owner", + "ui.recovery.visibility.wrong-fit-owner", + "ui.recovery.visibility.fit-offscreen", + "ui.recovery.visibility.fit-disabled", + "ui.recovery.visibility.remains-empty", + "ui.recovery.visibility.remains-offscreen", + "ui.recovery.visibility.wrong-id-after-fit", + "ui.recovery.visibility.wrong-owner-after-fit", + "ui.recovery.visibility.outside-after-fit", + "ui.recovery.visibility.missing-after-fit", + "ui.recovery.visibility.mutant-skip-fit", + "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", + "ui.recovery.visibility.mutant-wrong-identity-accepted", + "ui.recovery.visibility.mutant-outside-bounds-accepted" + }; + internal static readonly string[] DeleteIds = + { + "ui.delete.visibility.actual", + "ui.delete.visibility.missing-fit", + "ui.delete.visibility.wrong-identity", + "ui.delete.visibility.missing-offscreen", + "ui.delete.visibility.ungated-bounds", + "ui.delete.visibility.escaped-bounds", + "ui.delete.visibility.stale-visibility", + "ui.delete.visibility.missing-containment", + "ui.delete.visibility.missing-require", + "ui.delete.select.rejection", + "ui.delete.select.success", + "ui.delete.select.unavailable", + "ui.delete.select.foreign-pid", + "ui.delete.select.identity-drift", + "ui.delete.select.zero-bounds", + "ui.delete.select.offscreen", + "ui.delete.select.disabled", + "ui.delete.select.selected", + "ui.delete.select.runtime-unavailable", + "ui.delete.select.parent-unavailable", + "ui.delete.select.telemetry-write-failure", + "ui.delete.select.receipt-exists", + "ui.delete.select.receipt-unconfigured", + "ui.delete.select.deadline", + "ui.delete.select.long-identity", + "ui.delete.select.long-runtime", + "ui.delete.select.invalid-boolean", + "ui.delete.select.nonfinite-bounds", + "ui.delete.select.exception-chain", + "ui.delete.select.foreign-parent" + }; + internal static readonly string[] RootSourceIds = + { + "ui.root.source.line-5342", + "ui.root.source.line-5350", + "ui.root.source.line-5369", + "ui.root.source.line-5528", + "ui.root.source.line-5545", + "ui.root.source.line-5572", + "ui.root.source.line-5584", + "ui.root.source.line-5973", + "ui.root.source.line-5979", + "ui.root.source.line-5987", + "ui.root.source.line-6062", + "ui.root.source.line-6097", + "ui.root.source.line-6100", + "ui.root.source.line-6165", + "ui.root.source.line-6171", + "ui.root.source.line-6199", + "ui.root.source.line-6210", + "ui.root.source.line-6215", + "ui.root.source.line-6221", + "ui.root.source.line-6226", + "ui.root.source.line-6231", + "ui.root.source.line-6246", + "ui.root.source.line-6250", + "ui.root.source.line-6257", + "ui.root.source.line-6278", + "ui.root.source.line-6285", + "ui.root.source.line-6339", + "ui.root.source.line-6345", + "ui.root.source.line-6355", + "ui.root.source.line-6417", + "ui.root.source.line-6426", + "ui.root.source.line-6430", + "ui.root.source.line-6436", + "ui.root.source.line-6440", + "ui.root.source.line-6446", + "ui.root.source.line-6454", + "ui.root.source.line-6462", + "ui.root.source.line-6472", + "ui.root.source.line-6514", + "ui.root.source.line-6520", + "ui.root.source.line-6526", + "ui.root.source.line-6542", + "ui.root.source.line-6551", + "ui.root.source.line-6677", + "ui.root.source.line-6680", + "ui.root.source.line-6773", + "ui.root.source.line-6788", + "ui.root.source.line-6801", + "ui.root.source.line-6909", + "ui.root.source.line-6974", + "ui.root.source.line-7042", + "ui.root.source.line-7348", + "ui.root.source.line-7351", + "ui.root.source.line-7355", + "ui.root.source.line-7365", + "ui.root.source.line-7381", + "ui.root.source.line-7387", + "ui.root.source.line-7396", + "ui.root.evaluator.unknown-operator", + "ui.root.evaluator.unknown-resource", + "ui.root.evaluator.missing-file", + "ui.root.evaluator.malformed-tree", + "ui.root.evaluator.actual-predicate-false", + "ui.root.evaluator.wrong-production-text", + "ui.root.evaluator.depth-boundary", + "ui.root.evaluator.depth-overflow" + }; + internal static IReadOnlyList CreateSuites() => new IValidationSuite[] + { + new StandaloneSuites.CaseScriptSuite("ui.powershell-contracts", @"Tools\windows\ValidationRunner\Ui\UiPowerShellContracts.ps1", "UI_CASE_RESULT=", PowerShellIds), + new StandaloneSuites.CaseScriptSuite("ui.delete-contracts", @"Tools\windows\ValidationRunner\Ui\UiDeleteContracts.ps1", "UI_CASE_RESULT=", DeleteIds), + new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds) + }; +} diff --git a/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 new file mode 100644 index 00000000..dc44ed1a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 @@ -0,0 +1,289 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +if ($errors.Count) { throw 'Delete Select source does not parse' } +$results = [Collections.Generic.List[object]]::new() +function Assert-Delete([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new("Delete Select contract: $Message") } +} +function Invoke-DeleteCase([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Production PS AST/scope/ScriptProperty/ErrorRecord and receipt contract; nativeCalls=0' }) + }) +} +function Confirm-DeleteVisibility([string] $Text) { + $start = $Text.IndexOf('$deleteLoopTitle =', [StringComparison]::Ordinal) + $end = if ($start -ge 0) { + $Text.IndexOf('$deleteTarget = Find-FragmentByIdWithRetry $root $renameGraphIdentity $rawWalker', $start, [StringComparison]::Ordinal) + } else { -1 } + if ($start -lt 0 -or $end -le $start) { return $false } + $region = $Text.Substring($start, $end - $start) + $normalized = [regex]::Replace($region, '\s+', ' ') + $offset = 0 + foreach ($required in @( + '$fitDeleteCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker', + '$fitDeleteCanvas.Current.ProcessId -eq $process.Id', + '$fitDeleteCanvas.Current.Name -ceq "Fit canvas"', + '$fitDeleteCanvas.GetCurrentPattern( [System.Windows.Automation.InvokePattern]::Pattern ).Invoke()', + '$root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker)', + 'for ($deleteVisibilityAttempt = 0; $deleteVisibilityAttempt -lt 40; $deleteVisibilityAttempt++)', + '$deleteTargetVisible = $false', + '$deleteCandidate = Find-FragmentById $root $renameGraphIdentity $rawWalker', + '$deleteGraph = Find-FragmentById $root "graph" $rawWalker', + '$deleteCandidate.Current.ProcessId -eq $process.Id', + '$deleteCandidate.Current.AutomationId -ceq $renameGraphIdentity', + '$deleteGraph.Current.ProcessId -eq $process.Id', + '$deleteTargetOffscreen = $deleteCandidate.Current.IsOffscreen', + 'if (-not $deleteTargetOffscreen) {', + '$deleteTargetBounds = $deleteCandidate.Current.BoundingRectangle', + '$deleteGraphBounds = $deleteGraph.Current.BoundingRectangle', + '$deleteTargetVisible = -not $deleteTargetOffscreen -and', + '-not $deleteTargetBounds.IsEmpty -and', + '$deleteTargetBounds.Width -gt 0 -and $deleteTargetBounds.Height -gt 0 -and', + '-not $deleteGraphBounds.IsEmpty -and', + '$deleteTargetBounds.Left -ge $deleteGraphBounds.Left -and', + '$deleteTargetBounds.Top -ge $deleteGraphBounds.Top -and', + '$deleteTargetBounds.Right -le $deleteGraphBounds.Right -and', + '$deleteTargetBounds.Bottom -le $deleteGraphBounds.Bottom', + 'if ($deleteTargetVisible) { break }', + 'Start-Sleep -Milliseconds 100', + '$root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker)', + 'Require ($deleteTargetVisible)', + 'UIA_DELETE_TARGET_VISIBLE=' + )) { + $next = $normalized.IndexOf($required, $offset, [StringComparison]::Ordinal) + if ($next -lt 0) { return $false } + $offset = $next + $required.Length + } + $parseErrors = $null + $regionAst = [Management.Automation.Language.Parser]::ParseInput($region, [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count) { return $false } + $guards = @($regionAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and $node.Clauses.Count -eq 1 -and + [regex]::Replace($node.Clauses[0].Item1.Extent.Text, '[\s()]', '') -ceq '-not$deleteTargetOffscreen' + }, $true)) + if ($guards.Count -ne 1) { return $false } + $guard = $guards[0].Clauses[0].Item2 + foreach ($assignment in @( + '$deleteTargetBounds = $deleteCandidate.Current.BoundingRectangle', + '$deleteGraphBounds = $deleteGraph.Current.BoundingRectangle' + )) { + $assignments = @($regionAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Extent.Text -ceq $assignment + }, $true)) + if ($assignments.Count -ne 1 -or $assignments[0].Extent.StartOffset -lt $guard.Extent.StartOffset -or + $assignments[0].Extent.EndOffset -gt $guard.Extent.EndOffset) { return $false } + } + return $true +} +$visibilityCases = @( + @{ name = 'actual'; text = $source; accepted = $true }, + @{ name = 'missing-fit'; text = $source.Replace('$fitDeleteCanvas.GetCurrentPattern(', '$unusedFit.GetCurrentPattern('); accepted = $false }, + @{ name = 'wrong-identity'; text = $source.Replace('$deleteCandidate = Find-FragmentById $root $renameGraphIdentity $rawWalker', '$deleteCandidate = Find-FragmentById $root "other-card" $rawWalker'); accepted = $false }, + @{ name = 'missing-offscreen'; text = $source.Replace('$deleteTargetVisible = -not $deleteTargetOffscreen -and', '$deleteTargetVisible = $true -and'); accepted = $false }, + @{ name = 'ungated-bounds'; text = $source.Replace('if (-not $deleteTargetOffscreen) {', 'if ($true) {'); accepted = $false }, + @{ name = 'escaped-bounds'; text = $source.Replace('if (-not $deleteTargetOffscreen) {', 'if (-not $deleteTargetOffscreen) { } ; if ($true) {'); accepted = $false }, + @{ name = 'stale-visibility'; text = [regex]::new('(\$deleteVisibilityAttempt\+\+\)\s*\{\s*)\$deleteTargetVisible = \$false').Replace($source, '$1', 1); accepted = $false }, + @{ name = 'missing-containment'; text = $source.Replace('$deleteTargetBounds.Right -le $deleteGraphBounds.Right -and', '$true -and'); accepted = $false }, + @{ name = 'missing-require'; text = $source.Replace('Require ($deleteTargetVisible)', '$null = ($deleteTargetVisible)'); accepted = $false } +) +foreach ($case in $visibilityCases) { + Invoke-DeleteCase "ui.delete.visibility.$($case.name)" { + Assert-Delete ((Confirm-DeleteVisibility $case.text) -eq $case.accepted) ` + "$($case.name) must prove the same owned card visible inside graph bounds before Select reacquisition" + } +} + +$definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -cin @('Get-DeleteTargetDiagnosticValue', 'Get-DeleteTargetIdentityHash', 'Write-DeleteTargetSelectFailure') +}, $true)) +Assert-Delete ($definitions.Count -eq 3) 'Expected all three actual diagnostic helpers' +foreach ($definition in $definitions) { + Assert-Delete ($definition.Extent.Text -notmatch 'FromHandle|Add-Type|Start-Process|New-Item|FindAll|FindFirst|System\.Windows|GraphCodeUiaGateState') ` + 'Diagnostic helper acquired native or UIA capability' + . ([scriptblock]::Create($definition.Extent.Text)) +} +$selects = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Extent.Text -ceq '$deleteTargetSelection.Select()' +}, $true)) +Assert-Delete ($selects.Count -eq 1) 'Expected one actual Select call' +$statement = $selects[0].Parent +while ($statement.Parent -isnot [Management.Automation.Language.StatementBlockAst] -and + $statement.Parent -isnot [Management.Automation.Language.NamedBlockAst]) { $statement = $statement.Parent } +if ($statement.Parent.Parent -is [Management.Automation.Language.TryStatementAst] -and $statement.Parent.Statements.Count -eq 1) { + $statement = $statement.Parent.Parent +} +$expectedWrapper = @' +try { + $deleteTargetSelection.Select() + } catch { + try { + try { + Write-DeleteTargetSelectFailure $deleteTarget $deleteTargetSelection $renameGraphIdentity ` + $process.Id $shellWindow $_ $DeleteSelectFailureReceipt + } catch { + Write-Host ("UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=hresult:{0}" -f $_.Exception.HResult) + } + } finally { + throw + } + } +'@ +Assert-Delete ($statement.Extent.Text.Replace("`r`n", "`n") -ceq $expectedWrapper) 'Unsafe production Select wrapper' +$invokeSelect = [scriptblock]::Create($statement.Extent.Text) +function Assert-MockBindings($Target, $Selection, [hashtable] $State) { + foreach ($value in @($Target, $Selection)) { + Assert-Delete ($value.GetType() -eq [Management.Automation.PSCustomObject] -and + $value.PSObject.Properties['Current'] -is [Management.Automation.PSScriptProperty] -and + $value.PSObject.Properties['State'] -is [Management.Automation.PSNoteProperty] -and + [object]::ReferenceEquals($value.PSObject.Properties['State'].Value, $State)) 'Unbound mock Current or State' + } + Assert-Delete ($Target.PSObject.Methods['GetRuntimeId'] -is [Management.Automation.PSScriptMethod] -and + $Selection.PSObject.Methods['Select'] -is [Management.Automation.PSScriptMethod]) 'Non-script mock method' +} +$selectCases = @('rejection', 'success', 'unavailable', 'foreign-pid', 'identity-drift', + 'zero-bounds', 'offscreen', 'disabled', 'selected', 'runtime-unavailable', 'parent-unavailable', + 'telemetry-write-failure', 'receipt-exists', 'receipt-unconfigured', 'deadline', 'long-identity', + 'long-runtime', 'invalid-boolean', 'nonfinite-bounds', 'exception-chain', 'foreign-parent') +foreach ($case in $selectCases) { + Invoke-DeleteCase "ui.delete.select.$case" { + $state = @{ case = $case; selects = 0; reads = 0; original = [InvalidOperationException]::new('PRIVATE_EXCEPTION_SENTINEL') } + if ($case -ceq 'exception-chain') { + for ($index = 0; $index -lt 6; $index++) { + $state.original = [InvalidOperationException]::new('PRIVATE_CHAIN_SENTINEL', $state.original) + } + } + $deleteTarget = [pscustomobject]@{ State = $state } + $deleteTarget | Add-Member ScriptProperty Current { + $this.State.reads++ + if ($this.State.case -ceq 'unavailable') { throw 'PRIVATE_PROPERTY_SENTINEL' } + [pscustomobject]@{ + AutomationId = $(if ($this.State.case -ceq 'identity-drift') { 'PRIVATE_OTHER_ID' } + elseif ($this.State.case -ceq 'long-identity') { 'x' * 4097 } else { 'PRIVATE_TARGET_ID' }) + ProcessId = $(if ($this.State.case -ceq 'foreign-pid') { 7777 } else { 4242 }) + BoundingRectangle = [pscustomobject]@{ + Left = $(if ($this.State.case -ceq 'nonfinite-bounds') { [double]::NaN } else { 10.0 }); Top = 20.0 + Width = $(if ($this.State.case -ceq 'zero-bounds') { 0.0 } else { 30.0 }) + Height = $(if ($this.State.case -ceq 'zero-bounds') { 0.0 } else { 40.0 }) + } + IsOffscreen = ($this.State.case -ceq 'offscreen') + IsEnabled = $(if ($this.State.case -ceq 'invalid-boolean') { 'PRIVATE_BOOLEAN' } else { $this.State.case -cne 'disabled' }) + Name = 'PRIVATE_NAME_SENTINEL'; Value = 'PRIVATE_VALUE_SENTINEL' + } + } + $deleteTarget | Add-Member ScriptMethod GetRuntimeId { + $this.State.reads++ + if ($this.State.case -cin @('unavailable', 'runtime-unavailable')) { throw 'PRIVATE_RUNTIME_SENTINEL' } + if ($this.State.case -ceq 'deadline') { [Threading.Thread]::Sleep(550) } + if ($this.State.case -ceq 'long-runtime') { return [int[]](1..65) } + return [int[]]@(42, 7, 9) + } + $deleteTargetSelection = [pscustomobject]@{ State = $state } + $deleteTargetSelection | Add-Member ScriptMethod Select { + $this.State.selects++ + if ($this.State.case -cne 'success') { throw $this.State.original } + } + $deleteTargetSelection | Add-Member ScriptProperty Current { + $this.State.reads++ + if ($this.State.case -ceq 'unavailable') { throw 'PRIVATE_PATTERN_SENTINEL' } + $container = [pscustomobject]@{ State = $this.State } + $container | Add-Member ScriptProperty Current { + $this.State.reads++ + if ($this.State.case -ceq 'parent-unavailable') { throw 'PRIVATE_PARENT_SENTINEL' } + [pscustomobject]@{ + ProcessId = $(if ($this.State.case -ceq 'foreign-parent') { 7777 } else { 4242 }) + ControlType = [pscustomobject]@{ Id = 50033 }; ClassName = 'PRIVATE_CLASS_SENTINEL'; Name = 'PRIVATE_PARENT_SENTINEL' + } + } + [pscustomobject]@{ IsSelected = ($this.State.case -ceq 'selected'); SelectionContainer = $container } + } + $process = [pscustomobject]@{ Id = 4242 } + $shellWindow = [IntPtr]42 + $renameGraphIdentity = 'PRIVATE_TARGET_ID' + $ownedDirectory = Join-Path $ArtifactRoot ("delete-select-" + [guid]::NewGuid().ToString('N')) + $null = [IO.Directory]::CreateDirectory($ownedDirectory) + try { + $directory = if ($case -ceq 'telemetry-write-failure') { Join-Path $ownedDirectory 'absent' } else { $ownedDirectory } + $DeleteSelectFailureReceipt = Join-Path $directory 'delete-target-select-failure.json' + if ($case -ceq 'receipt-exists') { [IO.File]::WriteAllText($DeleteSelectFailureReceipt, 'preserved') } + if ($case -ceq 'receipt-unconfigured') { $DeleteSelectFailureReceipt = '' } + Assert-MockBindings $deleteTarget $deleteTargetSelection $state + $original = $null + try { $deleteTargetSelection.Select() } catch { $original = $_ } + $state.selects = 0 + Assert-MockBindings $deleteTarget $deleteTargetSelection $state + $output = @(& { try { & $invokeSelect } catch { $_ } } 6>&1) + $failures = @($output | Where-Object { $_ -is [Management.Automation.ErrorRecord] }) + $logs = @($output | Where-Object { $_ -is [Management.Automation.InformationRecord] }) + Assert-Delete ($state.selects -eq 1) "$case changed Select count" + if ($case -ceq 'success') { + Assert-Delete ($output.Count -eq 0 -and $state.reads -eq 0) 'Success emitted telemetry or sampled properties' + } else { + Assert-Delete ($failures.Count -eq 1 -and $failures[0].Exception.GetType() -eq $original.Exception.GetType() -and + $failures[0].Exception.Message -ceq $original.Exception.Message -and + $failures[0].Exception.HResult -eq $original.Exception.HResult -and + $failures[0].FullyQualifiedErrorId -ceq $original.FullyQualifiedErrorId -and + [object]::ReferenceEquals($failures[0].Exception.GetBaseException(), $state.original.GetBaseException())) ` + "$case changed the original exception" + $receiptLogs = @($logs | Where-Object { ([string]$_.MessageData).StartsWith('UIA_DELETE_TARGET_SELECT_FAILURE=') }) + Assert-Delete ($receiptLogs.Count -eq 1) "$case missing failure-only receipt" + $json = ([string]$receiptLogs[0].MessageData).Substring('UIA_DELETE_TARGET_SELECT_FAILURE='.Length) + Assert-Delete (-not $json.Contains('PRIVATE_')) "$case leaked private values" + $receipt = $json | ConvertFrom-Json + Assert-Delete ($receipt.expectedProcessId -eq 4242 -and $receipt.expectedWindow -eq 42) "$case lost owned context" + Assert-Delete ($state.reads -gt 0 -and $state.reads -le 16) "$case exceeded bounded reads" + switch -CaseSensitive ($case) { + unavailable { Assert-Delete ($receipt.processId.state -ceq 'unavailable' -and $receipt.bounds.state -ceq 'unavailable' -and $receipt.selected.state -ceq 'unavailable') 'Unavailable became success' } + foreign-pid { Assert-Delete ($receipt.processId.value -eq 7777 -and -not $receipt.ownedProcess) 'Foreign PID lost' } + identity-drift { Assert-Delete (-not $receipt.identityMatches) 'Identity drift lost' } + runtime-unavailable { Assert-Delete ($receipt.runtimeIdHash.state -ceq 'unavailable') 'Runtime unavailability lost' } + parent-unavailable { Assert-Delete ($receipt.parent.processId.state -ceq 'unavailable') 'Parent unavailability lost' } + zero-bounds { Assert-Delete ($receipt.bounds.value.width -eq 0 -and $receipt.bounds.value.height -eq 0) 'Zero bounds lost' } + offscreen { Assert-Delete ($receipt.offscreen.value -eq $true) 'Offscreen lost' } + disabled { Assert-Delete ($receipt.enabled.value -eq $false) 'Disabled lost' } + selected { Assert-Delete ($receipt.selected.value -eq $true) 'Selected state lost' } + deadline { Assert-Delete ($receipt.runtimeIdHash.state -ceq 'unavailable' -and $receipt.bounds.reason -ceq 'diagnostic-budget' -and $receipt.readCount -eq 3) 'Deadline allowed more reads' } + long-identity { Assert-Delete ($receipt.automationIdHash.state -ceq 'unavailable' -and $receipt.readCount -eq 2) 'Long identity allowed dependent reads' } + long-runtime { Assert-Delete ($receipt.runtimeIdHash.state -ceq 'unavailable') 'Long runtime accepted' } + invalid-boolean { Assert-Delete ($receipt.enabled.state -ceq 'unavailable') 'Invalid boolean accepted' } + nonfinite-bounds { Assert-Delete ($receipt.bounds.state -ceq 'unavailable') 'Nonfinite bounds accepted' } + exception-chain { Assert-Delete ($receipt.exceptionChainTruncated -and $receipt.exceptionHResults.Count -eq 4) 'Exception chain not capped' } + foreign-parent { Assert-Delete ($receipt.parent.processId.value -eq 7777 -and $receipt.parent.controlTypeId.reason -ceq 'untrusted-identity' -and $receipt.readCount -eq 9) 'Foreign parent allowed dependent reads' } + } + Assert-Delete ($receipt.readCount -le 11 -and $receipt.readLimit -eq 11 -and $receipt.budgetMilliseconds -eq 500 -and + $json.Length -le 8192 -and $receipt.exceptionHResults.Count -ge 1 -and $receipt.exceptionHResults.Count -le 4) "$case lost explicit bounds" + if ($case -cin @('foreign-pid', 'identity-drift', 'unavailable')) { + Assert-Delete ($receipt.bounds.reason -ceq 'untrusted-identity' -and $receipt.readCount -le 2) "$case sampled dependent state" + } + foreach ($unavailable in @('retired', 'active', 'invokable', 'providerBranch')) { + Assert-Delete ($receipt.$unavailable.state -ceq 'unavailable') 'Private provider state inferred' + } + $path = Join-Path $directory 'delete-target-select-failure.json' + if ($case -cin @('telemetry-write-failure', 'receipt-exists', 'receipt-unconfigured')) { + Assert-Delete (@($logs | Where-Object { ([string]$_.MessageData).StartsWith('UIA_DELETE_TARGET_SELECT_TELEMETRY_FAILURE=') }).Count -eq 1) 'Receipt write failure hidden' + if ($case -ceq 'receipt-exists') { Assert-Delete ([IO.File]::ReadAllText($path) -ceq 'preserved') 'Existing receipt overwritten' } + } else { Assert-Delete ([IO.File]::ReadAllText($path) -ceq $json) 'Raw receipt differs' } + } + } finally { [IO.Directory]::Delete($ownedDirectory, $true) } + } +} +Assert-Delete ($visibilityCases.Count -eq 9 -and $selectCases.Count -eq 21 -and $results.Count -eq 30) 'Wrong positive case count' +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-delete-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 new file mode 100644 index 00000000..6d3b2e70 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 @@ -0,0 +1,239 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +& (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +if ($errors.Count -ne 0) { throw 'Actual production UIA gate does not parse' } +$results = [Collections.Generic.List[object]]::new() + +function Assert-UiContract([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-UiCase([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Real production PowerShell functions with deliberate PSCustomObject fixture; nativeCalls=0' }) + }) +} +function Get-ProductionDefinition([string] $Name) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-UiContract ($definitions.Count -eq 1) "Expected one production function: $Name" + return $definitions[0].Extent.Text +} +function Invoke-IsolatedUiMock([scriptblock] $Action) { + $name = 'GRAPHCODE_VALIDATION_ROOT' + $before = [Environment]::GetEnvironmentVariables('Process') + try { + if ($before.Contains($name)) { Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop } + & $Action 6>&1 | ForEach-Object { + if ([string]$_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') { + Write-Host 'UIA_MOCK_RETENTION_SIGNAL=observed' + } elseif ($_ -is [Management.Automation.InformationRecord]) { + Write-Host $_.MessageData + } else { $_ } + } + } finally { + if ($before.Contains($name)) { [Environment]::SetEnvironmentVariable($name, $before[$name], 'Process') } + elseif (Test-Path -LiteralPath "Env:\$name") { Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop } + } +} +$originalEnvironment = [Environment]::GetEnvironmentVariables('Process') +try { + foreach ($membership in @('absent', 'empty', 'valued')) { + Invoke-UiCase "ui.mock.isolation.$membership" { + if ($membership -ceq 'absent') { + if (Test-Path Env:\GRAPHCODE_VALIDATION_ROOT) { Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT } + } else { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', + $(if ($membership -ceq 'empty') { '' } else { $ArtifactRoot }), 'Process') + } + $before = [Environment]::GetEnvironmentVariables('Process') + $messages = @(Invoke-IsolatedUiMock { + Assert-UiContract (-not [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ROOT')) ` + 'Mock retained enclosing validation-root authority' + Write-Host 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' + Write-Output 'mock-case-passed' + } 6>&1 | ForEach-Object { [string]$_ }) + $after = [Environment]::GetEnvironmentVariables('Process') + Assert-UiContract ($messages -notcontains 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' -and + $messages -contains 'UIA_MOCK_RETENTION_SIGNAL=observed' -and $messages -contains 'mock-case-passed' -and + $before.Contains('GRAPHCODE_VALIDATION_ROOT') -eq $after.Contains('GRAPHCODE_VALIDATION_ROOT') -and + (-not $before.Contains('GRAPHCODE_VALIDATION_ROOT') -or + [string]$before['GRAPHCODE_VALIDATION_ROOT'] -ceq [string]$after['GRAPHCODE_VALIDATION_ROOT']) -and + -not (Test-Path -LiteralPath (Join-Path $ArtifactRoot 'ownership-unproven.json'))) ` + "UIA mock fixture leaked retention intent or changed environment membership: $membership" + } + } + Invoke-UiCase 'ui.mock.isolation.failure-identity' { + $expected = [IO.IOException]::new('real mock assertion failure') + $failure = $null + try { Invoke-IsolatedUiMock { throw $expected } } catch { $failure = $_ } + Assert-UiContract ($failure -and [object]::ReferenceEquals($failure.Exception, $expected)) ` + 'UIA mock fixture swallowed or replaced a genuine assertion failure' + } + Invoke-UiCase 'ui.mock.isolation.ordinary-diagnostic' { + $text = 'prefix GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' + $output = @(Invoke-IsolatedUiMock { Write-Output $text }) + Assert-UiContract ($output.Count -eq 1 -and $output[0] -ceq $text) 'UIA fixture filtered ordinary diagnostic output' + } +} finally { + if ($originalEnvironment.Contains('GRAPHCODE_VALIDATION_ROOT')) { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', $originalEnvironment['GRAPHCODE_VALIDATION_ROOT'], 'Process') + } elseif (Test-Path Env:\GRAPHCODE_VALIDATION_ROOT) { Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT } +} + +$recoveryDefinition = Get-ProductionDefinition 'Wait-UiaUnwiredRecoveryVisibility' +$marker = $source.IndexOf(' $unwiredRecoveryChildren = @(Get-DirectChildren $graph $rawWalker)', [StringComparison]::Ordinal) +Assert-UiContract ($marker -ge 0) 'Recovery visibility region missing' +$regionStart = $source.LastIndexOf(' $graph = Find-FragmentByIdWithRetry $root "graph" $rawWalker', $marker, [StringComparison]::Ordinal) +$regionEnd = $source.IndexOf(' $wireAutomationId =', $marker, [StringComparison]::Ordinal) +Assert-UiContract ($regionStart -ge 0 -and $regionEnd -gt $regionStart) 'Recovery visibility region boundary missing' +$visibilityScene = [scriptblock]::Create($source.Substring($regionStart, $regionEnd - $regionStart)) + +function Require([bool] $condition, [string] $message) { Assert-UiContract $condition $message } +function Start-Sleep { param([int] $Milliseconds) $state.sleeps++ } +function New-RecoveryElement([string] $Kind, [bool] $Initial) { + $identity = switch ($Kind) { wire { 'wire-node-owned' } mark { 'mark-entry-owned' } graph { 'graph' } fit { 'fit-canvas' } } + $label = switch ($Kind) { wire { 'Wire it up' } mark { 'Mark as entry' } graph { 'Graph' } fit { 'Fit canvas' } } + $owner = 4242 + if (($Initial -and $Kind -ceq 'wire' -and $state.name -ceq 'wrong-source-owner') -or + ($Kind -ceq 'fit' -and $state.name -ceq 'wrong-fit-owner') -or + (-not $Initial -and $Kind -ceq 'wire' -and $state.name -ceq 'wrong-owner-after-fit')) { $owner = 7777 } + if (-not $Initial -and $Kind -ceq 'wire' -and $state.name -ceq 'wrong-id-after-fit') { $identity = 'wire-node-foreign' } + $offscreen = ($Kind -ceq 'fit' -and $state.name -ceq 'fit-offscreen') -or + ($Kind -cin @('wire', 'mark') -and + (($Initial -and $state.name -ceq 'initially-offscreen') -or + (-not $Initial -and $state.name -ceq 'remains-offscreen') -or + (-not $Initial -and $state.name -ceq 'delayed-visible' -and $state.reconnects -lt 3))) + $left = if ($Kind -ceq 'mark') { 200 } else { 100 } + $box = [pscustomobject]@{ Left = $left; Top = 100; Right = $left + 80; Bottom = 130; Width = 80; Height = 30; IsEmpty = $false } + if ($Kind -ceq 'graph') { + $box = [pscustomobject]@{ Left = 0; Top = 0; Right = 600; Bottom = 400; Width = 600; Height = 400; IsEmpty = $false } + } elseif ($Kind -cin @('wire', 'mark')) { + $empty = ($Initial -and $state.name -cne 'already-visible' -and $state.name -cne 'outside-before-fit' -and + ($state.name -cne 'mark-only-empty' -or $Kind -ceq 'mark')) -or + (-not $Initial -and $state.name -ceq 'remains-empty') + if ($empty) { $box = [pscustomobject]@{ Left = 0; Top = 0; Right = 0; Bottom = 0; Width = 0; Height = 0; IsEmpty = $true } } + if ($Kind -ceq 'wire' -and (($Initial -and $state.name -ceq 'outside-before-fit') -or + (-not $Initial -and $state.name -ceq 'outside-after-fit'))) { + $box = [pscustomobject]@{ Left = -10; Top = 100; Right = 70; Bottom = 130; Width = 80; Height = 30; IsEmpty = $false } + } + } + $current = [pscustomobject]@{ + AutomationId = $identity; Name = $label; ProcessId = $owner; IsOffscreen = $offscreen + IsEnabled = -not ($Kind -ceq 'fit' -and $state.name -ceq 'fit-disabled') + ControlType = [System.Windows.Automation.ControlType]::Button; Box = $box; State = $state + } + $current | Add-Member ScriptProperty BoundingRectangle { + if ($this.IsOffscreen) { $this.State.offscreenBoundsReads++; throw 'Offscreen recovery bounds were read' } + $this.State.boundsReads++ + return $this.Box + } + $element = [pscustomobject]@{ Current = $current; State = $state; Kind = $Kind } + $element | Add-Member ScriptMethod GetCurrentPattern { + param($pattern) + if ($pattern -ne [System.Windows.Automation.InvokePattern]::Pattern) { throw 'Unexpected recovery pattern' } + if ($this.Kind -ceq 'fit') { return $this.State.fitPattern } + $this.State.patternReads++ + return $this.State.actionPattern + } + return $element +} +function Get-DirectChildren($graph, $walker) { @(New-RecoveryElement wire $true; New-RecoveryElement mark $true) } +function Find-FragmentByIdWithRetry($root, [string] $id, $walker) { + switch -CaseSensitive ($id) { graph { New-RecoveryElement graph $true } fit-canvas { New-RecoveryElement fit $true } + default { throw "Unexpected initial recovery lookup: $id" } } +} +function Wait-ForRootReconnect($process, $walkers) { + Assert-UiContract ($process.Id -eq 4242) 'Foreign recovery root reconnect' + $state.reconnects++ + return $root +} +function Find-FragmentById($root, [string] $id, $walker) { + if ($id -ceq 'graph') { return New-RecoveryElement graph $false } + Assert-UiContract ($id -cin @('wire-node-owned', 'mark-entry-owned')) "Recovery reacquisition requested a different ID: $id" + $state.targetLookups++ + if ($state.name -ceq 'missing-after-fit' -and $id -ceq 'wire-node-owned') { return $null } + return New-RecoveryElement $(if ($id -ceq 'wire-node-owned') { 'wire' } else { 'mark' }) $false +} +$recoveryCases = @( + @{ name = 'initially-empty'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'initially-offscreen'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'already-visible'; expected = $true; fits = 0; reconnects = 0 }, + @{ name = 'mark-only-empty'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'delayed-visible'; expected = $true; fits = 1; reconnects = 3 }, + @{ name = 'outside-before-fit'; expected = $true; fits = 1; reconnects = 1 }, + @{ name = 'wrong-source-owner'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'wrong-fit-owner'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'fit-offscreen'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'fit-disabled'; expected = $false; fits = 0; reconnects = 0 }, + @{ name = 'remains-empty'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'remains-offscreen'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'wrong-id-after-fit'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'wrong-owner-after-fit'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'outside-after-fit'; expected = $false; fits = 1; reconnects = 40 }, + @{ name = 'missing-after-fit'; expected = $false; fits = 1; reconnects = 40 } +) +function Confirm-RecoveryCase($Case, [string] $Definition) { + . ([scriptblock]::Create($Definition)) + $state = @{ name = $Case.name; fits = 0; reconnects = 0; targetLookups = 0; patternReads = 0 + boundsReads = 0; offscreenBoundsReads = 0; recoveryInvokes = 0; sleeps = 0 } + $state.fitPattern = [pscustomobject]@{ State = $state } + $state.fitPattern | Add-Member ScriptMethod Invoke { $this.State.fits++ } + $state.actionPattern = [pscustomobject]@{ State = $state } + $state.actionPattern | Add-Member ScriptMethod Invoke { $this.State.recoveryInvokes++; throw 'Visibility contract must not invoke a recovery action' } + $process = [pscustomobject]@{ Id = 4242 } + $root = [pscustomobject]@{ Owner = 4242 } + $rawWalker = [pscustomobject]@{ Mocked = $true } + $failure = $null + try { $null = & $visibilityScene } catch { $failure = $_ } + $passed = ($null -eq $failure) -eq $Case.expected -and $state.fits -eq $Case.fits -and + $state.reconnects -eq $Case.reconnects -and $state.offscreenBoundsReads -eq 0 -and $state.recoveryInvokes -eq 0 -and + $state.patternReads -eq $(if ($Case.expected) { 2 } else { 0 }) -and $state.targetLookups -eq (2 * $Case.reconnects) + if (-not $Case.expected) { + $expectedDiagnostic = if ($Case.name -ceq 'wrong-source-owner') { + 'Unwired recovery source controls did not belong to the owned graph' + } elseif ($Case.fits -eq 0) { 'Unwired recovery could not acquire the owned visible Fit canvas action' } + else { 'Unwired recovery actions remained unavailable after Fit canvas' } + $passed = $passed -and $failure -and $failure.Exception.Message -ceq $expectedDiagnostic + } + return [bool]$passed +} +foreach ($case in $recoveryCases) { + Invoke-UiCase "ui.recovery.visibility.$($case.name)" { + Assert-UiContract (Confirm-RecoveryCase $case $recoveryDefinition) "Recovery visibility behavior differs: $($case.name)" + } +} +foreach ($mutant in @( + @{ name = 'skip-fit'; case = 'initially-empty'; old = ').Invoke()'; replacement = ') | Out-Null' }, + @{ name = 'unsafe-offscreen-bounds'; case = 'initially-offscreen'; old = 'if ($control.Current.IsOffscreen) { return $false }'; replacement = 'if ($false) { return $false }' }, + @{ name = 'wrong-identity-accepted'; case = 'wrong-id-after-fit'; old = '$wire.Current.AutomationId -cne $wireId -or'; replacement = '$false -or' }, + @{ name = 'outside-bounds-accepted'; case = 'outside-after-fit'; old = '$bounds.Left -lt $graphBounds.Left -or'; replacement = '$false -or' } +)) { + Invoke-UiCase "ui.recovery.visibility.mutant-$($mutant.name)" { + Assert-UiContract ([regex]::Matches($recoveryDefinition, [regex]::Escape($mutant.old)).Count -eq 1) 'Recovery mutation anchor is not unique' + $definition = $recoveryDefinition.Replace($mutant.old, $mutant.replacement) + $parseErrors = $null + $null = [Management.Automation.Language.Parser]::ParseInput($definition, [ref]$null, [ref]$parseErrors) + Assert-UiContract ($parseErrors.Count -eq 0) 'Recovery mutant parser failure' + Assert-UiContract (-not (Confirm-RecoveryCase ($recoveryCases | Where-Object name -CEQ $mutant.case) $definition)) ` + "Recovery mutant unexpectedly preserved visibility contract: $($mutant.name)" + } +} +Assert-UiContract ($results.Count -eq 25) 'PowerShell UI fixture requires five isolation and twenty recovery cases' +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-powershell-results.json'), + ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 new file mode 100644 index 00000000..669aca26 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 @@ -0,0 +1,147 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$uiRoot = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui' +$catalogText = [IO.File]::ReadAllText((Join-Path $uiRoot 'source-assertions.json')) +if ($catalogText.Length -gt 1048576) { throw 'UI source assertion data exceeded its size limit' } +$catalog = @($catalogText | ConvertFrom-Json -Depth 100) +if ($catalog.Count -ne 58) { throw 'Root UI declarative source assertion count must remain 58' } +$paths = @{ + gate = 'Tools\windows\uia-live-gate.ps1' + stub = 'Tools\windows\Stub-Daemon.ps1' + validation = 'Tools\windows\validate.ps1' + shellTests = 'Tools\windows\Tests\WindowsShell.Tests.ps1' + hardening = 'Tools\windows\Tests\Hardening.Tests.ps1' + workflow = '.github\workflows\windows-hardening.yml' + designTokens = 'graphcode-windows\src\DesignTokens.zig' + laneLayout = 'graphcode-windows\src\LaneLayout.zig' + graphCanvas = 'graphcode-windows\src\GraphCanvas.zig' +} +$resources = @{} +function Read-SourceResource([string] $Name) { + if (-not $paths.ContainsKey($Name)) { throw "Undeclared UI source resource: $Name" } + if (-not $resources.ContainsKey($Name)) { + $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $paths[$Name])) + if ($Name -ceq 'gate') { + foreach ($component in @('GraphCodeUiaGateState', 'GraphCodeUiaHostInfo', 'GraphCodeUiaAttribution', + 'GraphCodeMultiProjectRenameNative', 'UiaInputSafety')) { + $text += "`n" + [IO.File]::ReadAllText((Join-Path $uiRoot "Production\$component.cs")) + } + } + $resources[$Name] = $text + } + return $resources[$Name] +} +function Get-AssertionValue($Node, [int] $Depth = 0) { + if ($Depth -gt 96 -or $null -eq $Node) { throw 'UI source assertion tree is missing or exceeds its depth limit' } + switch -CaseSensitive ($Node.kind) { + literal { return $Node.value } + resource { return Read-SourceResource $Node.name } + not { return -not (Get-AssertionValue $Node.operand ($Depth + 1)) } + contains { + return ([string](Get-AssertionValue $Node.source ($Depth + 1))).Contains([string](Get-AssertionValue $Node.needle ($Depth + 1))) + } + binary { + if ($Node.operator -ceq 'And') { return (Get-AssertionValue $Node.left ($Depth + 1)) -and (Get-AssertionValue $Node.right ($Depth + 1)) } + if ($Node.operator -ceq 'Or') { return (Get-AssertionValue $Node.left ($Depth + 1)) -or (Get-AssertionValue $Node.right ($Depth + 1)) } + $left = Get-AssertionValue $Node.left ($Depth + 1) + $right = Get-AssertionValue $Node.right ($Depth + 1) + switch -CaseSensitive ($Node.operator) { + Imatch { return $left -match $right } + Inotmatch { return $left -notmatch $right } + Cmatch { return $left -cmatch $right } + Cnotmatch { return $left -cnotmatch $right } + Ieq { return $left -eq $right } + Ine { return $left -ne $right } + Ceq { return $left -ceq $right } + Cne { return $left -cne $right } + Igt { return $left -gt $right } + Ige { return $left -ge $right } + Ilt { return $left -lt $right } + Ile { return $left -le $right } + default { throw "Unknown declarative source assertion operator: $($Node.operator)" } + } + } + default { throw "Unknown declarative source assertion kind: $($Node.kind)" } + } +} +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$results = [Collections.Generic.List[object]]::new() +function Invoke-EvaluatorControl([string] $Name, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = "ui.root.evaluator.$Name"; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Bounded source evaluator negative control; nativeCalls=0' }) + }) +} +function Assert-EvaluatorRefusal([scriptblock] $Action) { + $refused = $false + try { $null = & $Action } catch { $refused = $true } + if (-not $refused) { throw 'Malformed source assertion did not fail closed' } +} +Invoke-EvaluatorControl 'unknown-operator' { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'binary'; operator = 'execute'; left = @{ kind = 'literal'; value = 1 }; right = @{ kind = 'literal'; value = 1 } } } +} +Invoke-EvaluatorControl 'unknown-resource' { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'resource'; name = 'not-a-production-resource' } } +} +Invoke-EvaluatorControl 'missing-file' { + $paths['missing-control'] = 'Tools\windows\ValidationRunner\.local\absent-source-oracle-control' + try { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'resource'; name = 'missing-control' } } + } finally { $paths.Remove('missing-control') } +} +Invoke-EvaluatorControl 'malformed-tree' { + Assert-EvaluatorRefusal { Get-AssertionValue @{ kind = 'binary'; operator = 'Imatch'; left = $null; right = @{ kind = 'literal'; value = 'anything' } } } +} +Invoke-EvaluatorControl 'depth-boundary' { + $tree = @{ kind = 'literal'; value = $false } + for ($level = 0; $level -lt 96; $level++) { $tree = @{ kind = 'not'; operand = $tree } } + if (Get-AssertionValue $tree) { throw 'Allowed-depth assertion changed its boolean result' } +} +Invoke-EvaluatorControl 'depth-overflow' { + $tree = @{ kind = 'literal'; value = $false } + for ($level = 0; $level -lt 97; $level++) { $tree = @{ kind = 'not'; operand = $tree } } + Assert-EvaluatorRefusal { Get-AssertionValue $tree } +} +function Test-OnlyGateResource($Node) { + switch -CaseSensitive ($Node.kind) { + resource { return $Node.name -ceq 'gate' } + literal { return $true } + binary { return (Test-OnlyGateResource $Node.left) -and (Test-OnlyGateResource $Node.right) } + not { return Test-OnlyGateResource $Node.operand } + contains { return (Test-OnlyGateResource $Node.source) -and (Test-OnlyGateResource $Node.needle) } + default { throw 'Unexpected node in evaluator control' } + } +} +$control = @($catalog | Where-Object { Test-OnlyGateResource $_.condition })[0] +Invoke-EvaluatorControl 'actual-predicate-false' { + if ($null -eq $control -or (Get-AssertionValue $control.condition)) { throw 'Actual original production predicate did not evaluate false' } +} +Invoke-EvaluatorControl 'wrong-production-text' { + $actual = Read-SourceResource 'gate' + try { + $resources['gate'] = 'deliberately wrong production source' + if (-not (Get-AssertionValue $control.condition)) { throw 'Source assertion accepted deliberately wrong production text' } + } finally { $resources['gate'] = $actual } +} +foreach ($test in $catalog) { + if (-not $seen.Add($test.id)) { throw "Duplicate source assertion ID: $($test.id)" } + $failure = $null + try { + if (Get-AssertionValue $test.condition) { throw [InvalidOperationException]::new($test.diagnostic) } + } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $test.id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { "Actual production source; original PS operators; source lines $($test.sourceLines); nativeCalls=0" }) + }) +} +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-root-source-results.json'), + ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/pending-source-assertions.json b/Tools/windows/ValidationRunner/Ui/pending-source-assertions.json new file mode 100644 index 00000000..05e3e471 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/pending-source-assertions.json @@ -0,0 +1,590 @@ +[ + { + "id": "ui.root.source.line-5346", + "sourceLines": "5346-5348", + "diagnostic": "RED: shell validation does not exercise delayed correlated responses", + "reason": "Not a declared source resource: source", + "owner": "Host" + }, + { + "id": "ui.root.source.line-5366", + "sourceLines": "5366-5368", + "diagnostic": "RED: UIA live gate source does not parse", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5380", + "sourceLines": "5380-5382", + "diagnostic": "RED: multi-project observer lacks a focused source-supported chrome classifier", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5392", + "sourceLines": "5392-5394", + "diagnostic": "RED: multi-project observer rejects stable recovery control $identity", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5396", + "sourceLines": "5396-5398", + "diagnostic": "RED: multi-project observer weakens its census by accepting an unknown element", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5399", + "sourceLines": "5399-5401", + "diagnostic": "RED: multi-project observer accepts a foreign recovery control", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5407", + "sourceLines": "5407-5409", + "diagnostic": "RED: multi-project observer lacks a bounded workspace-marker reacquire", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5410", + "sourceLines": "5410-5413", + "diagnostic": "RED: workspace-marker reacquire replays navigation or input", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5432", + "sourceLines": "5432-5435", + "diagnostic": "RED: delayed exact workspace markers do not satisfy the bounded reacquire", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5449", + "sourceLines": "5449-5451", + "diagnostic": "RED: permanent or unknown workspace-marker absence no longer fails closed", + "reason": "Not a declared source resource: permanentFailure", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5457", + "sourceLines": "5457-5459", + "diagnostic": "RED: multi-project loop activation lacks a recovery-control-safe card target", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5462", + "sourceLines": "5462-5464", + "diagnostic": "RED: card activation target does not retain the source-sized title strip", + "reason": "Unsupported assertion operator: Join", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5470", + "sourceLines": "5470-5472", + "diagnostic": "RED: multi-project card activation still lands on an unwired recovery control", + "reason": "Not a declared source resource: overlaps", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5477", + "sourceLines": "5477-5479", + "diagnostic": "RED: invalid multi-project card activation bounds no longer fail closed", + "reason": "Not a declared source resource: invalidActivation", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5485", + "sourceLines": "5485-5488", + "diagnostic": "RED: live multi-project loop navigation still clicks the whole card rectangle", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5501", + "sourceLines": "5501-5503", + "diagnostic": "RED: lane caption gate does not bind the source-derived overview-project identity", + "reason": "Not a declared source resource: laneCaptionStart", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5514", + "sourceLines": "5514-5516", + "diagnostic": "RED: lane caption gate lacks the no-summary caption/open-project contract: $required", + "reason": "Not a declared source resource: laneCaptionSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5524", + "sourceLines": "5524-5526", + "diagnostic": "RED: lane caption gate retains obsolete summary/button contract: $obsolete", + "reason": "Not a declared source resource: laneCaptionSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5537", + "sourceLines": "5537-5544", + "diagnostic": "RED: UIA prelaunch diagnostics omit process, desktop heap, window station, or session evidence", + "reason": "Not a declared source resource: prelaunchDiagnostic", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5553", + "sourceLines": "5553-5555", + "diagnostic": "RED: UIA live gate no longer parses after startup diagnostic changes", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5561", + "sourceLines": "5561-5563", + "diagnostic": "RED: UIA live gate is missing its functional sidebar navigation snapshot oracle", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5569", + "sourceLines": "5569-5571", + "diagnostic": "RED: UIA live gate is missing its bounded sidebar scroll/reacquire proof", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5580", + "sourceLines": "5580-5583", + "diagnostic": "RED: UIA live gate retains mutable sidebar handles instead of freezing each reacquired capture", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5632", + "sourceLines": "5632-5641", + "diagnostic": "RED: Sidebar navigation oracle missed destination order, open owners, section names, or contained controls", + "reason": "Unsupported assertion operator: Join", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5659", + "sourceLines": "5659-5662", + "diagnostic": "RED: Sidebar capture retained mutable UIA handles instead of point-in-time evidence", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5683", + "sourceLines": "5683-5687", + "diagnostic": "RED: Sidebar bounds proof rejected exact DPI192 identities observed across bounded viewport captures", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5696", + "sourceLines": "5696-5698", + "diagnostic": "RED: Sidebar bounds proof accepted a missing owned row", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5713", + "sourceLines": "5713-5715", + "diagnostic": "RED: Sidebar bounds proof accepted a foreign-process row", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5724", + "sourceLines": "5724-5726", + "diagnostic": "RED: Sidebar bounds proof accepted a duplicate owned row", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5739", + "sourceLines": "5739-5741", + "diagnostic": "RED: Sidebar bounds proof accepted wrong open-owner order", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5752", + "sourceLines": "5752-5754", + "diagnostic": "RED: Sidebar navigation oracle did not isolate local owner collapse", + "reason": "Unsupported assertion operator: Join", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5761", + "sourceLines": "5761-5763", + "diagnostic": "RED: Sidebar navigation oracle did not isolate remote owner collapse", + "reason": "Unsupported assertion operator: Join", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5773", + "sourceLines": "5773-5777", + "diagnostic": "RED: Sidebar navigation oracle accepted a temporary remote owner after fixture restore", + "reason": "Unsupported assertion operator: Join", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5783", + "sourceLines": "5783-5785", + "diagnostic": "RED: Sidebar navigation oracle failed to detect a projected unopened recent folder", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5792", + "sourceLines": "5792-5794", + "diagnostic": "RED: Sidebar navigation oracle accepted a row control extending beyond its owner", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5807", + "sourceLines": "5807-5809", + "diagnostic": "RED: Sidebar navigation oracle failed to preserve the owner order evidence", + "reason": "Unsupported assertion operator: Join", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5812", + "sourceLines": "5812-5814", + "diagnostic": "RED: Sidebar navigation fixture execution count changed unexpectedly", + "reason": "Not a declared source resource: sidebarFixtureCount", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5838", + "sourceLines": "5838-5838", + "diagnostic": "RED: UIA startup capture helper is missing: $helperName", + "reason": "Not a declared source resource: null", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5845", + "sourceLines": "5845-5850", + "diagnostic": "RED: UIA startup capture does not retain bounded, redacted child output", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5853", + "sourceLines": "5853-5856", + "diagnostic": "RED: UIA startup capture does not bound oversized diagnostics", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5859", + "sourceLines": "5859-5861", + "diagnostic": "RED: UIA startup capture does not distinguish a missing child log", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5882", + "sourceLines": "5882-5888", + "diagnostic": "RED: retained UIA startup report omits or exposes captured child and app output", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5897", + "sourceLines": "5897-5899", + "diagnostic": "RED: UIA host context native diagnostics type is missing", + "reason": "Not a declared source resource: hostInfoClassAt", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5905", + "sourceLines": "5905-5908", + "diagnostic": "RED: UIA host context native diagnostics type is missing", + "reason": "Not a declared source resource: hostInfoAddTypeAt", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5914", + "sourceLines": "5914-5916", + "diagnostic": "RED: UIA host context did not resolve the current Windows session", + "reason": "Unsupported assertion operator: IsNot", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5918", + "sourceLines": "5918-5922", + "diagnostic": "RED: UIA host context diagnostics omitted explicit session or desktop-heap status", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5945", + "sourceLines": "5945-5945", + "diagnostic": "RED: UIA owned-process traversal missed a controlled child", + "reason": "Not a declared source resource: treeObserved", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5947", + "sourceLines": "5947-5949", + "diagnostic": "RED: UIA owned-process teardown returned before the controlled root exited", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5970", + "sourceLines": "5970-5972", + "diagnostic": "RED: UIA owned-process traversal adopts processes older than a reused parent PID: $($pidReuseAdopted -join ',')", + "reason": "Unsupported assertion operator: Join", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-6057", + "sourceLines": "6057-6061", + "diagnostic": "RED: UIA ingress commands and scene transitions are not bounded and attributable", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6093", + "sourceLines": "6093-6096", + "diagnostic": "RED: UIA owned-sheet acquisition throws on stale handles instead of retrying exact identity", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6157", + "sourceLines": "6157-6164", + "diagnostic": "RED: UIA Delete Loop invocation does not re-establish the stable fixture selection after Rename", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6182", + "sourceLines": "6182-6198", + "diagnostic": "RED: UIA gate does not verify dynamic Rename, custom Delete, or Edit Details sheet behavior", + "reason": "Unsupported source observation", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6276", + "sourceLines": "6276-6276", + "diagnostic": "RED: multi-project live evidence lacks $required", + "reason": "Not a declared source resource: required", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6292", + "sourceLines": "6292-6292", + "diagnostic": "RED: unfiltered multi-project observer roster lacks $required", + "reason": "Not a declared source resource: required", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6298", + "sourceLines": "6298-6298", + "diagnostic": "RED: explicit complete overview taxonomy lacks $required", + "reason": "Not a declared source resource: required", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6305", + "sourceLines": "6305-6307", + "diagnostic": "RED: multi-project fitted card geometry lacks denominator-derived rounding budget: $required", + "reason": "Not a declared source resource: required", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6314", + "sourceLines": "6314-6321", + "diagnostic": "RED: new observer prefix/typed metadata/active-surface selection boundary remains unguarded or silently omitted", + "reason": "Not a declared source resource: metadataObserver", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6326", + "sourceLines": "6326-6326", + "diagnostic": "RED: bounded multi-project capture lacks $required", + "reason": "Not a declared source resource: required", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6352", + "sourceLines": "6352-6354", + "diagnostic": "RED: UIA foreground failure hides native return values and last-error diagnostics", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6412", + "sourceLines": "6412-6416", + "diagnostic": "RED: UIA live gate does not prove immediate Product Settings persistence, foreground focus, Return retention, and Escape close", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6467", + "sourceLines": "6467-6471", + "diagnostic": "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever", + "reason": "Not a declared source resource: appSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6591", + "sourceLines": "6591-6596", + "diagnostic": "RED: retained New Loop request history cannot select the create after a later outcome command", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6654", + "sourceLines": "6654-6676", + "diagnostic": "RED: custody reopened path must select Turn once, type instruction, change backend and prove unchanged instruction", + "reason": "Not a declared source resource: firstCustodyOpen", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6754", + "sourceLines": "6754-6756", + "diagnostic": "RED: UIA gate embedded C# type source is missing", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6770", + "sourceLines": "6770-6772", + "diagnostic": "RED: UIA gate calls undefined embedded C# member(s): $($uiaGateMissingMembers -join ', ')", + "reason": "Nondeclarative assertion node: MemberExpressionAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6853", + "sourceLines": "6853-6855", + "diagnostic": "RED: stable-but-wrong native text has no extracted retry-decision helper", + "reason": "Not a declared source resource: edgeTextHelperAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6864", + "sourceLines": "6864-6864", + "diagnostic": "Unexpected edge text retry decision '$decision", + "reason": "Not a declared source resource: decision", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6866", + "sourceLines": "6866-6868", + "diagnostic": "RED: stable empty text did not retry exactly once before the expected second attempt", + "reason": "Not a declared source resource: edgeTextAttempts", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6870", + "sourceLines": "6870-6872", + "diagnostic": "RED: edge retry-decision helper lost exact expected-text verification", + "reason": "Not a declared source resource: edgeTextHelperSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6877", + "sourceLines": "6877-6879", + "diagnostic": "RED: exact-match mutation negative did not find the expected helper branch", + "reason": "Not a declared source resource: mutatedEdgeTextHelperSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6892", + "sourceLines": "6892-6894", + "diagnostic": "RED: removing exact expected-text comparison evaded the stable-wrong retry regression", + "reason": "Not a declared source resource: mutationAttempts", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6905", + "sourceLines": "6905-6908", + "diagnostic": "RED: stable-wrong edge text did not exhaust and report exactly five attempts; calls=$edgeTextAttempts error=$($edgeTextExhaustion.Exception.Message)", + "reason": "Not a declared source resource: edgeTextAttempts", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6921", + "sourceLines": "6921-6923", + "diagnostic": "RED: custody graph-card wait helper is not extractable", + "reason": "Not a declared source resource: custodyCardWaitAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6925", + "sourceLines": "6925-6933", + "diagnostic": "RED: custody card wait lacks a positive baseline, unique UUID-bound observation, or action-free polling", + "reason": "Not a declared source resource: custodyCardWaitSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6939", + "sourceLines": "6939-6941", + "diagnostic": "RED: source-derived sketch-card AutomationId helper is missing", + "reason": "Not a declared source resource: sketchCardIdAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6958", + "sourceLines": "6958-6960", + "diagnostic": "RED: source-derived UIA card identity differs for $($expectedCard.NodeId)", + "reason": "Nondeclarative assertion node: CommandAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6967", + "sourceLines": "6967-6973", + "diagnostic": "RED: custody right-click can bypass fresh-fragment, unique-card, or hashed child-identity checks", + "reason": "Not a declared source resource: openSketchMenuAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7027", + "sourceLines": "7027-7041", + "diagnostic": "RED: connector evidence does not freeze exact physical-screen endpoints and clamp the target to its visible card intersection", + "reason": "Not a declared source resource: compositeEvidenceIndex", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7136", + "sourceLines": "7136-7138", + "diagnostic": "RED: connector-card selector is not extractable for named functional cases", + "reason": "Not a declared source resource: connectorCardSelectorAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7235", + "sourceLines": "7235-7239", + "diagnostic": "RED: edge connector functional case '$($case.Name)' failed: $($selectionError.Exception.Message)", + "reason": "Not a declared source resource: null", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7326", + "sourceLines": "7326-7328", + "diagnostic": "RED: NativeForms.zig node_labels table not found for node creation sheet label contract", + "reason": "Not a declared source resource: nativeFormsSource", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7337", + "sourceLines": "7337-7339", + "diagnostic": "RED: UIA live gate node creation sheet label map is missing", + "reason": "Not a declared source resource: labelHashAst", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7344", + "sourceLines": "7344-7346", + "diagnostic": "RED: UIA live gate node creation sheet label lookup for $labelId returned '$actualLabel', expected '$expectedLabel' from NativeForms.zig", + "reason": "Unsupported source observation", + "owner": "Ui" + } +] diff --git a/Tools/windows/ValidationRunner/Ui/source-assertions.json b/Tools/windows/ValidationRunner/Ui/source-assertions.json new file mode 100644 index 00000000..6b88d316 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/source-assertions.json @@ -0,0 +1,8951 @@ +[ + { + "id": "ui.root.source.line-5342", + "sourceLines": "5342-5344", + "diagnostic": "RED: terminal validation does not run provider pin no-divergence contracts", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)\"terminal-gate\" \\{\\s*& .*?ProviderPins\\.Tests\\.ps1.*?TerminalGate\\.Tests\\.ps1" + }, + "left": { + "kind": "resource", + "name": "validation" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5350", + "sourceLines": "5350-5352", + "diagnostic": "RED: Windows shell validation does not execute the UI Automation live gate", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\\.ps1" + }, + "left": { + "kind": "resource", + "name": "validation" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5369", + "sourceLines": "5369-5374", + "diagnostic": "RED: UIA live gate does not exercise and hash both exact cycle-card hierarchy scenes", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CYCLE_HIERARCHY_SHA256=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CYCLE_HIERARCHY_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostFixtureMutation\\(\\$shellWindow, 23\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\[switch\\]\\$CycleHierarchyOnly" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5528", + "sourceLines": "5528-5534", + "diagnostic": "RED: UIA startup failure does not capture both child streams, app log, and prelaunch host diagnostics", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "startup-failure\\.json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "prelaunch-diagnostics\\.json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-UiaPrelaunchDiagnostics" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "RedirectStandardOutput" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Get-UiaStartupFileDiagnostic" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5545", + "sourceLines": "5545-5548", + "diagnostic": "RED: UIA teardown does not enumerate, reap, and verify all owned descendants", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_PROCESS_TREE_CLEANUP=verified" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Get-UiaOwnedProcessDescendants" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5572", + "sourceLines": "5572-5574", + "diagnostic": "RED: UIA live gate does not scroll, reacquire, and prove each exact sidebar row in a bounded sequence", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)\\$sidebarScrollTargets = @\\(.*?ScrollIntoView\\(\\).*?Wait-ForRootReconnect.*?Get-SidebarNavigationBoundsProof.*?UIA_SIDEBAR_BOUNDS_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5584", + "sourceLines": "5584-5586", + "diagnostic": "RED: UIA live gate does not bound the later Needs-you row proof and restore the sidebar viewport", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)\\$needsYouIds = .*?ScrollIntoView\\(\\).*?Needs-you row remained offscreen after bounded scroll/reacquire.*?UIA_SIDEBAR_SCROLL_RESTORED=overview-destination" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-5973", + "sourceLines": "5973-5978", + "diagnostic": "RED: UIA gate does not identify visible shell HWND, background root access, missing modal and short TEMP remedy", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\[GraphCodeUiaGateState\\]::WindowIsVisible\\(\\$shellWindow\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "maxSandboxRootUtf16" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_UPDATE_DIALOG_DIAGNOSTICS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_ROOT_ACCESS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5979", + "sourceLines": "5979-5986", + "diagnostic": "RED: UIA gate does not use and verify the shared owned update sheet when desktop-tree lookup omits it", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\[System\\.Windows\\.Automation\\.AutomationElement\\]::NameProperty, \"GraphCode Update Available\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[System\\.Windows\\.Automation\\.AutomationElement\\]::NameProperty, \"An update is available\\.\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-UiaOwnedSheetIdentity \\$updateDialog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FromHandle\\(\\$nativeUpdateWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_UPDATE_DIALOG_DIRECT" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-5987", + "sourceLines": "5987-6000", + "diagnostic": "RED: UIA update-banner click does not use the owned published bounds and source-defined Later contract", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\$updateBannerViewportBottom|\\$updateBannerTop|\\$updateBannerBottom" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$graph = Find-FragmentById \\$root \"graph\" \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$loops = Find-FragmentById \\$root \"loops\" \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$projects = Find-FragmentById \\$root \"projects\" \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "RawView after update banner root" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$status\\.Current\\.Name -ceq \"Update offer deferred\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Wait-UiaOwnedSheetGoneByContentClass \\$process\\.Id \\$shellWindow \"GraphCodeUpdateOffer\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_UPDATE_BANNER_CLICK_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ScreenToClientPoint\\(\\s*\\$shellWindow, \\$updateBannerScreenX, \\$updateBannerScreenY" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-UpdateBannerClickTarget \\$updateBannerTarget \\$process\\.Id \\$shellWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$updateBanner\\.Current\\.BoundingRectangle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Test-UpdateBannerClickTarget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6062", + "sourceLines": "6062-6065", + "diagnostic": "RED: UIA native-form checks still depend on desktop Window control discovery instead of the owned captionless sheet", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Wait-UiaOwnedSheetGoneByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeNativeForm\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)\\$nativeFormWindow = Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeNativeForm\" \\$nativeForm\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6097", + "sourceLines": "6097-6099", + "diagnostic": "RED: UIA sheet-close waits return before modal teardown re-enables the owner", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)function Wait-UiaOwnedSheetGoneByContentClass\\(.*?\\$sheetGone = \\[GraphCodeUiaGateState\\]::FindVisibleOwnedSheetByContentClass\\(.*?if \\(\\$sheetGone -and \\[GraphCodeUiaGateState\\]::WindowIsEnabled\\(\\$owner\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-6100", + "sourceLines": "6100-6103", + "diagnostic": "RED: UIA Rename checks still depend on desktop Window control discovery instead of the owned captionless prompt sheet", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Wait-UiaOwnedSheetGoneByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeWindowsDialogContent\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)\\$renameDialog = Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeWindowsDialogContent\" \\$fixtureRenameTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6165", + "sourceLines": "6165-6170", + "diagnostic": "RED: UIA Rename and Update evidence reads the daemon log before the asynchronous command is recorded", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$renameLiveCommand = ConvertFrom-Json -InputObject \\(Wait-DaemonCommandLog.*?\"connected-daemon Rename submission\"\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$updateCommandJson = Wait-DaemonCommandLog \\$daemonCommandLogPath.*?\"Edit Details submission\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$renameCommandJson = Wait-DaemonCommandLog \\$daemonCommandLogPath.*?\"Rename submission\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)function Wait-DaemonCommandLog\\(.*?\\[Diagnostics\\.Stopwatch\\]::StartNew\\(\\).*?\\$watch\\.ElapsedMilliseconds -lt \\$timeoutMilliseconds.*?throw \"\\$label did not reach the daemon command log within" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6171", + "sourceLines": "6171-6181", + "diagnostic": "RED: migrated captionless sheets still use desktop Window discovery instead of owned content-class lookup", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\\$renameLiveTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Edit edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\"Delete Edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\"Delete All Loops\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Choose a saved template\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Create or edit node\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeNativeForm\"\\s+\"Update node\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\"GraphCodeWindowsDialogContent\"\\s+\\$deleteLoopTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$(deleteLoopCondition|updateWindowCondition|nodeFormCondition|sidebarNodeFormCondition|templatePickerCondition|deleteLoopsCondition|deleteEdgeCondition|edgeDialogCondition|renameLiveCondition)\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6199", + "sourceLines": "6199-6209", + "diagnostic": "RED: shared sheet title/hierarchy/identity contracts are missing or retain the obsolete Rename Loop title", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "SheetContentClass" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowOwner" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsCaptionlessSheet" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Test-MultiProjectOwnedSheetIdentity" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GraphCodeSheetViewport" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GraphCodeSheetHost" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-MultiProjectRenameTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Test-MultiProjectRenameTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\"Rename Loop\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6210", + "sourceLines": "6210-6214", + "diagnostic": "RED: update, Product Settings, or native form discovery still assumes a migrated content class is top-level", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Assert-UiaOwnedSheet \\$nativeFormWindow \\$process\\.Id \\$shellWindow \\$nativeForm\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FindVisibleOwnedSheetByContentClass\\(\\s*\\[uint32\\]\\$settingsProcess\\.Id, \\$settingsShellWindow, \"GraphCodeProductSettings\"\\s*\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "FindTopLevel\\(\"GraphCode(UpdateOffer|ProductSettings)\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6215", + "sourceLines": "6215-6220", + "diagnostic": "RED: migrated sheet identity checks do not retain exact surface-specific host titles", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\$settingsShellWindow \"GraphCode Settings\" \"GraphCodeProductSettings\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"GraphCodeRepositoryIngressDialog\" \\$ingress\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"GraphCodeUpdateOffer\" \"GraphCode Update Available\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\"GraphCode sheet\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6221", + "sourceLines": "6221-6225", + "diagnostic": "RED: UIA gate writes or reads cross-process edit text through the window caption instead of WM_SETTEXT/WM_GETTEXT", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)SetEditTextById\\(IntPtr parent, int controlId, string text\\) \\{[^}]*SetWindowText\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessageText\\(edit, 0x000D" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "SendMessageString\\(edit, 0x000C" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6226", + "sourceLines": "6226-6230", + "diagnostic": "RED: UIA gate reads the daemon command log without tolerating the recorder's open write handle", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "ReadAllText\\(\\$daemonCommandLogPath\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[IO\\.FileShare\\]::ReadWrite -bor \\[IO\\.FileShare\\]::Delete" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Read-DaemonCommandLog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6231", + "sourceLines": "6231-6239", + "diagnostic": "RED: UIA gate never observes a rename result returned by a connected daemon", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "rename stub daemon never applied the dispatched rename" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_RENAME_PROPAGATION_UNCONFIRMED" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_RENAME_PROPAGATION_CONFIRMED" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Remove-Item Env:GRAPHCODE_UIA_CONNECTION_FAILURE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "-ApplyGraphCommands" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_DAEMON_MODEL" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_CONNECTED_RENAME_PROPAGATION" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6246", + "sourceLines": "6246-6249", + "diagnostic": "RED: UIA gate throws on the known-intermittent graph card/sidebar propagation outcome instead of logging it", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\$renameLiveSidebarTitle -eq \\$renameFinalTitle\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\$renameLiveGraphTitle -eq \\$renameFinalTitle\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6250", + "sourceLines": "6250-6255", + "diagnostic": "RED: UIA gate no longer exercises the forced disconnected connection-failure path or reports the banner it observed", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "connectionFailureBanner = \\$connectionFailureBannerEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-Host \\(\"UIA_CONNECTION_FAILURE_BANNER_EVIDENCE=\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$connectionFailureBannerEvidence = \\[ordered\\]@\\{\\s*name = \\[string\\]\\$connectionAlert\\.Current\\.Name" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$env:GRAPHCODE_UIA_CONNECTION_FAILURE = \"1\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6257", + "sourceLines": "6257-6265", + "diagnostic": "RED: N3e lacks opt-in independent owner/receipt/publication state and the simultaneous live multi-project rename evidence", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)multiProjectRename = \\$multiProjectRenameEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-MultiProjectAutomationId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_MULTIPROJECT_RENAME_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Invoke-MultiProjectPublicationControl" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Invoke-MultiProjectRequest" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function New-MultiProjectPeer" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\[switch\\] \\$SeedMultiProjects" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6278", + "sourceLines": "6278-6284", + "diagnostic": "RED: dynamic project cards are not frozen after bounded Fit Canvas normalization and reacquisition", + "condition": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "\"fit-canvas\"" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "UIA_DYNAMIC_PROJECT_CARD_EVIDENCE=" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "function Get-DynamicProjectCardCapture" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "right": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "Get-DynamicProjectCardSnapshotDecision \"project\"" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "left": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "function Get-DynamicProjectCardSnapshotDecision" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6285", + "sourceLines": "6285-6288", + "diagnostic": "RED: workspace observation still accepts a project-only projection without mandatory owned loop-bar and toolbar proof", + "condition": { + "right": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "if ($null -ne $loopBar) { $matches = $matches -and" + }, + "source": { + "kind": "resource", + "name": "gate" + } + }, + "left": { + "kind": "not", + "operand": { + "kind": "contains", + "needle": { + "kind": "literal", + "value": "$matches = Test-MultiProjectObservedSurface $surface $matches $workspaceProjection $selectedOwner $multiProcess.Id" + }, + "source": { + "kind": "resource", + "name": "gate" + } + } + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6339", + "sourceLines": "6339-6344", + "diagnostic": "RED: stub daemon cannot apply a renameNode command and republish its graph", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "function New-StubGraphEvent" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedRenames" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$frame\\.command\\.graphCommand\\.command\\.renameNode" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$ApplyGraphCommands" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6345", + "sourceLines": "6345-6350", + "diagnostic": "RED: migrated standalone dialogs are not discovered by exact owner/PID/direct content class", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$ingressDialog = Wait-ForDesktopElement.*?\\$ingress\\.Title" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GraphCodeRepositoryIngressDialog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Wait-UiaOwnedSheetGoneByContentClass" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-UiaOwnedSheetByContentClass" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6355", + "sourceLines": "6355-6372", + "diagnostic": "RED: UIA live gate does not prove foreground ownership before accepting row focus", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\[GraphCodeUiaGateState\\]::IsForegroundWindow\\(\\$settingsWindow\\)\\).*?Product Settings window lost foreground ownership before accepting backend focus" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Product Settings backend control could not retain foreground focus" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$backendFocus = Retain-FocusWithRetry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Retain-FocusWithRetry \\$shellWindow \\$safeFocusRow \\$safeRowId \"before-retention\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$element\\.SetFocus\\(\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::ActivateWindow\\(\\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-FocusedElementIdentity \\$candidate \\$element \\$expectedAutomationId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Retain-FocusWithRetry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Hide-TestProviderZmxWindows|HideProcessWindows|HideWindow\\(\\$foreground\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_FOCUS_DIAGNOSTICS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsForegroundWindow\\(\\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[DllImport\\(\"kernel32\\.dll\"\\)\\]\\s*private static extern uint GetCurrentThreadId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostMessage\\(window, 0x0101, \\(UIntPtr\\)key, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsRetainedProcessAlive\\(expectedProcessId\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "AttachThreadInput\\(currentThread, foregroundThread" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "keybd_event" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6417", + "sourceLines": "6417-6425", + "diagnostic": "RED: UIA live gate does not wait deterministically for asynchronous modal windows", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Open Folder picker close" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "project-row New Loop node form" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "empty project New Loop node form" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "empty global New Loop node form" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_WAIT_DIAGNOSTICS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Wait-ForDesktopElementGone" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-ForDesktopElement" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6426", + "sourceLines": "6426-6429", + "diagnostic": "RED: UIA live gate reintroduced short fixed polling around New Loop modal commands", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Open Folder command was rejected.*?Start-Sleep -Milliseconds 200\\s*[\\r\\n]+\\s*Require \\(\\[GraphCodeUiaGateState\\]::PostCommand\\(\\$shellWindow, 4602\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)New Loop command was rejected.*?for \\(\\$index = 0; \\$index -lt 40 -and \\$null -eq \\$.*NodeForm" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6430", + "sourceLines": "6430-6435", + "diagnostic": "RED: UIA live gate does not reacquire GraphCode shell foreground within a bounded deadline before command paths, or performs the invasive Alt-tap activation even when already foreground", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Ensure-ShellForeground\\(.*?if \\(\\[GraphCodeUiaGateState\\]::IsForegroundWindow\\(\\$window\\)\\) \\{\\s*[\\r\\n]+\\s*return \\$true\\s*[\\r\\n]+\\s*\\}" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_FOREGROUND_DIAGNOSTICS phase=\\$label \\$\\(Get-FocusDiagnostics \\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::ActivateWindow\\(\\$window\\)\\s*[\\r\\n]+\\s*\\$acquired = \\$activated -and \\[GraphCodeUiaGateState\\]::IsForegroundWindow\\(\\$window\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Ensure-ShellForeground" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6436", + "sourceLines": "6436-6439", + "diagnostic": "RED: UIA modal waits do not boundedly reacquire foreground after a post-command foreground loss", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_WAIT_DIAGNOSTICS label=\\$label foregroundRecoveries=\\$foregroundRecoveries" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)function Wait-ForDesktopElement\\(.*?\\[switch\\] \\$RecoverForeground.*?\\$remainingMilliseconds = \\[Math\\]::Max\\(.*?\\$recoveryTimeout = \\[Math\\]::Min\\(1000, \\$remainingMilliseconds\\).*?UIA_WAIT_FOREGROUND_RECOVERY label=\\$label.*?Ensure-ShellForeground.*?-TimeoutMilliseconds \\$recoveryTimeout" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6440", + "sourceLines": "6440-6444", + "diagnostic": "RED: UIA live gate New Loop invocation is not preceded by verified foreground recovery at every site", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(Ensure-ShellForeground \\$shellWindow \"empty project New Loop\"\\)\\s*`\\s*[\\r\\n]+\\s*\"GraphCode shell did not reacquire foreground before empty project New Loop command\"\\s*[\\r\\n]+\\s*Require \\(\\[GraphCodeUiaGateState\\]::PostCommand\\(\\$shellWindow, 4602\\)\\)\\s*`\\s*[\\r\\n]+\\s*\"empty project New Loop command was rejected\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(Ensure-ShellForeground \\$shellWindow \"empty global New Loop\"\\)\\s*`\\s*[\\r\\n]+\\s*\"GraphCode shell did not reacquire foreground before empty global New Loop command\"\\s*[\\r\\n]+\\s*Require \\(\\[GraphCodeUiaGateState\\]::PostCommand\\(\\$shellWindow, 4602\\)\\)\\s*`\\s*[\\r\\n]+\\s*\"empty global New Loop command was rejected\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)Require \\(\\$null -ne \\$projectNewLoop\\) \"project row omitted New Loop\"\\s*[\\r\\n]+\\s*Require \\(Ensure-ShellForeground \\$shellWindow \"project-row New Loop\"\\)\\s*`\\s*[\\r\\n]+\\s*\"GraphCode shell did not reacquire foreground before invoking project-row New Loop\"\\s*[\\r\\n]+\\s*\\$projectNewLoop\\.GetCurrentPattern" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6446", + "sourceLines": "6446-6453", + "diagnostic": "RED: UIA live gate cannot open, read, or dismiss a native TrackPopupMenu popup", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "PostMessage\\(window, 0x802C, \\(UIntPtr\\)target, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(popup, 0x01E1, UIntPtr\\.Zero, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FindPopupMenuWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Close-PopupMenu" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-PopupMenuItems" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-ForPopupMenu" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6454", + "sourceLines": "6454-6461", + "diagnostic": "RED: UIA live gate does not assert the live project context menu's disabled Move item and deterministic dismissal", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "project context menu did not dismiss, leaving the shell blocked in its modal loop" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)PostContextMenu\\(\\$shellWindow, 2\\).*?\\$_\\.Id -in @\\(5149, 5151, 5144\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$moveProjectItem\\.Text -eq \\$moveProjectMenuText" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(-not \\$moveProjectItem\\.Enabled\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)PostContextMenu\\(\\$shellWindow, 1\\).*?Wait-ForPopupMenu \\$process \\$shellWindow \"project\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$moveProjectMenuText = \"Move Project\\.\\.\\. \\(unavailable: daemon support required\\)\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6462", + "sourceLines": "6462-6465", + "diagnostic": "RED: Windows shell validation does not run the context menu and gate fixture message tests", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Context menu and gate fixture message executable tests.*?zig test src\\\\MainWindow\\.zig" + }, + "left": { + "kind": "resource", + "name": "shellTests" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)Context menu and gate fixture message executable tests.*?zig test src\\\\GraphContextMenu\\.zig" + }, + "left": { + "kind": "resource", + "name": "shellTests" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6472", + "sourceLines": "6472-6513", + "diagnostic": "RED: UIA live gate does not measure blank-canvas, node-card, and edge context menus from live geometry", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA loop A|UIA loop B" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "daemonCommandUnchanged = \\$canvasEdgeDaemonCommandUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "editActionDialogOpened = \\$canvasEdgeEditDialogOpened" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Create or edit edge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Wait-UiaOwnedSheetByContentClass \\$process\\.Id \\$shellWindow\\s*`\\s*[\\r\\n]+\\s*\"GraphCodeNativeForm\" \"Edit edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "actionClick = \\$editEdgeClickEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_CANVAS_EDGE_ACTION_CLICK_EVIDENCE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "actionPopupClosed = \\$edgePopupClosed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "directCommandFallback = \\$editEdgeDirectCommandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "keyboardFallback = \\$editEdgeClick\\.UsedKeyboardFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "hilite = \\$editEdgeClick\\.Hilite" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "cursorBefore = @\\(\\$editEdgeClick\\.CursorBeforeX" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendInput" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)SendInput\\(\\(uint\\)inputs\\.Length.*?for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\).*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\).*?PostMessage\\(popup, 0x0101, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UsedSelectItemFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public bool Hilite, UsedKeyboardFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)private static extern void Sleep\\(uint milliseconds\\).*?for \\(int attempt = 0; attempt < 50 && !hilite; attempt\\+\\+\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\).*?Sleep\\(10\\).*?if \\(!hilite\\).*?throw new InvalidOperationException" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(point\\.X, point\\.Y\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GetCursorPos" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SetCursorPos" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickPopupMenuItem" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GetMenuItemRect" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Close-PopupMenu" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "state = \\$_.State" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "checked = \\$_.Checked" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function ConvertTo-PopupMenuEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Checked\\s+=\\s+\\(\\(\\$state -band 0x8\\) -ne 0\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PopupMenuItemState" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostRightClickAt\\(\\$shellWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PostRightClickAt\\(\\$ownerWindow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "BoundingRectangle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "canvas \\$\\(\\$probe\\.Label\\) context menu point .*? is outside live graph bounds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)Find-FragmentByIdWithRetry \\$root \"actual-size\" \\$rawWalker.*?\\.Invoke\\(\\).*?\\$graph = Find-FragmentByIdWithRetry \\$root \"graph\" \\$rawWalker.*?\\$canvasContextCards" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"canvas edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"canvas node\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\"canvas background\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "canvasContextMenu = \\$canvasContextMenuEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$canvasContextMenuEvidence" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_CANVAS_CONTEXT_MENU_EVIDENCE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6514", + "sourceLines": "6514-6516", + "diagnostic": "RED: UIA final summary loses nested canvas menu items and edge action measurements", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)canvasContextMenu = \\$canvasContextMenuEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-6520", + "sourceLines": "6520-6525", + "diagnostic": "RED: UIA popup Enter fallback can close the menu without choosing the intended item", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_MENU_CLICK command=\\$id .*?hiliteBeforeEnter=\\$\\(\\$click\\.HiliteBeforeEnter\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_MENU_CLICK command=\\$command .*?hiliteBeforeEnter=\\$\\(\\$click\\.HiliteBeforeEnter\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public bool HiliteAtEnterFallback, HiliteBeforeEnter;" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\) Sleep\\(10\\);.*?hiliteAtEnterFallback = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;\\s*SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;.*?if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException.*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6526", + "sourceLines": "6526-6541", + "diagnostic": "RED: UIA wait timeouts are not attributed to step, deadline, foreground owner, shell windows, and a shell-only capture", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)RemoveFromSelection\\(\\).*?GetSelection\\(\\)\\.Count -eq 0.*?RawView Worktrees event subscription.*?SelectionSourceRuntimeId = \\$null.*?\\$selectionEventRow = \\$safeRow.*?AddAutomationEventHandler\\(.*?\\$selectionEventDeadlineUtc.*?Get-UiaSelectionEventDecision \\$selectedOnTime.*?\\$safeRowId \\$safeRowRuntimeId.*?Require \\$selectedDecision\\.ready.*?\\$selectedAfterStableDrain.*?RemoveAutomationEventHandler\\(\\$selectedEvent, \\$selectionEventRow" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaLateEventAttribution \"ElementSelected after Select\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"gate failure: \\$failureStep\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"multi-project observation: \\$surface\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"sketch/custody modal: \\$title\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"edge modal: \\$title\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"foreground: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"desktop element gone: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"desktop element: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Write-UiaWaitAttribution \"popup menu: \\$label\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Get-FocusDiagnostics.*?\\$foregroundTitle = if \\(\\$expectedProcessId -ne 0 -and \\$foregroundProcessId -eq \\$expectedProcessId\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PrintWindow\\(\\$window, \\$hdc, 2\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_WAIT_TIMEOUT_ATTRIBUTION=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Write-UiaWaitAttribution" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6542", + "sourceLines": "6542-6550", + "diagnostic": "RED: stub daemon cannot apply exactly the createNode it received and republish the created loop type", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(\\$renameApplied -or \\$createApplied -or \\$edgeApplied(?: -or \\$promotionApplied)?\\)" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$null = Write-StubResultFile \\$ResultPath \\$json" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Write-StubResultFile.*?for \\(\\$attempt = 0; \\$attempt -lt 40; \\$attempt\\+\\+\\).*?Set-Content -LiteralPath \\$path -Value \\$json -NoNewline -ErrorAction Stop.*?catch \\[IO\\.IOException\\].*?\\$nativeCode = Get-StubResultWriteWin32Error \\$exception.*?if \\(\\$nativeCode -notin @\\(32, 33\\)\\).*?STUB_RESULT_WRITE_FAILURE.*?throw.*?STUB_RESULT_WRITE_EXHAUSTED.*?STUB_RESULT_WRITE_RETRY.*?Start-Sleep -Milliseconds 25" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Get-StubResultWriteWin32Error.*?if \\(\\$current -is \\[IO\\.IOException\\]\\).*?\\$current\\.HResult -band 0xFFFF.*?if \\(\\$nativeCode -in @\\(32, 33\\)\\).*?return 0" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeLoopTypes\\[\\$id\\]" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedCreates" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "\\$frame\\.command\\.graphCommand\\.command\\.createNode\\._0" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6551", + "sourceLines": "6551-6577", + "diagnostic": "RED: UIA live gate does not drive the node creation sheet through conditional fields, rejected input, and a daemon-rendered create", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "renderedCardCount -gt 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "renderedSidebarCount -gt 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "afterEdit = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "afterTileClick = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)UIA_NODE_CREATION_SHEET_EVIDENCE=.*?\\$postCreateWorkspaceId = Get-MultiProjectAutomationId \"workspace-loop-bar\" \\$createdNodeId.*?\\$postCreateWorkspace = Wait-ForGraphChildren \\$renameRoot \\$rawWalker.*?\\^workspace-\\(loop-bar\\|show-graph\\)-.*?AutomationId -ceq \\$postCreateWorkspaceId.*?Selected loop workspace.*?\\^workspace-show-graph-.*?Show in Graph.*?\\$postCreateShowGraph = @\\(.*?workspace-show-graph-.*?Show in Graph.*?GetCurrentPattern\\(\\[System\\.Windows\\.Automation\\.InvokePattern\\]::Pattern\\)\\.Invoke\\(\\).*?\\$postCreateOverview = Wait-ForGraphChildren \\$renameRoot \\$rawWalker.*?\\^workspace-\\(loop-bar\\|show-graph\\)-.*?Count -eq 0.*?\\^canvas-card-.*?Count -gt 0.*?New Loop exact-ID workspace did not settle on an active graph before sketch/custody.*?# --- Connected sketch promotion and custody child" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[double\\]\\$wireNode\\.heartbeatIntervalSeconds -eq \\$expectedHeartbeatIntervalSeconds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeSheetReceivedCommands\\[0\\]\\.command\\s*\\|\\s*ConvertFrom-Json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeSheetReceivedCommands\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeSheetReceivedCommands\\s*=\\s*@\\(\\$nodeSheetStubAfter\\.receivedGraphCommands\\s*\\|\\s*Where-Object" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)for \\(\\$index = 0; \\$index -lt 100 -and \\$null -eq \\$nodeSheetDispatchedCommand; \\$index\\+\\+\\).*?Start-Sleep -Milliseconds 50" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-NodeCreationLogHasCreate" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedCreates" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "node creation dispatched modelTier" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$wireNode\\s*=\\s*\\$nodeSheetDispatchedCommand\\.command\\.createNode\\._0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ComboSelection\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendKeyInput\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "reasonClearedOnTypeChange = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandLogUnchanged = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Say what to do each time to continue\\." + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Say what done looks like and use positive timing values\\." + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "9600 \\+ \\$tileIndex" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "VisibleChildIds\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickScreenPoint" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nodeCreationSheetEvidence = \\[ordered\\]@\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_SHEET_EVIDENCE" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6677", + "sourceLines": "6677-6679", + "diagnostic": "RED: UIA final summary omits the parsable node creation sheet evidence", + "condition": { + "right": { + "kind": "literal", + "value": "(?s)nodeCreationSheet = \\$nodeCreationSheetEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-6680", + "sourceLines": "6680-6750", + "diagnostic": "RED: UIA gate does not prove all sketch promotions and custody child through native interaction, correlated wire, no mutation and rendered hit tests", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "createdBy" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promoteNode" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotionRequests" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotions" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "rejected/cancelled no-dispatch evidence combines unchanged UIA-recorder bytes with unchanged stub received/applied/request/response/graph counts" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "editing is not validated and that result does not establish a dropped key" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "instructionEditing = \"validated by native Sketch-Type exact readback before and after backend change\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "requestCount = \\[int\\]\\$before\\.requestCount" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotionRequests = @\\(\\$before\\.appliedPromotionRequests\\)\\.Count" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "daemonCountsBefore = \\$beforeCounts; daemonCountsAfter = \\$afterCounts" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "firstInstruction -ceq \\$custodyFirstInstruction" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "instructionUnchanged = \\(\\$custodySubmitFields\\.firstInstruction -ceq \\$custodyFirstInstruction\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyRendered = Open-SketchNodeMenu \"UIA custody child\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyGraphReturn = Wait-ForGraphChildren \\$renameRoot \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyShowGraph\\.GetCurrentPattern\\(\\[System\\.Windows\\.Automation\\.InvokePattern\\]::Pattern\\)\\.Invoke\\(\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyWorkspace = Wait-ForGraphChildren \\$renameRoot \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyWorkspaceId = Get-MultiProjectAutomationId \"workspace-loop-bar\" \\$custodyId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\(\\$custodyLogged \\| ConvertTo-Json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyLogged = \\(Read-DaemonCommandLog" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyCommand\\.projectPath -ceq \"graphcode://stub/project\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "exactIdOutcome = @\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "-not \\[string\\]::IsNullOrWhiteSpace\\(\\[string\\]\\$custodyOutcomes\\[0\\]\\.requestID\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\(ConvertTo-SketchCanonicalJson \\$custodyOutcomeCommand\\) -ceq \\$custodyExpectedOutcomeJson" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyOutcomes\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyExpectedOutcomeJson = ConvertTo-SketchCanonicalJson \\$custodyExpectedOutcome" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Select-Object -Skip @\\(\\$custodyBefore\\.receivedGraphCommands\\)\\.Count" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyNewCommands\\.Count -eq 2" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[int\\]\\$custodyAfter\\.responseCount -eq \\[int\\]\\$custodyBefore\\.responseCount \\+ 2" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[int\\]\\$custodyAfter\\.requestCount -eq \\[int\\]\\$custodyBefore\\.requestCount \\+ 2" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyWire = \\$custodyCommand\\.command\\.createNode\\._0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyCommand = \\[string\\]\\$custodyReceived\\[0\\]\\.command \\| ConvertFrom-Json" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyReceived\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyReceived = @\\(\\$custodyAfter\\.receivedGraphCommands \\| Where-Object \\{ \\$_.requestID -ceq \\$custodyRequest \\}\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyInstructionAfterBackend = Sketch-Field 9104" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Sketch-Type 9104 \\$custodyFirstInstruction" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$custodyFirstInstruction = \"Work on the requested Windows shell task\\.\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "receivedWireRaw = \\$receivedWireRaw" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Test-SketchPromotionReceipt \\$after \\$request \\$expectedWire" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Test-SketchPromotionReceipt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function ConvertTo-SketchCanonicalJson" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "renderedHitTests" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "TypeEditTextById" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsControlOwnedBy" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)public static bool HoverPopupMenuItem.*?FindPopupForMenu\\(ownerProcess, menu\\).*?SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(client\\.X, client\\.Y\\)\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickPopupMenuItem" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$renderedPromotedState = \\$sameCardAutomationId -and\\s*\\$promotionChoices\\.Count -eq 0 -and \\$newChildChoices\\.Count -eq 1" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$sameCardAutomationId = \\$renderMenu\\.cardId -ceq \\$menu\\.cardId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Sketch-Cancel.*?Start-Sleep -Milliseconds 200.*?\\$keyboardFallback = \\[GraphCodeUiaGateState\\]::PostKeyboard\\(\\$script:edgeWorkflowWindow, 0x1B\\).*?keyboardFallback = \\$keyboardFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Sketch-Submit.*?Start-Sleep -Milliseconds 200.*?\\$buttonFallback = \\[GraphCodeUiaGateState\\]::ClickButton\\(\\$button\\).*?buttonFallback = \\$buttonFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$providerZmxBaseline" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_PROVIDER_PROCESS_CLEANUP=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Stop-UiaOwnedProviderProcesses" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "modalCommandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendCommand\\(\\s*\\$renameShellWindow, \\[uint32\\]\\$case\\.Command\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$reopen = Open-SketchNodeMenu \\$title.*?\\$reopenKeyboardReveal = \\[GraphCodeUiaGateState\\]::RevealSubmenuByKeyboard\\(.*?Turn promotion submenu did not reopen by hover or keyboard" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "real Promote to submenu popup did not open by hover or keyboard" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$submenuKeyboardReveal = \\[GraphCodeUiaGateState\\]::RevealSubmenuByKeyboard\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public static bool RevealSubmenuByKeyboard\\(IntPtr popup, IntPtr menu, int position\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_PROMOTION_RENDER_ATTEMPT=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotionRequests" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedPromotions" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandLogBytesUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "createdBy" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promotion\\.timed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promotion\\.turn" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "promotion\\.goal" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)sketchCustody = \\$sketchCustodyEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_CUSTODY_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6773", + "sourceLines": "6773-6787", + "diagnostic": "RED: UIA live gate does not prove the native edge create/edit round trip and no-mutation paths", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "graphCommandsAfter" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "graphCommandsBefore" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "renderedEdgeId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandLogBytesUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeUpdates" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeCreates" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ClickPopupMenuItem" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowIsVisible\\(\\$edgeWorkflowWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Enter the template or script that should carry context\\." + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "updateEdge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "createEdge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)edgeWorkflow = \\$edgeWorkflowEvidence.*?\\}\\s*\\|\\s*ConvertTo-Json -Depth 8 -Compress" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_WORKFLOW_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6788", + "sourceLines": "6788-6800", + "diagnostic": "RED: UIA edge retry/modal/identity proof regressed", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\$Matches\\[1\\] -in @\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\$after -eq \"\\$index\\|\\$expected\"\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "postedFallbacks=\\$postedFallbacks" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)\\$postedFallbacks = 0.*?\\$fallbackDelta = \\$index - \\[int\\]\\(\\$after\\.Split\\(\"\\|\"\\)\\[0\\]\\).*?\\[GraphCodeUiaGateState\\]::PostKeyboard\\(\\$control, \\$fallbackKey\\).*?\\$postedFallbacks\\+\\+" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_COMBO id=\\$id attempt=\\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$delta = \\$index - \\[int\\]\\(\\$after\\.Split\\(\"\\|\"\\)\\[0\\]\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "waitCommandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "commandFallback = \\$commandFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_MODAL_CENSUS" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowIsVisible\\(\\$edgeWorkflowWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "FindVisibleProcessWindow\\(\\[uint32\\]\\$renameProcess\\.Id, \\$title\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6801", + "sourceLines": "6801-6844", + "diagnostic": "RED: edge native text entry lacks native ownership, live layout, focus, clear/retype, or exact verification", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(\\$layoutRetry = 0; \\$layoutRetry -lt 20" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "attempts=\\$attemptsExecuted/5" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-EdgeTextAttemptDecision \\$stable \\$after \\$text \\$attempt 5" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(\\$attempt = 1; \\$attempt -le 5; \\$attempt\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "clearSent=\\$clearSent/\\$clearExpected textSent=\\$textSent/\\$textExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\$inputCountsFull\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditTextSent = checked\\(\\(uint\\)typed\\[0\\]\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendUnicodeInput\\(parent, expectedProcessId, edit, text\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditClearSent = SendKeyInputs\\(LastEditClearExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditTextSent" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditTextExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditClearSent" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditClearExpected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9107 \"cycle guard max\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9106 \"cycle guard until\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9105 \"payload\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_SUBMIT_FIELD name=\\$label id=\\$id" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Read-EdgeStableText\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$renameProcess\\.WaitForInputIdle\\(1000\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_TEXT_STABLE id=\\$id attempt=\\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(\\$stableRetry = 0; \\$stableRetry -lt 10" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)var clear = new KeyInputRecord\\[2\\].*?EditBufferText\\(edit\\).*?var records = new KeyInputRecord\\[text\\.Length \\* 2\\]" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessage\\(edit, 0x00B1, UIntPtr\\.Zero, new IntPtr\\(-1\\)\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessageText\\(edit, 0x000D" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "VirtualKey = 0x2E" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowTextOf\\(\\$edgeWorkflowWindow\\) -eq \\$script:edgeWorkflowTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "WindowProcessId\\(\\$edgeWorkflowWindow\\) -eq \\$renameProcess\\.Id" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FocusedControlInDialog\\(\\$edgeWorkflowWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "HasVisibleBounds\\(\\$control\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsControlOwnedBy\\(\\$edgeWorkflowWindow, \\$control, \\$id\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$completed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "messageFallback=\\$messageFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)if \\(\\$messageFallback\\).*?\\$renameProcess\\.WaitForInputIdle\\(1000\\).*?SetEditTextById\\(\\$edgeWorkflowWindow, \\$id, \\$text\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendMessageString\\(edit, 0x000C, UIntPtr\\.Zero, text\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "LastEditUsedMessageFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::TypeEditTextById\\(\\$edgeWorkflowWindow, \\$id, \\$text\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_EDGE_TEXT_STABLE id=\\$id attempt=\\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-TypeText \\$field\\.Id \\$field\\.Text" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Edge-Click.*?Start-Sleep -Milliseconds 200.*?\\$buttonFallback = \\[GraphCodeUiaGateState\\]::ClickButton\\(\\$control\\).*?buttonFallback = \\$buttonFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "public static bool ClickButton\\(IntPtr button\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-EdgeTextAttemptDecision\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Edge-TypeText\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6909", + "sourceLines": "6909-6915", + "diagnostic": "RED: custody child render check has no bounded observation-only card reacquisition", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Open-SketchNodeMenu \"UIA custody child\" -SkipActualSize -ExpectedCardId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Wait-SketchGraphCard \"UIA custody child\" \\$custodyId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_SKETCH_CUSTODY_CARD_WAIT=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-SketchCardAutomationId\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "function Wait-SketchGraphCard\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-6974", + "sourceLines": "6974-7009", + "diagnostic": "RED: edge workflow does not use unique connector dragging and locked endpoints while preserving edit/CAS and no-mutation proofs", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(\\$renameApplied -or \\$createApplied -or \\$edgeApplied(?: -or \\$promotionApplied)?\\)" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeUpdateRequests" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeCreateRequests" + }, + "left": { + "kind": "resource", + "name": "stub" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "appliedEdgeUpdateRequests" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeCancelBytesUnchanged" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeChange\\.spec\\.condition -eq \"onSuccess\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeChange\\.expectedSpec\\.condition -eq \"onFailure\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "edgeCreateWire\\.command\\.createEdge\\.from" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-DirectChildren \\$graph \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Assert-EdgePrefill \"1\\|Only after success\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Assert-EdgePrefill \"2\\|Only after failure\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 2 \"cancel changed edge\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 1 \"update edge OK\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 1 \"valid edge OK\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Click 1 \"invalid edge OK\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-TypeText \\$field\\.Id \\$field\\.Text" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9104 1 \"Apply a text template\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9103 1 \"Only after success\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9103 2 \"Only after failure\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9102 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Open-EdgeMenu \\$true 5110" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9101 \"locked target identity\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Read-EdgeStableText 9100 \"locked source identity\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::PostMouseButtonAt\\(\\s*\\$renameShellWindow,\\s*0x0202" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[GraphCodeUiaGateState\\]::PostMouseButtonAt\\(\\s*\\$renameShellWindow,\\s*0x0201" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Select-EdgeConnectorCardPair \\$cards \\$sourceTitle \"Stub node B\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Open-EdgeCreateDialogByConnectorDrag\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Select-EdgeConnectorCardPair\\(" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(\\$title -eq \"Create or edit edge\"\\)\\s*\\{\\s*5120" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$edgeSourceChoice\\b|\\$edgeTargetChoice\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$edgeCreateMenu\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9101\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Edge-Combo 9100\\b" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "Open-EdgeMenu \\$false\\s+5120" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7042", + "sourceLines": "7042-7130", + "diagnostic": "RED: overview/project navigation evidence depends on absolute fitted card coordinates instead of normalized shared geometry", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "\\.Equals\\(\\$laneSnapshot\\.CardBounds\\[" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$cardCaptures \\| ForEach-Object \\{ \\$_.bounds \\}" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$sameNormalizedLaneGeometry\\s*=\\s*\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$readNormalizedLaneGeometry\\s*=\\s*\\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Imatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$restoredLaneComparison\\.Equal" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpected = \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActual = \\$restoredLaneGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_RESTORED_LANE_NORMALIZED_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Compare-MultiProjectNormalizedLaneGeometry `\\s*\\$restoredLaneGeometry \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored lane sheet pan up was rejected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored lane sheet pan move was rejected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored lane sheet pan down was rejected" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "before-restored-lane-sheet-pan" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "nonOverlap = \\$laneNonOverlap" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaYError = \\$laneNormalizedComparison\\.DeltaYError" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaXError = \\$laneNormalizedComparison\\.DeltaXError" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ExpectedDeltaYBudget = \\$expectedDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ActualDeltaYBudget = \\$actualDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ExpectedDeltaXBudget = \\$expectedDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ActualDeltaXBudget = \\$actualDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpectedDeltaYBudget = \\$laneNormalizedComparison\\.ExpectedDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActualDeltaYBudget = \\$laneNormalizedComparison\\.ActualDeltaYBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpectedDeltaXBudget = \\$laneNormalizedComparison\\.ExpectedDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActualDeltaXBudget = \\$laneNormalizedComparison\\.ActualDeltaXBudget" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaYTolerance = \\$laneNormalizedComparison\\.DeltaYTolerance" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedDeltaXTolerance = \\$laneNormalizedComparison\\.DeltaXTolerance" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedEqual = \\$laneNormalizedEqual" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedExpected = \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalizedActual = \\$laneOpenedGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_LANE_OPEN_NORMALIZED_EVIDENCE=" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Compare-MultiProjectNormalizedLaneGeometry `\\s*\\$laneOpenedGeometry \\$laneSnapshotGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-MultiProjectNormalizedLaneGeometry `\\s*\\$laneOpenedCaptures \\$laneProjectIds \\$laneNames" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Get-MultiProjectNormalizedLaneGeometry `\\s*\\$laneSnapshot\\.Cards \\$laneOverviewIds \\$laneNames" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$_.Current\\.AutomationId -cin \\$laneOverviewIds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$restoredLaneProbe = Wait-ForGraphChildren \\$root \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "opened project requires one exact card for \\$id" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$laneOpenedCards = @\\(\\$laneProjectIds \\| ForEach-Object" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$_.Current\\.AutomationId -cin \\$laneProjectIds" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$laneOpenedProbe = Wait-ForGraphChildren \\$root \\$rawWalker" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "restored overview lane geometry omitted the exact owned Fit Canvas action" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "opened project lane geometry omitted the exact owned Fit Canvas action" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "lane overview geometry omitted the exact owned Fit Canvas action" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$firstBounds = @\\(\\$cardCaptures\\[0\\]\\.bounds\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "bounds = @\\(\\$rect.Left, \\$rect.Top, \\$rect.Right, \\$rect.Bottom\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "automationId = \\[string\\]\\$_.Current.AutomationId" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$cards = @\\(\\$laneOverviewIds \\| ForEach-Object" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$deltaYTolerance = \\$actualDeltaYBudget \\+ \\$expectedDeltaYBudget \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$deltaXTolerance = \\$actualDeltaXBudget \\+ \\$expectedDeltaXBudget \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$expected\\.PixelHeight - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$actual\\.PixelHeight - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$expected\\.PixelWidth - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\[Math\\]::Max\\(1, \\$actual\\.PixelWidth - 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$expectedDeltaYBudget = 96 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$actualDeltaYBudget = 96 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$expectedDeltaXBudget = 250 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$actualDeltaXBudget = 250 \\*" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "VerticalClipping = \\$verticalClipping" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "HorizontalClipping = \\$horizontalClipping" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ObservedPixelHeights = \\$visibleHeights" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ObservedPixelWidths = \\$visibleWidths" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$heightTolerance = 1 / \\$scaleY \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$widthTolerance = 1 / \\$scaleX \\+ 0\\.01" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PixelHeight = \\[double\\]\\$pixelHeight" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "PixelWidth = \\[double\\]\\$pixelWidth" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ScaleY = \\$scaleY" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ScaleX = \\$scaleX" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$rect\\[3\\] - \\$pixelHeight" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$rect\\[2\\] - \\$pixelWidth" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane requires an unclipped full-size peer on each axis" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$verticalFullPeer = \\$false" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$horizontalFullPeer = \\$false" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "lane card height differs without an exact viewport clipping edge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "lane card width differs without an exact viewport clipping edge" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$pixelHeight = \\[double\\]\\(\\(\\$visibleHeights \\| Measure-Object -Maximum\\)\\.Maximum\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$pixelWidth = \\[double\\]\\(\\(\\$visibleWidths \\| Measure-Object -Maximum\\)\\.Maximum\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane cards overlap" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane card is outside the graph viewport" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane card identity/order differs from the exact fixture" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "normalized lane source geometry must remain exactly 250x96" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Compare-MultiProjectNormalizedLaneGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "function Get-MultiProjectNormalizedLaneGeometry" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "y \\+ scaled\\(Tokens\\.loop_card_height, state\\)" + }, + "left": { + "kind": "resource", + "name": "graphCanvas" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "x \\+ scaled\\(Tokens\\.loop_card_width, state\\)" + }, + "left": { + "kind": "resource", + "name": "graphCanvas" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "pub const card_height: f32 = 96;" + }, + "left": { + "kind": "resource", + "name": "laneLayout" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "pub const card_width: f32 = 250;" + }, + "left": { + "kind": "resource", + "name": "laneLayout" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "pub const loop_card_height: i32 = 96;" + }, + "left": { + "kind": "resource", + "name": "designTokens" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "pub const loop_card_width: i32 = 250;" + }, + "left": { + "kind": "resource", + "name": "designTokens" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7348", + "sourceLines": "7348-7350", + "diagnostic": "RED: UIA live gate node creation sheet label comparison can pass vacuously on an empty expected list", + "condition": { + "right": { + "kind": "literal", + "value": "expected label list is empty or blank" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + } + }, + { + "id": "ui.root.source.line-7351", + "sourceLines": "7351-7354", + "diagnostic": "RED: UIA focus retention waits for any FocusChanged event instead of the focused row identity", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(\\[GraphCodeUiaGateState\\]::FocusSourceAutomationId -eq \\$safeRowId\\) \"FocusChanged source identity changed\"" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)for \\(\\$index = 0; \\$index -lt 20 -and\\s*\\[GraphCodeUiaGateState\\]::FocusSourceAutomationId -ne \\$safeRowId; \\$index\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7355", + "sourceLines": "7355-7364", + "diagnostic": "RED: UIA live gate node creation sheet cannot measure and record controls occluded outside the monitor work area", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_OCCLUSION" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "overlapPixels = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "footerOccludedByTaskbar = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "has no visible portion inside" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\(-not \\$hit\\.VisibleEmpty\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "RealChildWindowFromPoint\\(dialog, clientPoint\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "HitTarget = !visibleEmpty && sameTopLevel && realChild == target" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "SystemParametersInfoRect\\(0x0030" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7365", + "sourceLines": "7365-7380", + "diagnostic": "RED: UIA live gate node creation sheet cannot click and record a footer control partly covered by scrolled content", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "uncoveredRectangles = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "ChosenUncoveredRectangle = piece" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "GetWindow\\(target, 3\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SubtractCoveredRectangles" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "has no verified uncovered point in" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_CONTENT_OCCLUSION" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "createCentreClicks = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "coveredFraction = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "footerOccludedByContent = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\} else if \\(!hit\\.HitTarget\\) \\{" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "return RealChildWindowFromPoint\\(dialog, clientPoint\\) == target;" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(int column = 1; column <= 5; column\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "for \\(int row = 1; row <= 3; row\\+\\+\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "if \\(!hit\\.HitTarget && !visibleEmpty && dialog != IntPtr\\.Zero\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7381", + "sourceLines": "7381-7386", + "diagnostic": "RED: UIA live gate does not check the native modal remains visible after rejected Create", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$stillOpen" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$stillOpen = \\$nativeVisible -and \\$nativeTitle -eq \\$nodeSheetTitle" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$nativeVisible = \\[GraphCodeUiaGateState\\]::WindowIsVisible\\(\\$nodeSheetWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_INVALID_WINDOW" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7387", + "sourceLines": "7387-7395", + "diagnostic": "RED: UIA live gate omits modal liveness retry, native closure, or footer-click geometry", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "sub-3px verified uncovered strip" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "(?s)function Invoke-NodeSheetClick.*?Start-Sleep -Milliseconds 200.*?\\$buttonFallback = \\[GraphCodeUiaGateState\\]::ClickButton\\(\\$control\\).*?buttonFallback = \\$buttonFallback" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_FOOTER_CLICK" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "if \\(-not \\[GraphCodeUiaGateState\\]::WindowIsVisible\\(\\$nodeSheetWindow\\) -or" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "Require \\$nodeSheetClosed" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "UIA_NODE_CREATION_INVALID_WINDOW" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "(?s)for \\(\\$attempt = 1; \\$attempt -le 10; \\$attempt\\+\\+\\).*?uiaRecoveredAtAttempt = \\$attempt" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + }, + { + "id": "ui.root.source.line-7396", + "sourceLines": "7396-7403", + "diagnostic": "RED: occluded Goal Create cannot submit with a measured, focused native Enter fallback", + "condition": { + "right": { + "right": { + "kind": "literal", + "value": "Invoke-NodeSheetClick 1 \"Create \\(\\$loopType, invalid\\)\" -allowOccludedEnter:\\(\\$loopType -eq \"goalBased\"\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "SendKeyInput\\(\\$nodeSheetWindow, \\[uint32\\]\\$renameProcess.Id, \\$goalEdit, 0x0D, 1\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "FocusControl\\(\\$nodeSheetWindow, \\$goalEdit\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "IsForegroundWindow\\(\\$nodeSheetWindow\\)" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "right": { + "kind": "literal", + "value": "\\$allowOccludedEnter -and -not \\$hit\\.HitTarget -and \\$hit\\.ScannedPoints -gt 0" + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "left": { + "right": { + "kind": "literal", + "value": "mouseSubmitUnavailable = " + }, + "left": { + "kind": "resource", + "name": "gate" + }, + "kind": "binary", + "operator": "Inotmatch" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + }, + "kind": "binary", + "operator": "Or" + } + } +] \ No newline at end of file diff --git a/Tools/windows/ValidationRunner/Ui/source-case-map.json b/Tools/windows/ValidationRunner/Ui/source-case-map.json index 79af22f7..7327b22e 100644 --- a/Tools/windows/ValidationRunner/Ui/source-case-map.json +++ b/Tools/windows/ValidationRunner/Ui/source-case-map.json @@ -5,871 +5,2052 @@ "id": "ui.activation.foreground-refused", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "195-290", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.foreground-acquired", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "195-290", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.ownership-refused", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "195-290", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.invalid-window-refused", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "195-290", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.owned-key", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.foreign-foreground", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.foreign-focus", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.sibling-focus", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.missing-focus", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.dead-retained-process", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.hidden-window", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.disabled-window", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.owned-unicode", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.unicode-foreign-focus", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.owned-mouse", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.mouse-hit-loss", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.mouse-cursor-loss", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.mouse-foreign-foreground", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.owned-edit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.edit-focus-loss-after-select", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.edit-focus-loss-after-read", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.owned-submenu-hover", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.submenu-foreign-foreground", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.submenu-unretained-process", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.input.submenu-covered-point", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "758-890", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.owned-shell", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.owned-popup", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.foreign-foreground", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.foreign-foreground-root", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.foreign-hit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.underlying-shell-hit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.hidden-popup", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.unknown-owner", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.foreign-popup", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.predicate.absent-popup", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1221-1255", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.owned", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.owned-enter", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.foreign-foreground", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.foreign-after-hover", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.foreign-before-enter", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.foreign-owner-all", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.foreign-hit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.underlying-shell-hit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.hidden-popup", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.cursor-changed", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.effect.cursor-unreadable", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1256-1328", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.geometry.seven-pixel-band", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7626-7643", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.geometry.multiple-covers", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7626-7643", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.gate-state.activation-reference", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7408-7430", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.gate-state.input-reference", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7408-7430", - "state": "Implemented" - }, - { - "id": "ui.native", - "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", - "lines": "5896-5924", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.named-direct-viewport", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.hidden-write-read", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.missing-id", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.missing-write-read", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.direct-precedence", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.wrong-root", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.wrong-viewport", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.other-sibling", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.no-deeper-fallback", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.direct-viewport-required", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.native-form", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.workspace-manager", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.unknown-content", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.lookup.null-parent", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.hit.nested-edit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.hit.content-background", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.hit.wrong-root", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.hit.wrong-container", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.hit.no-recursion", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.native.hit.direct-child", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7431-7624", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.host-info.current-session", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5896-5924", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.source.actual", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-223", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.source.unshared", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-223", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.source.missing-initial-owner", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-223", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.source.missing-fresh-owner", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-223", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.source.missing-restore", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-223", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.activation.source.missing-native-error", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-223", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.actual", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.bypass-guard", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.stale-foreground", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.stale-cursor", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.missing-held-owner-check", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.missing-pre-cursor-guard", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.missing-pre-enter-guard", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.wrong-hit-root", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.popup.source.extra-input", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1164-1220", - "state": "Implemented" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.mock.isolation.absent", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "19-88", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.mock.isolation.empty", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "19-88", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.mock.isolation.valued", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "19-88", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.mock.isolation.failure-identity", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "19-88", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.mock.isolation.ordinary-diagnostic", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "19-88", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.initially-empty", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.initially-offscreen", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.already-visible", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.mark-only-empty", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.delayed-visible", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.outside-before-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.wrong-source-owner", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.wrong-fit-owner", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.fit-offscreen", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.fit-disabled", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.remains-empty", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.remains-offscreen", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.wrong-id-after-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.wrong-owner-after-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.outside-after-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.missing-after-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.mutant-skip-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.mutant-unsafe-offscreen-bounds", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.mutant-wrong-identity-accepted", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.recovery.visibility.mutant-outside-bounds-accepted", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "989-1162", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.actual", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.missing-fit", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.wrong-identity", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.missing-offscreen", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.ungated-bounds", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.escaped-bounds", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.stale-visibility", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.missing-containment", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.visibility.missing-require", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1330-1424", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.rejection", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.success", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.unavailable", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.foreign-pid", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.identity-drift", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.zero-bounds", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.offscreen", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.disabled", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.selected", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.runtime-unavailable", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.parent-unavailable", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.telemetry-write-failure", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.receipt-exists", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.receipt-unconfigured", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.deadline", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.long-identity", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.long-runtime", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.invalid-boolean", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.nonfinite-bounds", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.exception-chain", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.delete.select.foreign-parent", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "1426-1670", - "state": "Incomplete" + "state": "Implemented", + "owner": "Ui" }, { "id": "ui.host-info.diagnostics", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5896-5924", - "state": "Incomplete" + "state": "Incomplete", + "owner": "Ui" }, { - "id": "ui.root.source-assertions", + "id": "ui.production.consumer-linkage", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", - "lines": "5342-7403", - "state": "Incomplete" + "lines": "169-194;6751-6778;7408-7430", + "state": "Incomplete", + "owner": "Ui" }, { - "id": "ui.production.consumer-linkage", + "id": "ui.root.source.line-5342", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", - "lines": "169-194;6751-6778;7408-7430", - "state": "Incomplete" + "lines": "5342-5344", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5350", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5350-5352", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5369", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5369-5374", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5528", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5528-5534", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5545", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5545-5548", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5572", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5572-5574", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5584", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5584-5586", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5973", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5973-5978", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5979", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5979-5986", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5987", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5987-6000", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6062", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6062-6065", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6097", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6097-6099", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6100", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6100-6103", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6165", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6165-6170", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6171", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6171-6181", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6199", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6199-6209", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6210", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6210-6214", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6215", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6215-6220", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6221", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6221-6225", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6226", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6226-6230", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6231", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6231-6239", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6246", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6246-6249", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6250", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6250-6255", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6257", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6257-6265", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6278", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6278-6284", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6285", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6285-6288", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6339", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6339-6344", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6345", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6345-6350", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6355", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6355-6372", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6417", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6417-6425", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6426", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6426-6429", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6430", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6430-6435", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6436", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6436-6439", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6440", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6440-6444", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6446", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6446-6453", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6454", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6454-6461", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6462", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6462-6465", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6472", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6472-6513", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6514", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6514-6516", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6520", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6520-6525", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6526", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6526-6541", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6542", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6542-6550", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6551", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6551-6577", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6677", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6677-6679", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6680", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6680-6750", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6773", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6773-6787", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6788", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6788-6800", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6801", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6801-6844", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6909", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6909-6915", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6974", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6974-7009", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7042", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7042-7130", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7348", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7348-7350", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7351", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7351-7354", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7355", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7355-7364", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7365", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7365-7380", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7381", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7381-7386", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7387", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7387-7395", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7396", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7396-7403", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.unknown-operator", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.unknown-resource", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.missing-file", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.malformed-tree", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.actual-predicate-false", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.wrong-production-text", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.depth-boundary", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.evaluator.depth-overflow", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "Compiled source evaluator negative controls", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5346", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5346-5348", + "state": "Incomplete", + "owner": "Host" + }, + { + "id": "ui.root.source.line-5366", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5366-5368", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5380", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5380-5382", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5392", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5392-5394", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5396", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5396-5398", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5399", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5399-5401", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5407", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5407-5409", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5410", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5410-5413", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5432", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5432-5435", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5449", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5449-5451", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5457", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5457-5459", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5462", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5462-5464", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5470", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5470-5472", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5477", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5477-5479", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5485", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5485-5488", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5501", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5501-5503", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5514", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5514-5516", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5524", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5524-5526", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5537", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5537-5544", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5553", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5553-5555", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5561", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5561-5563", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5569", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5569-5571", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5580", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5580-5583", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5632", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5632-5641", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5659", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5659-5662", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5683", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5683-5687", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5696", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5696-5698", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5713", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5713-5715", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5724", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5724-5726", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5739", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5739-5741", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5752", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5752-5754", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5761", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5761-5763", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5773", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5773-5777", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5783", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5783-5785", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5792", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5792-5794", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5807", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5807-5809", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5812", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5812-5814", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5838", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5838-5838", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5845", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5845-5850", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5853", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5853-5856", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5859", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5859-5861", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5882", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5882-5888", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5897", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5897-5899", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5905", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5905-5908", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5914", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5914-5916", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5918", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5918-5922", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-5945", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5945-5945", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5947", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5947-5949", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-5970", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "5970-5972", + "state": "Incomplete", + "owner": "Ownership" + }, + { + "id": "ui.root.source.line-6057", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6057-6061", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6093", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6093-6096", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6157", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6157-6164", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6182", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6182-6198", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6276", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6276-6276", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6292", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6292-6292", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6298", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6298-6298", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6305", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6305-6307", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6314", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6314-6321", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6326", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6326-6326", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6352", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6352-6354", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6412", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6412-6416", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6467", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6467-6471", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6591", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6591-6596", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6654", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6654-6676", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6754", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6754-6756", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6770", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6770-6772", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6853", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6853-6855", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6864", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6864-6864", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6866", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6866-6868", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6870", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6870-6872", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6877", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6877-6879", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6892", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6892-6894", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6905", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6905-6908", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6921", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6921-6923", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6925", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6925-6933", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6939", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6939-6941", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6958", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6958-6960", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-6967", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "6967-6973", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7027", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7027-7041", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7136", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7136-7138", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7235", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7235-7239", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7326", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7326-7328", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7337", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7337-7339", + "state": "Incomplete", + "owner": "Ui" + }, + { + "id": "ui.root.source.line-7344", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7344-7346", + "state": "Incomplete", + "owner": "Ui" } ] } From 185e524e61dfdcac577ddd0d6aba86c780a02f7a Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:55:17 -0700 Subject: [PATCH 12/46] Preserve ownership PowerShell semantics in protected focused adapters Run exact receipt7/capture14/tree4/provider4/pre-UIA10/retention6 families through the compiled owned-child transport. Keep mixed and newly reconciled startup/tree assertions mandatory incomplete, with immutable 126-template/192-assertion source correspondence. Expose host terminal retention hold so process exit cannot close unproved jobs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ownership.json | 10 +- .../Ownership/Adapters/AdapterSupport.ps1 | 72 ++ .../Ownership/Adapters/Capture.Contracts.ps1 | 163 ++++ .../Adapters/PreUiaIsolation.Contracts.ps1 | 82 ++ .../Ownership/Adapters/Receipt.Contracts.ps1 | 83 ++ .../Adapters/RetentionBoundary.Contracts.ps1 | 82 ++ .../Ownership/Adapters/RetentionChild.ps1 | 34 + .../Adapters/TreeProvider.Contracts.ps1 | 106 +++ .../Ownership/OwnedChildSupervisor.cs | 6 + .../Ownership/OwnershipJobFixture.cs | 16 + .../ValidationRunner/Ownership/README.md | 27 +- .../Ownership/SuiteRegistration.cs | 31 +- .../Ownership/source-correspondence.json | 894 ++++++++++++++++++ 13 files changed, 1589 insertions(+), 17 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/Receipt.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs create mode 100644 Tools/windows/ValidationRunner/Ownership/source-correspondence.json diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json index a1a52fc9..67a7b0a3 100644 --- a/Tools/windows/ValidationRunner/Manifests/ownership.json +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -87,6 +87,14 @@ { "id": "ownership.legacy.rename-submit", "owner": "Ownership", "minimumCount": 1 }, { "id": "ownership.legacy.fresh-capture", "owner": "Ownership", "minimumCount": 1 }, { "id": "ownership.legacy.packaging-shared-linkage", "owner": "Ownership", "minimumCount": 1 }, - { "id": "ownership.legacy.uia-shared-linkage", "owner": "Ownership", "minimumCount": 1 } + { "id": "ownership.legacy.uia-shared-linkage", "owner": "Ownership", "minimumCount": 1 }, + { "id": "ownership.ui-startup.helpers", "owner": "Ownership", "minimumCount": 13, "exactCount": 13 }, + { "id": "ownership.ui-startup.redacted-log", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.ui-startup.truncated-log", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.ui-startup.missing-log", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.ui-startup.retained-report", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.ui-process-tree.controlled-child", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.ui-process-tree.cleanup-complete", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.ui-process-tree.pid-reuse", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 } ] } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 new file mode 100644 index 00000000..fc209a49 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 @@ -0,0 +1,72 @@ +$ErrorActionPreference = 'Stop' +$script:OwnershipCases = [Collections.Generic.List[object]]::new() + +function Read-OwnershipProductionAst([string] $Path) { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$errors) + if ($errors.Count -ne 0) { throw "Production source failed to parse: $Path" } + return $ast +} + +function Import-OwnershipProductionFunctions($Ast, [string[]] $Names, [hashtable] $Replacements = @{}) { + foreach ($name in $Names) { + $definitions = @($Ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($definitions.Count -ne 1) { throw "Expected one complete production function: $name" } + $text = $definitions[0].Extent.Text + foreach ($key in $Replacements.Keys) { $text = $text.Replace($key, $Replacements[$key]) } + . ([scriptblock]::Create($text)) + } +} + +function Assert-Ownership([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} + +function Invoke-OwnershipCase([string] $Id, [scriptblock] $Body) { + if (@($script:OwnershipCases | Where-Object Id -CEQ $Id).Count -ne 0) { throw "Duplicate ownership case: $Id" } + try { + & $Body 6>&1 | ForEach-Object { + if ([string]$_ -cne 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') { $_ } + } | Out-Null + $script:OwnershipCases.Add([ordered]@{ Id = $Id; Passed = $true; Failure = $null }) + } catch { + $script:OwnershipCases.Add([ordered]@{ Id = $Id; Passed = $false; Failure = $_.ToString() }) + } +} + +function Complete-OwnershipFamily([string] $Id, [int] $Expected, [string[]] $Groups = @('powershell-semantics')) { + $failed = @($script:OwnershipCases | Where-Object { -not $_.Passed }) + $passed = $script:OwnershipCases.Count -eq $Expected -and $failed.Count -eq 0 -and $Expected -gt 0 + $row = [ordered]@{ + id = $Id + status = $(if ($passed) { 0 } else { 1 }) + executedCount = $script:OwnershipCases.Count + evidence = (@{ + expected = $Expected + executed = $script:OwnershipCases.Count + failed = $failed.Count + cases = $script:OwnershipCases.ToArray() + nativeProductCalls = 0 + } | ConvertTo-Json -Depth 12 -Compress) + groups = $Groups + } + Write-Output ('OWNERSHIP_CASE_RESULT=' + ($row | ConvertTo-Json -Depth 14 -Compress)) + $script:OwnershipCases.Clear() + if (-not $passed) { $script:OwnershipAdapterFailed = $true } +} + +function Restore-OwnershipEnvironment([Collections.IDictionary] $Snapshot, [string[]] $Names) { + foreach ($name in $Names) { + if ($Snapshot.Contains($name)) { [Environment]::SetEnvironmentVariable($name, $Snapshot[$name], 'Process') } + else { Remove-OwnershipEnvironment $name } + } +} + +function Remove-OwnershipEnvironment([string] $Name) { + if ([Environment]::GetEnvironmentVariables('Process').Contains($Name)) { + Remove-Item -LiteralPath "Env:\$Name" -ErrorAction Stop + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 new file mode 100644 index 00000000..0fd73672 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 @@ -0,0 +1,163 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +$jobReplacements = @{ + '[StandaloneProcessJob]' = '[GraphCode.Validation.Ownership.OwnershipJobFixture]' + '[GraphCode.Validation.Ownership.StandaloneProcessJob]' = '[GraphCode.Validation.Ownership.OwnershipJobFixture]' +} +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Complete-MultiProjectCapture', + 'Start-UiaOwnedProcess', 'Invoke-UiaCleanupActions') $jobReplacements +$saved = [Environment]::GetEnvironmentVariables('Process') +Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' +try { + Invoke-OwnershipCase 'cleanup-all-actions-preserve-first-and-retention' { + $state = @{ calls = 0 } + $first = [IO.IOException]::new('original cleanup error') + $tagged = New-UiaOwnershipFailure 'later ownership refusal' 6>$null + $failure = $null + try { + Invoke-UiaCleanupActions @( + { $state.calls++; throw $first }, + { $state.calls++; throw $tagged }, + { $state.calls++ } + ) + } catch { $failure = $_ } + Assert-Ownership ($state.calls -eq 3 -and $failure -and + [Object]::ReferenceEquals($failure.Exception.InnerExceptions[0], $first) -and + $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) 'Cleanup skipped an action, replaced first exception identity, or lost later retention.' + } + $boundaries = @($gate.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if ($cleanupFailures.Count -gt 0) {', [StringComparison]::Ordinal) + }, $true)) + if ($boundaries.Count -ne 1) { throw 'Expected one actual late multi-project cleanup boundary.' } + foreach ($hasPrimary in @($false, $true)) { + Invoke-OwnershipCase "late-cleanup-primary-$hasPrimary" { + $cleanupFailures = [Collections.Generic.List[Exception]]::new() + $first = [IO.IOException]::new('first phase cleanup error') + $cleanupFailures.Add($first) + $cleanupFailures.Add((New-UiaOwnershipFailure 'later phase ownership refusal' 6>$null)) + $primaryError = if ($hasPrimary) { + [Management.Automation.ErrorRecord]::new([IO.IOException]::new('original phase error'), 'Phase', 'NotSpecified', $null) + } else { $null } + $original = $primaryError + $failure = $null + try { . ([scriptblock]::Create($boundaries[0].Extent.Text)) } catch { $failure = $_ } + $reported = if ($hasPrimary) { $primaryError.Exception } else { $failure.Exception } + Assert-Ownership ($reported -and $reported.Data['GraphCodeValidationRootRetentionRequired'] -eq $true -and + ((-not $hasPrimary -and [Object]::ReferenceEquals($reported.InnerExceptions[0], $first)) -or + ($hasPrimary -and -not $failure -and [Object]::ReferenceEquals($primaryError, $original) -and + $reported.Message -ceq 'original phase error'))) 'Late cleanup lost ErrorRecord/original exception identity or retention intent.' + } + } + foreach ($variant in @('assigned-success', 'unassigned-barrier', 'root-survivor', 'job-survivor', 'reader-fault', 'primary-and-survivor')) { + Invoke-OwnershipCase $variant { + $state = @{ kills = 0; disposed = 0 } + $root = [pscustomobject]@{ + HasExited = $false + ExitVerified = $variant -cne 'root-survivor' + StandardOutput = [IO.StreamReader]::new([IO.MemoryStream]::new()) + StandardError = [IO.StreamReader]::new([IO.MemoryStream]::new()) + } + $root | Add-Member ScriptMethod Kill { $state.kills++; $this.HasExited = $true } + $root | Add-Member ScriptMethod WaitForExit { $this.ExitVerified } + $root | Add-Member ScriptMethod Dispose { $state.disposed++ } + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Empty = $variant -notin @('job-survivor', 'primary-and-survivor') + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Terminated = 0 + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Closed = 0 + $capture = [ordered]@{ + process = $root; rootProcess = $root; rootPID = 44; rootStartUtcTicks = 1 + targetPID = 44; targetStartUtcTicks = 1; job = [IntPtr]12 + assigned = $variant -cne 'unassigned-barrier'; stem = 'controlled'; streamingLogs = $true; cleaned = $false + stdout = [Threading.Tasks.Task]::FromResult[string]('stdout') + stderr = [Threading.Tasks.Task]::FromResult[string]('stderr') + } + if ($variant -ceq 'reader-fault') { + $capture.stdout = [Threading.Tasks.Task]::FromException[string]([IO.IOException]::new('original reader failure')) + } + $primary = if ($variant -ceq 'primary-and-survivor') { + [Management.Automation.ErrorRecord]::new([IO.IOException]::new('original error'), 'Original', 'NotSpecified', $null) + } else { $null } + $originalPrimary = $primary + $failure = $null + try { $null = Complete-MultiProjectCapture $capture $ArtifactRoot 1000 $primary } catch { $failure = $_ } + $succeeded = $variant -in @('assigned-success', 'unassigned-barrier') + if ($succeeded) { + Assert-Ownership (-not $failure -and $capture.cleaned -and + [GraphCode.Validation.Ownership.OwnershipJobFixture]::Closed -eq 1 -and $state.disposed -eq 1 -and + $state.kills -eq [int]($variant -ceq 'unassigned-barrier')) 'Assigned job or unreleased retained barrier cleanup changed.' + } else { + $reported = if ($primary) { $primary.Exception } else { $failure.Exception } + Assert-Ownership ($reported -and $reported.Data['GraphCodeValidationRootRetentionRequired'] -eq $true -and + -not $capture.cleaned -and [GraphCode.Validation.Ownership.OwnershipJobFixture]::Closed -eq 0 -and + $state.disposed -eq 0 -and $state.kills -eq 0) 'UNKNOWN cleanup released authority/readers or lost retention.' + if ($primary) { + Assert-Ownership (-not $failure -and [Object]::ReferenceEquals($primary, $originalPrimary) -and + $primary.Exception.Message -ceq 'original error') 'Cleanup replaced the original ErrorRecord.' + } + $root.StandardOutput.Dispose() + $root.StandardError.Dispose() + } + } + } + $path = Join-Path $ArtifactRoot ('capture-' + [guid]::NewGuid().ToString('N') + '.log') + $reader = [IO.StreamReader]::new([IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes("first`nsecond"))) + try { + Invoke-OwnershipCase 'shared-raw-stream' { + $task = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($reader, $path) + $text = $task.GetAwaiter().GetResult() + Assert-Ownership ($text -ceq "first`nsecond" -and [IO.File]::ReadAllText($path) -ceq $text) 'Complete production stream capture changed raw output.' + } + Invoke-OwnershipCase 'shared-create-new-evidence' { + $failure = $null + try { $null = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($reader, $path) } catch { $failure = $_ } + Assert-Ownership ($failure -and [IO.File]::ReadAllText($path) -ceq "first`nsecond") 'Production stream capture overwrote evidence.' + } + } finally { $reader.Dispose() } + $launch = @{} + function Start-MultiProjectOwnedProcess { + param($file, $arguments, $environment, $stem, [switch]$Visible, [switch]$RegisterForUia, + $standardOutputPath, $standardErrorPath, $logDirectory, $targetWorkingDirectory) + $launch.arguments = $arguments; $launch.environment = $environment + $launch.visible = $Visible; $launch.registered = $RegisterForUia + $launch.cwd = $targetWorkingDirectory; $launch.stdout = $standardOutputPath + return @{ process = 'controlled-owned-target' } + } + foreach ($hidden in @($false, $true)) { + Invoke-OwnershipCase "owned-routing-hidden-$hidden" { + $stderr = Join-Path $ArtifactRoot 'fixture-stderr.log' + $result = Start-UiaOwnedProcess -file 'controlled.exe' -arguments @('space argument', '', '"literal"') -standardErrorPath $stderr -Hidden:$hidden + Assert-Ownership ($result -ceq 'controlled-owned-target' -and $launch.arguments.Count -eq 3 -and + $launch.arguments[0] -ceq 'space argument' -and $launch.arguments[1] -ceq '' -and + $launch.arguments[2] -ceq '"literal"' -and $launch.visible -eq (-not $hidden) -and + $launch.registered -and $launch.cwd -ceq (Get-Location).ProviderPath -and + $launch.environment.TEMP -ceq $env:TEMP -and + $launch.stdout -ceq (Join-Path $ArtifactRoot 'fixture-stdout.log')) 'Actual launch wrapper changed argv/environment/cwd/visibility/stdio/registry routing.' + } + } + Invoke-OwnershipCase 'actual-assignment-membership-retain-order' { + $definition = $gate.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Start-MultiProjectOwnedProcess' + }, $true) + Assert-Ownership ($null -ne $definition) 'Actual owned launcher missing.' + $source = $definition.Extent.Text + $assign = $source.IndexOf('::Assign') + $release = $source.IndexOf('[IO.File]::WriteAllText($gate, "assigned")') + $member = $source.IndexOf('::RequireJobMember') + $retain = $source.IndexOf('[GraphCodeUiaGateState]::RetainProcess') + Assert-Ownership ($assign -ge 0 -and $release -gt $assign -and $member -gt $release -and $retain -gt $member) 'Actual gate lost job assignment, native membership or retained input authority order.' + } +} finally { + Restore-OwnershipEnvironment $saved @('GRAPHCODE_VALIDATION_ROOT') +} +Complete-OwnershipFamily 'ownership.legacy.capture-ownership' 14 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 new file mode 100644 index 00000000..e589038e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\validate.ps1')) +$region = [regex]::Match($source, + '(?s) \$preUiaProcesses =.*?(?= Invoke-Native "Native UI Automation live gate")') +if (-not $region.Success) { throw 'Actual pre-UIA advisory isolation guard is missing.' } +$guard = [scriptblock]::Create($region.Value + "`n`$state.gateEntries++") +$repoRoot = Join-Path $ArtifactRoot 'pre-uia-repo' +$created = [datetime]::new(2026, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) +function New-IsolationObservation([int] $PidValue, [string] $Path, [datetime] $Creation) { + [pscustomobject]@{ + ProcessId = $PidValue; ParentProcessId = 4000; ExecutablePath = $Path + CreationDate = $Creation; Name = [IO.Path]::GetFileName($Path); SessionId = 1 + } +} +$shell = New-IsolationObservation 4242 (Join-Path $repoRoot 'graphcode-windows.exe') $created +$daemon = New-IsolationObservation 4243 (Join-Path $repoRoot 'graphcoded.exe') $created +$foreign = New-IsolationObservation 4244 (Join-Path $ArtifactRoot 'foreign\graphcoded.exe') $created +$sibling = New-IsolationObservation 4245 (Join-Path ($repoRoot + '-other') 'graphcoded.exe') $created +$reused = New-IsolationObservation 4242 $shell.ExecutablePath $created.AddSeconds(1) +$uppercase = New-IsolationObservation 4246 $shell.ExecutablePath.ToUpperInvariant() $created +$cases = @( + @{ id = 'unexpected-shell'; rows = @($shell); baseline = @(); refused = @(4242) }, + @{ id = 'unexpected-daemon'; rows = @($daemon); baseline = @(); refused = @(4243) }, + @{ id = 'empty-inventory'; rows = @(); baseline = @(); refused = @() }, + @{ id = 'baseline-generation'; rows = @($shell); baseline = @($shell); refused = @() }, + @{ id = 'foreign-repository'; rows = @($foreign); baseline = @(); refused = @() }, + @{ id = 'sibling-prefix'; rows = @($sibling); baseline = @(); refused = @() }, + @{ id = 'reused-baseline-pid'; rows = @($reused); baseline = @($shell); refused = @(4242) }, + @{ id = 'multiple-candidates'; rows = @($shell, $daemon); baseline = @(); refused = @(4242, 4243) }, + @{ id = 'case-insensitive-repository'; rows = @($uppercase); baseline = @(); refused = @(4246) }, + @{ id = 'baseline-and-unexpected'; rows = @($shell, $daemon); baseline = @($shell); refused = @(4243) } +) +function Get-CimInstance { + param($ClassName, $Filter, $ErrorAction) + $state.probes++ + Assert-Ownership ($ClassName -ceq 'Win32_Process' -and + $Filter -ceq "Name = 'graphcode-windows.exe' OR Name = 'graphcoded.exe'") 'Guard changed its single advisory query.' + return $state.rows +} +function Stop-Process { $state.terminations++; throw 'Forbidden CIM-only termination.' } +function Get-Process { throw 'Advisory guard must not acquire or terminate a process.' } +function Start-Sleep { throw 'Advisory guard must not poll.' } +function Write-Host { param($Object) $state.messages.Add([string]$Object) } +foreach ($case in $cases) { + Invoke-OwnershipCase $case.id { + $state = @{ rows = $case.rows; probes = 0; terminations = 0; gateEntries = 0; messages = [Collections.Generic.List[string]]::new() } + $uiaProductProcessBaseline = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $case.baseline) { + $null = $uiaProductProcessBaseline.Add("$([int]$entry.ProcessId)|$($entry.CreationDate.ToUniversalTime().ToString('o'))") + } + $failure = $null + try { & $guard | Out-Null } catch { $failure = $_ } + Assert-Ownership ($state.probes -eq 1 -and $state.terminations -eq 0) 'Advisory query was repeated or used as termination authority.' + $snapshots = @($state.messages | Where-Object { $_.StartsWith('WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT=', [StringComparison]::Ordinal) }) + Assert-Ownership ($snapshots.Count -eq 1) 'Advisory snapshot missing or duplicated.' + $snapshot = @($snapshots[0].Substring('WINDOWS_SHELL_PRE_UIA_PROCESS_SNAPSHOT='.Length) | ConvertFrom-Json) + Assert-Ownership ($snapshot.Count -eq $case.rows.Count) 'Snapshot lost original observations.' + if ($case.refused.Count -gt 0) { + Assert-Ownership ($failure -and $failure.Exception.Message.Contains('not attested as run-owned') -and + $failure.Exception.Message.Contains('no processes were terminated') -and + $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -ceq $true -and + $state.gateEntries -eq 0) 'Guard did not refuse before gate with exact retention and authority diagnostics.' + foreach ($observedPid in $case.refused) { + Assert-Ownership ($failure.Exception.Message -match "(?&1 | ForEach-Object { + if ([string]$_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') { + 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' + } else { $_ } + } + } finally { Restore-OwnershipEnvironment $snapshot @('GRAPHCODE_VALIDATION_ROOT') } +} +$fixture = Join-Path $ArtifactRoot ('receipt-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $fixture -ErrorAction Stop +try { + foreach ($variant in @('valid', 'existing', 'missing', 'file', 'empty', 'absent', 'isolated')) { + Invoke-OwnershipCase $variant { + $root = Join-Path $fixture $variant + if ($variant -in @('valid', 'existing', 'isolated')) { + $null = New-Item -ItemType Directory -Path $root + } + if ($variant -ceq 'file') { [IO.File]::WriteAllText($root, 'not a directory') } + $path = Join-Path $root 'ownership-unproven.json' + if ($variant -ceq 'existing') { [IO.File]::WriteAllText($path, 'preserve-existing-receipt') } + if ($variant -ceq 'absent') { + Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' + } else { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', $(if ($variant -ceq 'empty') { '' } else { $root }), 'Process') + } + $records = @( + if ($variant -ceq 'isolated') { Invoke-ReceiptMock { New-UiaOwnershipFailure 'controlled receipt refusal' } } + else { New-UiaOwnershipFailure 'controlled receipt refusal' 6>&1 } + ) + $failures = @($records | Where-Object { $_ -is [Exception] }) + $signals = @($records | Where-Object { + [string]$_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' + }) + Assert-Ownership ($failures.Count -eq 1 -and $signals.Count -eq [int]($variant -cne 'isolated')) 'Producer changed exception or exact information stream signal.' + $failure = $failures[0] + Assert-Ownership ($failure.GetType() -eq [InvalidOperationException] -and + $failure.Message -ceq 'controlled receipt refusal' -and + $failure.Data['GraphCodeValidationRootRetentionRequired'] -is [bool] -and + $failure.Data['GraphCodeValidationRootRetentionRequired']) 'Producer lost exact exception type/message/boolean retention intent.' + $roots = @($failure.Data['GraphCodeValidationRetainedRoots']) + $expectedRoots = [int]($variant -in @('valid', 'existing')) + Assert-Ownership ($roots.Count -eq $expectedRoots -and + ($expectedRoots -eq 0 -or $roots[0] -ceq $root)) 'Producer retained-root identity differs.' + $writeFailed = -not [string]::IsNullOrWhiteSpace($failure.Data['GraphCodeValidationRetentionReceiptFailure']) + Assert-Ownership ($writeFailed -eq ($variant -in @('existing', 'missing', 'file', 'empty'))) 'Receipt write failure was lost or fabricated.' + if ($variant -ceq 'valid') { + $bytes = [IO.File]::ReadAllBytes($path) + Assert-Ownership ($bytes.Length -gt 0 -and $bytes.Length -le 4096 -and $bytes[0] -ne 0xEF) 'Receipt is not bounded BOM-free UTF-8.' + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json + Assert-Ownership ($receipt.schemaVersion -eq 1 -and $receipt.validationRoot -ceq $root -and + $receipt.reason -ceq 'ownership_unproven') 'Receipt schema/root/reason mismatch.' + } + if ($variant -ceq 'existing') { + Assert-Ownership ([IO.File]::ReadAllText($path) -ceq 'preserve-existing-receipt') 'Producer overwrote an existing receipt.' + } + if ($variant -ceq 'isolated') { + Assert-Ownership (-not [IO.File]::Exists($path) -and $roots.Count -eq 0 -and + [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', 'Process') -ceq $root -and + @($records | Where-Object { [string]$_ -ceq 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' }).Count -eq 1) 'Mock authority escaped, exact signal filtering changed, or environment not restored.' + } + } + } +} finally { + Restore-OwnershipEnvironment $saved @('GRAPHCODE_VALIDATION_ROOT') +} +Complete-OwnershipFamily 'ownership.legacy.ownership-receipts' 7 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 new file mode 100644 index 00000000..b623e2fe --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$consumer = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\validate.ps1') +. Import-OwnershipProductionFunctions $consumer @('Invoke-Native', 'Get-WindowsShellProfileSnapshot', + 'Assert-WindowsShellProfileUnchanged', 'Invoke-WindowsShellValidationIsolation') +$keys = @('GRAPHCODE_VALIDATION_ROOT', 'GRAPHCODE_SUPPORT_DIR', 'LOCALAPPDATA', 'TEMP', 'TMP') +$before = [Environment]::GetEnvironmentVariables('Process') +$fixture = Join-Path $ArtifactRoot ('retention-boundary-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $fixture +$pwsh = Join-Path $PSHOME 'pwsh.exe' +$child = Join-Path $PSScriptRoot 'RetentionChild.ps1' +$cases = @( + @{ id = 'sentinel-exit17'; mode = 'sentinel'; code = 17 }, + @{ id = 'sentinel-exit0'; mode = 'sentinel'; code = 0 }, + @{ id = 'receipt-only-exit17'; mode = 'receipt-only'; code = 17 }, + @{ id = 'receipt-only-exit0'; mode = 'receipt-only'; code = 0 }, + @{ id = 'ordinary-exit17'; mode = 'ordinary'; code = 17 }, + @{ id = 'isolated-mock-exit0'; mode = 'isolated'; code = 0 } +) +try { + foreach ($case in $cases) { + Invoke-OwnershipCase $case.id { + $state = @{ root = $null; output = [Collections.Generic.List[string]]::new() } + $failure = $null + try { + Invoke-WindowsShellValidationIsolation -WorkspaceRoot $fixture -ValidationRootParent (Join-Path $fixture 'isolations') ` + -UserProfileRoot (Join-Path $fixture 'profile') -LocalAppDataRoot (Join-Path $fixture 'local') -Action { + $state.root = $env:GRAPHCODE_VALIDATION_ROOT + Invoke-Native 'controlled actual UIA retention boundary' { + & $pwsh -NoProfile -NonInteractive -File $child -RepositoryRoot $RepositoryRoot -Mode $case.mode -ExitCode $case.code + } | ForEach-Object { $state.output.Add([string]$_) } + } + } catch { $failure = $_ } + $sentinels = @($state.output | Where-Object { $_ -ceq 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' }) + $observations = @($state.output | Where-Object { $_.StartsWith('{"validationRoot":', [StringComparison]::Ordinal) }) + Assert-Ownership ($observations.Count -eq 1) 'Child did not emit exactly one typed observation.' + $observed = $observations[0] | ConvertFrom-Json + $reportsOwnership = $case.mode -in @('sentinel', 'receipt-only') + $tagged = $case.mode -cne 'ordinary' + $retained = $reportsOwnership -or $case.code -ne 0 + $expectedRoot = if ($case.mode -ceq 'isolated') { $null } else { $state.root } + Assert-Ownership (($null -ne $failure) -eq $retained -and $LASTEXITCODE -eq $case.code -and + $sentinels.Count -eq [int]($case.mode -ceq 'sentinel') -and $observed.validationRoot -ceq $expectedRoot -and + $observed.tagged -eq $tagged -and $observed.booleanTag -eq $tagged -and + (Test-Path -LiteralPath $state.root) -eq $retained -and + ($null -ne $failure -and $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) -eq $retained) ` + 'Actual retention signal/receipt/exit/root disposition or LASTEXITCODE differs.' + if ($reportsOwnership) { + Assert-Ownership (@($observed.observedRoots).Count -eq 1 -and $observed.observedRoots[0] -ceq $state.root -and + $failure.Exception.Data['GraphCodeValidationRetainedRoots'] -contains $state.root) 'Exact root identity lost at actual consumer boundary.' + $bytes = [IO.File]::ReadAllBytes((Join-Path $state.root 'ownership-unproven.json')) + Assert-Ownership ($bytes.Length -gt 0 -and $bytes.Length -le 4096) 'Actual child did not publish bounded receipt.' + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json + Assert-Ownership ($receipt.schemaVersion -eq 1 -and $receipt.validationRoot -ceq $state.root -and + $receipt.reason -ceq 'ownership_unproven') 'Actual child receipt payload changed.' + } + if ($case.mode -ceq 'ordinary') { + Assert-Ownership ($failure.Exception.Data['GraphCodeValidationRootRetentionReason'] -ceq 'workload_failed_without_cleanup_proof') 'Unclassified workload failure lost conservative disposition.' + } + if ($case.mode -ceq 'isolated') { + Assert-Ownership (@($observed.observedRoots).Count -eq 0 -and + @($state.output | Where-Object { $_ -ceq 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' }).Count -eq 1) 'Mock isolation leaked root authority or did not observe the exact retention signal.' + } + $after = [Environment]::GetEnvironmentVariables('Process') + foreach ($key in $keys) { + Assert-Ownership ($before.Contains($key) -eq $after.Contains($key) -and + (-not $before.Contains($key) -or [string]$before[$key] -ceq [string]$after[$key])) "Isolation changed environment membership/value: $key" + } + } + } +} finally { + Restore-OwnershipEnvironment $before $keys +} +Complete-OwnershipFamily 'ownership.legacy.retention-boundary' 6 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 new file mode 100644 index 00000000..8161bc2e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionChild.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet('sentinel', 'receipt-only', 'ordinary', 'isolated')][string] $Mode, + [Parameter(Mandatory)][int] $ExitCode +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure') +$snapshot = [Environment]::GetEnvironmentVariables('Process') +try { + if ($Mode -ceq 'isolated') { Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' } + $failure = $null + try { + if ($Mode -cne 'ordinary') { + if ($Mode -in @('receipt-only', 'isolated')) { + $ownership = New-UiaOwnershipFailure 'controlled UIA ownership refusal' 6>$null + if ($Mode -ceq 'isolated') { Write-Output 'OWNERSHIP_EXPECTED_RETENTION_SIGNAL=observed' } + } else { $ownership = New-UiaOwnershipFailure 'controlled UIA ownership refusal' } + throw $ownership + } + } catch { $failure = $_.Exception } + [ordered]@{ + validationRoot = [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ROOT', 'Process') + tagged = $null -ne $failure -and $failure.Data['GraphCodeValidationRootRetentionRequired'] -eq $true + booleanTag = $null -ne $failure -and $failure.Data['GraphCodeValidationRootRetentionRequired'] -is [bool] + observedRoots = @($(if ($failure) { $failure.Data['GraphCodeValidationRetainedRoots'] })) + } | ConvertTo-Json -Compress +} finally { + Restore-OwnershipEnvironment $snapshot @('GRAPHCODE_VALIDATION_ROOT') +} +exit $ExitCode diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 new file mode 100644 index 00000000..c93742d3 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gatePath = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' +$gate = Read-OwnershipProductionAst $gatePath +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Get-UiaOwnedProcessDescendants', 'Stop-UiaOwnedProcessTrees', + 'Stop-UiaOwnedProviderProcesses') @{ '[Diagnostics.Process[]]' = '[object[]]' } +$saved = [Environment]::GetEnvironmentVariables('Process') +Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' +try { + function Protect-UiaStartupDiagnosticText { param($text) return $text } + $state = @{ childKills = 0; rootKills = 0; completions = [Collections.Generic.List[string]]::new(); refuse = $false } + function Complete-MultiProjectCapture($capture, $logDirectory) { + $state.completions.Add($capture.stem) + if ($state.refuse -and $capture.stem -ceq 'first') { throw (New-UiaOwnershipFailure 'controlled job survivor') } + $capture.cleaned = $true + } + $created = [datetime]::new(2026, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) + function Get-CimInstance { + param($ClassName, $Filter) + @( + [pscustomobject]@{ ProcessId = 44; ParentProcessId = 1; CreationDate = $created; Name = 'root.exe'; ExecutablePath = 'D:\root.exe' } + [pscustomobject]@{ ProcessId = 9931; ParentProcessId = 44; CreationDate = $created.AddSeconds(1); Name = 'child.exe'; ExecutablePath = 'D:\child.exe' } + ) | Where-Object { -not $Filter -or $_.ProcessId -eq 9931 } + } + function Get-Process { + $child = [pscustomobject]@{ HasExited = $false } + $child | Add-Member ScriptMethod Kill { $state.childKills++ } + $child | Add-Member ScriptMethod WaitForExit { $true } + $child | Add-Member ScriptMethod Dispose {} + return $child + } + $root = [pscustomobject]@{ Id = 44; HasExited = $false } + $root | Add-Member ScriptMethod Kill { $state.rootKills++ } + $root | Add-Member ScriptMethod WaitForExit { $true } + Invoke-OwnershipCase 'unowned-root' { + $script:UiaGateOwnedCaptures = @() + $failure = $null + try { Stop-UiaOwnedProcessTrees @($root) } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true -and + $state.rootKills -eq 0 -and $state.childKills -eq 0) 'Advisory process observations supplied termination authority.' + } + Invoke-OwnershipCase 'no-launch' { + $script:UiaGateOwnedCaptures = @() + $output = @(Stop-UiaOwnedProcessTrees @() 6>&1) -join "`n" + Assert-Ownership ($output.Contains('CLEANUP=not_needed') -and -not $output.Contains('CLEANUP=verified')) 'Empty registry claimed verified cleanup.' + } + foreach ($refuse in @($true, $false)) { + Invoke-OwnershipCase "owned-jobs-refusal-$refuse" { + $state.refuse = $refuse + $state.completions.Clear() + $other = [pscustomobject]@{ Id = 45; HasExited = $false } + $script:UiaGateOwnedCaptures = @( + @{ process = $root; stem = 'first'; logDirectory = $ArtifactRoot; cleaned = $false }, + @{ process = $other; stem = 'second'; logDirectory = $ArtifactRoot; cleaned = $false } + ) + $failure = $null + try { Stop-UiaOwnedProcessTrees @($root, $other) } catch { $failure = $_ } + Assert-Ownership (($state.completions -join ',') -ceq 'first,second' -and + ($null -ne $failure) -eq $refuse -and + (-not $refuse -or $failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) -and + $state.rootKills -eq 0 -and $state.childKills -eq 0) 'Later owned lease skipped, primary retention lost, or CIM-only target killed.' + } + } + Complete-OwnershipFamily 'ownership.legacy.tree-ownership' 4 + $expected = 'D:\ownership-provider\zmx.exe' + $candidate = [pscustomobject]@{ + Name = 'zmx.exe'; ProcessId = 9931; ExecutablePath = $expected; CreationDate = $created + } + Invoke-OwnershipCase 'foreign-window-hiding-removed' { + Assert-Ownership (-not [IO.File]::ReadAllText($gatePath).Contains('Hide-TestProviderZmxWindows')) 'Unattested global provider-window hiding remains reachable.' + } + foreach ($variant in @('unattested-provider', 'baseline-provider', 'empty-inventory')) { + Invoke-OwnershipCase $variant { + $provider = @{ kills = 0; rows = @($candidate) } + if ($variant -ceq 'empty-inventory') { $provider.rows = @() } + $baseline = [Collections.Generic.HashSet[string]]::new() + if ($variant -ceq 'baseline-provider') { $null = $baseline.Add("$($candidate.ProcessId)|$([string]$candidate.CreationDate)") } + function Get-CimInstance { return $provider.rows } + function Get-Process { + $process = [pscustomobject]@{ HasExited = $false } + $process | Add-Member ScriptMethod Kill { $provider.kills++ } + $process | Add-Member ScriptMethod WaitForExit { $true } + $process | Add-Member ScriptMethod Dispose {} + return $process + } + $failure = $null + try { Stop-UiaOwnedProviderProcesses $expected $baseline } catch { $failure = $_ } + $mustRefuse = $variant -ceq 'unattested-provider' + Assert-Ownership ($provider.kills -eq 0 -and ($null -ne $failure) -eq $mustRefuse -and + (-not $mustRefuse -or ($failure.Exception.Data['GraphCodeValidationRootRetentionRequired'] -is [bool] -and + $failure.Exception.Data['GraphCodeValidationRootRetentionRequired']))) 'Provider advisory inventory was elevated to authority or its refusal/tag changed.' + } + } + Complete-OwnershipFamily 'ownership.legacy.provider-ownership' 4 +} finally { + Restore-OwnershipEnvironment $saved @('GRAPHCODE_VALIDATION_ROOT') +} +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs index 4badcb4f..deb4075a 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -69,6 +69,7 @@ private static extern bool CreateProcessW(string file, StringBuilder command, In // Unknown leases stay reachable: a finalizer must not close a kill-on-close job. private static readonly ConcurrentBag retained = new(); + private static readonly Task lifetimeHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task; private readonly Deadline deadline; private readonly string artifactRoot; private IntPtr root, thread, job; @@ -81,6 +82,11 @@ private static extern bool CreateProcessW(string file, StringBuilder command, In public Task StandardOutput => stdout ?? throw new InvalidOperationException("stdout capture unavailable."); public Task StandardError => stderr ?? throw new InvalidOperationException("stderr capture unavailable."); public static int RetainedLeaseCount => retained.Count; + public static bool RequiresLifetimeHold => RetainedLeaseCount != 0; + + // The host must await this in its outermost finally, including reporting failures. + // Returning from Main would close kill-on-close jobs despite the retained references. + public static Task HoldRetainedResourcesAsync() => RequiresLifetimeHold ? lifetimeHold : Task.CompletedTask; public OwnedChildObservation Observe() => new(RootExited(), StandaloneProcessJob.Active(job), StandardOutput.IsCompleted, StandardError.IsCompleted); diff --git a/Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs b/Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs new file mode 100644 index 00000000..e68ac6cd --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnershipJobFixture.cs @@ -0,0 +1,16 @@ +using System; + +namespace GraphCode.Validation.Ownership; + +public static class OwnershipJobFixture +{ + public static bool Empty; + public static int Terminated, Closed; + public static void Terminate(IntPtr job) => Terminated++; + public static void WaitForEmpty(IntPtr job, int timeout) + { + if (!Empty) throw new InvalidOperationException("Controlled fake job has a survivor."); + } + public static int Active(IntPtr job) => Empty ? 0 : 1; + public static void Close(IntPtr job) => Closed++; +} diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md index 3f648838..ad11eb95 100644 --- a/Tools/windows/ValidationRunner/Ownership/README.md +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -20,6 +20,9 @@ membership, accounting, bounded census and packaging diagnostics. the host-owned packaging and UIA adapters; merely compiling these files does not establish that integration. Streaming uses `AutoFlush`, which flushes bytes without prematurely flushing an encoder containing half a surrogate pair. +The host must await `HoldRetainedResourcesAsync()` in its outermost `finally`. +Retained references alone do not suffice: process exit closes a kill-on-close +job even if the garbage collector never finalized anything. `OwnedFixture.TryRunAsync(args)` must run before ordinary host argument parsing. It recognizes only `--ownership-fixture`. The compiled fixture tests native @@ -41,11 +44,19 @@ request/response/publication/control core from `Stub-Daemon.ps1:29-294`; it is the implementation used by the compiled real-pipe fixture, not a passing fake. Its presence does not claim that the old PowerShell stub has been rewired. -Twelve separate mandatory units retain the original seven ownership/mixed root -families, three mixed source-boundary assertions, and two shared-production -linkage obligations. Until explicit host-owned PowerShell integration adapters -preserve the actual scope, ScriptMethod, ErrorRecord identity, streams and -mocking assertions, those units report `Incomplete`, never a synthetic pass. -The six-case retention boundary and mixed eight-mode capture/cleanup negative -controls remain mandatory. Source occurrences of `Assert-MultiCase` and -`Invoke-MultiCase` are not runtime counts. +Six focused protected PowerShell families now preserve 45 actual assertions: +receipt producer seven, capture ownership fourteen, tree four, provider four, +pre-UIA isolation ten, and end-to-end retention six. They use actual production +functions from the integrated repository, not the exported runner. The adapter +transport is the host's authoritative held-job supervisor. Scope, ScriptMethod, +ErrorRecord identity, information streams and missing-versus-empty environment +membership remain PowerShell assertions. + +The full mixed family, three mixed source-boundary assertions, two production +linkage obligations and eight newly reconciled startup/tree root obligations +remain separately mandatory. Their `Incomplete` results cannot be replaced by +the compiled helper tests. `source-correspondence.json` records every one of +the immutable mixed source's 126 invocation templates and 192 assertion +commands, with stable source IDs and zero claimed execution. Those source +occurrences are not runtime counts. The mixed eight-mode capture/cleanup +negative controls remain required. diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs index d6dfe2d5..115d4a29 100644 --- a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -14,7 +14,20 @@ namespace GraphCode.Validation.Ownership; public static class SuiteRegistration { - public static IReadOnlyList CreateSuites() => [new OwnershipSuite()]; + public static IReadOnlyList CreateSuites() => + [ + new OwnershipSuite(), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.receipts", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\Receipt.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.ownership-receipts"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.tree-provider", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\TreeProvider.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.tree-ownership", "ownership.legacy.provider-ownership"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.capture", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\Capture.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.capture-ownership"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.pre-uia", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\PreUiaIsolation.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.pre-uia-isolation"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.retention", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\RetentionBoundary.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.retention-boundary"]) + ]; } public sealed class OwnershipSuite : IValidationSuite @@ -43,18 +56,20 @@ public sealed class OwnershipSuite : IValidationSuite private static readonly string[] nativeIds = ["normal", "abrupt", "timeout", "inherited", "inherited-timeout", "arguments-environment", "real-pipe"]; private static readonly (string Id, string Evidence)[] unported = [ - ("legacy.ownership-receipts", "Actual PS producer/stream/scope assertions: Test-UiaOwnershipReceiptContracts, source 90-168; exactly seven cases."), - ("legacy.retention-boundary", "Actual PS producer/Invoke-Native/isolation boundary: Test-UiaRetentionBoundaryContracts, source 292-429; exactly six cases including receipt-only and sentinel-only exit0/17 obligations."), - ("legacy.capture-ownership", "Actual ScriptMethod/mock/ErrorRecord assertions: Test-UiaCaptureOwnershipContracts, source 430-596; exactly fourteen cases; native helper linkage requires host adapters."), - ("legacy.tree-ownership", "Actual PS lease registry and continuation assertions: Test-UiaTreeOwnershipContracts, source 597-673; exactly four cases."), - ("legacy.provider-ownership", "Actual PS advisory-CIM refusal/removed hiding assertions: Test-UiaProviderOwnershipContracts, source 674-757; exactly four cases."), - ("legacy.pre-uia-isolation", "Actual runner PS region and environment/scope assertions: Test-PreUiaProcessIsolationContracts, source 892-988; exactly ten cases."), ("legacy.mixed-multiproject", "Complete mixed source 1672-4245 remains mandatory: UI predicates, native/fresh snapshot policies, receipts, strict protocol, owned real pipes, eight bounded-capture and primary/cleanup negative controls. Source invocation inventories are not executed counts."), ("legacy.peer-emission", "Assert-MultiProjectPeerEmissionContract source 4247-4265 requires actual PS AST source correspondence."), ("legacy.rename-submit", "Assert-MultiProjectRenameSubmitContract source 4266-4289 requires actual caller/production Zig route correspondence."), ("legacy.fresh-capture", "Assert-MultiProjectFreshCaptureContract source 4290-4315 requires actual ownership/cache/observer correspondence."), ("legacy.packaging-shared-linkage", "Packaging.Standalone.Tests.ps1 must load this complete StandaloneProcessJob implementation, not its embedded duplicate."), - ("legacy.uia-shared-linkage", "uia-live-gate.ps1 must load these complete UiaOwnedStreamCapture/StandaloneProcessJob implementations, including actual native membership.") + ("legacy.uia-shared-linkage", "uia-live-gate.ps1 must load these complete UiaOwnedStreamCapture/StandaloneProcessJob implementations, including actual native membership."), + ("ui-startup.helpers", "Source 5838 requires thirteen complete actual production startup helpers; helper presence is not runtime behavior."), + ("ui-startup.redacted-log", "Source 5845: bounded actual startup file diagnostic must preserve loader output and redact the canary."), + ("ui-startup.truncated-log", "Source 5853: actual startup diagnostic must read exactly eight bytes and mark truncation."), + ("ui-startup.missing-log", "Source 5859: actual startup diagnostic must distinguish missing from empty output."), + ("ui-startup.retained-report", "Source 5882: actual retained startup JSON must preserve independent stdout/stderr/app logs and redact canaries."), + ("ui-process-tree.controlled-child", "Source 5945: actual gate registered held process/job must expose its controlled descendant."), + ("ui-process-tree.cleanup-complete", "Source 5947: actual gate Stop-UiaOwnedProcessTrees must finish retained root/job/readers before cleanup succeeds."), + ("ui-process-tree.pid-reuse", "Source 5970: actual advisory descendant traversal must reject older generations and never supply termination authority.") ]; public string Id => "ownership"; diff --git a/Tools/windows/ValidationRunner/Ownership/source-correspondence.json b/Tools/windows/ValidationRunner/Ownership/source-correspondence.json new file mode 100644 index 00000000..425ff872 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/source-correspondence.json @@ -0,0 +1,894 @@ +{ + "schemaVersion": 1, + "sourceManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "rootSourceSha256": "202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869", + "inventoryOnly": true, + "executedCount": 0, + "mixedSourceInvocationTemplates": 126, + "mixedSourceAssertionCommands": 192, + "mandatoryFamily": "ownership.legacy.mixed-multiproject", + "templates": [ + { + "id": "ownership.legacy.mixed.source-line-1724", + "line": 1724, + "template": "\"DPI192 narrow New Tab overflow still proves both mounted stable tabs\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1733", + "line": 1733, + "template": "\"wide New Tab roster proves the same mounted stable tabs without overflow\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1749", + "line": 1749, + "template": "\"mounted tab refuses permanent $($case.name)\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1757", + "line": 1757, + "template": "\"selection event accepts exactly one stable owned source\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1769", + "line": 1769, + "template": "\"selection event refuses $($case.name)\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1777", + "line": 1777, + "template": "\"compact overview narrow client retains the Mac lane and requires real pan\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1789", + "line": 1789, + "template": "\"compact overview wide client and DPI preserve exact geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1804", + "line": 1804, + "template": "\"compact overview fitted rounding retains source geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1811", + "line": 1811, + "template": "\"compact overview rejects obsolete 220x86 card geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1818", + "line": 1818, + "template": "\"compact overview rejects the obsolete 760-pixel lane floor\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1824", + "line": 1824, + "template": "\"dynamic Rename title uses the exact fixture title\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1837", + "line": 1837, + "template": "(\"dynamic Rename title rejects \" + ($titleMutation -replace \"`n\",\" newline \"))", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1843", + "line": 1843, + "template": "\"bounded Rename title pattern accepts one unavailable-fixture title\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1854", + "line": 1854, + "template": "(\"bounded Rename title pattern rejects malformed case \" + $titleMutation.Length)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1870", + "line": 1870, + "template": "\"Delete custom sheet accepts exact owned captionless identity\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1879", + "line": 1879, + "template": "(\"Delete custom sheet rejects \" + $identityMutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1917", + "line": 1917, + "template": "\"update banner click accepts owned foreground target after Later\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1924", + "line": 1924, + "template": "(\"update banner click rejects wrong \" + $targetMutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-1951", + "line": 1951, + "template": "\"actual caption Open assignment consumes the exported caption snapshot\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2060", + "line": 2060, + "template": "\"canonical owned startup event retained without changing primary\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2068", + "line": 2068, + "template": "\"canonical startup event absence is explicitly unknown\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2074", + "line": 2074, + "template": "\"owned canonical event missing module fields retains explicit unavailability\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2083", + "line": 2083, + "template": "\"actual query no-match error remains unknown while startup primary survives\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2101", + "line": 2101, + "template": "(\"startup event refuses \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2109", + "line": 2109, + "template": "(\"startup query refuses invalid held \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2126", + "line": 2126, + "template": "(\"startup retains typed secondary writer failures \" + $faults)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2137", + "line": 2137, + "template": "\"startup refuses event transport exceeding eight results\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2143", + "line": 2143, + "template": "\"startup refuses duplicate actual record identity\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2149", + "line": 2149, + "template": "\"startup default transport refuses non-hosted execution without event query\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2165", + "line": 2165, + "template": "\"startup query escapes exact executable XPath and XML\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2173", + "line": 2173, + "template": "\"actual first startup failure branch invokes diagnostic once\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2193", + "line": 2193, + "template": "\"actual new caller streams all five records before unchanged fifth guard\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2221", + "line": 2221, + "template": "(\"streamed actual Edge-TypeText transport \" + $scenario)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2305", + "line": 2305, + "template": "(\"stream preserves primary and explicit sink diagnostics \" + $failureMode)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2342", + "line": 2342, + "template": "\"stream rethrows identical primary exception object\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2359", + "line": 2359, + "template": "(\"production stream catch preserves primary through \" + $faults)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2411", + "line": 2411, + "template": "\"complete actual accepted whole peer receipt preserves bytes and all state\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2439", + "line": 2439, + "template": "\"whole peer receipt accepts a correlated nonmutating session open\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2445", + "line": 2445, + "template": "\"whole peer receipt rejects a session open for a foreign owner's node\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2460", + "line": 2460, + "template": "(\"complete peer receipt rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2520", + "line": 2520, + "template": "(\"retained complete receipt reader rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2706", + "line": 2706, + "template": "(\"single sequenced entry exact target \" + $expected.Length + \":\" + $expected.Trim())", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2724", + "line": 2724, + "template": "\"prompt viewport resolved edit preserves actual FocusMouse ownership guard\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2732", + "line": 2732, + "template": "(\"prompt viewport actual FocusMouse refuses \" + $viewportMiss)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2742", + "line": 2742, + "template": "\"already owned GUI edit focus uses zero focus input\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2751", + "line": 2751, + "template": "(\"bounded focus and pre-input phase rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2785", + "line": 2785, + "template": "(\"actual production FocusMouse refuses late \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2808", + "line": 2808, + "template": "(\"single sequenced edit rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2845", + "line": 2845, + "template": "\"new sequenced native declaration compile only\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2850", + "line": 2850, + "template": "\"sequenced entry rejects unknown empty expected prefill before any input\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2857", + "line": 2857, + "template": "\"sequenced refusal diagnostic failure preserves exact primary\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2871", + "line": 2871, + "template": "(\"actual new Rename caller uses native \" + $(if ($cancelRoute) { \"Cancel9808\" } else { \"OK9800\" }))", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2897", + "line": 2897, + "template": "\"Rename clips actual button to modal client and work rectangles\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2906", + "line": 2906, + "template": "\"Rename uses measured uncovered point without reading covering captions\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-2924", + "line": 2924, + "template": "(\"Rename native guard rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3013", + "line": 3013, + "template": "(\"Rename actual input receipt rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3024", + "line": 3024, + "template": "(\"production Rename Click rejects final \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3040", + "line": 3040, + "template": "\"Rename stale native read retains exact error and unavailable actual fields\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3057", + "line": 3057, + "template": "\"Rename production native bindings compile without native invocation\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3129", + "line": 3129, + "template": "\"fresh cached capture verifies owned metadata before and after content\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3138", + "line": 3138, + "template": "\"fresh snapshot distinguishes unsupported property from zero default\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3154", + "line": 3154, + "template": "(\"fresh capture rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3202", + "line": 3202, + "template": "\"unknown cache error is not converted to retryable metadata\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3214", + "line": 3214, + "template": "\"retired snapshot retains typed availability error before content\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3224", + "line": 3224, + "template": "\"other COM failure is not a retirement-shaped fallback\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3237", + "line": 3237, + "template": "(\"whole observation reacquisition \" + $scenario)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3268", + "line": 3268, + "template": "\"whole capture collection rejects later provider runtime replacement\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3278", + "line": 3278, + "template": "\"guarded prefix handles null without pretending metadata is ready\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3283", + "line": 3283, + "template": "\"owned available semantic metadata reads PID first and again\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3291", + "line": 3291, + "template": "\"owned source-supported static chrome is classified explicitly\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3296", + "line": 3296, + "template": "\"owned restart session chrome remains in the complete workspace metadata batch\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3314", + "line": 3314, + "template": "(\"restart metadata rejects \" + $case.name)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3331", + "line": 3331, + "template": "(\"metadata batch rejects \" + $case.name)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3341", + "line": 3341, + "template": "\"foreign semantic fragment stays counted and fails strict roster\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3353", + "line": 3353, + "template": "\"two exact positive owner publications\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3368", + "line": 3368, + "template": "\"whole typed correlated rename receipt\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3373", + "line": 3373, + "template": "\"one-shot interleaved event with synthetic selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3383", + "line": 3383, + "template": "\"unchanged title still dispatches\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3392", + "line": 3392, + "template": "\"hash identity is owner and surface scoped\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3423", + "line": 3423, + "template": "\"retained hosted identities with current compact geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3443", + "line": 3443, + "template": "\"source summary identity independent fixture and hosted goldens\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3458", + "line": 3458, + "template": "\"workspace marker independent prefix and row-key goldens\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3469", + "line": 3469, + "template": "\"unknown automation kind rejected\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3482", + "line": 3482, + "template": "\"workspace projection requires both positively owned source markers\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3487", + "line": 3487, + "template": "\"classified restart chrome alone cannot prove workspace owner identity\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3497", + "line": 3497, + "template": "(\"workspace projection rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3524", + "line": 3524, + "template": "\"project request rejects actual workspace chrome\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3530", + "line": 3530, + "template": "\"matching project surface selects its parent and selected loop\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3535", + "line": 3535, + "template": "\"other project owner stays unselected on project surface\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3540", + "line": 3540, + "template": "\"workspace keeps selected loop without parent-project selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3545", + "line": 3545, + "template": "\"overview keeps selected loop without parent-project selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3550", + "line": 3550, + "template": "\"Quick Chats keeps selected loop without parent-project selection\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3555", + "line": 3555, + "template": "\"workspace rejects selected parent project row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3560", + "line": 3560, + "template": "\"project surface rejects missing selected parent row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3565", + "line": 3565, + "template": "\"overview rejects stale selected parent project row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3570", + "line": 3570, + "template": "\"Quick Chats rejects stale selected parent project row\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3575", + "line": 3575, + "template": "\"workspace rejects loss of selected loop owner\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3596", + "line": 3596, + "template": "\"unfiltered ordinary overview roster exactly two owners and cards\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3601", + "line": 3601, + "template": "\"compact overview roster rejects obsolete card dimensions\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3608", + "line": 3608, + "template": "\"compact overview roster rejects obsolete narrow caption position\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3625", + "line": 3625, + "template": "\"fresh owned content cannot bless an overview missing its peer card\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3635", + "line": 3635, + "template": "\"project roster retains two owners and exactly one selected project card\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3732", + "line": 3732, + "template": "(\"unfiltered roster rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3755", + "line": 3755, + "template": "\"project caption classification keeps full four distinct from two nodes\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3763", + "line": 3763, + "template": "\"source project caption geometry is separate from node geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3771", + "line": 3771, + "template": "(\"classified roster rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3792", + "line": 3792, + "template": "\"literal lane geometry and DPI scaling\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3816", + "line": 3816, + "template": "\"DPI192 fitted destinations retain shared source lane geometry\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3846", + "line": 3846, + "template": "(\"DPI192 normalized lane rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3889", + "line": 3889, + "template": "\"live target clipped to viewport\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3894", + "line": 3894, + "template": "\"offscreen target rejected\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3897", + "line": 3897, + "template": "\"resumeSession acknowledges an exact fixture owner without changing its graph\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3908", + "line": 3908, + "template": "\"resumeSession refuses a node belonging to another project\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3930", + "line": 3930, + "template": "(\"request \" + $entry.Key)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3941", + "line": 3941, + "template": "(\"JSON \" + $json)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3943", + "line": 3943, + "template": "\"duplicate request\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3948", + "line": 3948, + "template": "\"unanswered publication\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3954", + "line": 3954, + "template": "(\"control \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3973", + "line": 3973, + "template": "(\"response \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-3988", + "line": 3988, + "template": "(\"receipt rejects \" + $mutation)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4053", + "line": 4053, + "template": "\"owned real pipe hello\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4056", + "line": 4056, + "template": "\"fragmented real two-owner frames\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4063", + "line": 4063, + "template": "\"real rename application response publication\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4072", + "line": 4072, + "template": "\"idle real pipe publication control\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4093", + "line": 4093, + "template": "\"default fixture unchanged over real pipe\"", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + }, + { + "id": "ownership.legacy.mixed.source-line-4152", + "line": 4152, + "template": "(\"bounded capture \" + $mode)", + "executedCount": 0, + "disposition": "Mandatory; not replaced by standalone compiled peer cases" + } + ] +} From 0f1a551f71223b05ac2a24ea59935ac54948f90a Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 13:56:05 -0700 Subject: [PATCH 13/46] Share compiled production bindings across UIA packaging and validation Target production bindings at .NET 8 and fixture bindings at netstandard2.1; keep host .NET 10. Prevent process exit while retained UNKNOWN authority remains. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Import-CompiledValidationAssembly.ps1 | 11 +- .../Tests/Packaging.Standalone.Tests.ps1 | 138 +- .../Adapters/PowerShellPayload.cs | 2 + .../Windows/WindowsValidationBindings.csproj | 20 + .../ValidationRunner/Host/HostSelfTests.cs | 17 + .../GraphCodeMultiProjectEditNative.cs | 62 + .../ValidationRunner/Host/TerminalSafety.cs | 23 + Tools/windows/ValidationRunner/Program.cs | 6 + .../ValidationRunner/ValidationRunner.csproj | 4 +- Tools/windows/uia-live-gate.ps1 | 1706 +---------------- 10 files changed, 191 insertions(+), 1798 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj create mode 100644 Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs create mode 100644 Tools/windows/ValidationRunner/Host/TerminalSafety.cs diff --git a/Tools/windows/Import-CompiledValidationAssembly.ps1 b/Tools/windows/Import-CompiledValidationAssembly.ps1 index cfa4268c..da1263b8 100644 --- a/Tools/windows/Import-CompiledValidationAssembly.ps1 +++ b/Tools/windows/Import-CompiledValidationAssembly.ps1 @@ -5,17 +5,20 @@ param( ) $ErrorActionPreference = "Stop" -if ($RequiredType -cne "GraphCode.ValidationFixtureDirectoryNative" -and [Environment]::Version.Major -ne 10) { - throw "Compiled validation bindings require PowerShell on .NET 10 (PowerShell 7.6); observed $([Environment]::Version)" +if ($RequiredType -cne "GraphCode.ValidationFixtureDirectoryNative" -and [Environment]::Version.Major -lt 8) { + throw "Compiled Windows bindings require PowerShell on .NET 8 or newer (PowerShell 7.4+); observed $([Environment]::Version)" } if (-not [IO.Path]::IsPathFullyQualified($AssemblyPath)) { throw "Compiled validation assembly must be an explicit absolute path" } -$selected = (Resolve-Path -LiteralPath $AssemblyPath -ErrorAction Stop).Path $projectRoot = Join-Path $PSScriptRoot "ValidationRunner" $fileName = if ($RequiredType -ceq "GraphCode.ValidationFixtureDirectoryNative") { "ValidationFixtureBindings.dll" -} else { "ValidationRunner.dll" } +} else { "WindowsValidationBindings.dll" } +if ([IO.Path]::GetFileName($AssemblyPath) -ceq "ValidationRunner.dll") { + $AssemblyPath = Join-Path ([IO.Path]::GetDirectoryName($AssemblyPath)) $fileName +} +$selected = (Resolve-Path -LiteralPath $AssemblyPath -ErrorAction Stop).Path $expected = @("Debug", "Release") | ForEach-Object { [IO.Path]::GetFullPath((Join-Path $projectRoot "bin\$_\net10.0-windows\$fileName")) } diff --git a/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 b/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 index 520eb6f1..c0b31c79 100644 --- a/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 +++ b/Tools/windows/Tests/Packaging.Standalone.Tests.ps1 @@ -175,7 +175,7 @@ function Invoke-Setup( $primaryError = $null $clock = [Diagnostics.Stopwatch]::StartNew() try { - $job = [StandaloneProcessJob]::Create() + $job = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() $report.ControllerStage = "process-start" $started = $process.Start() if (-not $started) { throw "Could not start standalone setup host" } @@ -184,7 +184,7 @@ function Invoke-Setup( $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() $report.ControllerStage = "job-assign" - [StandaloneProcessJob]::Assign($job, $process.Handle) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($job, $process.Handle) $assigned = $true $report.ControllerStage = "gate-release" [IO.File]::WriteAllText($gate, "assigned") @@ -231,9 +231,9 @@ function Invoke-Setup( if ($process.HasExited) { $report.ExitCode = $process.ExitCode } } if ($assigned) { - $report.ActiveBeforeTeardown = [StandaloneProcessJob]::Active($job) - $report.JobBeforeTeardown = [StandaloneProcessJob]::Summary($job) - $report.OwnedProcessesBeforeTeardown = [StandaloneProcessJob]::Processes($job) + $report.ActiveBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) + $report.JobBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Summary($job) + $report.OwnedProcessesBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Processes($job) } # ReadToEndAsync cannot expose partial pipes; distinguish pending capture from empty output. $report.StdoutBeforeTeardown = if ($stdout -and $stdout.IsCompletedSuccessfully) { @@ -253,16 +253,16 @@ function Invoke-Setup( } catch { $cleanupErrors.Add("capturing standalone stage evidence: $($_.Exception.Message)") } try { if ($assigned) { - [StandaloneProcessJob]::Terminate($job) - [StandaloneProcessJob]::WaitForEmpty($job, 5000) - $report.ActiveAfterTeardown = [StandaloneProcessJob]::Active($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($job, 5000) + $report.ActiveAfterTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) } elseif ($started -and -not $process.HasExited) { $process.Kill($true) } if ($started -and -not $process.WaitForExit(5000)) { throw "Standalone host survived termination" } } catch { $cleanupErrors.Add("owned process termination: $($_.Exception.Message)") } try { - if ($job -ne [IntPtr]::Zero) { [StandaloneProcessJob]::Close($job) } + if ($job -ne [IntPtr]::Zero) { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($job) } } catch { $cleanupErrors.Add("closing owned job: $($_.Exception.Message)") } try { if ($stdout -and $stderr) { @@ -315,100 +315,12 @@ function Invoke-StandaloneFixture { } } -if (-not ("StandaloneProcessJob" -as [type])) { - Add-Type @' -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.InteropServices; -public static class StandaloneProcessJob { - [StructLayout(LayoutKind.Sequential)] - struct BasicLimits { public long ProcessTime, JobTime; public uint Flags; public UIntPtr Min, Max; public uint Active; public UIntPtr Affinity; public uint Priority, Scheduling; } - [StructLayout(LayoutKind.Sequential)] - struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; } - [StructLayout(LayoutKind.Sequential)] - struct Limits { public BasicLimits Basic; public IoCounters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; } - [StructLayout(LayoutKind.Sequential)] - struct Accounting { public long User, Kernel, PeriodUser, PeriodKernel; public uint Faults, Total, Active, Terminated; } - [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr CreateJobObjectW(IntPtr attributes, IntPtr name); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int info, ref Limits limits, uint size); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int info, out Accounting accounting, uint size, IntPtr length); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int info, IntPtr buffer, uint size, IntPtr length); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result); - [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetProcessTimes(IntPtr process, out long creation, out long exit, out long kernel, out long user); - [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, System.Text.StringBuilder name, ref uint size); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); - [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); - public static IntPtr Create() { - var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero); - if (job == IntPtr.Zero) throw new Win32Exception(); - var limits = new Limits(); limits.Basic.Flags = 0x2000; // KILL_ON_JOB_CLOSE - if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf())) { - var error = new Win32Exception(); CloseHandle(job); throw error; - } - return job; - } - public static void Assign(IntPtr job, IntPtr process) { if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); } - public static uint Active(IntPtr job) { - Accounting value; - if (!QueryInformationJobObject(job, 1, out value, (uint)Marshal.SizeOf(), IntPtr.Zero)) throw new Win32Exception(); - return value.Active; - } - public static string Summary(IntPtr job) { - Accounting value; - if (!QueryInformationJobObject(job, 1, out value, (uint)Marshal.SizeOf(), IntPtr.Zero)) throw new Win32Exception(); - return String.Format("total={0}; active={1}; terminated={2}; user100ns={3}; kernel100ns={4}", - value.Total, value.Active, value.Terminated, value.User, value.Kernel); - } - public static string Processes(IntPtr job) { - const int capacity = 1024; - int size = 8 + capacity * IntPtr.Size; - var buffer = Marshal.AllocHGlobal(size); - try { - if (!QueryInformationJobObject(job, 3, buffer, (uint)size, IntPtr.Zero)) throw new Win32Exception(); - int count = Marshal.ReadInt32(buffer, 4); - if (count < 0 || count > capacity) throw new InvalidOperationException("Owned process snapshot exceeds capacity"); - var identities = new System.Collections.Generic.List(); - for (int i = 0; i < count; i++) { - int pid = checked((int)Marshal.ReadIntPtr(buffer, 8 + i * IntPtr.Size).ToInt64()); - var process = OpenProcess(0x1000, false, (uint)pid); // QUERY_LIMITED_INFORMATION - if (process == IntPtr.Zero) { - identities.Add("pid=" + pid + "; unavailable-win32=" + Marshal.GetLastWin32Error()); - continue; - } - try { - bool owned; - if (!IsProcessInJob(process, job, out owned)) throw new Win32Exception(); - if (!owned) { identities.Add("pid=" + pid + "; no-longer-owned"); continue; } - long creation, exit, kernel, user; - uint length = 32768; - var name = new System.Text.StringBuilder((int)length); - if (!GetProcessTimes(process, out creation, out exit, out kernel, out user) || - !QueryFullProcessImageNameW(process, 0, name, ref length)) { - identities.Add("pid=" + pid + "; identity-unavailable-win32=" + Marshal.GetLastWin32Error()); - continue; - } - identities.Add(String.Format("pid={0}; creationUtcTicks={1}; name={2}", - pid, DateTime.FromFileTimeUtc(creation).Ticks, System.IO.Path.GetFileNameWithoutExtension(name.ToString()))); - } finally { if (!CloseHandle(process)) throw new Win32Exception(); } - } - return String.Join(" | ", identities); - } finally { Marshal.FreeHGlobal(buffer); } - } - public static void Terminate(IntPtr job) { if (!TerminateJobObject(job, 124)) throw new Win32Exception(); } - public static void WaitForEmpty(IntPtr job, int milliseconds) { - var clock = Stopwatch.StartNew(); - while (Active(job) != 0) { - if (clock.ElapsedMilliseconds >= milliseconds) throw new TimeoutException("Owned standalone job still has active processes"); - System.Threading.Thread.Sleep(10); - } - } - public static void Close(IntPtr job) { if (!CloseHandle(job)) throw new Win32Exception(); } -} -'@ +$validationAssemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $validationAssemblyPath) { + $validationAssemblyPath = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") } +& (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $validationAssemblyPath -RequiredType "GraphCode.Validation.Ownership.StandaloneProcessJob" | Out-Null function Test-StandaloneHarness([switch] $diagnosticOnly, [switch] $launcherOnly) { $hostPath = (Get-Command pwsh).Source @@ -694,7 +606,7 @@ Write-Output "CASE PASS: $case" } $process = [Diagnostics.Process]::new() $process.StartInfo = $start - $job = [StandaloneProcessJob]::Create() + $job = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() $started = $false $assigned = $false try { @@ -702,16 +614,16 @@ Write-Output "CASE PASS: $case" if (-not $started) { throw "Could not start harness self-test worker" } $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() - [StandaloneProcessJob]::Assign($job, $process.Handle) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($job, $process.Handle) $assigned = $true [IO.File]::WriteAllText($gate, "assigned") $workerBudget = if ($case -like "diagnostic-*" -or $case -like "launcher-*") { 70000 } else { 12000 } $finished = $process.WaitForExit($workerBudget) $settle = [Diagnostics.Stopwatch]::StartNew() - while ($finished -and [StandaloneProcessJob]::Active($job) -ne 0 -and $settle.ElapsedMilliseconds -lt 250) { + while ($finished -and [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) -ne 0 -and $settle.ElapsedMilliseconds -lt 250) { Start-Sleep -Milliseconds 10 } - $activeBeforeTeardown = [StandaloneProcessJob]::Active($job) + $activeBeforeTeardown = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job) if (-not $finished) { foreach ($role in @("root", "descendant")) { $identityPath = Join-Path $caseRoot "$role-identity" @@ -724,8 +636,8 @@ Write-Output "CASE PASS: $case" } Write-Output "Watchdog readiness: descendantReady=$(Test-Path (Join-Path $caseRoot 'descendant-ready')); markerWritten=$(Test-Path (Join-Path $caseRoot 'marker-written'))" } - [StandaloneProcessJob]::Terminate($job) - [StandaloneProcessJob]::WaitForEmpty($job, 5000) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($job, 5000) if (-not $process.WaitForExit(5000)) { throw "Self-test worker survived owned job termination" } if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]] @($stdout, $stderr), 5000)) { throw "Self-test output capture exceeded its deadline" @@ -734,26 +646,26 @@ Write-Output "CASE PASS: $case" Write-Output $output if (-not $finished -or $process.ExitCode -ne 0 -or $activeBeforeTeardown -ne 0 -or $output -notmatch "CASE PASS: $case") { $failures++ - Write-Output "CASE FAIL: $caseId; finished=$finished; exit=$($process.ExitCode); activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([StandaloneProcessJob]::Active($job))" + Write-Output "CASE FAIL: $caseId; finished=$finished; exit=$($process.ExitCode); activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job))" if (-not $finished -or $activeBeforeTeardown -ne 0 -or $output -match "Standalone setup (?:output capture )?timed out") { throw "Stopped standalone harness at unexpected timeout or ownership failure: $caseId" } } else { $passed++ - Write-Output "Owned worker job: activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([StandaloneProcessJob]::Active($job))" + Write-Output "Owned worker job: activeBeforeTeardown=$activeBeforeTeardown; activeAfterTeardown=$([GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($job))" } } finally { try { if ($assigned) { - [StandaloneProcessJob]::Terminate($job) - [StandaloneProcessJob]::WaitForEmpty($job, 5000) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($job, 5000) } elseif ($started -and -not $process.HasExited) { $process.Kill($true) if (-not $process.WaitForExit(5000)) { throw "Unassigned self-test worker survived termination" } } } finally { - try { [StandaloneProcessJob]::Close($job) } finally { $process.Dispose() } + try { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($job) } finally { $process.Dispose() } } } } diff --git a/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs index b2346b45..a8692ba2 100644 --- a/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs +++ b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Text; using GraphCode.Validation.Ownership; +using GraphCode.Validation.Host; namespace GraphCode.Validation.Adapters; @@ -48,6 +49,7 @@ public static async Task RunAsync( throw new InvalidOperationException($"{id}: owned child cleanup is UNKNOWN; artifacts retained: {artifacts}"); if (result.StandardOutput.Contains("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven", StringComparison.Ordinal)) { + TerminalSafety.MarkUnknown(); var failure = new InvalidOperationException($"{id}: descendant reported UNKNOWN ownership; artifacts retained: {artifacts}"); failure.Data["GraphCodeValidationRootRetentionRequired"] = true; throw failure; diff --git a/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj b/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj new file mode 100644 index 00000000..f608f2c7 --- /dev/null +++ b/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj @@ -0,0 +1,20 @@ + + + net8.0-windows + true + true + latest + enable + true + false + + + + + + + + + diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index 62490758..eb783a06 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -108,6 +108,23 @@ void RejectSummary(string value) Check("console-invalid-id-isolation", () => Assert(!ConsoleReport.Unit(passed with { Id = "\nVALIDATION_RUNNER_EXECUTED=1" }).Contains('\n'), "invalid case ID injected a root line")); + Check("terminal-known-exit", () => Assert(TerminalSafety.ExitBarrier(0, false).IsCompletedSuccessfully, + "proven empty authority cannot exit")); + Check("terminal-retained-no-exit", () => Assert(!TerminalSafety.ExitBarrier(1, false).IsCompleted, + "retained kill-on-close authority was allowed to exit")); + Check("terminal-unknown-no-exit", () => Assert(!TerminalSafety.ExitBarrier(0, true).IsCompleted, + "UNKNOWN descendant authority was allowed to exit")); + Check("terminal-report-failure-no-exit", () => + { + Task? barrier = null; + try + { + try { throw new IOException("controlled report writer failure"); } + finally { barrier = TerminalSafety.ExitBarrier(1, false); } + } + catch (IOException) { } + Assert(barrier is not null && !barrier.IsCompleted, "reporting exception bypassed terminal hold"); + }); foreach (var error in errors) Console.Error.WriteLine(error); Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); return errors.Count == 0 ? 0 : 1; diff --git a/Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs b/Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs new file mode 100644 index 00000000..3d8e78fb --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Production/GraphCodeMultiProjectEditNative.cs @@ -0,0 +1,62 @@ +#nullable disable +using System; +using System.Text; +using System.Diagnostics; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class GraphCodeMultiProjectEditNative { + [StructLayout(LayoutKind.Sequential)] private struct Rect {public int Left,Top,Right,Bottom;} + [StructLayout(LayoutKind.Sequential)] private struct GuiInfo { + public int Size; public uint Flags; public IntPtr Active,Focus,Capture,MenuOwner,MoveSize,Caret; public Rect CaretRect; + } + [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", SetLastError=true)] + private static extern IntPtr Message(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern IntPtr TextMessage(IntPtr window, uint message, UIntPtr wparam, StringBuilder text, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern IntPtr SetTextMessage(IntPtr window, uint message, UIntPtr wparam, string text, uint flags, uint timeout, out UIntPtr result); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid); + [DllImport("user32.dll",SetLastError=true)] private static extern bool GetGUIThreadInfo(uint thread,ref GuiInfo info); + private static uint Remaining(int budget, Stopwatch watch) { + long value=budget-watch.ElapsedMilliseconds; + if(value<=0) throw new TimeoutException("N3e edit transaction deadline expired"); + return (uint)value; + } + private static UIntPtr ReadMessage(IntPtr window,uint message,UIntPtr first,IntPtr last,int budget,Stopwatch watch) { + UIntPtr result; + if(Message(window,message,first,last,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e edit message unavailable/timed out"); + return result; + } + public static string Buffer(IntPtr edit,int budget) { + var watch=Stopwatch.StartNew(); + int length=checked((int)ReadMessage(edit,0x000E,UIntPtr.Zero,IntPtr.Zero,budget,watch).ToUInt64()); + if(length<0 || length>65536) throw new InvalidOperationException("N3e edit buffer exceeds bound"); + var text=new StringBuilder(length+1); UIntPtr result; + if(TextMessage(edit,0x000D,(UIntPtr)text.Capacity,text,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_GETTEXT unavailable/timed out"); + return text.ToString(); + } + public static int[] Selection(IntPtr edit,int budget) { + var watch=Stopwatch.StartNew(); IntPtr memory=Marshal.AllocHGlobal(8); + try { + Marshal.WriteInt32(memory,-1); Marshal.WriteInt32(memory,4,-1); + ReadMessage(edit,0x00B0,(UIntPtr)memory.ToInt64(),IntPtr.Add(memory,4),budget,watch); + return new[]{Marshal.ReadInt32(memory),Marshal.ReadInt32(memory,4)}; + } finally {Marshal.FreeHGlobal(memory);} + } + public static void SelectAll(IntPtr edit,int budget) { + ReadMessage(edit,0x00B1,UIntPtr.Zero,new IntPtr(-1),budget,Stopwatch.StartNew()); + } + public static void SetText(IntPtr edit,string text,int budget) { + UIntPtr result; + if(SetTextMessage(edit,0x000C,UIntPtr.Zero,text,0x23,(uint)budget,out result)==IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_SETTEXT unavailable/timed out"); + } + public static IntPtr FocusOf(IntPtr modal) { + uint pid; uint thread=GetWindowThreadProcessId(modal,out pid); + var info=new GuiInfo{Size=Marshal.SizeOf(typeof(GuiInfo))}; + if(thread==0 || !GetGUIThreadInfo(thread,ref info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e GetGUIThreadInfo"); + return info.Focus; + } +} diff --git a/Tools/windows/ValidationRunner/Host/TerminalSafety.cs b/Tools/windows/ValidationRunner/Host/TerminalSafety.cs new file mode 100644 index 00000000..5c599eda --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/TerminalSafety.cs @@ -0,0 +1,23 @@ +namespace GraphCode.Validation.Host; + +public static class TerminalSafety +{ + private static int reportedUnknown; + + public static void MarkUnknown() => Interlocked.Exchange(ref reportedUnknown, 1); + + public static Task ExitBarrier(int retainedLeaseCount, bool unknownReported) + { + if (retainedLeaseCount == 0 && !unknownReported) return Task.CompletedTask; + // Never complete this task: process exit would implicitly close retained kill-on-close jobs. + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task; + } + + public static async Task PreserveRetainedAuthorityAsync(int retainedLeaseCount) + { + var barrier = ExitBarrier(retainedLeaseCount, Volatile.Read(ref reportedUnknown) != 0); + if (barrier.IsCompleted) return; + try { Console.Error.WriteLine("ROOT_AUTHORITY_RETAINED: terminal hold; manual ownership recovery required"); } + finally { await barrier; } + } +} diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index b5843978..c3a71ecc 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -6,6 +6,11 @@ using GraphCode.Validation.Ownership; using GraphCode.Validation.Adapters; +try { return await RunAsync(args); } +finally { await TerminalSafety.PreserveRetainedAuthorityAsync(OwnedChildSupervisor.RetainedLeaseCount); } + +static async Task RunAsync(string[] args) +{ if (await OwnedFixture.TryRunAsync(args) is int fixtureExit) return fixtureExit; if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); @@ -82,3 +87,4 @@ Console.WriteLine("CompiledValidationRunner: PASS"); Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); return 0; +} diff --git a/Tools/windows/ValidationRunner/ValidationRunner.csproj b/Tools/windows/ValidationRunner/ValidationRunner.csproj index 84b45f03..c0353e70 100644 --- a/Tools/windows/ValidationRunner/ValidationRunner.csproj +++ b/Tools/windows/ValidationRunner/ValidationRunner.csproj @@ -11,9 +11,11 @@ + + - + diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index ffe8b04a..f6a7b896 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -11,1485 +11,18 @@ param( $ErrorActionPreference = "Stop" Import-Module -Name (Join-Path $PSScriptRoot "ShellProcessOwnership.psm1") -ErrorAction Stop -Add-Type -AssemblyName UIAutomationClient -Add-Type -AssemblyName UIAutomationTypes -Add-Type -AssemblyName System.Drawing -$uiaActivationType = "GraphCode.Windows.UiaWindowActivation" -as [type] -if ($null -eq $uiaActivationType) { - $uiaActivationDirectory = Join-Path ([IO.Path]::GetTempPath()) ("gc-uia-activation-" + [guid]::NewGuid().ToString("N")) - $null = New-Item -ItemType Directory -Path $uiaActivationDirectory -ErrorAction Stop - $uiaActivationAssemblyPath = Join-Path $uiaActivationDirectory "UiaWindowActivation.dll" - # Keep the assembly file for this host's lifetime so later helper compilation can reference it. - Add-Type -Path (Join-Path $PSScriptRoot "UiaWindowActivation.cs") -OutputAssembly $uiaActivationAssemblyPath - Add-Type -Path $uiaActivationAssemblyPath - $uiaActivationType = "GraphCode.Windows.UiaWindowActivation" -as [type] -} -$uiaActivationAssemblyPath = $uiaActivationType.Assembly.Location -if (-not [IO.Path]::IsPathFullyQualified($uiaActivationAssemblyPath) -or - -not [IO.File]::Exists($uiaActivationAssemblyPath)) { - throw "UIA activation component has no file-backed assembly reference" -} -Add-Type -TypeDefinition @" -using System; -using System.Runtime.InteropServices; -using System.Text; -using System.Windows.Automation; -public static class GraphCodeUiaGateState { - public static volatile bool LiveObserved; - public static volatile bool NamePropertyObserved; - public static volatile bool FocusObserved; - public static int LiveEvents; - public static int NamePropertyEvents; - public static int SelectedEvents; - public static int AddedEvents; - public static int RemovedEvents; - public static int TogglePropertyEvents; - public static uint LastEditClearExpected; - public static uint LastEditClearSent; - public static uint LastEditTextExpected; - public static uint LastEditTextSent; - public static bool LastEditUsedMessageFallback; - public static string LiveSourceAutomationId; - public static string LiveSourceName; - public static string LiveSourceRuntimeId; - public static string NamePropertySourceAutomationId; - public static string FocusSourceAutomationId; - public static string SelectionSourceAutomationId; - public static string SelectionSourceRuntimeId; - public static string TogglePropertySourceAutomationId; - public static readonly AutomationEventHandler LiveHandler = HandleLive; - public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; - public static readonly AutomationFocusChangedEventHandler FocusHandler = HandleFocus; - public static readonly AutomationEventHandler SelectedHandler = HandleSelected; - public static readonly AutomationEventHandler AddedHandler = HandleAdded; - public static readonly AutomationEventHandler RemovedHandler = HandleRemoved; - public static readonly AutomationPropertyChangedEventHandler TogglePropertyHandler = HandleToggleProperty; - private static void HandleLive(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element == null) return; - LiveSourceAutomationId = element.Current.AutomationId; - LiveSourceName = element.Current.Name; - LiveSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - LiveEvents++; - LiveObserved = true; - } - private static void HandleNameProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) NamePropertySourceAutomationId = element.Current.AutomationId; - NamePropertyEvents++; - NamePropertyObserved = true; - } - private static void HandleFocus(object sender, AutomationFocusChangedEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) FocusSourceAutomationId = element.Current.AutomationId; - FocusObserved = true; - } - private static void HandleSelected(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) { - SelectionSourceAutomationId = element.Current.AutomationId; - SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - } - SelectedEvents++; - } - private static void HandleAdded(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) { - SelectionSourceAutomationId = element.Current.AutomationId; - SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - } - AddedEvents++; - } - private static void HandleRemoved(object sender, AutomationEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) { - SelectionSourceAutomationId = element.Current.AutomationId; - SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); - } - RemovedEvents++; - } - private static void HandleToggleProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { - var element = sender as AutomationElement; - if (element != null) TogglePropertySourceAutomationId = element.Current.AutomationId; - TogglePropertyEvents++; - } - [DllImport("user32.dll", SetLastError = true)] - private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); - [DllImport("user32.dll")] - private static extern IntPtr SendMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); - [DllImport("user32.dll", EntryPoint = "SendMessageTimeoutW", SetLastError = true)] - private static extern IntPtr SendMessageTimeout( - IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, - uint flags, uint timeoutMilliseconds, out UIntPtr result - ); - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] - private static extern IntPtr SendMessageString(IntPtr window, uint message, UIntPtr wParam, string lParam); - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] - private static extern IntPtr SendMessageText( - IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam - ); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string windowName); - private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] - private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern bool SetWindowText(IntPtr window, string text); - [DllImport("user32.dll")] - private static extern int GetDlgCtrlID(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetDlgItem(IntPtr dialog, int controlId); - [DllImport("user32.dll")] - private static extern IntPtr SetFocus(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetFocus(); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool SetForegroundWindow(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool BringWindowToTop(IntPtr window); - [DllImport("kernel32.dll")] - private static extern uint GetCurrentThreadId(); - [DllImport("kernel32.dll")] - private static extern void Sleep(uint milliseconds); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowTextLength(IntPtr window); - [DllImport("user32.dll")] - private static extern bool ShowWindow(IntPtr window, int command); - [DllImport("user32.dll")] - private static extern IntPtr SetActiveWindow(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsWindowVisible(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsWindowEnabled(IntPtr window); - [DllImport("user32.dll")] - private static extern int GetMenuItemCount(IntPtr menu); - [DllImport("user32.dll")] - private static extern uint GetMenuItemID(IntPtr menu, int position); - [DllImport("user32.dll")] - private static extern uint GetMenuState(IntPtr menu, uint item, uint flags); - [DllImport("user32.dll")] - private static extern bool GetMenuItemRect(IntPtr window, IntPtr menu, uint position, out RECT rect); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool SetCursorPos(int x, int y); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool GetCursorPos(out ScreenPoint point); - [StructLayout(LayoutKind.Sequential)] - private struct MouseInput { - public int X, Y; - public uint MouseData, Flags, Time; - public UIntPtr ExtraInfo; - } - [StructLayout(LayoutKind.Sequential)] - private struct Input { - public uint Type; - public MouseInput Mouse; - } - [DllImport("user32.dll", SetLastError = true)] - private static extern uint SendInput(uint count, Input[] inputs, int size); - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMenuStringW")] - private static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int max, uint flags); - // The File/Loop/Terminal/Workspace/View/Help bar is a real SetMenu menu bar, - // not a TrackPopupMenu popup, so (unlike the popup context menu below) its - // live enabled state is readable directly through the Win32 menu API against - // GetMenu(shell) -- no MN_GETHMENU / popup-window workaround required. - // MF_BYCOMMAND (0) searches the whole menu tree for the command identifier. - public static uint MenuCommandState(IntPtr menuBar, uint command) { - if (menuBar == IntPtr.Zero) return 0xFFFFFFFF; - return GetMenuState(menuBar, command, 0x0000); - } - public static IntPtr GetMenuBar(IntPtr window) { - return GetMenu(window); - } - public static IntPtr FindPopupMenuWindow(uint processId) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var actualClass = new StringBuilder(256); - GetClassName(window, actualClass, actualClass.Capacity); - if (!String.Equals(actualClass.ToString(), "#32768", StringComparison.Ordinal)) return true; - if (!IsWindowVisible(window)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } - public static IntPtr FindVisibleOwnedSheetByContentClass( - uint processId, IntPtr expectedOwner, string contentClass) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - GetWindow(window, 4) == expectedOwner && - String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && - String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - public static int CountVisibleOwnedSheetsByContentClass( - uint processId, IntPtr expectedOwner, string contentClass) { - int count = 0; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - GetWindow(window, 4) == expectedOwner && - String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && - String.Equals(SheetContentClass(window), contentClass, StringComparison.Ordinal)) { - count++; - } - return true; - }, IntPtr.Zero); - return count; - } - public static IntPtr FindVisibleOwnedSheet(uint processId, string title) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal) && - String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - // MN_GETHMENU. The live UIA tree exposes a popup menu only as an empty Pane - // with no MenuItem children, so the menu itself is read through the Win32 - // menu API against the real HMENU the shell handed to TrackPopupMenu. - public static IntPtr PopupMenuHandle(IntPtr popup) { - if (popup == IntPtr.Zero) return IntPtr.Zero; - return SendMessage(popup, 0x01E1, UIntPtr.Zero, IntPtr.Zero); - } - public static IntPtr FindPopupForMenu(uint processId, IntPtr menu) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - String.Equals(ClassOf(window), "#32768", StringComparison.Ordinal) && - PopupMenuHandle(window) == menu) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - public static bool HoverPopupMenuItem(IntPtr owner, IntPtr menu, int position) { - RECT item; - if (menu == IntPtr.Zero || !GetMenuItemRect(owner, menu, (uint)position, out item)) return false; - int x = item.Right - Math.Min(12, (item.Right - item.Left) / 2); - int y = (item.Top + item.Bottom) / 2; - uint ownerProcess = WindowProcessId(owner); - IntPtr popup = FindPopupForMenu(ownerProcess, menu); - if (popup == IntPtr.Zero) return false; - RequireOwnedPopupForeground(popup, owner, ownerProcess); - IntPtr hit = WindowFromPoint(new ScreenPoint { X = x, Y = y }); - if (GetAncestor(hit, 2) != popup || WindowProcessId(hit) != ownerProcess) - throw new InvalidOperationException("Submenu hover point is covered; no cursor movement was injected"); - if (!SetCursorPos(x, y)) - throw new InvalidOperationException(String.Format("SetCursorPos for submenu failed: Win32Error={0}", Marshal.GetLastWin32Error())); - ScreenPoint at; - if (!GetCursorPos(out at)) return false; - var client = new ScreenPoint { X = x, Y = y }; - if (!ScreenToClient(popup, ref client)) return false; - RequireOwnedPopupForeground(popup, owner, ownerProcess); - SendMessage(popup, 0x0200, UIntPtr.Zero, MouseLParam(client.X, client.Y)); - bool hilite = false; - for (int attempt = 0; attempt < 50 && !hilite; attempt++) { - hilite = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - if (!hilite) Sleep(10); - } - return at.X == x && at.Y == y && hilite; - } - // Keyboard reveal of a real submenu: hilite the parent item (MN_SELECTITEM) - // and post VK_RIGHT, the same navigation a keyboard user performs. Unlike a - // WM_COMMAND posted to the shell (which TrackPopupMenu/TPM_RETURNCMD menus - // never route), this opens the shell's own submenu popup. - public static bool RevealSubmenuByKeyboard(IntPtr popup, IntPtr menu, int position) { - if (popup == IntPtr.Zero || menu == IntPtr.Zero || !IsWindowVisible(popup)) return false; - SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); - if ((GetMenuState(menu, (uint)position, 0x0400) & 0x0080) == 0) return false; - return PostMessage(popup, 0x0100, (UIntPtr)0x27, IntPtr.Zero) && - PostMessage(popup, 0x0101, (UIntPtr)0x27, IntPtr.Zero); - } - public static int PopupMenuItemCount(IntPtr menu) { - if (menu == IntPtr.Zero) return -1; - return GetMenuItemCount(menu); - } - public static uint PopupMenuItemId(IntPtr menu, int position) { - if (menu == IntPtr.Zero) return 0; - return GetMenuItemID(menu, position); - } - public static string PopupMenuItemText(IntPtr menu, int position) { - if (menu == IntPtr.Zero) return ""; - var text = new StringBuilder(512); - GetMenuString(menu, (uint)position, text, text.Capacity, 0x0400); - return text.ToString(); - } - public static uint PopupMenuItemState(IntPtr menu, int position) { - if (menu == IntPtr.Zero) return 0xFFFFFFFF; - return GetMenuState(menu, (uint)position, 0x0400); - } - // MainWindow.wm_uia_context_menu (WM_APP + 44). - public static bool PostContextMenu(IntPtr window, uint target) { - return PostMessage(window, 0x802C, (UIntPtr)target, IntPtr.Zero); - } - // MainWindow.wm_uia_present_form (WM_APP + 45). - public static bool PostPresentForm(IntPtr window, uint form) { - return PostMessage(window, 0x802D, (UIntPtr)form, IntPtr.Zero); - } - public static bool DismissPopupMenu(IntPtr popup, IntPtr owner) { - bool posted = popup != IntPtr.Zero && - PostMessage(popup, 0x0100, (UIntPtr)0x1B, IntPtr.Zero); - if (!posted && owner != IntPtr.Zero) { - SendMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); - } - return posted; - } - public static void CancelPopupMenu(IntPtr owner) { - if (owner != IntPtr.Zero) PostMessage(owner, 0x001F, UIntPtr.Zero, IntPtr.Zero); - } - public static IntPtr FindChild(IntPtr parent, string className) { - return FindWindowEx(parent, IntPtr.Zero, className, null); - } - public static IntPtr FindTopLevel(string className, uint processId) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var actualClass = new StringBuilder(256); - GetClassName(window, actualClass, actualClass.Capacity); - if (!String.Equals(actualClass.ToString(), className, StringComparison.Ordinal)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } - public static string[] GetListItems(IntPtr list) { - if (list == IntPtr.Zero) return new string[0]; - int count = (int)SendMessage(list, 0x018B, UIntPtr.Zero, IntPtr.Zero); - var result = new string[count]; - for (int index = 0; index < count; index++) { - int length = (int)SendMessage(list, 0x018A, (UIntPtr)index, IntPtr.Zero); - var text = new StringBuilder(length + 1); - SendMessageText(list, 0x0189, (UIntPtr)index, text); - result[index] = text.ToString(); - } - return result; - } - public static bool PostFixtureMutation(IntPtr window, uint mutation) { - return PostMessage(window, 0x802A, (UIntPtr)mutation, IntPtr.Zero); - } - public static bool PostPaletteRefresh(IntPtr window) { - return PostMessage(window, 0x802B, UIntPtr.Zero, IntPtr.Zero); - } - public static bool PostTaggedExitCollision(IntPtr window) { - return PostMessage(window, 0x0111, new UIntPtr(0x8000000000001008UL), IntPtr.Zero); - } - public static bool PostKeyboard(IntPtr window, uint key) { - return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero) && - PostMessage(window, 0x0101, (UIntPtr)key, IntPtr.Zero); - } - public static bool SendReturn(IntPtr window) { - if (window == IntPtr.Zero) return false; - SendMessage(window, 0x0100, (UIntPtr)0x0D, IntPtr.Zero); - return true; - } - public static bool SendCommand(IntPtr window, uint command) { - if (window == IntPtr.Zero) return false; - UIntPtr result; - return SendMessageTimeout( - window, 0x0111, (UIntPtr)command, IntPtr.Zero, - 0x0001 | 0x0002, 5000, out result - ) != IntPtr.Zero; - } - public static bool ClickButton(IntPtr button) { - if (button == IntPtr.Zero) return false; - SendMessage(button, 0x00F5, UIntPtr.Zero, IntPtr.Zero); - return true; - } - public static int GetCheckState(IntPtr window) { - if (window == IntPtr.Zero) return -1; - return (int)SendMessage(window, 0x00F0, UIntPtr.Zero, IntPtr.Zero); - } - public static bool FocusControl(IntPtr parent, IntPtr control) { - if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; - uint expectedProcessId = WindowProcessId(parent); - RequireOwnedInputWindow(parent, expectedProcessId); - if (WindowProcessId(control) != expectedProcessId || GetAncestor(control, 2) != parent || - !ActivateWindow(parent)) return false; - uint ignoredProcessId; - uint parentThread = GetWindowThreadProcessId(parent, out ignoredProcessId); - uint currentThread = GetCurrentThreadId(); - bool attached = currentThread != parentThread && - AttachThreadInput(currentThread, parentThread, true); - try { - RequireOwnedForeground(parent, expectedProcessId); - SetFocus(control); - return IsForegroundWindow(parent) && FocusedControlInDialog(parent) == control; - } finally { - if (attached) AttachThreadInput(currentThread, parentThread, false); - } - } - [StructLayout(LayoutKind.Sequential)] - private struct GuiThreadInfo { - public int Size; - public uint Flags; - public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret; - public RECT CaretRectangle; - } - [DllImport("user32.dll", SetLastError = true)] - private static extern bool GetGUIThreadInfo(uint thread, ref GuiThreadInfo info); - public static IntPtr FocusedControlInDialog(IntPtr parent) { - uint processId; - uint thread = GetWindowThreadProcessId(parent, out processId); - var info = new GuiThreadInfo { Size = Marshal.SizeOf(typeof(GuiThreadInfo)) }; - if (thread == 0 || !GetGUIThreadInfo(thread, ref info)) - throw new InvalidOperationException("Owned focus could not be observed through GetGUIThreadInfo"); - return info.Focus; - } - private static readonly System.Collections.Generic.Dictionary - RetainedProcesses = new System.Collections.Generic.Dictionary(); - public static void RetainProcess(System.Diagnostics.Process process) { - if (process == null || process.Handle == IntPtr.Zero) - throw new InvalidOperationException("UIA process has no retained launch handle"); - RetainedProcesses[checked((uint)process.Id)] = process; - } - private static bool IsRetainedProcessAlive(uint processId) { - System.Diagnostics.Process process; - if (!RetainedProcesses.TryGetValue(processId, out process)) return false; - try { return process.Handle != IntPtr.Zero && !process.HasExited; } - catch (ObjectDisposedException) { return false; } - catch (InvalidOperationException) { return false; } - } - private static void RequireOwnedInputWindow(IntPtr owner, uint expectedProcessId) { - if (owner == IntPtr.Zero || expectedProcessId == 0 || !IsRetainedProcessAlive(expectedProcessId) || - WindowProcessId(owner) != expectedProcessId || !WindowIsVisible(owner) || !WindowIsEnabled(owner)) - throw new InvalidOperationException("Input target lost retained process/window ownership; no input was injected"); - } - private static void RequireOwnedForeground(IntPtr owner, uint expectedProcessId) { - RequireOwnedInputWindow(owner, expectedProcessId); - IntPtr foreground = GetForegroundWindow(); - if (foreground != owner || WindowProcessId(foreground) != expectedProcessId) - throw new InvalidOperationException("Owned foreground changed; no input was injected"); - } - public static void RequireOwnedKeyboardInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus) { - RequireOwnedForeground(owner, expectedProcessId); - IntPtr focus = FocusedControlInDialog(owner); - if (focus == IntPtr.Zero || WindowProcessId(focus) != expectedProcessId || - GetAncestor(focus, 2) != owner || (expectedFocus != IntPtr.Zero && focus != expectedFocus)) - throw new InvalidOperationException("Owned keyboard focus changed; no key input was injected"); - RequireOwnedForeground(owner, expectedProcessId); - } - private static void RequireOwnedMouseInput( - IntPtr owner, uint expectedProcessId, ScreenPoint expected, IntPtr expectedControl - ) { - RequireOwnedForeground(owner, expectedProcessId); - ScreenPoint actual; - if (!GetCursorPos(out actual) || actual.X != expected.X || actual.Y != expected.Y) - throw new InvalidOperationException("Owned mouse cursor changed; no mouse input was injected"); - IntPtr hit = WindowFromPoint(actual); - if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner || WindowProcessId(hit) != expectedProcessId || - (expectedControl != IntPtr.Zero && !ResolvesToControl(expectedControl, owner, actual))) - throw new InvalidOperationException("Owned mouse hit changed; no mouse input was injected"); - } - public static bool IsControlOwnedBy(IntPtr parent, IntPtr control, int controlId) { - return parent != IntPtr.Zero && control != IntPtr.Zero && - GetDlgCtrlID(control) == controlId && GetAncestor(control, 2) == parent; - } - public static int ControlIdOf(IntPtr control) { - return control == IntPtr.Zero ? 0 : GetDlgCtrlID(control); - } - public static bool HasVisibleBounds(IntPtr window) { - RECT rect; - return window != IntPtr.Zero && IsWindowVisible(window) && - GetWindowRect(window, out rect) && rect.Right > rect.Left && rect.Bottom > rect.Top; - } - public static string LastActivationDiagnostic = "not attempted"; - private sealed class WindowActivationOperations : GraphCode.Windows.IUiaWindowActivationOperations { - public uint WindowProcessId(IntPtr window) { - return GraphCodeUiaGateState.WindowProcessId(window); - } - public bool IsRetainedProcessAlive(uint processId) { - return GraphCodeUiaGateState.IsRetainedProcessAlive(processId); - } - public void ShowWindow(IntPtr window, int command) { - GraphCodeUiaGateState.ShowWindow(window, command); - } - public GraphCode.Windows.UiaActivationCallResult BringWindowToTop(IntPtr window) { - bool succeeded = GraphCodeUiaGateState.BringWindowToTop(window); - int error = Marshal.GetLastWin32Error(); - return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); - } - public GraphCode.Windows.UiaActivationCallResult SetForegroundWindow(IntPtr window) { - bool succeeded = GraphCodeUiaGateState.SetForegroundWindow(window); - int error = Marshal.GetLastWin32Error(); - return new GraphCode.Windows.UiaActivationCallResult(succeeded, error); - } - public bool IsForegroundWindow(IntPtr window) { - return GraphCodeUiaGateState.IsForegroundWindow(window); - } - } - private static readonly GraphCode.Windows.IUiaWindowActivationOperations ActivationOperations = - new WindowActivationOperations(); - public static bool ActivateWindow(IntPtr window) { - return GraphCode.Windows.UiaWindowActivation.ActivateWindow( - ActivationOperations, window, out LastActivationDiagnostic); - } - public static bool IsForegroundWindow(IntPtr window) { - return window != IntPtr.Zero && GetForegroundWindow() == window; - } - public static IntPtr CurrentForegroundWindow() { - return GetForegroundWindow(); - } - public static uint WindowProcessId(IntPtr window) { - if (window == IntPtr.Zero) return 0; - uint processId; - GetWindowThreadProcessId(window, out processId); - return processId; - } - public static string WindowClass(IntPtr window) { - if (window == IntPtr.Zero) return ""; - var text = new StringBuilder(256); - GetClassName(window, text, text.Capacity); - return text.ToString(); - } - public static string WindowTitle(IntPtr window) { - if (window == IntPtr.Zero) return ""; - int length = GetWindowTextLength(window); - var text = new StringBuilder(length + 1); - GetWindowText(window, text, text.Capacity); - return text.ToString(); - } - // Diagnostic only (Tools/windows/uia-live-gate.ps1's Get-DirectChildren - // exhaustion path): enumerates every top-level window currently owned by - // processId with its class, visibility, and title, so a failure that - // reports MainWindowHandle == 0 can be told apart as window-destroyed - // (empty result), window-hidden (a result exists but is not visible), or - // handle-churn (a visible, healthy-looking window exists under a handle - // Process.MainWindowHandle no longer reports). Does not filter or assert - // anything - it is read verbatim into a log line. - public static string[] DescribeTopLevelWindows(uint processId) { - var results = new System.Collections.ArrayList(); - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var classText = new StringBuilder(256); - GetClassName(window, classText, classText.Capacity); - int titleLength = GetWindowTextLength(window); - var titleText = new StringBuilder(titleLength + 1); - GetWindowText(window, titleText, titleText.Capacity); - bool visible = IsWindowVisible(window); - results.Add(window.ToInt64() + ":" + classText.ToString() + ":" + - (visible ? "visible" : "hidden") + ":" + titleText.ToString()); - return true; - }, IntPtr.Zero); - return (string[])results.ToArray(typeof(string)); - } - public static bool PostMouseClick(IntPtr window) { - return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); - } - // A modal disables its owner for as long as it is up. Win32 refuses a caption - // close against a WS_DISABLED window, so the owner must be observed enabled - // again before a close can be attributed to the shell rather than to a modal - // that has not finished tearing down. - public static bool WindowIsEnabled(IntPtr window) { - return IsWindowEnabled(window); - } - public static bool WindowIsVisible(IntPtr window) { - return window != IntPtr.Zero && IsWindowVisible(window); - } - public static IntPtr WindowOwner(IntPtr window) { - return window == IntPtr.Zero ? IntPtr.Zero : GetWindow(window, 4); - } - public static bool IsCaptionlessSheet(IntPtr window) { - if (window == IntPtr.Zero || - !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) - return false; - ulong style = unchecked((ulong)GetWindowLongPtr(window, -16).ToInt64()); - return (style & 0x80000000UL) != 0 && (style & 0x00C00000UL) == 0; - } - public static string SheetContentClass(IntPtr window) { - if (window == IntPtr.Zero || - !String.Equals(WindowClass(window), "GraphCodeSheetHost", StringComparison.Ordinal)) - return ""; - IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); - if (viewport == IntPtr.Zero) return ""; - string[] contentClasses = new[] { - "GraphCodeWindowsDialogContent", - "GraphCodeNativeForm", - "GraphCodeProductSettings", - "GraphCodeWorkspaceManager", - "GraphCodeRepositoryIngressDialog", - "GraphCodeRepositoryOperationDialog", - "GraphCodeCodespaceIngressDialog", - "GraphCodeUpdateOffer", - "GraphCodeUpdateInstall" - }; - foreach (string contentClass in contentClasses) - if (FindWindowEx(viewport, IntPtr.Zero, contentClass, null) != IntPtr.Zero) - return contentClass; - return ""; - } - public static IntPtr FindVisibleProcessWindow(uint processId, string title) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner == processId && IsWindowVisible(window) && - String.Equals(WindowTitle(window), title, StringComparison.Ordinal)) { - result = window; - return false; - } - return true; - }, IntPtr.Zero); - return result; - } - // Real client-coordinate mouse messages posted directly to the target window, - // matching the same WM_MOUSEMOVE/WM_LBUTTONDOWN/WM_LBUTTONUP messages the OS - // delivers for genuine mouse input, without moving the shared desktop's real - // cursor (multiple fleet sessions share this desktop). PostMouseButtonAt sets - // the MK_LBUTTON flag in wParam, matching the wParam a real WM_LBUTTONDOWN/UP - // carries; ClickAt below (and its existing sidebar-update-banner caller) keep - // working unchanged since App.zig's click handling only decodes the lParam - // x/y, not the button-state bits in wParam. - private static IntPtr MouseLParam(int x, int y) { - return (IntPtr)(((y & 0xFFFF) << 16) | (x & 0xFFFF)); - } - public static bool PostMouseButtonAt(IntPtr window, uint message, int x, int y) { - return PostMessage(window, message, (UIntPtr)0x0001, MouseLParam(x, y)); - } - public static bool ClickAt(IntPtr window, int x, int y) { - return PostMouseButtonAt(window, 0x0201, x, y) && PostMouseButtonAt(window, 0x0202, x, y); - } - public static bool PostMouseMoveAt(IntPtr window, int clientX, int clientY) { - return PostMessage(window, 0x0200, UIntPtr.Zero, MouseLParam(clientX, clientY)); - } - public static bool PostMouseDragMoveAt(IntPtr window, int clientX, int clientY) { - return PostMessage(window, 0x0200, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); - } - public static IntPtr SendMouseButtonAt(IntPtr window, uint message, int clientX, int clientY) { - return SendMessage(window, message, (UIntPtr)0x0001, MouseLParam(clientX, clientY)); - } - public static bool PostMouseClickAt(IntPtr window, int clientX, int clientY) { - return PostMouseButtonAt(window, 0x0201, clientX, clientY) && - PostMouseButtonAt(window, 0x0202, clientX, clientY); - } - public static bool PostRightClickAt(IntPtr window, int clientX, int clientY) { - IntPtr point = MouseLParam(clientX, clientY); - return PostMessage(window, 0x0204, (UIntPtr)0x0002, point) && - PostMessage(window, 0x0205, UIntPtr.Zero, point); - } - public static int[] ClickOwnedScreenRectangle(IntPtr owner, int left, int top, int right, int bottom, bool rightClick) { - uint expectedProcessId = WindowProcessId(owner); - RequireOwnedForeground(owner, expectedProcessId); - if (!WindowIsVisible(owner) || right <= left || bottom <= top) - throw new InvalidOperationException("Owned rectangle is not visibly hittable"); - var point = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; - IntPtr hit = WindowFromPoint(point); - if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner) - throw new InvalidOperationException("Live rectangle is covered by another top-level window"); - if (!SetCursorPos(point.X, point.Y)) - throw new InvalidOperationException("Unable to move cursor into owned live rectangle"); - ScreenPoint actual; - if (!GetCursorPos(out actual) || actual.X != point.X || actual.Y != point.Y) - throw new InvalidOperationException("Owned live-rectangle cursor position differs"); - var inputs = new Input[] { - new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0008u : 0x0002u } }, - new Input { Type = 0, Mouse = new MouseInput { Flags = rightClick ? 0x0010u : 0x0004u } } - }; - RequireOwnedMouseInput(owner, expectedProcessId, point, IntPtr.Zero); - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("Owned rectangle SendInput sent {0}/{1}", sent, inputs.Length)); - return new int[] { left, top, right, bottom, actual.X, actual.Y, (int)sent, inputs.Length }; - } - public static bool PostOwnedScreenPoint(IntPtr owner, int screenX, int screenY, bool rightClick) { - if (!WindowIsVisible(owner)) return false; - var point = new ScreenPoint { X = screenX, Y = screenY }; - IntPtr hit = WindowFromPoint(point); - if (hit == IntPtr.Zero || GetAncestor(hit, 2) != owner || !ScreenToClient(owner, ref point)) - return false; - if (rightClick) { - return PostMessage(owner, 0x0204, (UIntPtr)0x0002, MouseLParam(point.X, point.Y)) && - PostMessage(owner, 0x0205, UIntPtr.Zero, MouseLParam(point.X, point.Y)); - } - return PostMessage(owner, 0x0201, (UIntPtr)0x0001, MouseLParam(point.X, point.Y)) && - PostMessage(owner, 0x0202, UIntPtr.Zero, MouseLParam(point.X, point.Y)); - } - public static bool IsOwnedPopupInput( - IntPtr owner, IntPtr popup, uint expectedProcessId, uint popupProcessId, - uint foregroundProcessId, IntPtr foregroundRoot, uint pointProcessId, - IntPtr pointRoot, bool popupVisible - ) { - return owner != IntPtr.Zero && popup != IntPtr.Zero && expectedProcessId != 0 && - popupProcessId == expectedProcessId && foregroundProcessId == expectedProcessId && - (foregroundRoot == owner || foregroundRoot == popup) && - pointProcessId == expectedProcessId && pointRoot == popup && popupVisible; - } - public sealed class PopupItemHit { - public int Position, ItemId, Left, Top, Right, Bottom; - public int ScreenX, ScreenY, ClientX, ClientY, CursorBeforeX, CursorBeforeY; - public int CursorAtX, CursorAtY; - public bool Hilite, UsedKeyboardFallback, UsedSelectItemFallback; - public bool HiliteAtEnterFallback, HiliteBeforeEnter; - } - public static PopupItemHit ClickPopupMenuItem(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId) { - if (popup == IntPtr.Zero || position < 0) return null; - IntPtr menu = PopupMenuHandle(popup); - RECT item; - if (menu == IntPtr.Zero || !GetMenuItemRect(owner, menu, (uint)position, out item) || - item.Right <= item.Left || item.Bottom <= item.Top || - PopupMenuItemId(menu, position) != (uint)commandId) return null; - int screenX = (item.Left + item.Right) / 2; - int screenY = (item.Top + item.Bottom) / 2; - if (screenX < item.Left || screenX >= item.Right || - screenY < item.Top || screenY >= item.Bottom) return null; - var point = new ScreenPoint { - X = screenX, - Y = screenY - }; - if (!ScreenToClient(popup, ref point)) return null; - ScreenPoint before, at; - if (!GetCursorPos(out before)) - throw new InvalidOperationException(String.Format("GetCursorPos before popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - RequireOwnedPopupForeground(popup, owner, expectedProcessId); - if (!SetCursorPos(screenX, screenY)) - throw new InvalidOperationException(String.Format("SetCursorPos for popup click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!GetCursorPos(out at)) - throw new InvalidOperationException(String.Format("GetCursorPos after popup move failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (at.X != screenX || at.Y != screenY) - throw new InvalidOperationException(String.Format("Popup cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, screenX, screenY)); - SendMessage(popup, 0x0200, UIntPtr.Zero, MouseLParam(point.X, point.Y)); - bool hilite = false; - for (int attempt = 0; attempt < 50 && !hilite; attempt++) { - hilite = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - if (!hilite) Sleep(10); - } - bool selectItemFallback = false; - if (!hilite) { - SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); - selectItemFallback = true; - for (int attempt = 0; attempt < 50 && !hilite; attempt++) { - hilite = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - if (!hilite) Sleep(10); - } - } - if (!hilite) - throw new InvalidOperationException("Popup item did not acknowledge hover before click"); - var inputs = new[] { - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } - }; - ScreenPoint cursorBeforeInput; - if (!GetCursorPos(out cursorBeforeInput) || cursorBeforeInput.X != screenX || cursorBeforeInput.Y != screenY) - throw new InvalidOperationException("Popup input cursor changed; no mouse input was injected"); - uint inputOwnerProcessId = WindowProcessId(owner); - if (inputOwnerProcessId != expectedProcessId || !IsRetainedProcessAlive(expectedProcessId)) - throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); - IntPtr foregroundBeforeInput = GetForegroundWindow(); - IntPtr inputPointWindow = WindowFromPoint(cursorBeforeInput); - if (!IsOwnedPopupInput( - owner, popup, inputOwnerProcessId, WindowProcessId(popup), - WindowProcessId(foregroundBeforeInput), GetAncestor(foregroundBeforeInput, 2), - WindowProcessId(inputPointWindow), GetAncestor(inputPointWindow, 2), IsWindowVisible(popup))) - throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} popup mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); - for (int attempt = 0; attempt < 20 && IsWindowVisible(popup); attempt++) Sleep(10); - bool keyboardFallback = false; - bool hiliteAtEnterFallback = false; - bool hiliteBeforeEnter = false; - if (IsWindowVisible(popup)) { - // Injected clicks can be dropped (or held) by the desktop's input stack, - // and the menu can lose its hover hilite meanwhile. Enter with no hilited - // item ends TrackPopupMenu with command 0: the popup closes exactly as if - // the item had been chosen, but no command reaches the shell. Re-assert - // the intended item and prove its hilite immediately before Enter. - hiliteAtEnterFallback = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - SendMessage(popup, 0x01E5, (UIntPtr)position, IntPtr.Zero); - hiliteBeforeEnter = (GetMenuState(menu, (uint)position, 0x0400) & 0x0080) != 0; - } - if (IsWindowVisible(popup)) { - if (!hiliteBeforeEnter) - throw new InvalidOperationException(String.Format("Popup item {0} was not hilited before the Enter fallback (hilite at fallback={1})", commandId, hiliteAtEnterFallback)); - RequireOwnedPopupForeground(popup, owner, expectedProcessId); - keyboardFallback = - PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero) && - PostMessage(popup, 0x0101, (UIntPtr)0x0D, IntPtr.Zero); - if (!keyboardFallback) - throw new InvalidOperationException(String.Format("Popup Enter fallback was rejected: Win32Error={0}", Marshal.GetLastWin32Error())); - } - return new PopupItemHit { - Position = position, ItemId = commandId, Left = item.Left, Top = item.Top, - Right = item.Right, Bottom = item.Bottom, - ScreenX = screenX, ScreenY = screenY, ClientX = point.X, ClientY = point.Y, - CursorBeforeX = before.X, CursorBeforeY = before.Y, - CursorAtX = at.X, CursorAtY = at.Y, Hilite = hilite, - UsedKeyboardFallback = keyboardFallback, - UsedSelectItemFallback = selectItemFallback, - HiliteAtEnterFallback = hiliteAtEnterFallback, - HiliteBeforeEnter = hiliteBeforeEnter - }; - } - private static void RequireOwnedPopupForeground(IntPtr popup, IntPtr owner, uint expectedProcessId) { - IntPtr foreground = GetForegroundWindow(); - IntPtr foregroundRoot = GetAncestor(foreground, 2); - if (expectedProcessId == 0 || !IsRetainedProcessAlive(expectedProcessId) || WindowProcessId(owner) != expectedProcessId || - WindowProcessId(popup) != expectedProcessId || WindowProcessId(foreground) != expectedProcessId || - (foregroundRoot != owner && foregroundRoot != popup) || !IsWindowVisible(popup)) - throw new InvalidOperationException("Popup input ownership changed; no further input was injected"); - } - // Sidebar.updateBannerRect/updateBannerAt are pixel-only hit-test geometry with - // no UIA identity of their own, so a genuine click requires the real live client - // height rather than an assumed window size. - public static int ClientHeight(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return 0; - return rect.Bottom - rect.Top; - } - // Posts the WM_MOUSEWHEEL the OS delivers to the window under the cursor. Its - // lParam is in screen coordinates; delta is in wheel units (negative scrolls - // content up, so the sidebar advances). - [DllImport("user32.dll")] - private static extern bool ClientToScreen(IntPtr window, ref ScreenPoint point); - public static bool WheelAt(IntPtr window, int clientX, int clientY, int delta) { - var point = new ScreenPoint { X = clientX, Y = clientY }; - if (window == IntPtr.Zero || !ClientToScreen(window, ref point)) return false; - return PostMessage(window, 0x020A, new UIntPtr((uint)((delta & 0xFFFF) << 16)), MouseLParam(point.X, point.Y)); - } - [StructLayout(LayoutKind.Sequential)] - private struct RECT { public int Left; public int Top; public int Right; public int Bottom; } - [DllImport("user32.dll")] - private static extern bool GetClientRect(IntPtr window, out RECT rect); - [DllImport("user32.dll")] - private static extern IntPtr GetMenu(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetSubMenu(IntPtr menu, int position); - public static IntPtr NativeMenu(IntPtr window) { - return window == IntPtr.Zero ? IntPtr.Zero : GetMenu(window); - } - public static IntPtr NativeSubMenu(IntPtr menu, int position) { - return menu == IntPtr.Zero ? IntPtr.Zero : GetSubMenu(menu, position); - } - // App.zig only rebuilds recent_folders (and the rest of the native chrome) - // from the live GraphModel on WM_INITMENUPOPUP - the same message real - // Windows sends right before a menu bar popup is displayed. Reading the - // HMENU without first sending this leaves it holding whatever was current - // the last time a menu was actually opened (or, at startup, an empty - // "No recent folders" placeholder installed before the fixture loaded). - // Sending it here reproduces the exact real trigger, not a shortcut. - public static void RefreshNativeMenuFromLiveModel(IntPtr window, IntPtr menu) { - SendMessage(window, 0x0117, (UIntPtr)(ulong)menu.ToInt64(), IntPtr.Zero); - } - [StructLayout(LayoutKind.Sequential)] - public struct ScreenPoint { public int X; public int Y; } - [DllImport("user32.dll")] - private static extern bool ScreenToClient(IntPtr window, ref ScreenPoint point); - [DllImport("user32.dll")] - private static extern bool IsIconic(IntPtr window); - public static bool IsWindowMinimized(IntPtr window) { return IsIconic(window); } - public static bool ScreenToClientPoint(IntPtr window, int screenX, int screenY, out int clientX, out int clientY) { - var point = new ScreenPoint { X = screenX, Y = screenY }; - bool ok = ScreenToClient(window, ref point); - clientX = point.X; - clientY = point.Y; - return ok; - } - public static int[] ClientScreenBounds(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetClientRect(window, out rect)) return null; - var topLeft = new ScreenPoint { X = rect.Left, Y = rect.Top }; - var bottomRight = new ScreenPoint { X = rect.Right, Y = rect.Bottom }; - if (!ClientToScreen(window, ref topLeft) || !ClientToScreen(window, ref bottomRight)) - return null; - return new[] { topLeft.X, topLeft.Y, bottomRight.X, bottomRight.Y }; - } - public static bool PostCommand(IntPtr window, uint command) { - return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); - } - public static bool PostClose(IntPtr window) { - return PostMessage(window, 0x0010, UIntPtr.Zero, IntPtr.Zero); - } - public static bool SetFirstEditText(IntPtr parent, string text) { - var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); - if (edit == IntPtr.Zero || !SetWindowText(edit, text)) return false; - ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); - SendMessage(parent, 0x0111, (UIntPtr)command, edit); - return true; - } - public static bool SetEditTextById(IntPtr parent, int controlId, string text) { - var edit = ControlById(parent, controlId); - // Cross-process SetWindowText only updates the stored caption; WM_SETTEXT reaches the EDIT buffer. - if (edit == IntPtr.Zero || SendMessageString(edit, 0x000C, UIntPtr.Zero, text) == IntPtr.Zero) return false; - ulong command = ((ulong)0x0300 << 16) | (uint)controlId; - SendMessage(parent, 0x0111, (UIntPtr)command, edit); - return true; - } - public static string EditTextById(IntPtr parent, int controlId) { - var edit = ControlById(parent, controlId); - if (edit == IntPtr.Zero) return null; - return EditBufferText(edit); - } - private static string EditBufferText(IntPtr edit) { - // Cross-process GetWindowText returns the stored caption; WM_GETTEXT reads the EDIT buffer. - int length = (int)SendMessage(edit, 0x000E, UIntPtr.Zero, IntPtr.Zero); - var text = new StringBuilder(length + 1); - SendMessageText(edit, 0x000D, (UIntPtr)text.Capacity, text); - return text.ToString(); - } - public static string FirstEditText(IntPtr parent) { - var edit = ControlById(parent, 9100); - if (edit == IntPtr.Zero) edit = ControlById(parent, 9904); - if (edit == IntPtr.Zero) edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); - if (edit == IntPtr.Zero) return null; - return EditBufferText(edit); - } - // Node creation sheet helpers. Every click below is a real cursor move plus - // SendInput at the live window rectangle of the target control, and the - // window under that point is checked first so a covered or mis-scaled target - // fails instead of silently clicking something else. - [DllImport("user32.dll")] - private static extern bool GetWindowRect(IntPtr window, out RECT rect); - [DllImport("user32.dll")] - private static extern IntPtr WindowFromPoint(ScreenPoint point); - private delegate bool EnumChildProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] - private static extern bool EnumChildWindows(IntPtr parent, EnumChildProc callback, IntPtr parameter); - [StructLayout(LayoutKind.Sequential)] - private struct KeybdInput { - public ushort VirtualKey, ScanCode; - public uint Flags, Time; - public UIntPtr ExtraInfo; - } - // INPUT is a union sized by MOUSEINPUT; the trailing padding keeps this - // keyboard record the same size so SendInput accepts cbSize. - [StructLayout(LayoutKind.Sequential)] - private struct KeyInputRecord { - public uint Type; - public KeybdInput Key; - public uint Pad0, Pad1; - } - [DllImport("user32.dll", SetLastError = true, EntryPoint = "SendInput")] - private static extern uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); - [DllImport("user32.dll", SetLastError = true, EntryPoint = "SystemParametersInfoW")] - private static extern bool SystemParametersInfoRect(uint action, uint param, out RECT rect, uint winIni); - [DllImport("user32.dll")] - private static extern IntPtr GetAncestor(IntPtr window, uint flags); - [DllImport("user32.dll")] - private static extern IntPtr RealChildWindowFromPoint(IntPtr parent, ScreenPoint clientPoint); - [DllImport("user32.dll")] - private static extern IntPtr GetWindow(IntPtr window, uint command); - [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] - private static extern IntPtr GetWindowLongPtr(IntPtr window, int index); - public sealed class ControlClick { - public int ControlId, Left, Top, Right, Bottom, ScreenX, ScreenY; - public int CursorBeforeX, CursorBeforeY, CursorAtX, CursorAtY; - public bool HitTarget; - public int WindowAtPointId; - public string WindowAtPointClass, WindowAtPointRootClass, WindowAtPointText; - public int WindowAtPointProcessId; - public int RealChildId; - public string RealChildClass, RealChildText; - public bool SameTopLevel; - public bool VisibleEmpty; - public int WorkLeft, WorkTop, WorkRight, WorkBottom; - public bool OutsideWorkArea; - public int CenterX, CenterY; - public bool CenterHitTarget; - public string WindowAtCenterClass, WindowAtCenterRootClass; - public bool ScanUsed; - public int ScannedPoints, CoveredPoints; - public int CoveringId, CoveringLeft, CoveringTop, CoveringRight, CoveringBottom; - public string CoveringClass, CoveringText; - public string[] Samples; - public string[] CoveringWindows; - public int[][] UncoveredRectangles; - public int[] ChosenUncoveredRectangle; - public bool NarrowUncovered; - } - public static int[][] SubtractCoveredRectangles(int[] visible, int[][] covers) { - var remaining = new System.Collections.ArrayList(); - if (visible[2] > visible[0] && visible[3] > visible[1]) remaining.Add(visible); - foreach (int[] cover in covers) { - var next = new System.Collections.ArrayList(); - foreach (int[] piece in remaining) { - int left = Math.Max(piece[0], cover[0]), top = Math.Max(piece[1], cover[1]); - int right = Math.Min(piece[2], cover[2]), bottom = Math.Min(piece[3], cover[3]); - if (left >= right || top >= bottom) { next.Add(piece); continue; } - if (piece[0] < left) next.Add(new[] { piece[0], piece[1], left, piece[3] }); - if (right < piece[2]) next.Add(new[] { right, piece[1], piece[2], piece[3] }); - if (piece[1] < top) next.Add(new[] { left, piece[1], right, top }); - if (bottom < piece[3]) next.Add(new[] { left, bottom, right, piece[3] }); - } - remaining = next; - } - var result = new int[remaining.Count][]; - remaining.CopyTo(result); - Array.Sort(result, delegate(int[] a, int[] b) { - long areaA = (long)(a[2] - a[0]) * (a[3] - a[1]); - long areaB = (long)(b[2] - b[0]) * (b[3] - b[1]); - int byArea = areaB.CompareTo(areaA); - if (byArea != 0) return byArea; - int byTop = a[1].CompareTo(b[1]); - return byTop != 0 ? byTop : a[0].CompareTo(b[0]); - }); - return result; - } - private static IntPtr ResolveChild(IntPtr dialog, ScreenPoint point) { - var clientPoint = point; - if (!ScreenToClient(dialog, ref clientPoint)) return IntPtr.Zero; - return RealChildWindowFromPoint(dialog, clientPoint); - } - private static bool ResolvesToControl(IntPtr target, IntPtr dialog, ScreenPoint point) { - IntPtr atPoint = WindowFromPoint(point); - if (atPoint == IntPtr.Zero || GetAncestor(atPoint, 2) != GetAncestor(target, 2)) return false; - var clientPoint = point; - if (!ScreenToClient(dialog, ref clientPoint)) return false; - return RealChildWindowFromPoint(dialog, clientPoint) == target; - } - private static string ClassOf(IntPtr window) { - if (window == IntPtr.Zero) return ""; - var name = new StringBuilder(128); - GetClassName(window, name, name.Capacity); - return name.ToString(); - } - public static int[] WindowBounds(IntPtr window) { - RECT rect; - if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return null; - return new[] { rect.Left, rect.Top, rect.Right, rect.Bottom }; - } - public static IntPtr ControlById(IntPtr parent, int controlId) { - if (parent == IntPtr.Zero) return IntPtr.Zero; - IntPtr direct = GetDlgItem(parent, controlId); - if (direct != IntPtr.Zero) return direct; - if (!String.Equals(ClassOf(parent), "GraphCodeSheetHost", StringComparison.Ordinal)) - return IntPtr.Zero; - IntPtr viewport = FindWindowEx(parent, IntPtr.Zero, "GraphCodeSheetViewport", null); - if (viewport == IntPtr.Zero) return IntPtr.Zero; - string[] contentClasses = new[] { - "GraphCodeWindowsDialogContent", - "GraphCodeNativeForm", - "GraphCodeProductSettings", - "GraphCodeWorkspaceManager", - "GraphCodeRepositoryIngressDialog", - "GraphCodeRepositoryOperationDialog", - "GraphCodeCodespaceIngressDialog", - "GraphCodeUpdateOffer", - "GraphCodeUpdateInstall" - }; - foreach (string contentClass in contentClasses) { - IntPtr content = FindWindowEx(viewport, IntPtr.Zero, contentClass, null); - if (content == IntPtr.Zero) continue; - IntPtr nested = GetDlgItem(content, controlId); - if (nested != IntPtr.Zero) return nested; - } - return IntPtr.Zero; - } - public static ControlClick ClickScreenPoint(IntPtr target) { - RECT rect; - if (target == IntPtr.Zero || !GetWindowRect(target, out rect) || - rect.Right <= rect.Left || rect.Bottom <= rect.Top) return null; - RECT work; - if (!SystemParametersInfoRect(0x0030, 0, out work, 0)) - throw new InvalidOperationException(String.Format("SPI_GETWORKAREA failed: Win32Error={0}", Marshal.GetLastWin32Error())); - var rectCenter = new ScreenPoint { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }; - IntPtr atRectCenter = WindowFromPoint(rectCenter); - // A user clicks the part of a control they can see. When the control lies - // inside the work area this is exactly its centre; only when it crosses the - // work-area edge does the point move into the visible portion. A control with - // no visible portion is reported as VisibleEmpty and never clicked. - int visibleLeft = Math.Max(rect.Left, work.Left), visibleTop = Math.Max(rect.Top, work.Top); - int visibleRight = Math.Min(rect.Right, work.Right), visibleBottom = Math.Min(rect.Bottom, work.Bottom); - var center = new ScreenPoint { X = (visibleLeft + visibleRight) / 2, Y = (visibleTop + visibleBottom) / 2 }; - bool visibleEmpty = visibleRight <= visibleLeft || visibleBottom <= visibleTop; - IntPtr atPoint = visibleEmpty ? IntPtr.Zero : WindowFromPoint(center); - IntPtr rootAtPoint = atPoint == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atPoint, 2); - uint ownerProcess = 0; - if (rootAtPoint != IntPtr.Zero) GetWindowThreadProcessId(rootAtPoint, out ownerProcess); - // Real mouse input skips HTTRANSPARENT children such as plain static text; - // cross-process WindowFromPoint does not. RealChildWindowFromPoint applies - // the same rule within the dialog, and the top-level check still rejects - // any other window (for example the taskbar) covering the point. - IntPtr dialog = GetAncestor(target, 1); - IntPtr realChild = IntPtr.Zero; - if (!visibleEmpty && dialog != IntPtr.Zero) { - var clientPoint = center; - if (ScreenToClient(dialog, ref clientPoint)) realChild = RealChildWindowFromPoint(dialog, clientPoint); - } - bool sameTopLevel = rootAtPoint != IntPtr.Zero && rootAtPoint == GetAncestor(target, 2); - var hit = new ControlClick { - ControlId = GetDlgCtrlID(target), Left = rect.Left, Top = rect.Top, Right = rect.Right, - Bottom = rect.Bottom, ScreenX = center.X, ScreenY = center.Y, - HitTarget = !visibleEmpty && sameTopLevel && realChild == target, - VisibleEmpty = visibleEmpty, - WindowAtPointId = atPoint == IntPtr.Zero ? 0 : GetDlgCtrlID(atPoint), - WindowAtPointClass = ClassOf(atPoint), - WindowAtPointText = atPoint == IntPtr.Zero ? "" : EditBufferText(atPoint), - WindowAtPointRootClass = ClassOf(rootAtPoint), - WindowAtPointProcessId = (int)ownerProcess, - RealChildId = realChild == IntPtr.Zero ? 0 : GetDlgCtrlID(realChild), - RealChildClass = ClassOf(realChild), - RealChildText = realChild == IntPtr.Zero ? "" : EditBufferText(realChild), - SameTopLevel = sameTopLevel, - WorkLeft = work.Left, WorkTop = work.Top, WorkRight = work.Right, WorkBottom = work.Bottom, - OutsideWorkArea = rect.Left < work.Left || rect.Top < work.Top || rect.Right > work.Right || rect.Bottom > work.Bottom, - CenterX = rectCenter.X, CenterY = rectCenter.Y, - CenterHitTarget = atRectCenter == target, - WindowAtCenterClass = ClassOf(atRectCenter), - WindowAtCenterRootClass = ClassOf(atRectCenter == IntPtr.Zero ? IntPtr.Zero : GetAncestor(atRectCenter, 2)) - }; - // Subtract visible siblings above the target in Z order. A narrow uncovered - // strip can fall between grid rows, so try the largest residual rectangle - // before scanning the fixed grid as a diagnostic fallback. - if (!hit.HitTarget && !visibleEmpty && dialog != IntPtr.Zero) { - IntPtr covering = realChild != IntPtr.Zero && realChild != target && realChild != dialog ? realChild : atPoint; - RECT coveringRect; - if (covering != IntPtr.Zero && GetWindowRect(covering, out coveringRect)) { - hit.CoveringLeft = coveringRect.Left; hit.CoveringTop = coveringRect.Top; - hit.CoveringRight = coveringRect.Right; hit.CoveringBottom = coveringRect.Bottom; - } - hit.CoveringId = covering == IntPtr.Zero ? 0 : GetDlgCtrlID(covering); - hit.CoveringClass = ClassOf(covering); - hit.CoveringText = covering == IntPtr.Zero ? "" : EditBufferText(covering); - var covers = new System.Collections.ArrayList(); - var coveringWindows = new System.Collections.ArrayList(); - for (IntPtr sibling = GetWindow(target, 3); sibling != IntPtr.Zero; sibling = GetWindow(sibling, 3)) { - RECT siblingRect; - if (!IsWindowVisible(sibling) || !GetWindowRect(sibling, out siblingRect)) continue; - int left = Math.Max(visibleLeft, siblingRect.Left), top = Math.Max(visibleTop, siblingRect.Top); - int right = Math.Min(visibleRight, siblingRect.Right), bottom = Math.Min(visibleBottom, siblingRect.Bottom); - if (left >= right || top >= bottom) continue; - var overlapCenter = new ScreenPoint { X = (left + right) / 2, Y = (top + bottom) / 2 }; - if (ResolveChild(dialog, overlapCenter) != sibling) continue; - covers.Add(new[] { siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom }); - coveringWindows.Add(String.Format("{0}|{1}|{2}|{3},{4},{5},{6}", GetDlgCtrlID(sibling), - ClassOf(sibling), EditBufferText(sibling), siblingRect.Left, siblingRect.Top, siblingRect.Right, siblingRect.Bottom)); - } - hit.CoveringWindows = (string[])coveringWindows.ToArray(typeof(string)); - hit.UncoveredRectangles = SubtractCoveredRectangles(new[] { visibleLeft, visibleTop, visibleRight, visibleBottom }, - (int[][])covers.ToArray(typeof(int[]))); - foreach (int[] piece in hit.UncoveredRectangles) { - var candidate = new ScreenPoint { X = (piece[0] + piece[2]) / 2, Y = (piece[1] + piece[3]) / 2 }; - if (!ResolvesToControl(target, dialog, candidate)) continue; - if (piece[2] - piece[0] < 3 || piece[3] - piece[1] < 3) { - hit.NarrowUncovered = true; - continue; - } - center = candidate; - hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; - hit.HitTarget = true; - hit.ScanUsed = true; - hit.ChosenUncoveredRectangle = piece; - break; - } - int width = visibleRight - visibleLeft, height = visibleBottom - visibleTop; - var samples = new string[15]; - for (int row = 1; row <= 3; row++) { - for (int column = 1; column <= 5; column++) { - var candidate = new ScreenPoint { X = visibleLeft + column * width / 6, Y = visibleTop + row * height / 4 }; - IntPtr resolved = ResolveChild(dialog, candidate); - bool uncovered = ResolvesToControl(target, dialog, candidate); - if (uncovered && hit.NarrowUncovered) uncovered = false; - samples[hit.ScannedPoints++] = String.Format("{0},{1}|{2}|{3}|{4}|{5}", candidate.X, candidate.Y, - resolved == IntPtr.Zero ? 0 : GetDlgCtrlID(resolved), ClassOf(resolved), uncovered ? "control" : "covered", - resolved == IntPtr.Zero ? "" : EditBufferText(resolved)); - if (!uncovered) { - hit.CoveredPoints++; - } else if (!hit.HitTarget) { - center = candidate; - hit.ScreenX = candidate.X; hit.ScreenY = candidate.Y; - hit.HitTarget = true; - hit.ScanUsed = true; - } - } - } - hit.Samples = samples; - } - if (!hit.HitTarget) return hit; - uint expectedProcessId = WindowProcessId(dialog); - RequireOwnedForeground(dialog, expectedProcessId); - ScreenPoint before, at; - if (!GetCursorPos(out before)) - throw new InvalidOperationException(String.Format("GetCursorPos before control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!SetCursorPos(center.X, center.Y)) - throw new InvalidOperationException(String.Format("SetCursorPos for control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (!GetCursorPos(out at)) - throw new InvalidOperationException(String.Format("GetCursorPos after control move failed: Win32Error={0}", Marshal.GetLastWin32Error())); - if (at.X != center.X || at.Y != center.Y) - throw new InvalidOperationException(String.Format("Control cursor landed at ({0},{1}), expected ({2},{3})", at.X, at.Y, center.X, center.Y)); - hit.CursorBeforeX = before.X; hit.CursorBeforeY = before.Y; - hit.CursorAtX = at.X; hit.CursorAtY = at.Y; - var inputs = new[] { - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, - new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } - }; - RequireOwnedMouseInput(dialog, expectedProcessId, center, target); - uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} control mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); - return hit; - } - public static int SendKeyInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, ushort virtualKey, int count) { - if (count <= 0) return 0; - var inputs = new KeyInputRecord[count * 2]; - for (int index = 0; index < count; index++) { - inputs[index * 2] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey } }; - inputs[index * 2 + 1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = virtualKey, Flags = 0x0002 } }; - } - RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); - uint sent = SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); - if (sent != inputs.Length) - throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} key events (cbSize={2}): Win32Error={3}", sent, inputs.Length, Marshal.SizeOf(typeof(KeyInputRecord)), Marshal.GetLastWin32Error())); - return count; - } - public static int[] SendUnicodeInput(IntPtr owner, uint expectedProcessId, IntPtr expectedFocus, string text) { - var records = new KeyInputRecord[text.Length * 2]; - int offset = 0; - foreach (char character in text) { - records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } }; - records[offset++] = new KeyInputRecord { Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } }; - } - if (records.Length == 0) return new int[] { 0, 0 }; - RequireOwnedKeyboardInput(owner, expectedProcessId, expectedFocus); - uint sent = SendKeyInputs((uint)records.Length, records, Marshal.SizeOf(typeof(KeyInputRecord))); - if (sent != records.Length) - throw new InvalidOperationException(String.Format("Owned Unicode SendInput injected {0}/{1}", sent, records.Length)); - return new int[] { checked((int)sent), records.Length }; - } - public static bool TypeEditTextById(IntPtr parent, int controlId, string text) { - LastEditClearExpected = LastEditClearSent = 0; - LastEditTextExpected = LastEditTextSent = 0; - LastEditUsedMessageFallback = false; - IntPtr edit = ControlById(parent, controlId); - if (edit == IntPtr.Zero || !FocusControl(parent, edit)) return false; - uint expectedProcessId = WindowProcessId(parent); - SendMessage(edit, 0x00B1, UIntPtr.Zero, new IntPtr(-1)); - var clear = new KeyInputRecord[2]; - clear[0] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E } }; - clear[1] = new KeyInputRecord { Type = 1, Key = new KeybdInput { VirtualKey = 0x2E, Flags = 2 } }; - LastEditClearExpected = (uint)clear.Length; - RequireOwnedKeyboardInput(parent, expectedProcessId, edit); - LastEditClearSent = SendKeyInputs(LastEditClearExpected, clear, Marshal.SizeOf(typeof(KeyInputRecord))); - if (LastEditClearSent != LastEditClearExpected) return false; - bool cleared = String.IsNullOrEmpty(EditBufferText(edit)); - int[] typed = SendUnicodeInput(parent, expectedProcessId, edit, text); - LastEditTextExpected = checked((uint)typed[1]); - LastEditTextSent = checked((uint)typed[0]); - bool matched = cleared && String.Equals(EditTextById(parent, controlId), text, StringComparison.Ordinal); - if (!matched) { - SendMessageString(edit, 0x000C, UIntPtr.Zero, text); - LastEditUsedMessageFallback = true; - matched = String.Equals(EditTextById(parent, controlId), text, StringComparison.Ordinal); - } - return matched; - } - public static int[] VisibleChildIds(IntPtr parent, int first, int last) { - var buffer = new int[Math.Max(0, last - first + 1)]; - int found = 0; - for (int id = first; id <= last; id++) { - IntPtr child = ControlById(parent, id); - if (child != IntPtr.Zero && IsWindowVisible(child)) buffer[found++] = id; - } - var result = new int[found]; - Array.Copy(buffer, result, found); - return result; - } - public static string[] VisibleStaticTexts(IntPtr parent) { - var buffer = new string[256]; - int found = 0; - if (parent == IntPtr.Zero) return new string[0]; - EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) { - var actualClass = new StringBuilder(64); - GetClassName(window, actualClass, actualClass.Capacity); - if (String.Equals(actualClass.ToString(), "Static", StringComparison.OrdinalIgnoreCase) && - IsWindowVisible(window) && found < buffer.Length) { - buffer[found++] = EditBufferText(window); - } - return true; - }, IntPtr.Zero); - var result = new string[found]; - Array.Copy(buffer, result, found); - return result; - } - public static string WindowTextOf(IntPtr window) { - return window == IntPtr.Zero ? null : EditBufferText(window); - } - public static string ComboSelection(IntPtr parent, int controlId) { - IntPtr combo = ControlById(parent, controlId); - if (combo == IntPtr.Zero) return null; - int index = (int)SendMessage(combo, 0x0147, UIntPtr.Zero, IntPtr.Zero); - if (index < 0) return index + "|"; - int length = (int)SendMessage(combo, 0x0149, (UIntPtr)index, IntPtr.Zero); - var text = new StringBuilder(Math.Max(length, 0) + 1); - SendMessageText(combo, 0x0148, (UIntPtr)index, text); - return index + "|" + text.ToString(); - } -} -"@ -ReferencedAssemblies @( - [System.Windows.Automation.AutomationElement].Assembly.Location, - [System.Windows.Automation.AutomationEventArgs].Assembly.Location, - [Diagnostics.Process].Assembly.Location, - "System.Collections", - "System.ComponentModel.Primitives", - $uiaActivationAssemblyPath -) - -Add-Type -TypeDefinition @" -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -public static class GraphCodeUiaHostInfo { - private const int UOI_NAME = 2; - [DllImport("user32.dll")] - private static extern IntPtr GetProcessWindowStation(); - [DllImport("user32.dll")] - private static extern IntPtr GetThreadDesktop(uint threadId); - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool GetUserObjectInformation( - IntPtr handle, int index, StringBuilder info, uint length, out uint needed - ); - [DllImport("kernel32.dll")] - private static extern uint GetCurrentThreadId(); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool ProcessIdToSessionId(uint processId, out uint sessionId); - [DllImport("user32.dll")] - private static extern uint GetGuiResources(IntPtr process, uint flags); - private static string ObjectName(IntPtr handle) { - if (handle == IntPtr.Zero) return null; - var name = new StringBuilder(256); - uint needed; - if (!GetUserObjectInformation(handle, UOI_NAME, name, (uint)(name.Capacity * 2), out needed)) - return null; - return name.ToString(); - } - public static string CurrentWindowStationName() { - return ObjectName(GetProcessWindowStation()); - } - public static string CurrentDesktopName() { - return ObjectName(GetThreadDesktop(GetCurrentThreadId())); - } - public static uint CurrentSessionId() { - uint sessionId; - if (!ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out sessionId)) - throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); - return sessionId; - } - public static uint GuiResourceCount(int processId, uint flags) { - using (var process = Process.GetProcessById(processId)) - return GetGuiResources(process.Handle, flags); - } -} -"@ - -# Wait-timeout attribution. Reads only the foreground window's owning process -# id/name and class (never another application's title or content), every -# top-level window owned by the shell process, and a PrintWindow capture of the -# shell's own window (not a screen copy, so no other application is captured). -Add-Type -TypeDefinition @" -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Text; -public static class GraphCodeUiaAttribution { - private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] - private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); - [DllImport("user32.dll")] - private static extern bool IsWindowVisible(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsWindowEnabled(IntPtr window); - [DllImport("user32.dll")] - private static extern bool IsHungAppWindow(IntPtr window); - [DllImport("user32.dll")] - private static extern IntPtr GetWindow(IntPtr window, uint command); - [StructLayout(LayoutKind.Sequential)] - public struct Rect { public int Left, Top, Right, Bottom; } - [DllImport("user32.dll")] - private static extern bool GetWindowRect(IntPtr window, out Rect rect); - [DllImport("user32.dll")] - public static extern bool PrintWindow(IntPtr window, IntPtr hdc, uint flags); - public sealed class WindowRecord { - public long Handle; public long Owner; public string ClassName; public string Title; - public bool Visible, Enabled, Hung; public int Left, Top, Right, Bottom; - } - private static string ClassOf(IntPtr window) { - var text = new StringBuilder(256); - GetClassName(window, text, text.Capacity); - return text.ToString(); - } - public static long ForegroundHandle() { return GetForegroundWindow().ToInt64(); } - public static uint ProcessOf(IntPtr window) { - if (window == IntPtr.Zero) return 0; - uint processId; - GetWindowThreadProcessId(window, out processId); - return processId; - } - public static string ClassName(IntPtr window) { - return window == IntPtr.Zero ? "" : ClassOf(window); - } - public static Rect Bounds(IntPtr window) { - Rect rect; - if (window == IntPtr.Zero || !GetWindowRect(window, out rect)) return new Rect(); - return rect; - } - // Titles are read only for windows owned by processId (the gate's own shell). - public static WindowRecord[] ProcessWindows(uint processId) { - var results = new List(); - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId) return true; - var title = new StringBuilder(512); - GetWindowText(window, title, title.Capacity); - Rect rect; - GetWindowRect(window, out rect); - results.Add(new WindowRecord { - Handle = window.ToInt64(), Owner = GetWindow(window, 4).ToInt64(), - ClassName = ClassOf(window), Title = title.ToString(), - Visible = IsWindowVisible(window), Enabled = IsWindowEnabled(window), - Hung = IsHungAppWindow(window), - Left = rect.Left, Top = rect.Top, Right = rect.Right, Bottom = rect.Bottom - }); - return true; - }, IntPtr.Zero); - return results.ToArray(); - } -} -"@ +$validationAssemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $validationAssemblyPath) { + $validationAssemblyPath = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") +} +foreach ($requiredType in @( + "GraphCode.Windows.UiaWindowActivation", "GraphCodeUiaGateState", "GraphCodeUiaHostInfo", + "GraphCodeUiaAttribution", "GraphCodeMultiProjectEditNative", "GraphCodeMultiProjectRenameNative", + "GraphCode.Validation.Ownership.StandaloneProcessJob", "GraphCode.Validation.Ownership.UiaOwnedStreamCapture" + )) { + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $validationAssemblyPath -RequiredType $requiredType | Out-Null +} $script:uiaAttributionDirectory = $null $script:uiaAttributionSequence = 0 @@ -4755,71 +3288,7 @@ function Get-MultiProjectRenameTitle([string] $loopTitle) { } function New-MultiProjectEditNativeApi([string] $expectedTitle = "") { - if(-not("GraphCodeMultiProjectEditNative" -as [type])){ - Add-Type -TypeDefinition @' -using System; -using System.Text; -using System.Diagnostics; -using System.ComponentModel; -using System.Runtime.InteropServices; -public static class GraphCodeMultiProjectEditNative { - [StructLayout(LayoutKind.Sequential)] private struct Rect {public int Left,Top,Right,Bottom;} - [StructLayout(LayoutKind.Sequential)] private struct GuiInfo { - public int Size; public uint Flags; public IntPtr Active,Focus,Capture,MenuOwner,MoveSize,Caret; public Rect CaretRect; - } - [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", SetLastError=true)] - private static extern IntPtr Message(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam, uint flags, uint timeout, out UIntPtr result); - [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern IntPtr TextMessage(IntPtr window, uint message, UIntPtr wparam, StringBuilder text, uint flags, uint timeout, out UIntPtr result); - [DllImport("user32.dll", EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern IntPtr SetTextMessage(IntPtr window, uint message, UIntPtr wparam, string text, uint flags, uint timeout, out UIntPtr result); - [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid); - [DllImport("user32.dll",SetLastError=true)] private static extern bool GetGUIThreadInfo(uint thread,ref GuiInfo info); - private static uint Remaining(int budget, Stopwatch watch) { - long value=budget-watch.ElapsedMilliseconds; - if(value<=0) throw new TimeoutException("N3e edit transaction deadline expired"); - return (uint)value; - } - private static UIntPtr ReadMessage(IntPtr window,uint message,UIntPtr first,IntPtr last,int budget,Stopwatch watch) { - UIntPtr result; - if(Message(window,message,first,last,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e edit message unavailable/timed out"); - return result; - } - public static string Buffer(IntPtr edit,int budget) { - var watch=Stopwatch.StartNew(); - int length=checked((int)ReadMessage(edit,0x000E,UIntPtr.Zero,IntPtr.Zero,budget,watch).ToUInt64()); - if(length<0 || length>65536) throw new InvalidOperationException("N3e edit buffer exceeds bound"); - var text=new StringBuilder(length+1); UIntPtr result; - if(TextMessage(edit,0x000D,(UIntPtr)text.Capacity,text,0x23,Remaining(budget,watch),out result)==IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_GETTEXT unavailable/timed out"); - return text.ToString(); - } - public static int[] Selection(IntPtr edit,int budget) { - var watch=Stopwatch.StartNew(); IntPtr memory=Marshal.AllocHGlobal(8); - try { - Marshal.WriteInt32(memory,-1); Marshal.WriteInt32(memory,4,-1); - ReadMessage(edit,0x00B0,(UIntPtr)memory.ToInt64(),IntPtr.Add(memory,4),budget,watch); - return new[]{Marshal.ReadInt32(memory),Marshal.ReadInt32(memory,4)}; - } finally {Marshal.FreeHGlobal(memory);} - } - public static void SelectAll(IntPtr edit,int budget) { - ReadMessage(edit,0x00B1,UIntPtr.Zero,new IntPtr(-1),budget,Stopwatch.StartNew()); - } - public static void SetText(IntPtr edit,string text,int budget) { - UIntPtr result; - if(SetTextMessage(edit,0x000C,UIntPtr.Zero,text,0x23,(uint)budget,out result)==IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e WM_SETTEXT unavailable/timed out"); - } - public static IntPtr FocusOf(IntPtr modal) { - uint pid; uint thread=GetWindowThreadProcessId(modal,out pid); - var info=new GuiInfo{Size=Marshal.SizeOf(typeof(GuiInfo))}; - if(thread==0 || !GetGUIThreadInfo(thread,ref info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"N3e GetGUIThreadInfo"); - return info.Focus; - } -} -'@ - } + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCodeMultiProjectEditNative" | Out-Null $api=New-MultiProjectRenameNativeApi $expectedTitle $api | Add-Member NoteProperty Watch ([Diagnostics.Stopwatch]::StartNew()) $api | Add-Member NoteProperty LastFocusPostedFallback $false @@ -5092,86 +3561,7 @@ function Invoke-MultiProjectSequencedEdit( } function New-MultiProjectRenameNativeApi([string] $expectedTitle = "") { - if (-not ("GraphCodeMultiProjectRenameNative" -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -public static class GraphCodeMultiProjectRenameNative { - [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } - [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } - [StructLayout(LayoutKind.Sequential)] private struct MonitorInfo { - public int Size; public Rect Monitor, Work; public uint Flags; - } - [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr window, uint flags); - [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(Point point); - [DllImport("user32.dll")] private static extern IntPtr RealChildWindowFromPoint(IntPtr window, Point point); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] - private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string title); - [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="GetClassNameW")] - private static extern int GetClassName(IntPtr window, System.Text.StringBuilder name, int capacity); - [DllImport("user32.dll", SetLastError=true)] private static extern bool GetClientRect(IntPtr window, out Rect rect); - [DllImport("user32.dll", SetLastError=true)] private static extern bool ClientToScreen(IntPtr window, ref Point point); - [DllImport("user32.dll", SetLastError=true)] private static extern bool ScreenToClient(IntPtr window, ref Point point); - [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr window, uint flags); - [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] - private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info); - public static int[] ClientBounds(IntPtr window) { - Rect rect; - if (!GetClientRect(window, out rect)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetClientRect"); - var first = new Point { X=rect.Left, Y=rect.Top }; - var last = new Point { X=rect.Right, Y=rect.Bottom }; - if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ClientToScreen"); - return new[] { first.X, first.Y, last.X, last.Y }; - } - public static int[] WorkBounds(IntPtr window) { - var info = new MonitorInfo { Size=Marshal.SizeOf(typeof(MonitorInfo)) }; - if (!GetMonitorInfo(MonitorFromWindow(window, 2), ref info)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename GetMonitorInfo"); - return new[] { info.Work.Left, info.Work.Top, info.Work.Right, info.Work.Bottom }; - } - private static bool ContainsScreenPoint(IntPtr window, int x, int y) { - Rect rect; - if (!GetClientRect(window, out rect)) return false; - var first = new Point { X=rect.Left, Y=rect.Top }; - var last = new Point { X=rect.Right, Y=rect.Bottom }; - if (!ClientToScreen(window, ref first) || !ClientToScreen(window, ref last)) return false; - return x >= first.X && x < last.X && y >= first.Y && y < last.Y; - } - public static IntPtr ChildAt(IntPtr window, int x, int y) { - var point = new Point { X=x, Y=y }; - if (!ScreenToClient(window, ref point)) throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename ScreenToClient"); - IntPtr direct = RealChildWindowFromPoint(window, point); - var rootClass = new System.Text.StringBuilder(128); - var childClass = new System.Text.StringBuilder(128); - GetClassName(window, rootClass, rootClass.Capacity); - if (direct == IntPtr.Zero && - String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal)) { - IntPtr viewport = FindWindowEx(window, IntPtr.Zero, "GraphCodeSheetViewport", null); - if (ContainsScreenPoint(viewport, x, y)) direct = viewport; - } - GetClassName(direct, childClass, childClass.Capacity); - if (!String.Equals(rootClass.ToString(), "GraphCodeSheetHost", StringComparison.Ordinal) || - !String.Equals(childClass.ToString(), "GraphCodeSheetViewport", StringComparison.Ordinal)) - return direct; - IntPtr content = FindWindowEx(direct, IntPtr.Zero, "GraphCodeWindowsDialogContent", null); - if (content == IntPtr.Zero) return direct; - var viewportPoint = new Point { X=x, Y=y }; - if (!ScreenToClient(direct, ref viewportPoint)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename viewport ScreenToClient"); - IntPtr viewportChild = RealChildWindowFromPoint(direct, viewportPoint); - if (viewportChild != content && - !(viewportChild == IntPtr.Zero && ContainsScreenPoint(content, x, y))) return direct; - var contentPoint = new Point { X=x, Y=y }; - if (!ScreenToClient(content, ref contentPoint)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Rename content ScreenToClient"); - IntPtr nested = RealChildWindowFromPoint(content, contentPoint); - return nested == IntPtr.Zero ? content : nested; - } -} -'@ - } + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCodeMultiProjectRenameNative" | Out-Null $api = New-Object PSObject $api | Add-Member NoteProperty ExpectedTitle $expectedTitle $api | Add-Member ScriptMethod ProcessId { param($window) [GraphCodeUiaGateState]::WindowProcessId($window) } @@ -5706,17 +4096,7 @@ function Test-MultiProjectRenameReceipt($before, $after, [string] $path, [string } function Initialize-MultiProjectProcessJob([string] $sourcePath) { - if ("StandaloneProcessJob" -as [type]) { return } - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseFile($sourcePath, [ref]$null, [ref]$errors) - if ($errors.Count -ne 0) { throw "MULTIPROJECT_JOB: approved standalone ownership source does not parse" } - $definitions = @($ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.StringConstantExpressionAst] -and - $node.Value.Contains("public static class StandaloneProcessJob") - }, $true)) - if ($definitions.Count -ne 1) { throw "MULTIPROJECT_JOB: exact approved job ownership definition missing" } - Add-Type $definitions[0].Value + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCode.Validation.Ownership.StandaloneProcessJob" | Out-Null } function Wait-MultiProjectCapture($capture, [int] $timeoutMilliseconds = 5000) { @@ -5746,10 +4126,10 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t $drained = $false try { if ($capture.assigned) { - [StandaloneProcessJob]::Terminate($capture.job) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($capture.job) $remaining = [Math]::Max(0, $timeoutMilliseconds - [int]$clock.ElapsedMilliseconds) - [StandaloneProcessJob]::WaitForEmpty($capture.job, $remaining) - $empty = [StandaloneProcessJob]::Active($capture.job) -eq 0 + [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($capture.job, $remaining) + $empty = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($capture.job) -eq 0 if (-not $empty) { throw "owned job census is not empty" } } else { # The assignment barrier was never released; only the retained launcher exists. @@ -5792,7 +4172,7 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t catch { $errors.Add("disposing $stream reader: $($_.Exception.Message)") } } try { - if ($capture.job -ne [IntPtr]::Zero) { [StandaloneProcessJob]::Close($capture.job) } + if ($capture.job -ne [IntPtr]::Zero) { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($capture.job) } } catch { $errors.Add("closing owned job: $($_.Exception.Message)") } try { if ($capture.process -and $capture.process -ne $capture.rootProcess) { $capture.process.Dispose() } @@ -5823,42 +4203,8 @@ function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $t return $diagnostic } -function Initialize-UiaOwnedStreamCapture { - if ("UiaOwnedStreamCapture" -as [type]) { return } - Add-Type -TypeDefinition @' -using System; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using System.ComponentModel; -using System.Runtime.InteropServices; -public static class UiaOwnedStreamCapture { - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool contained); - public static void RequireJobMember(IntPtr process, IntPtr job) { - bool contained; - if (!IsProcessInJob(process, job, out contained)) throw new Win32Exception(Marshal.GetLastWin32Error()); - if (!contained) throw new InvalidOperationException("Published UIA target is not a member of its owned launch job"); - } - public static Task Read(StreamReader reader, string path) { - var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite); - return Capture(reader, file); - } - private static async Task Capture(StreamReader reader, FileStream file) { - using (var writer = new StreamWriter(file, new UTF8Encoding(false))) { - var text = new StringBuilder(); - var buffer = new char[4096]; - int count; - while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) { - text.Append(buffer, 0, count); - await writer.WriteAsync(buffer, 0, count).ConfigureAwait(false); - await writer.FlushAsync().ConfigureAwait(false); - } - return text.ToString(); - } - } -} -'@ +function Initialize-UiaOwnedStreamCapture() { + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") -AssemblyPath $validationAssemblyPath -RequiredType "GraphCode.Validation.Ownership.UiaOwnedStreamCapture" | Out-Null } function Start-UiaOwnedProcess( @@ -5934,7 +4280,7 @@ try { foreach ($argument in @("-NoProfile", "-NonInteractive", "-EncodedCommand", [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)))) { $start.ArgumentList.Add($argument) } $capture = [ordered]@{ process = $null; rootProcess = $null; rootPID = $null; rootStartUtcTicks = $null - targetPID = $null; targetStartUtcTicks = $null; job = [StandaloneProcessJob]::Create() + targetPID = $null; targetStartUtcTicks = $null; job = [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() assigned = $false; stdout = $null; stderr = $null; stem = $stem cleaned = $false; streamingLogs = [bool]$standardOutputPath; logDirectory = $logDirectory } if ($RegisterForUia) { @@ -5949,14 +4295,14 @@ try { $handle = $root.Handle if ($standardOutputPath) { Initialize-UiaOwnedStreamCapture - $capture.stdout = [UiaOwnedStreamCapture]::Read($root.StandardOutput, $standardOutputPath) - $capture.stderr = [UiaOwnedStreamCapture]::Read($root.StandardError, $standardErrorPath) + $capture.stdout = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($root.StandardOutput, $standardOutputPath) + $capture.stderr = [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read($root.StandardError, $standardErrorPath) if ($capture.stdout.IsFaulted -or $capture.stderr.IsFaulted) { throw "UIA owned stream could not open its exact log paths" } } else { $capture.stdout = $root.StandardOutput.ReadToEndAsync() $capture.stderr = $root.StandardError.ReadToEndAsync() } - [StandaloneProcessJob]::Assign($capture.job, $handle) + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($capture.job, $handle) $capture.assigned = $true [IO.File]::WriteAllText($gate, "assigned") $deadline = [DateTime]::UtcNow.AddSeconds(10) @@ -5977,7 +4323,7 @@ try { $capture.targetPID = $target.Id $capture.targetStartUtcTicks = $identity.startUtcTicks Initialize-UiaOwnedStreamCapture - [UiaOwnedStreamCapture]::RequireJobMember($target.Handle, $capture.job) + [GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::RequireJobMember($target.Handle, $capture.job) if ("GraphCodeUiaGateState" -as [type]) { [GraphCodeUiaGateState]::RetainProcess($target) } return $capture } catch { From b02bba9a133fdbd6346f1ca08e31ec5adb1b0461 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:00:20 -0700 Subject: [PATCH 14/46] Preserve functional UI cases and verify consumed production source linkage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ui.json | 90 ++++ .../ValidationRunner/Ui/UiAdapterCases.cs | 89 +++- .../Ui/UiFunctionalContracts.ps1 | 394 +++++++++++++++++ .../Ui/UiRootSourceContracts.ps1 | 46 +- .../Ui/source-assertions.json | 417 ++++++++++++++---- .../ValidationRunner/Ui/source-case-map.json | 205 ++++++--- 6 files changed, 1079 insertions(+), 162 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index a2ae751c..ebe16fe7 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1703,6 +1703,96 @@ "owner": "Ui", "minimumCount": 1, "requiredGroups": [] + }, + { + "id": "ui.connector.blank-title", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.different-target-title", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.duplicate-source", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.duplicate-target", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.missing-source", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.missing-target", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.owned-pair", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.connector.same-endpoint", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.disabled-action", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.enabled-heading", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.exact", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.extra-item", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.reordered", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.separator-heading", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] + }, + { + "id": "ui.promotion.wrong-caption", + "owner": "Ui", + "minimumCount": 1, + "requiredGroups": [] } ] } diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs index 0d2eab77..c3b74172 100644 --- a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -68,6 +68,15 @@ internal static class UiAdapterCases }; internal static readonly string[] RootSourceIds = { + "ui.production.consumer-linkage", + "ui.root.evaluator.actual-predicate-false", + "ui.root.evaluator.depth-boundary", + "ui.root.evaluator.depth-overflow", + "ui.root.evaluator.malformed-tree", + "ui.root.evaluator.missing-file", + "ui.root.evaluator.unknown-operator", + "ui.root.evaluator.unknown-resource", + "ui.root.evaluator.wrong-production-text", "ui.root.source.line-5342", "ui.root.source.line-5350", "ui.root.source.line-5369", @@ -125,20 +134,80 @@ internal static class UiAdapterCases "ui.root.source.line-7365", "ui.root.source.line-7381", "ui.root.source.line-7387", - "ui.root.source.line-7396", - "ui.root.evaluator.unknown-operator", - "ui.root.evaluator.unknown-resource", - "ui.root.evaluator.missing-file", - "ui.root.evaluator.malformed-tree", - "ui.root.evaluator.actual-predicate-false", - "ui.root.evaluator.wrong-production-text", - "ui.root.evaluator.depth-boundary", - "ui.root.evaluator.depth-overflow" + "ui.root.source.line-7396" + }; + internal static readonly string[] FunctionalIds = + { + "ui.connector.blank-title", + "ui.connector.different-target-title", + "ui.connector.duplicate-source", + "ui.connector.duplicate-target", + "ui.connector.missing-source", + "ui.connector.missing-target", + "ui.connector.owned-pair", + "ui.connector.same-endpoint", + "ui.promotion.disabled-action", + "ui.promotion.enabled-heading", + "ui.promotion.exact", + "ui.promotion.extra-item", + "ui.promotion.reordered", + "ui.promotion.separator-heading", + "ui.promotion.wrong-caption", + "ui.root.source.line-5366", + "ui.root.source.line-5380", + "ui.root.source.line-5392", + "ui.root.source.line-5396", + "ui.root.source.line-5399", + "ui.root.source.line-5407", + "ui.root.source.line-5410", + "ui.root.source.line-5432", + "ui.root.source.line-5449", + "ui.root.source.line-5457", + "ui.root.source.line-5462", + "ui.root.source.line-5470", + "ui.root.source.line-5477", + "ui.root.source.line-5485", + "ui.root.source.line-5553", + "ui.root.source.line-5561", + "ui.root.source.line-5569", + "ui.root.source.line-5580", + "ui.root.source.line-5632", + "ui.root.source.line-5659", + "ui.root.source.line-5683", + "ui.root.source.line-5696", + "ui.root.source.line-5713", + "ui.root.source.line-5724", + "ui.root.source.line-5739", + "ui.root.source.line-5752", + "ui.root.source.line-5761", + "ui.root.source.line-5773", + "ui.root.source.line-5783", + "ui.root.source.line-5792", + "ui.root.source.line-5807", + "ui.root.source.line-5812", + "ui.root.source.line-6853", + "ui.root.source.line-6864", + "ui.root.source.line-6866", + "ui.root.source.line-6870", + "ui.root.source.line-6877", + "ui.root.source.line-6892", + "ui.root.source.line-6905", + "ui.root.source.line-6921", + "ui.root.source.line-6925", + "ui.root.source.line-6939", + "ui.root.source.line-6958", + "ui.root.source.line-6967", + "ui.root.source.line-7136", + "ui.root.source.line-7235", + "ui.root.source.line-7326", + "ui.root.source.line-7337", + "ui.root.source.line-7344" }; internal static IReadOnlyList CreateSuites() => new IValidationSuite[] { new StandaloneSuites.CaseScriptSuite("ui.powershell-contracts", @"Tools\windows\ValidationRunner\Ui\UiPowerShellContracts.ps1", "UI_CASE_RESULT=", PowerShellIds), new StandaloneSuites.CaseScriptSuite("ui.delete-contracts", @"Tools\windows\ValidationRunner\Ui\UiDeleteContracts.ps1", "UI_CASE_RESULT=", DeleteIds), - new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds) + new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds), + new StandaloneSuites.CaseScriptSuite("ui.functional-contracts", @"Tools\windows\ValidationRunner\Ui\UiFunctionalContracts.ps1", "UI_CASE_RESULT=", FunctionalIds) }; } diff --git a/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 new file mode 100644 index 00000000..e2bde819 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiFunctionalContracts.ps1 @@ -0,0 +1,394 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +& (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +$results = [Collections.Generic.List[object]]::new() +function Assert-Functional([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-Functional([string] $Id, [scriptblock] $Assertion, [int] $Count = 1) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = $Count + evidence = $(if ($failure) { $failure.ToString() } else { 'Current production PS function/AST with deliberate managed fixtures' }) + }) +} +function Read-Function([string] $Name) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-Functional ($definitions.Count -eq 1) "Expected one actual production helper: $Name" + return $definitions[0].Extent.Text +} +Invoke-Functional 'ui.root.source.line-5366' { Assert-Functional ($errors.Count -eq 0) 'UIA production source does not parse' } +Invoke-Functional 'ui.root.source.line-5553' { Assert-Functional ($errors.Count -eq 0) 'UIA startup source does not parse' } +Assert-Functional ($errors.Count -eq 0) 'Cannot import malformed production source' +foreach ($binding in @( + @{ line = 5380; name = 'Test-MultiProjectSourceSupportedChromeId' }, + @{ line = 5407; name = 'Wait-MultiProjectWorkspaceMarkerPublication' }, + @{ line = 5457; name = 'Get-MultiProjectCardActivationRectangle' }, + @{ line = 5561; name = 'Get-SidebarNavigationSnapshot' }, + @{ line = 5569; name = 'Get-SidebarNavigationBoundsProof' }, + @{ line = 6853; name = 'Get-EdgeTextAttemptDecision' }, + @{ line = 6921; name = 'Wait-SketchGraphCard' }, + @{ line = 6939; name = 'Get-SketchCardAutomationId' }, + @{ line = 7136; name = 'Select-EdgeConnectorCardPair' } +)) { + Invoke-Functional "ui.root.source.line-$($binding.line)" { $null = Read-Function $binding.name } + . ([scriptblock]::Create((Read-Function $binding.name))) +} +. ([scriptblock]::Create((Read-Function 'New-SidebarNavigationCapture'))) +. ([scriptblock]::Create((Read-Function 'Test-SketchPromotionSubmenuItems'))) +function Test-MultiProjectAutomationPrefix([string] $value, [string] $prefix) { + return $value.StartsWith($prefix, [StringComparison]::Ordinal) +} +Invoke-Functional 'ui.root.source.line-5392' { + $checked = 0 + foreach ($id in @('wire-node-1000000000000000001', 'mark-entry-1000000000000000002', + 'overview-wire-node-1000000000000000003', 'overview-mark-entry-1000000000000000004')) { + Assert-Functional (Test-MultiProjectSourceSupportedChromeId $id 'owned') "Stable recovery control rejected: $id" + $checked++ + } + Assert-Functional ($checked -eq 4) 'Stable recovery census did not execute four cases' +} 4 +Invoke-Functional 'ui.root.source.line-5396' { + Assert-Functional (-not (Test-MultiProjectSourceSupportedChromeId 'future-unknown-control-1000000000000000005' 'owned')) 'Unknown chrome accepted' +} +Invoke-Functional 'ui.root.source.line-5399' { + Assert-Functional (-not (Test-MultiProjectSourceSupportedChromeId 'overview-wire-node-1000000000000000003' 'foreign')) 'Foreign chrome accepted' +} +Invoke-Functional 'ui.root.source.line-5410' { + Assert-Functional ((Read-Function 'Wait-MultiProjectWorkspaceMarkerPublication') -notmatch 'Invoke-MultiProjectControl|PostOwned|ClickOwned|PostMouse|InvokePattern') ` + 'Workspace-marker retry replays navigation or input' +} +Invoke-Functional 'ui.root.source.line-5432' { + $clock = @{ Now = [DateTimeOffset]::Parse('2026-10-27T00:00:00Z') } + $reads = @{ Count = 0 } + $loopBarId = 'workspace-loop-bar-1000000000000000001' + $toolbarId = 'workspace-toolbar-1000000000000000002' + $observed = Wait-MultiProjectWorkspaceMarkerPublication { + $reads.Count++ + if ($reads.Count -lt 3) { return @() } + @([pscustomobject]@{ automationID = [pscustomobject]@{ value = $loopBarId }; ownership = 'owned'; observationReady = $true }, + [pscustomobject]@{ automationID = [pscustomobject]@{ value = $toolbarId }; ownership = 'owned'; observationReady = $true }) + } $loopBarId $toolbarId $clock.Now.AddSeconds(1) { $clock.Now } { + param([int] $Milliseconds) $clock.Now = $clock.Now.AddMilliseconds($Milliseconds) + } + Assert-Functional ($observed.attempts -eq 3 -and $observed.loopBar -ceq $loopBarId -and $observed.toolbar -ceq $toolbarId) ` + 'Delayed exact markers did not satisfy bounded reacquisition' +} +Invoke-Functional 'ui.root.source.line-5449' { + $clock = @{ Now = [DateTimeOffset]::Parse('2026-10-27T00:00:00Z') } + $failure = $null + try { + $null = Wait-MultiProjectWorkspaceMarkerPublication { + @([pscustomobject]@{ automationID = [pscustomobject]@{ value = 'workspace-loop-bar-1999999999999999999' }; ownership = 'owned'; observationReady = $true }) + } 'workspace-loop-bar-1000000000000000001' 'workspace-toolbar-1000000000000000002' $clock.Now.AddMilliseconds(250) { $clock.Now } { + param([int] $Milliseconds) $clock.Now = $clock.Now.AddMilliseconds($Milliseconds) + } + } catch { $failure = $_ } + Assert-Functional ($failure -and $failure.Exception.Message -ceq 'MULTIPROJECT_WORKSPACE_MARKERS: exact owned loop bar and toolbar did not publish before the deadline') ` + 'Unknown marker absence no longer fails closed' +} +Invoke-Functional 'ui.root.source.line-5462' { + $rectangle = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 250.0, 96.0) + Assert-Functional (($rectangle -join ',') -ceq '0,0,250,24') 'Card activation lost source-sized title strip' +} +Invoke-Functional 'ui.root.source.line-5470' { + $rectangle = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 250.0, 96.0) + $checked = 0 + foreach ($recovery in @(@(11.0, 31.0, 84.0, 51.0), @(90.0, 31.0, 176.0, 51.0))) { + Assert-Functional (-not ($rectangle[0] -lt $recovery[2] -and $rectangle[2] -gt $recovery[0] -and + $rectangle[1] -lt $recovery[3] -and $rectangle[3] -gt $recovery[1])) 'Activation overlaps a recovery control' + $checked++ + } + Assert-Functional ($checked -eq 2) 'Recovery intersection count changed' +} 2 +Invoke-Functional 'ui.root.source.line-5477' { + $failure = $null + try { $null = Get-MultiProjectCardActivationRectangle @(0.0, 0.0, 0.0, 96.0) } catch { $failure = $_ } + Assert-Functional ($failure -and $failure.Exception.Message -ceq 'MULTIPROJECT_BOUNDS: positive card rectangle required for activation') 'Invalid activation bounds accepted' +} +Invoke-Functional 'ui.root.source.line-5485' { + Assert-Functional ((Read-Function 'Open-MultiProjectLoop').Contains('Get-MultiProjectCardActivationRectangle $row.card.bounds')) 'Live loop navigation clicks whole card' +} +Invoke-Functional 'ui.root.source.line-5580' { + $null = Read-Function 'New-SidebarNavigationCapture' + Assert-Functional ($source -match '\$sidebarCaptures\.Add\(\(New-SidebarNavigationCapture \$sidebarNavigationChildren\)\)') 'Sidebar retains mutable captures' +} +function New-SidebarElement([string] $Id, [string] $Name, [double] $Left, [double] $Top, + [double] $Right, [double] $Bottom, [int] $Owner = 4242) { + [pscustomobject]@{ Current = [pscustomobject]@{ + AutomationId = $Id; Name = $Name; ProcessId = $Owner + BoundingRectangle = [pscustomobject]@{ Left = $Left; Top = $Top; Right = $Right; Bottom = $Bottom; Width = $Right - $Left; Height = $Bottom - $Top } + } } +} +$sidebar = @( + (New-SidebarElement 'overview-destination:graph' 'Graph' 12 20 220 52), + (New-SidebarElement 'quick-chats-destination:quick-chats' 'Quick Chats' 12 56 220 88), + (New-SidebarElement 'sidebar-section-1' 'Local Projects' 12 100 220 121), + (New-SidebarElement 'open-project-1' 'UIA project' 12 121 220 153), + (New-SidebarElement 'sidebar-section-2' 'Remote Repositories' 12 168 220 189), + (New-SidebarElement 'open-project-2' 'Temporary remote' 12 192 220 224), + (New-SidebarElement 'overview-destination' 'Graph' 12 17 220 49), + (New-SidebarElement 'quick-chats-destination' 'Quick Chats' 12 53 220 85), + (New-SidebarElement 'project-new-loop-1' 'New Loop' 174 17 198 49), + (New-SidebarElement 'project-disclosure-2' 'Collapse project' 198 17 220 49), + (New-SidebarElement 'quick-chat-new-3' 'New Chat' 174 53 198 85), + (New-SidebarElement 'quick-chats-disclosure-4' 'Collapse Quick Chats' 198 53 220 85), + (New-SidebarElement 'project-new-loop-5' 'New Loop' 174 121 198 153), + (New-SidebarElement 'project-disclosure-6' 'Collapse project' 198 121 220 153) +) +$initial = @($sidebar | ForEach-Object { + if ($_.Current.Name -in @('Remote Repositories', 'Temporary remote')) { New-SidebarElement $_.Current.AutomationId $_.Current.Name 0 0 0 0 } else { $_ } +}) +$remote = @($sidebar | ForEach-Object { + if ($_.Current.Name -in @('Remote Repositories', 'Temporary remote')) { $_ } else { New-SidebarElement $_.Current.AutomationId $_.Current.Name 0 0 0 0 } +}) +Invoke-Functional 'ui.root.source.line-5632' { + $snapshot = Get-SidebarNavigationSnapshot $sidebar + Assert-Functional (($snapshot.destinationIds -join '|') -ceq 'overview-destination|quick-chats-destination' -and + ($snapshot.destinationNames -join '|') -ceq 'Graph|Quick Chats' -and $snapshot.ownerNames.Count -eq 2 -and + ($snapshot.ownerNames -join '|') -ceq 'UIA project|Temporary remote' -and + ($snapshot.sectionNames -join '|') -ceq 'Local Projects|Remote Repositories' -and $snapshot.recentRowIds.Count -eq 0 -and + $snapshot.rowBoundsValid -and $snapshot.controlsValid -and $snapshot.controlsChecked -eq 6) 'Sidebar baseline oracle differs' +} +Invoke-Functional 'ui.root.source.line-5659' { + $mutable = @($sidebar | ForEach-Object { + $b = $_.Current.BoundingRectangle + New-SidebarElement $_.Current.AutomationId $_.Current.Name $b.Left $b.Top $b.Right $b.Bottom + }) + $frozen = New-SidebarNavigationCapture $mutable + foreach ($item in $mutable) { + foreach ($name in @('Left', 'Top', 'Right', 'Bottom', 'Width', 'Height')) { $item.Current.BoundingRectangle.$name = 0 } + } + $proof = Get-SidebarNavigationBoundsProof @($frozen) 4242 + Assert-Functional ($proof.structureValid -and $proof.rowBoundsValid -and $proof.controlsValid) 'Sidebar capture retained mutable handles' +} +Invoke-Functional 'ui.root.source.line-5683' { + $proof = Get-SidebarNavigationBoundsProof @([pscustomobject]@{ Children = $initial }, [pscustomobject]@{ Children = $remote }) 4242 + Assert-Functional ($proof.structureValid -and $proof.rowBoundsValid -and $proof.controlsValid -and + $proof.rowsChecked -eq 6 -and $proof.controlsChecked -eq 6) 'DPI192 bounded captures rejected' +} +foreach ($test in @( + @{ line = 5696; mode = 'missing' }, @{ line = 5713; mode = 'foreign' }, @{ line = 5724; mode = 'duplicate' }, @{ line = 5739; mode = 'wrong-order' } +)) { + Invoke-Functional "ui.root.source.line-$($test.line)" { + $first = $initial + $second = $remote + switch ($test.mode) { + missing { $second = @($remote | Where-Object { $_.Current.AutomationId -ne 'open-project-2' }) } + foreign { $second = @($remote | ForEach-Object { + if ($_.Current.AutomationId -eq 'open-project-2') { New-SidebarElement $_.Current.AutomationId $_.Current.Name 12 192 220 224 9999 } else { $_ } + }) } + duplicate { $second = @($remote + (New-SidebarElement 'open-project-2' 'Temporary remote' 12 192 220 224)) } + wrong-order { + $first = @($initial | Where-Object { $_.Current.AutomationId -notin @('open-project-1', 'open-project-2') }) + + @($initial | Where-Object { $_.Current.AutomationId -eq 'open-project-2' }) + + @($initial | Where-Object { $_.Current.AutomationId -eq 'open-project-1' }) + } + } + $proof = Get-SidebarNavigationBoundsProof @([pscustomobject]@{ Children = $first }, [pscustomobject]@{ Children = $second }) 4242 + Assert-Functional (-not $proof.structureValid) "Sidebar accepted $($test.mode) owned row" + } +} +Invoke-Functional 'ui.root.source.line-5752' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar | Where-Object { $_.Current.AutomationId -notin @('open-project-1', 'project-new-loop-5', 'project-disclosure-6') }) + Assert-Functional (($snapshot.ownerNames -join '|') -ceq 'Temporary remote') 'Local owner collapse not isolated' +} +Invoke-Functional 'ui.root.source.line-5761' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar | Where-Object { $_.Current.AutomationId -ne 'open-project-2' }) + Assert-Functional (($snapshot.ownerNames -join '|') -ceq 'UIA project') 'Remote owner collapse not isolated' +} +Invoke-Functional 'ui.root.source.line-5773' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar | Where-Object { $_.Current.AutomationId -notin @('sidebar-section-2', 'open-project-2') }) + Assert-Functional (($snapshot.ownerNames -join '|') -ceq 'UIA project' -and + ($snapshot.sectionNames -join '|') -ceq 'Local Projects' -and $snapshot.recentRowIds.Count -eq 0) 'Temporary owner survived restoration' +} +Invoke-Functional 'ui.root.source.line-5783' { + $snapshot = Get-SidebarNavigationSnapshot @($sidebar + (New-SidebarElement 'project-row-recent' 'Recent folder' 12 228 220 260)) + Assert-Functional ($snapshot.recentRowIds.Count -eq 1) 'Unopened recent folder not detected' +} +Invoke-Functional 'ui.root.source.line-5792' { + $clipped = @($sidebar | Where-Object { $_.Current.AutomationId -ne 'project-new-loop-5' }) + + (New-SidebarElement 'project-new-loop-5' 'New Loop' 174 121 228 153) + Assert-Functional (-not (Get-SidebarNavigationSnapshot $clipped).controlsValid) 'Control outside owner accepted' +} +Invoke-Functional 'ui.root.source.line-5807' { + $reordered = @($sidebar | Where-Object { $_.Current.AutomationId -notin @('open-project-1', 'open-project-2') }) + + @((New-SidebarElement 'open-project-2' 'Temporary remote' 12 124 220 156), (New-SidebarElement 'open-project-1' 'UIA project' 12 192 220 224)) + Assert-Functional (((Get-SidebarNavigationSnapshot $reordered).ownerNames -join '|') -ceq 'Temporary remote|UIA project') 'Owner order evidence lost' +} +Invoke-Functional 'ui.root.source.line-5812' { + $sidebarIds = @(5632,5659,5683,5696,5713,5724,5739,5752,5761,5773,5783,5792,5807) + $executed = @($results | Where-Object { $_.id -in @($sidebarIds | ForEach-Object { "ui.root.source.line-$_" }) }) + Assert-Functional ($executed.Count -eq 13) 'Sidebar fixture execution count changed' +} 13 + +$retrySource = Read-Function 'Get-EdgeTextAttemptDecision' +$retryDecisions = [Collections.Generic.List[string]]::new() +Invoke-Functional 'ui.root.source.line-6866' { + $attempts = 0 + for ($attempt = 1; $attempt -le 5; $attempt++) { + $attempts++ + $value = if ($attempts -eq 1) { '' } else { 'expected text' } + $decision = Get-EdgeTextAttemptDecision $true $value 'expected text' $attempt 5 + $retryDecisions.Add($decision) + if ($decision -eq 'complete') { break } + Assert-Functional ($decision -eq 'retry') 'Unexpected text retry decision' + } + Assert-Functional ($attempts -eq 2 -and $value -ceq 'expected text') 'Stable empty text did not retry exactly once' +} 2 +Invoke-Functional 'ui.root.source.line-6864' { + Assert-Functional ($retryDecisions.Count -eq 2 -and ($retryDecisions -join ',') -ceq 'retry,complete') 'Unexpected text retry decision sequence' +} +Invoke-Functional 'ui.root.source.line-6870' { + Assert-Functional ($retrySource -match '\$actual -ceq \$expected') 'Exact expected-text verification missing' +} +$mutatedRetry = $retrySource.Replace('if ($actual -ceq $expected) { return "complete" }', 'if ($stable) { return "complete" }') +Invoke-Functional 'ui.root.source.line-6877' { + Assert-Functional ($mutatedRetry -cne $retrySource) 'Retry mutation anchor missing' +} +Invoke-Functional 'ui.root.source.line-6892' { + $attempts = 0 + for ($attempt = 1; $attempt -le 5; $attempt++) { + $attempts++ + $value = if ($attempts -eq 1) { '' } else { 'expected text' } + $decision = & { + . ([scriptblock]::Create($mutatedRetry)) + Get-EdgeTextAttemptDecision $true $value 'expected text' $attempt 5 + } + if ($decision -eq 'complete') { break } + } + Assert-Functional (-not ($attempts -eq 2 -and $value -ceq 'expected text')) 'Exact-match mutant escaped regression' +} +Invoke-Functional 'ui.root.source.line-6905' { + $attempts = 0 + $failure = $null + try { + for ($attempt = 1; $attempt -le 5; $attempt++) { $attempts++; $null = Get-EdgeTextAttemptDecision $true '' 'expected text' $attempt 5 } + } catch { $failure = $_ } + Assert-Functional ($attempts -eq 5 -and $failure -and $failure.Exception.Message -match 'attempts=5/5') 'Stable-wrong text did not exhaust exactly five attempts' +} 5 +Invoke-Functional 'ui.root.source.line-6925' { + $body = Read-Function 'Wait-SketchGraphCard' + foreach ($pattern in @('\$baselineCount -gt 0', '\$lastTitleCount -eq 1 -and \$lastIdentityCount -eq 1', + '\$expectedNodeId', '\$expectedAutomationId', '\$_.Current\.AutomationId -ceq \$expectedAutomationId', '\$graphSequence')) { + Assert-Functional ($body -match $pattern) 'Custody card wait lost bounded unique identity proof' + } + Assert-Functional ($body -notmatch 'Invoke\(\)|PostRightClick|Actual Size') 'Custody wait performs an action' +} +Invoke-Functional 'ui.root.source.line-6958' { + $checked = 0 + foreach ($expected in @( + @('66666666-6666-4666-8666-000000000001', 'canvas-card-1213325934034940293'), + @('66666666-6666-4666-8666-000000000002', 'canvas-card-1213322635500055660'), + @('66666666-6666-4666-8666-000000000003', 'canvas-card-1213323735011683871'))) { + Assert-Functional ((Get-SketchCardAutomationId $expected[0]) -ceq $expected[1]) 'Source-derived card identity differs' + $checked++ + } + Assert-Functional ($checked -eq 3) 'Sketch identity case count changed' +} 3 +Invoke-Functional 'ui.root.source.line-6967' { + $body = Read-Function 'Open-SketchNodeMenu' + foreach ($pattern in @('\$cards\.Count -eq 1', '\$ExpectedCardId', + '\$cards\[0\]\.Current\.AutomationId -ceq \$ExpectedCardId', 'Get-DirectChildren \$graph \$rawWalker')) { + Assert-Functional ($body -match $pattern) 'Custody right-click bypasses fresh unique identity proof' + } +} + +function New-ConnectorCard([string] $Id, [string] $Name) { + [pscustomobject]@{ Current = [pscustomobject]@{ AutomationId = $Id; Name = $Name } } +} +$connectorCases = @( + @{ name = 'owned-pair'; source = 'source'; target = 'target'; expected = $true; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target' 'target')) }, + @{ name = 'duplicate-source'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source-a' 'source'), (New-ConnectorCard 'canvas-card-source-b' 'source'), (New-ConnectorCard 'canvas-card-target' 'target')) }, + @{ name = 'missing-source'; source = 'source'; target = 'target'; expected = $false; cards = @((New-ConnectorCard 'canvas-card-target' 'target')) }, + @{ name = 'missing-target'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'button-target' 'target')) }, + @{ name = 'different-target-title'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target' 'other target')) }, + @{ name = 'duplicate-target'; source = 'source'; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target-a' 'target'), (New-ConnectorCard 'canvas-card-target-b' 'target')) }, + @{ name = 'same-endpoint'; source = 'same'; target = 'same'; expected = $false; cards = @((New-ConnectorCard 'canvas-card-same' 'same')) }, + @{ name = 'blank-title'; source = ' '; target = 'target'; expected = $false; cards = @( + (New-ConnectorCard 'canvas-card-source' 'source'), (New-ConnectorCard 'canvas-card-target' 'target')) } +) +foreach ($test in $connectorCases) { + Invoke-Functional "ui.connector.$($test.name)" { + $failure = $null + $pair = $null + try { $pair = Select-EdgeConnectorCardPair $test.cards $test.source $test.target } catch { $failure = $_ } + if ($test.expected) { + Assert-Functional (-not $failure -and $pair.Source.Current.AutomationId -ceq 'canvas-card-source' -and + $pair.Target.Current.AutomationId -ceq 'canvas-card-target') 'Named source/target pair differs' + } else { Assert-Functional ($null -ne $failure) "Ambiguous connector pair accepted: $($test.name)" } + } +} +Invoke-Functional 'ui.root.source.line-7235' { + Assert-Functional (@($results | Where-Object { $_.id.StartsWith('ui.connector.', [StringComparison]::Ordinal) }).Count -eq 8) 'Connector fixture case count changed' +} 8 +function New-PromotionItems { + # Native menu captions deliberately use the production em dash. + @( + [pscustomobject]@{ Id = 0; Text = 'Keep the session, add a shape'; Enabled = $false; Separator = $false }, + [pscustomobject]@{ Id = 5116; Text = "Goal $([char]0x2014) asks for a done check"; Enabled = $true; Separator = $false }, + [pscustomobject]@{ Id = 5117; Text = "Turn $([char]0x2014) asks where to pause"; Enabled = $true; Separator = $false }, + [pscustomobject]@{ Id = 5118; Text = "Timed $([char]0x2014) asks for a cadence"; Enabled = $true; Separator = $false } + ) +} +foreach ($name in @('exact', 'extra-item', 'reordered', 'enabled-heading', 'disabled-action', 'wrong-caption', 'separator-heading')) { + Invoke-Functional "ui.promotion.$name" { + $items = @(New-PromotionItems) + switch ($name) { + extra-item { $items += [pscustomobject]@{ Id = 5120; Text = 'Unexpected'; Enabled = $true; Separator = $false } } + reordered { $items = @($items[0], $items[2], $items[1], $items[3]) } + enabled-heading { $items[0].Enabled = $true } + disabled-action { $items[1].Enabled = $false } + wrong-caption { $items[2].Text = "Turn $([char]0x2014) unexpected caption" } + separator-heading { $items[0].Separator = $true } + } + Assert-Functional ([bool](Test-SketchPromotionSubmenuItems $items) -eq ($name -ceq 'exact')) "Promotion submenu oracle differs: $name" + } +} +$nativeForms = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\NativeForms.zig')) +$nativeLabels = [regex]::Match($nativeForms, '(?s)const node_labels = \[_\]\[\]const u8\{(.*?)\};') +Invoke-Functional 'ui.root.source.line-7326' { Assert-Functional $nativeLabels.Success 'NativeForms node_labels table missing' } +$labelAssignments = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -ceq '$nodeSheetFieldLabels' +}, $true)) +Invoke-Functional 'ui.root.source.line-7337' { + Assert-Functional ($labelAssignments.Count -eq 1 -and $labelAssignments[0].Right.Find({ + param($node) $node -is [Management.Automation.Language.HashtableAst] + }, $true)) 'Production node creation label map missing' +} +Invoke-Functional 'ui.root.source.line-7344' { + Assert-Functional ($nativeLabels.Success -and $labelAssignments.Count -eq 1) 'Label sources unavailable' + $labels = @([regex]::Matches($nativeLabels.Groups[1].Value, '"((?:[^"\\]|\\.)*)"') | ForEach-Object { $_.Groups[1].Value }) + $hashAst = $labelAssignments[0].Right.Find({ param($node) $node -is [Management.Automation.Language.HashtableAst] }, $true) + $map = $hashAst.SafeGetValue() + $checked = 0 + foreach ($id in @(9100,9102,9103,9104,9106,9107,9108,9109,9110,9111,9112,9113,9114)) { + $expected = $labels[$id - 9100] + Assert-Functional (-not [string]::IsNullOrWhiteSpace($expected) -and [string]$map[$id] -eq $expected) "Node creation label differs: $id" + $checked++ + } + Assert-Functional ($checked -eq 13) 'Node label count changed' +} 13 + +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-functional-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 index 669aca26..8bf8e983 100644 --- a/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiRootSourceContracts.ps1 @@ -19,18 +19,46 @@ $paths = @{ designTokens = 'graphcode-windows\src\DesignTokens.zig' laneLayout = 'graphcode-windows\src\LaneLayout.zig' graphCanvas = 'graphcode-windows\src\GraphCanvas.zig' + 'gate-state' = 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs' + 'input-safety' = 'Tools\windows\ValidationRunner\Ui\Production\UiaInputSafety.cs' + 'host-info' = 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaHostInfo.cs' } $resources = @{} +$linkage = @{ verified = $false } +function Assert-ProductionSourceLinkage { + if ($linkage.verified) { return } + $gateText = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $paths.gate)) + if ($gateText -match '\bclass\s+(GraphCodeUiaGateState|GraphCodeUiaHostInfo|GraphCodeUiaAttribution|GraphCodeMultiProjectRenameNative)\b' -or + -not $gateText.Contains('Import-CompiledValidationAssembly.ps1') -or + -not $gateText.Contains('-AssemblyPath $validationAssemblyPath -RequiredType $requiredType')) { + throw 'UIA production consumer does not load the single compiled production binding' + } + $bindings = $null + foreach ($name in @('GraphCodeUiaGateState', 'GraphCodeUiaHostInfo', 'GraphCodeUiaAttribution', + 'GraphCodeMultiProjectRenameNative', 'GraphCode.Windows.UiaWindowActivation')) { + if (-not $gateText.Contains('"' + $name + '"')) { throw "Production gate does not require the shared type: $name" } + $current = & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType $name + if ($bindings -and -not [object]::ReferenceEquals($current, $bindings)) { throw 'UIA production components do not share one binding assembly' } + $bindings = $current + } + $type = $bindings.GetType('GraphCodeUiaGateState', $true) + $input = $type.GetField('InputSafety', [Reflection.BindingFlags]'NonPublic,Static') + if (-not $input -or $input.FieldType -ne $bindings.GetType('GraphCode.Windows.UiaInputSafety', $true)) { + throw 'Production gate does not use the exact tested shared input component' + } + $project = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Bindings\Windows\WindowsValidationBindings.csproj')) + if (-not $project.Contains('Compile Include="..\..\Ui\Production\*.cs"') -or + -not $project.Contains('Compile Include="..\..\..\UiaWindowActivation.cs"')) { + throw 'Production binding does not compile the actual current shared sources' + } + $linkage.verified = $true +} function Read-SourceResource([string] $Name) { if (-not $paths.ContainsKey($Name)) { throw "Undeclared UI source resource: $Name" } if (-not $resources.ContainsKey($Name)) { + if ($Name -cin @('gate-state', 'input-safety', 'host-info')) { Assert-ProductionSourceLinkage } $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $paths[$Name])) - if ($Name -ceq 'gate') { - foreach ($component in @('GraphCodeUiaGateState', 'GraphCodeUiaHostInfo', 'GraphCodeUiaAttribution', - 'GraphCodeMultiProjectRenameNative', 'UiaInputSafety')) { - $text += "`n" + [IO.File]::ReadAllText((Join-Path $uiRoot "Production\$component.cs")) - } - } $resources[$Name] = $text } return $resources[$Name] @@ -70,6 +98,12 @@ function Get-AssertionValue($Node, [int] $Depth = 0) { } $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $results = [Collections.Generic.List[object]]::new() +$linkageFailure = $null +try { Assert-ProductionSourceLinkage } catch { $linkageFailure = $_ } +$results.Add([ordered]@{ + id = 'ui.production.consumer-linkage'; status = $(if ($linkageFailure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($linkageFailure) { $linkageFailure.ToString() } else { 'Actual gate imports exact file-backed shared assembly and current ordinary production sources' }) +}) function Invoke-EvaluatorControl([string] $Name, [scriptblock] $Assertion) { $failure = $null try { & $Assertion } catch { $failure = $_ } diff --git a/Tools/windows/ValidationRunner/Ui/source-assertions.json b/Tools/windows/ValidationRunner/Ui/source-assertions.json index 6b88d316..6fe6d17e 100644 --- a/Tools/windows/ValidationRunner/Ui/source-assertions.json +++ b/Tools/windows/ValidationRunner/Ui/source-assertions.json @@ -1178,10 +1178,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageText\\(edit, 0x000D", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -1190,10 +1195,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageString\\(edit, 0x000C", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "kind": "binary", "operator": "Or" @@ -1985,10 +1995,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "\\[DllImport\\(\"kernel32\\.dll\"\\)\\]\\s*private static extern uint GetCurrentThreadId", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -1998,10 +2013,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "PostMessage\\(window, 0x0101, \\(UIntPtr\\)key, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2011,10 +2031,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "IsRetainedProcessAlive\\(expectedProcessId\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2392,10 +2417,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "PostMessage\\(window, 0x802C, \\(UIntPtr\\)target, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2405,10 +2435,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(popup, 0x01E1, UIntPtr\\.Zero, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2784,14 +2819,19 @@ "right": { "right": { "kind": "literal", - "value": "(?s)SendInput\\(\\(uint\\)inputs\\.Length.*?for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\).*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\).*?PostMessage\\(popup, 0x0101, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + "value": "(?s)SendInput\\(\\(uint\\)inputs\\.Length.*?for \\(int attempt = 0; attempt < 20 && operations\\.IsWindowVisible\\(popup\\); attempt\\+\\+\\).*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\).*?PostMessage\\(popup, 0x0101, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "(?s)SendInput\\(\\(uint\\)inputs\\.Length.*?for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\).*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\).*?PostMessage\\(popup, 0x0101, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2801,10 +2841,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2827,36 +2872,66 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public bool Hilite, UsedKeyboardFallback", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { - "right": { - "kind": "literal", - "value": "(?s)private static extern void Sleep\\(uint milliseconds\\).*?for \\(int attempt = 0; attempt < 50 && !hilite; attempt\\+\\+\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\).*?Sleep\\(10\\).*?if \\(!hilite\\).*?throw new InvalidOperationException" - }, + "kind": "binary", + "operator": "Or", "left": { - "kind": "resource", - "name": "gate" + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "gate-state" + }, + "right": { + "kind": "literal", + "value": "private static extern void Sleep\\(uint milliseconds\\)" + } }, - "kind": "binary", - "operator": "Inotmatch" + "right": { + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "input-safety" + }, + "right": { + "kind": "literal", + "value": "(?s)for \\(int attempt = 0; attempt < 50 && !hilite; attempt\\+\\+\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\).*?operations\\.Sleep\\(10\\).*?if \\(!hilite\\).*?throw new InvalidOperationException" + } + }, + "migration": { + "legacyPattern": "(?s)private static extern void Sleep\\(uint milliseconds\\).*?for \\(int attempt = 0; attempt < 50 && !hilite; attempt\\+\\+\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\).*?Sleep\\(10\\).*?if \\(!hilite\\).*?throw new InvalidOperationException", + "reason": "Sleep ABI declaration and policy body now live in two explicitly linked production sources" + } }, "left": { "right": { "right": { "kind": "literal", - "value": "SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(point\\.X, point\\.Y\\)\\)" + "value": "SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, operations\\.MouseLParam\\(point\\.X, point\\.Y\\)\\)" }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(point\\.X, point\\.Y\\)\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2866,10 +2941,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "GetCursorPos", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2879,10 +2959,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SetCursorPos", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -2905,10 +2990,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "GetMenuItemRect", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -3303,22 +3393,32 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public bool HiliteAtEnterFallback, HiliteBeforeEnter;", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { "kind": "literal", - "value": "(?s)for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\) Sleep\\(10\\);.*?hiliteAtEnterFallback = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;\\s*SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;.*?if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException.*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + "value": "(?s)for \\(int attempt = 0; attempt < 20 && operations\\.IsWindowVisible\\(popup\\); attempt\\+\\+\\) operations\\.Sleep\\(10\\);.*?hiliteAtEnterFallback = \\(operations\\.GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;\\s*operations\\.SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = \\(operations\\.GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;.*?if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException.*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "(?s)for \\(int attempt = 0; attempt < 20 && IsWindowVisible\\(popup\\); attempt\\+\\+\\) Sleep\\(10\\);.*?hiliteAtEnterFallback = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;\\s*SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = \\(GetMenuState\\(menu, \\(uint\\)position, 0x0400\\) & 0x0080\\) != 0;.*?if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException.*?PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "kind": "binary", "operator": "Or" @@ -4666,14 +4766,19 @@ "right": { "right": { "kind": "literal", - "value": "(?s)public static bool HoverPopupMenuItem.*?FindPopupForMenu\\(ownerProcess, menu\\).*?SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(client\\.X, client\\.Y\\)\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\)" + "value": "(?s)public bool HoverPopupMenuItem.*?FindPopupForMenu\\(ownerProcess, menu\\).*?SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, operations\\.MouseLParam\\(client\\.X, client\\.Y\\)\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\)" }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "(?s)public static bool HoverPopupMenuItem.*?FindPopupForMenu\\(ownerProcess, menu\\).*?SendMessage\\(popup, 0x0200, UIntPtr\\.Zero, MouseLParam\\(client\\.X, client\\.Y\\)\\).*?GetMenuState\\(menu, \\(uint\\)position, 0x0400\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -4852,10 +4957,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public static bool RevealSubmenuByKeyboard\\(IntPtr popup, IntPtr menu, int position\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -5667,10 +5777,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "LastEditTextSent = checked\\(\\(uint\\)typed\\[0\\]\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -5680,23 +5795,33 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendUnicodeInput\\(parent, expectedProcessId, edit, text\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { "right": { "kind": "literal", - "value": "LastEditClearSent = SendKeyInputs\\(LastEditClearExpected" + "value": "LastEditClearSent = operations\\.SendKeyInputs\\(LastEditClearExpected" }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "LastEditClearSent = SendKeyInputs\\(LastEditClearExpected", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -5856,16 +5981,36 @@ }, "left": { "right": { - "right": { - "kind": "literal", - "value": "(?s)var clear = new KeyInputRecord\\[2\\].*?EditBufferText\\(edit\\).*?var records = new KeyInputRecord\\[text\\.Length \\* 2\\]" - }, + "kind": "binary", + "operator": "Or", "left": { - "kind": "resource", - "name": "gate" + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "input-safety" + }, + "right": { + "kind": "literal", + "value": "(?s)var clear = new KeyInputRecord\\[2\\].*?EditBufferText\\(edit\\).*?SendUnicodeInput\\(parent, expectedProcessId, edit, text\\)" + } }, - "kind": "binary", - "operator": "Inotmatch" + "right": { + "kind": "binary", + "operator": "Inotmatch", + "left": { + "kind": "resource", + "name": "input-safety" + }, + "right": { + "kind": "literal", + "value": "(?s)public int\\[\\] SendUnicodeInput\\(.*?var records = new KeyInputRecord\\[text\\.Length \\* 2\\]" + } + }, + "migration": { + "legacyPattern": "(?s)var clear = new KeyInputRecord\\[2\\].*?EditBufferText\\(edit\\).*?var records = new KeyInputRecord\\[text\\.Length \\* 2\\]", + "reason": "Preserve clear/read/Unicode call and Unicode record creation without depending on method declaration order" + } }, "left": { "right": { @@ -5875,10 +6020,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessage\\(edit, 0x00B1, UIntPtr\\.Zero, new IntPtr\\(-1\\)\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -5888,10 +6038,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageText\\(edit, 0x000D", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -5901,10 +6056,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "input-safety" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "VirtualKey = 0x2E", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -6018,10 +6178,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SendMessageString\\(edit, 0x000C, UIntPtr\\.Zero, text\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -6096,10 +6261,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "public static bool ClickButton\\(IntPtr button\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8396,10 +8566,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "RealChildWindowFromPoint\\(dialog, clientPoint\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8409,10 +8584,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "HitTarget = !visibleEmpty && sameTopLevel && realChild == target", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8421,10 +8601,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SystemParametersInfoRect\\(0x0030", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "kind": "binary", "operator": "Or" @@ -8473,10 +8658,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "ChosenUncoveredRectangle = piece", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8486,10 +8676,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "GetWindow\\(target, 3\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8499,10 +8694,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "SubtractCoveredRectangles", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8577,10 +8777,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "\\} else if \\(!hit\\.HitTarget\\) \\{", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8590,10 +8795,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "return RealChildWindowFromPoint\\(dialog, clientPoint\\) == target;", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8603,10 +8813,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "for \\(int column = 1; column <= 5; column\\+\\+\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8616,10 +8831,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "for \\(int row = 1; row <= 3; row\\+\\+\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "left": { "right": { @@ -8628,10 +8848,15 @@ }, "left": { "kind": "resource", - "name": "gate" + "name": "gate-state" }, "kind": "binary", - "operator": "Inotmatch" + "operator": "Inotmatch", + "migration": { + "legacyResource": "gate", + "legacyPattern": "if \\(!hit\\.HitTarget && !visibleEmpty && dialog != IntPtr\\.Zero\\)", + "reason": "Ordinary shared production source; native dependency receiver/static-to-instance seam only" + } }, "kind": "binary", "operator": "Or" @@ -8948,4 +9173,4 @@ "operator": "Or" } } -] \ No newline at end of file +] diff --git a/Tools/windows/ValidationRunner/Ui/source-case-map.json b/Tools/windows/ValidationRunner/Ui/source-case-map.json index 7327b22e..8060cc83 100644 --- a/Tools/windows/ValidationRunner/Ui/source-case-map.json +++ b/Tools/windows/ValidationRunner/Ui/source-case-map.json @@ -999,7 +999,7 @@ "id": "ui.production.consumer-linkage", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "169-194;6751-6778;7408-7430", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -1475,98 +1475,98 @@ "id": "ui.root.source.line-5366", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5366-5368", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5380", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5380-5382", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5392", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5392-5394", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5396", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5396-5398", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5399", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5399-5401", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5407", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5407-5409", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5410", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5410-5413", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5432", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5432-5435", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5449", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5449-5451", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5457", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5457-5459", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5462", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5462-5464", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5470", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5470-5472", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5477", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5477-5479", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5485", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5485-5488", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -1601,126 +1601,126 @@ "id": "ui.root.source.line-5553", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5553-5555", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5561", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5561-5563", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5569", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5569-5571", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5580", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5580-5583", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5632", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5632-5641", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5659", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5659-5662", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5683", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5683-5687", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5696", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5696-5698", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5713", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5713-5715", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5724", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5724-5726", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5739", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5739-5741", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5752", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5752-5754", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5761", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5761-5763", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5773", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5773-5777", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5783", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5783-5785", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5792", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5792-5794", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5807", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5807-5809", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5812", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5812-5814", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -1930,84 +1930,84 @@ "id": "ui.root.source.line-6853", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6853-6855", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6864", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6864-6864", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6866", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6866-6868", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6870", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6870-6872", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6877", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6877-6879", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6892", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6892-6894", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6905", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6905-6908", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6921", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6921-6923", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6925", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6925-6933", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6939", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6939-6941", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6958", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6958-6960", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6967", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6967-6973", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -2021,35 +2021,140 @@ "id": "ui.root.source.line-7136", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7136-7138", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-7235", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7235-7239", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-7326", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7326-7328", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-7337", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7337-7339", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-7344", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7344-7346", - "state": "Incomplete", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.enabled-heading", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.owned-pair", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.missing-source", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.reordered", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.disabled-action", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.missing-target", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.different-target-title", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.extra-item", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.duplicate-source", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.same-endpoint", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.separator-heading", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.blank-title", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.wrong-caption", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.promotion.exact", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", + "owner": "Ui" + }, + { + "id": "ui.connector.duplicate-target", + "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "lines": "7130-7323", + "state": "Implemented", "owner": "Ui" } ] From 28a772cdb8eb1116598bf37f10fa43e3526c31cf Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:03:42 -0700 Subject: [PATCH 15/46] Latch live descendant retention before owned cleanup Observe the exact UNKNOWN sentinel incrementally in both shared production readers. Retain the original lease immediately, cancel controller waits only, and refuse termination or close after the latch. Add fake-only retention-decision tests and exact fragmented marker controls. Verify startup diagnostics and mixed source boundaries through protected linked-production adapters. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ownership.json | 6 ++ .../Ownership/Adapters/AdapterSupport.ps1 | 14 ++- .../Ownership/Adapters/Capture.Contracts.ps1 | 4 +- .../Adapters/MixedSource.Contracts.ps1 | 70 +++++++++++++++ .../Adapters/PreUiaIsolation.Contracts.ps1 | 3 +- .../Ownership/Adapters/Receipt.Contracts.ps1 | 3 +- .../Adapters/RetentionBoundary.Contracts.ps1 | 3 +- .../Adapters/StartupDiagnostics.Contracts.ps1 | 81 +++++++++++++++++ .../Adapters/TreeProvider.Contracts.ps1 | 3 +- .../Ownership/OwnedChildSupervisor.cs | 86 +++++++++++++++---- .../Ownership/OwnedCleanupCoordinator.cs | 23 ++++- .../ValidationRunner/Ownership/README.md | 25 ++++-- .../Ownership/RetentionSignalObserver.cs | 42 +++++++++ .../Ownership/SuiteRegistration.cs | 44 +++++++--- .../Ownership/UiaOwnedStreamCapture.cs | 10 ++- 15 files changed, 370 insertions(+), 47 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json index 67a7b0a3..32d7261d 100644 --- a/Tools/windows/ValidationRunner/Manifests/ownership.json +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -32,6 +32,12 @@ { "id": "ownership.managed.cleanup-only-error", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.cleanup-original-deadline", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.cleanup-later-actions", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.cleanup-descendant-unknown", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.signal-fragmented", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.signal-crlf", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.signal-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.signal-exact-only", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.signal-overlong", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-nested-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 index fc209a49..2a9314b7 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 @@ -1,6 +1,12 @@ $ErrorActionPreference = 'Stop' $script:OwnershipCases = [Collections.Generic.List[object]]::new() +function Import-OwnershipBindings([string] $RepositoryRoot, [string] $AssemblyPath, + [string] $RequiredType = 'GraphCode.Validation.Ownership.StandaloneProcessJob') { + & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType $RequiredType | Out-Null +} + function Read-OwnershipProductionAst([string] $Path) { $errors = $null $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$errors) @@ -29,10 +35,16 @@ function Invoke-OwnershipCase([string] $Id, [scriptblock] $Body) { if (@($script:OwnershipCases | Where-Object Id -CEQ $Id).Count -ne 0) { throw "Duplicate ownership case: $Id" } try { & $Body 6>&1 | ForEach-Object { - if ([string]$_ -cne 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') { $_ } + if ([string]$_ -cne 'GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven' -or + -not $script:OwnershipMockSignals) { $_ } } | Out-Null $script:OwnershipCases.Add([ordered]@{ Id = $Id; Passed = $true; Failure = $null }) } catch { + if (-not $script:OwnershipMockSignals -and $_.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) { + [Console]::Out.WriteLine('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') + [Console]::Out.Flush() + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) + } $script:OwnershipCases.Add([ordered]@{ Id = $Id; Passed = $false; Failure = $_.ToString() }) } } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 index 0fd73672..9821d36c 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 @@ -6,7 +6,9 @@ param( ) . (Join-Path $PSScriptRoot 'AdapterSupport.ps1') -[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +Import-OwnershipBindings $RepositoryRoot $AssemblyPath 'GraphCode.Validation.Ownership.OwnershipJobFixture' Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop $gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') $jobReplacements = @{ diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 new file mode 100644 index 00000000..dfc97ee6 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedSource.Contracts.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$path = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' +$source = [IO.File]::ReadAllText($path) +$ast = Read-OwnershipProductionAst $path +function Find-ProductionFunction([string] $Name) { + $nodes = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-Ownership ($nodes.Count -eq 1) "Complete actual production function missing: $Name" + return $nodes[0] +} +Invoke-OwnershipCase 'peer-emission' { + $phase = Find-ProductionFunction 'Invoke-MultiProjectRenamePhase' + $calls = @($phase.FindAll({ param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Write-MultiProjectPeerReceipt' + }, $true)) + Assert-Ownership ($calls.Count -eq 1 -and + $calls[0].Extent.Text -ceq 'Write-MultiProjectPeerReceipt (Read-DaemonCommandLog $peerPath) $afterUnchanged' -and + $phase.Extent.Text.IndexOf($calls[0].Extent.Text) -lt $phase.Extent.Text.IndexOf('multi-project shell rejected Exit') -and + $phase.Extent.Text.IndexOf($calls[0].Extent.Text) -gt $phase.Extent.Text.IndexOf('$finalOverview = Wait-MultiProjectObservation "overview"')) 'Complete peer receipt is not uniquely bound to final owned file before teardown.' + foreach ($token in @('UIA_MULTIPROJECT_PEER_RECEIPT=', 'owned-stub-final-file-read', 'rawJson', 'utf8Sha256', + 'Assert-MultiProjectCompleteReport', 'WarningAction Stop')) { + Assert-Ownership ($source.Contains($token)) "Actual peer receipt route missing $token" + } +} +Complete-OwnershipFamily 'ownership.legacy.peer-emission' 1 @('source-only') +Invoke-OwnershipCase 'rename-submit' { + $caller = Find-ProductionFunction 'Invoke-MultiProjectNativeRename' + Assert-Ownership ($caller.Extent.Text.Contains('Invoke-MultiProjectRenameAction') -and + -not $caller.Extent.Text.Contains('Sketch-Submit') -and -not $caller.Extent.Text.Contains('Sketch-Cancel')) 'Rename routes through the wrong native form family.' + $dialogs = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\WindowsNativeDialogs.zig')) + $forms = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\NativeForms.zig')) + Assert-Ownership ($dialogs -match 'const ok_id = 9800;' -and $dialogs -match 'const cancel_id = 9808;' -and + $dialogs -match '9904 \+ index \* 8' -and $forms -match 'const ok_id = 1;') 'Independently sourced native Rename/Sketch control goldens changed.' + foreach ($token in @('function Invoke-MultiProjectRenameAction', 'function Read-MultiProjectRenameField', + 'function Test-MultiProjectRenameField', 'UIA_MULTIPROJECT_RENAME_NATIVE_TARGET=', + 'UIA_MULTIPROJECT_RENAME_NATIVE_FAILURE=', 'UIA_MULTIPROJECT_RENAME_NATIVE_ACTION=', + '$buttonId = if ($Cancel) { 9808 } else { 9800 }', 'ClientBounds', 'WorkBounds', 'topOwner', 'rectState', + 'hitSamples', 'beforeInput', 'enterFallbackUsed = $false', 'actualPriorButton1ReturnEstablished = $false', + 'Get-MultiProjectRenameTitle $owner.title', '-expectedTitle $renameTitle', + ('Only the name changes ' + [char]0x2014 + ' the loop keeps its session, its edges, and its work.'))) { + Assert-Ownership ($source.Contains($token)) "Actual strict Rename route missing $token" + } +} +Complete-OwnershipFamily 'ownership.legacy.rename-submit' 1 @('source-only') +Invoke-OwnershipCase 'fresh-capture' { + foreach ($token in @('function Read-MultiProjectElementCache', 'function Read-MultiProjectOwnershipSnapshot', + 'function ConvertTo-MultiProjectPropertyState', 'function Confirm-MultiProjectOwnedSnapshot', + 'UIA_MULTIPROJECT_FRESH_OWNERSHIP=', 'UIA_MULTIPROJECT_CACHED_OWNERSHIP=', 'UIA_MULTIPROJECT_CAPTURE_VERIFY=', + 'UIA_MULTIPROJECT_CONTAINER_OWNERSHIP=', 'UIA_MULTIPROJECT_SNAPSHOT_DRIFT=', 'priorStablePID', 'priorIdentity', + 'semanticCauseEstablished', 'wholeObservationDiscarded', '::ProcessIdProperty, $true', + '::AutomationIdProperty, $true', '::NotSupported', 'row identity, not a graph publication generation')) { + Assert-Ownership ($source.Contains($token)) "Actual fresh ownership capture missing $token" + } + $observer = Find-ProductionFunction 'Get-MultiProjectObservation' + Assert-Ownership ($observer.Extent.Text -notmatch '\.Current\.(Name|BoundingRectangle|AutomationId)' -and + $observer.Extent.Text.Contains('verifiedOwnedSnapshotCount') -and $observer.Extent.Text.Contains('Confirm-MultiProjectOwnedSnapshot') -and + $observer.Extent.Text.Contains('graph container ownership unavailable before child capture') -and + $observer.Extent.Text.Contains('projects container ownership unavailable before child capture')) 'Actual observer bypasses fresh/cached ownership or whole-observation container verification.' +} +Complete-OwnershipFamily 'ownership.legacy.fresh-capture' 1 @('source-only') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 index e589038e..9842289d 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/PreUiaIsolation.Contracts.ps1 @@ -6,7 +6,8 @@ param( ) . (Join-Path $PSScriptRoot 'AdapterSupport.ps1') -[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath $source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\validate.ps1')) $region = [regex]::Match($source, '(?s) \$preUiaProcesses =.*?(?= Invoke-Native "Native UI Automation live gate")') diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/Receipt.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/Receipt.Contracts.ps1 index a89c6f5b..f56ed770 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/Receipt.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/Receipt.Contracts.ps1 @@ -6,7 +6,8 @@ param( ) . (Join-Path $PSScriptRoot 'AdapterSupport.ps1') -[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop $gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') . Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure') diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 index b623e2fe..cf57f172 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/RetentionBoundary.Contracts.ps1 @@ -6,7 +6,8 @@ param( ) . (Join-Path $PSScriptRoot 'AdapterSupport.ps1') -[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath $consumer = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\validate.ps1') . Import-OwnershipProductionFunctions $consumer @('Invoke-Native', 'Get-WindowsShellProfileSnapshot', 'Assert-WindowsShellProfileUnchanged', 'Invoke-WindowsShellValidationIsolation') diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 new file mode 100644 index 00000000..aedfac01 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/StartupDiagnostics.Contracts.ps1 @@ -0,0 +1,81 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath 'GraphCodeUiaHostInfo' +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +$names = @( + 'Protect-UiaStartupDiagnosticText', 'Get-UiaStartupDiagnosticValue', 'Get-UiaStartupFileDiagnostic', + 'Get-UiaStartupImageHash', 'Write-UiaStartupFailureDiagnostic', 'Get-UiaPrelaunchDiagnostics', + 'Get-UiaOwnedProcessDescendants', 'Stop-UiaOwnedProcessTrees', 'New-UiaOwnershipFailure', + 'Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture', + 'Start-MultiProjectOwnedProcess', 'Complete-MultiProjectCapture' +) +foreach ($name in $names) { + Invoke-OwnershipCase $name { + $definitions = @($gate.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-Ownership ($definitions.Count -eq 1) "Required complete production startup helper missing or ambiguous: $name" + } +} +Complete-OwnershipFamily 'ownership.ui-startup.helpers' 13 @('source-only') +. Import-OwnershipProductionFunctions $gate $names +$root = Join-Path $ArtifactRoot ('startup-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$path = Join-Path $root 'capture.log' +[IO.File]::WriteAllText($path, "loader failed`npassword=private-canary`n") +Invoke-OwnershipCase 'redacted-log' { + $diagnostic = Get-UiaStartupFileDiagnostic $path 'logs\shell-stderr.log' + Assert-Ownership ($diagnostic.state -ceq 'available' -and $diagnostic.content -match 'loader failed' -and + $diagnostic.content -notmatch 'private-canary' -and $diagnostic.readBytes -eq $diagnostic.lengthBytes) 'Actual startup file diagnostic lost output, redaction or full-read proof.' +} +Complete-OwnershipFamily 'ownership.ui-startup.redacted-log' 1 +Invoke-OwnershipCase 'truncated-log' { + $diagnostic = Get-UiaStartupFileDiagnostic $path 'logs\shell-stderr.log' 8 + Assert-Ownership ($diagnostic.state -ceq 'truncated' -and $diagnostic.readBytes -eq 8 -and + $diagnostic.lengthBytes -gt 8) 'Actual diagnostic read exceeded or failed to report the eight-byte bound.' +} +Complete-OwnershipFamily 'ownership.ui-startup.truncated-log' 1 +Invoke-OwnershipCase 'missing-log' { + $diagnostic = Get-UiaStartupFileDiagnostic (Join-Path $root 'missing.log') 'logs\missing.log' + Assert-Ownership ($diagnostic.state -ceq 'missing') 'Actual missing log was misclassified as empty or available.' +} +Complete-OwnershipFamily 'ownership.ui-startup.missing-log' 1 +Invoke-OwnershipCase 'retained-report' { + $logs = Join-Path $root 'logs' + $support = Join-Path $root 'support' + $null = New-Item -ItemType Directory -Path $logs, $support + [IO.File]::WriteAllText((Join-Path $logs 'shell-stderr.log'), "loader failed`npassword=stderr-canary`n") + [IO.File]::WriteAllText((Join-Path $logs 'shell-stdout.log'), 'child output captured') + [IO.File]::WriteAllText((Join-Path $support 'graphcode-windows.log'), "app initialization failed`nsecret=app-canary`n") + $self = Get-Process -Id $PID + try { Write-UiaStartupFailureDiagnostic $self 0 $path $root $logs $support } + finally { $self.Dispose() } + $report = Get-Content -LiteralPath (Join-Path $logs 'startup-failure.json') -Raw | ConvertFrom-Json + Assert-Ownership ($report.stderr.content -match 'loader failed' -and $report.stdout.content -match 'child output captured' -and + $report.applicationLog.content -match 'app initialization failed' -and $report.stderr.content -notmatch 'stderr-canary' -and + $report.applicationLog.content -notmatch 'app-canary') 'Actual retained report lost independent stderr/stdout/app logs or exposed canaries.' +} +Complete-OwnershipFamily 'ownership.ui-startup.retained-report' 1 +Invoke-OwnershipCase 'pid-reuse' { + $created = [datetime]::new(2026, 1, 1, 12, 0, 0, [DateTimeKind]::Utc) + function Get-CimInstance { + @( + [pscustomobject]@{ ProcessId = 624; ParentProcessId = 7416; Name = 'conhost.exe'; ExecutablePath = ''; CreationDate = $created } + [pscustomobject]@{ ProcessId = 700; ParentProcessId = 624; Name = 'child.exe'; ExecutablePath = ''; CreationDate = $created.AddSeconds(5) } + [pscustomobject]@{ ProcessId = 636; ParentProcessId = 624; Name = 'csrss.exe'; ExecutablePath = ''; CreationDate = $created.AddHours(-3) } + [pscustomobject]@{ ProcessId = 732; ParentProcessId = 636; Name = 'wininit.exe'; ExecutablePath = ''; CreationDate = $created.AddHours(-2) } + ) + } + function Stop-Process { throw 'Advisory traversal must not terminate anything.' } + function Get-Process { throw 'Advisory traversal must not reopen an observed PID.' } + $names = @(Get-UiaOwnedProcessDescendants @(624) | ForEach-Object Name) + Assert-Ownership (($names -join ',') -ceq 'child.exe') 'Advisory traversal adopted a process predating its reused parent generation.' +} +Complete-OwnershipFamily 'ownership.ui-process-tree.pid-reuse' 1 +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 index c93742d3..99025798 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/TreeProvider.Contracts.ps1 @@ -6,7 +6,8 @@ param( ) . (Join-Path $PSScriptRoot 'AdapterSupport.ps1') -[Reflection.Assembly]::LoadFrom($AssemblyPath) | Out-Null +$script:OwnershipMockSignals = $true +Import-OwnershipBindings $RepositoryRoot $AssemblyPath Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop $gatePath = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' $gate = Read-OwnershipProductionAst $gatePath diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs index deb4075a..000a24d7 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -72,6 +72,9 @@ private static extern bool CreateProcessW(string file, StringBuilder command, In private static readonly Task lifetimeHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task; private readonly Deadline deadline; private readonly string artifactRoot; + private readonly object retentionGate = new(); + private readonly CancellationTokenSource retentionCancellation = new(); + private bool descendantUnknown, retentionRegistered; private IntPtr root, thread, job; private bool assigned, resumed, completed; private StreamReader? stdoutReader, stderrReader; @@ -88,6 +91,27 @@ private static extern bool CreateProcessW(string file, StringBuilder command, In // Returning from Main would close kill-on-close jobs despite the retained references. public static Task HoldRetainedResourcesAsync() => RequiresLifetimeHold ? lifetimeHold : Task.CompletedTask; + private void ObserveDescendantUnknown() + { + lock (retentionGate) + { + if (descendantUnknown) return; + descendantUnknown = true; + RegisterRetention(); + retentionCancellation.Cancel(); + } + } + + private void RegisterRetention() + { + lock (retentionGate) + { + if (retentionRegistered) return; + retained.Add(this); + retentionRegistered = true; + } + } + public OwnedChildObservation Observe() => new(RootExited(), StandaloneProcessJob.Active(job), StandardOutput.IsCompleted, StandardError.IsCompleted); @@ -185,8 +209,10 @@ private void Launch(ProcessStartInfo info) stdoutReader = MakeReader(ref outRead, info.StandardOutputEncoding); stderrReader = MakeReader(ref errRead, info.StandardErrorEncoding); string stem = $"owned-{ProcessId}-{creationFileTime}"; - stdout = UiaOwnedStreamCapture.Read(stdoutReader, Path.Combine(artifactRoot, stem + "-stdout.log")); - stderr = UiaOwnedStreamCapture.Read(stderrReader, Path.Combine(artifactRoot, stem + "-stderr.log")); + stdout = UiaOwnedStreamCapture.Read(stdoutReader, Path.Combine(artifactRoot, stem + "-stdout.log"), + new RetentionSignalObserver(ObserveDescendantUnknown)); + stderr = UiaOwnedStreamCapture.Read(stderrReader, Path.Combine(artifactRoot, stem + "-stderr.log"), + new RetentionSignalObserver(ObserveDescendantUnknown)); if (deadline.Expired) throw new TimeoutException("Owned launch original deadline exceeded before resume."); uint previous = ResumeThread(thread); if (previous == uint.MaxValue) throw new Win32Exception(); @@ -263,15 +289,17 @@ public async Task CompleteAsync(CancellationToken cancellation Exception? primary = null; int exitCode = -1; string output = "", error = ""; + using var waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retentionCancellation.Token); try { while (!RootExited()) { - cancellationToken.ThrowIfCancellationRequested(); + waiting.Token.ThrowIfCancellationRequested(); if (deadline.Expired) throw new TimeoutException("OWNED_CAPTURE_EXIT: original deadline exceeded."); - await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds), cancellationToken).ConfigureAwait(false); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds), waiting.Token).ConfigureAwait(false); } - await RequireDrainAsync([StandardOutput, StandardError], deadline, cancellationToken).ConfigureAwait(false); + await RequireDrainAsync([StandardOutput, StandardError], deadline, waiting.Token).ConfigureAwait(false); + waiting.Token.ThrowIfCancellationRequested(); if (!GetExitCodeProcess(root, out uint code)) throw new Win32Exception(); exitCode = unchecked((int)code); output = await StandardOutput.ConfigureAwait(false); @@ -312,38 +340,58 @@ private Task CleanupAsync(Exception? primary) => private sealed class NativeCleanupAuthority(OwnedChildSupervisor owner) : IOwnedCleanupAuthority { public string Identity => $"pid={owner.ProcessId}; creation={owner.creationFileTime}; job={owner.job}"; + public bool OwnershipUnproven { get { lock (owner.retentionGate) return owner.descendantUnknown; } } public Task? StandardOutput => owner.stdout; public Task? StandardError => owner.stderr; public IReadOnlyList CloseActions => [ - () => owner.stdoutReader?.Dispose(), () => owner.stderrReader?.Dispose(), - () => Close(ref owner.thread), - () => { if (owner.job != IntPtr.Zero) { StandaloneProcessJob.Close(owner.job); owner.job = IntPtr.Zero; } }, - () => Close(ref owner.root) + () => CloseOwned(() => owner.stdoutReader?.Dispose()), () => CloseOwned(() => owner.stderrReader?.Dispose()), + () => CloseOwned(() => Close(ref owner.thread)), + () => CloseOwned(() => { if (owner.job != IntPtr.Zero) { StandaloneProcessJob.Close(owner.job); owner.job = IntPtr.Zero; } }), + () => CloseOwned(() => Close(ref owner.root)), () => CloseOwned(owner.retentionCancellation.Dispose) ]; public void Terminate() { - if (owner.assigned) StandaloneProcessJob.Terminate(owner.job); - else if (owner.root != IntPtr.Zero) + lock (owner.retentionGate) { - if (owner.resumed) throw new InvalidOperationException("Unassigned root was unexpectedly resumed."); - if (WaitForSingleObject(owner.root, 0) != 0 && !TerminateProcess(owner.root, 124)) - throw new Win32Exception(); + if (owner.descendantUnknown) throw new InvalidOperationException("UNKNOWN descendant ownership; termination withheld."); + if (owner.assigned) StandaloneProcessJob.Terminate(owner.job); + else if (owner.root != IntPtr.Zero) + { + if (owner.resumed) throw new InvalidOperationException("Unassigned root was unexpectedly resumed."); + if (WaitForSingleObject(owner.root, 0) != 0 && !TerminateProcess(owner.root, 124)) + throw new Win32Exception(); + } } } - public Task WaitForJobEmptyAsync(Deadline deadline) + private void CloseOwned(Action action) { - if (owner.job == IntPtr.Zero) return Task.FromResult(true); - StandaloneProcessJob.WaitForEmpty(owner.job, deadline); - return Task.FromResult(StandaloneProcessJob.Active(owner.job) == 0); + lock (owner.retentionGate) + { + if (owner.descendantUnknown) throw new InvalidOperationException("UNKNOWN descendant ownership; close withheld."); + action(); + } + } + + public async Task WaitForJobEmptyAsync(Deadline deadline) + { + if (owner.job == IntPtr.Zero) return true; + while (StandaloneProcessJob.Active(owner.job) != 0) + { + if (OwnershipUnproven) throw new InvalidOperationException("UNKNOWN descendant ownership; job wait withheld."); + if (deadline.Expired) throw new TimeoutException("Owned job still has active processes."); + await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds)).ConfigureAwait(false); + } + return true; } public async Task WaitForRootExitAsync(Deadline deadline) { while (owner.root != IntPtr.Zero && !owner.RootExited()) { + if (OwnershipUnproven) throw new InvalidOperationException("UNKNOWN descendant ownership; root wait withheld."); if (deadline.Expired) throw new TimeoutException("Held root exit unproved."); await Task.Delay(Math.Min(10, deadline.RemainingMilliseconds)).ConfigureAwait(false); } @@ -352,7 +400,7 @@ public async Task WaitForRootExitAsync(Deadline deadline) public void Retain(Exception failure) { - retained.Add(owner); + owner.RegisterRetention(); OwnershipRetention.Tag(failure, owner.artifactRoot); failure.Data["OwnedRetainedLease"] = owner; } diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs index 380e0bda..09cdad8f 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs @@ -8,6 +8,7 @@ namespace GraphCode.Validation.Ownership; public interface IOwnedCleanupAuthority { string Identity { get; } + bool OwnershipUnproven { get; } Task? StandardOutput { get; } Task? StandardError { get; } void Terminate(); @@ -24,9 +25,27 @@ public static async Task CompleteAsync(IOwnedCleanupAuthority { var deadline = new Deadline(timeout); var failures = new List(); + if (authority.OwnershipUnproven) + { + var unknownProof = new OwnedCleanupProof(false, false, + authority.StandardOutput?.IsCompletedSuccessfully ?? true, + authority.StandardError?.IsCompletedSuccessfully ?? true, false); + foreach (Task? reader in new[] { authority.StandardOutput, authority.StandardError }) + if (reader?.IsFaulted == true) failures.Add(reader.Exception!); + var unknown = new AggregateException("OWNED_CLEANUP_UNPROVEN: live descendant retention signal; termination and close withheld.", failures); + Exception reported = primary ?? unknown; + reported.Data["OwnedCleanupProof"] = unknownProof; + reported.Data["OwnedCleanupFailure"] = unknown; + authority.Retain(reported); + if (primary == null) throw unknown; + return unknownProof; + } bool rootExited = false, jobEmpty = false; bool outEof = authority.StandardOutput == null, errEof = authority.StandardError == null; - try { authority.Terminate(); } + try + { + authority.Terminate(); + } catch (Exception failure) { failures.Add(failure); } try { @@ -55,7 +74,7 @@ public static async Task CompleteAsync(IOwnedCleanupAuthority catch (Exception failure) { failures.Add(new IOException(stream.Name + " EOF unproved.", failure)); } } bool closed = false; - if (new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, false).CanRelease) + if (!authority.OwnershipUnproven && new OwnedCleanupProof(rootExited, jobEmpty, outEof, errEof, false).CanRelease) { closed = true; foreach (Action close in authority.CloseActions) diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md index ad11eb95..f4d03f60 100644 --- a/Tools/windows/ValidationRunner/Ownership/README.md +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -23,6 +23,13 @@ without prematurely flushing an encoder containing half a surrogate pair. The host must await `HoldRetainedResourcesAsync()` in its outermost `finally`. Retained references alone do not suffice: process exit closes a kill-on-close job even if the garbage collector never finalized anything. +Both independent readers watch the exact retention sentinel incrementally, +including split chunks, CRLF and terminal EOF. A live signal immediately roots +the original lease and cancels only the controller's wait, never the readers. +The cleanup coordinator refuses termination, proof waits and close on that +latch; native kill/close boundaries also recheck it under the same lock. +The host can therefore enter its terminal retention hold without waiting for +the workload timeout to kill the very resources it is required to preserve. `OwnedFixture.TryRunAsync(args)` must run before ordinary host argument parsing. It recognizes only `--ownership-fixture`. The compiled fixture tests native @@ -38,7 +45,7 @@ and root-runner SHA-256 `202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869`. The old runner is never executed, embedded or extracted for execution. -The 68 managed and seven controlled-native cases are individually registered +The 74 managed and seven controlled-native cases are individually registered with exact-count manifest guards. The typed peer implements the complete request/response/publication/control core from `Stub-Daemon.ps1:29-294`; it is the implementation used by the compiled real-pipe fixture, not a passing fake. @@ -52,11 +59,19 @@ transport is the host's authoritative held-job supervisor. Scope, ScriptMethod, ErrorRecord identity, information streams and missing-versus-empty environment membership remain PowerShell assertions. -The full mixed family, three mixed source-boundary assertions, two production -linkage obligations and eight newly reconciled startup/tree root obligations -remain separately mandatory. Their `Incomplete` results cannot be replaced by -the compiled helper tests. `source-correspondence.json` records every one of +The full mixed family, two production linkage obligations and two actual +gate-owned controlled-tree obligations remain separately mandatory and +`Incomplete`; they cannot be replaced by compiled helper tests. +`source-correspondence.json` records every one of the immutable mixed source's 126 invocation templates and 192 assertion commands, with stable source IDs and zero claimed execution. Those source occurrences are not runtime counts. The mixed eight-mode capture/cleanup negative controls remain required. + +The three mixed source-boundary assertions now have actual protected +PowerShell source checks. Six newly reconciled startup/tree units also run: +thirteen complete helper-presence checks (source evidence), three bounded file +diagnostic cases, one retained report case, and the advisory PID-reuse +negative control. These eighteen checks are not a native product startup +walkthrough. The two actual gate-owned controlled-tree obligations remain +mandatory until their linked-production integration runs. diff --git a/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs b/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs new file mode 100644 index 00000000..ce01283f --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs @@ -0,0 +1,42 @@ +using System; +using System.Text; + +namespace GraphCode.Validation.Ownership; + +public interface IOwnedStreamObserver +{ + void OnText(string text); + void OnEof(); +} + +public sealed class RetentionSignalObserver(Action observed) : IOwnedStreamObserver +{ + private readonly StringBuilder line = new(); + private bool overlong, reported; + private const string Signal = "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven"; + + public void OnText(string text) + { + foreach (char character in text) + { + if (character == '\n') { FinishLine(); continue; } + if (overlong) continue; + if (line.Length > Signal.Length) { overlong = true; line.Clear(); continue; } + line.Append(character); + } + } + + public void OnEof() => FinishLine(); + + private void FinishLine() + { + if (!overlong) + { + string value = line.ToString(); + if (value.EndsWith('\r')) value = value[..^1]; + if (!reported && value == Signal) { reported = true; observed(); } + } + line.Clear(); + overlong = false; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs index 115d4a29..573feea4 100644 --- a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -26,7 +26,12 @@ public static IReadOnlyList CreateSuites() => new Adapters.StandaloneSuites.CaseScriptSuite("ownership.pre-uia", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\PreUiaIsolation.Contracts.ps1", "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.pre-uia-isolation"]), new Adapters.StandaloneSuites.CaseScriptSuite("ownership.retention", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\RetentionBoundary.Contracts.ps1", - "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.retention-boundary"]) + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.retention-boundary"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.startup", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\StartupDiagnostics.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.ui-startup.helpers", "ownership.ui-startup.redacted-log", "ownership.ui-startup.truncated-log", + "ownership.ui-startup.missing-log", "ownership.ui-startup.retained-report", "ownership.ui-process-tree.pid-reuse"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-source", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedSource.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.peer-emission", "ownership.legacy.rename-submit", "ownership.legacy.fresh-capture"]) ]; } @@ -44,6 +49,7 @@ public sealed class OwnershipSuite : IValidationSuite "proof-success", "proof-root-survivor", "proof-job-survivor", "proof-stdout-fault", "proof-stderr-fault", "cleanup-success", "cleanup-root-survivor", "cleanup-job-survivor", "cleanup-stdout-fault", "cleanup-both-stream-faults", "cleanup-primary-error", "cleanup-only-error", "cleanup-original-deadline", "cleanup-later-actions", + "cleanup-descendant-unknown", "signal-fragmented", "signal-crlf", "signal-eof", "signal-exact-only", "signal-overlong", "json-malformed", "json-duplicate", "json-nested-duplicate", "json-array", "json-case-sensitive", "frame-fragmented-unicode", "frame-clean-eof", "frame-short-header", "frame-short-body", "frame-zero", "frame-over-limit", "frame-invalid-utf8", "peer-positive-rename", "peer-resume-unchanged", "peer-foreign-owner", "peer-foreign-node", @@ -57,19 +63,10 @@ public sealed class OwnershipSuite : IValidationSuite private static readonly (string Id, string Evidence)[] unported = [ ("legacy.mixed-multiproject", "Complete mixed source 1672-4245 remains mandatory: UI predicates, native/fresh snapshot policies, receipts, strict protocol, owned real pipes, eight bounded-capture and primary/cleanup negative controls. Source invocation inventories are not executed counts."), - ("legacy.peer-emission", "Assert-MultiProjectPeerEmissionContract source 4247-4265 requires actual PS AST source correspondence."), - ("legacy.rename-submit", "Assert-MultiProjectRenameSubmitContract source 4266-4289 requires actual caller/production Zig route correspondence."), - ("legacy.fresh-capture", "Assert-MultiProjectFreshCaptureContract source 4290-4315 requires actual ownership/cache/observer correspondence."), ("legacy.packaging-shared-linkage", "Packaging.Standalone.Tests.ps1 must load this complete StandaloneProcessJob implementation, not its embedded duplicate."), ("legacy.uia-shared-linkage", "uia-live-gate.ps1 must load these complete UiaOwnedStreamCapture/StandaloneProcessJob implementations, including actual native membership."), - ("ui-startup.helpers", "Source 5838 requires thirteen complete actual production startup helpers; helper presence is not runtime behavior."), - ("ui-startup.redacted-log", "Source 5845: bounded actual startup file diagnostic must preserve loader output and redact the canary."), - ("ui-startup.truncated-log", "Source 5853: actual startup diagnostic must read exactly eight bytes and mark truncation."), - ("ui-startup.missing-log", "Source 5859: actual startup diagnostic must distinguish missing from empty output."), - ("ui-startup.retained-report", "Source 5882: actual retained startup JSON must preserve independent stdout/stderr/app logs and redact canaries."), ("ui-process-tree.controlled-child", "Source 5945: actual gate registered held process/job must expose its controlled descendant."), - ("ui-process-tree.cleanup-complete", "Source 5947: actual gate Stop-UiaOwnedProcessTrees must finish retained root/job/readers before cleanup succeeds."), - ("ui-process-tree.pid-reuse", "Source 5970: actual advisory descendant traversal must reject older generations and never supply termination authority.") + ("ui-process-tree.cleanup-complete", "Source 5947: actual gate Stop-UiaOwnedProcessTrees must finish retained root/job/readers before cleanup succeeds.") ]; public string Id => "ownership"; @@ -258,6 +255,7 @@ private static async Task ManagedAsync(string id, string root, string alpha, str CloseFails = id is "cleanup-only-error" or "cleanup-later-actions", ConsumeDeadline = id == "cleanup-original-deadline" }; + authority.OwnershipUnproven = id == "cleanup-descendant-unknown"; if (id is "cleanup-stdout-fault" or "cleanup-both-stream-faults") authority.StandardOutput = Task.FromException(new IOException("original stdout")); if (id == "cleanup-both-stream-faults") @@ -284,7 +282,8 @@ private static async Task ManagedAsync(string id, string root, string alpha, str Require(failure.InnerExceptions.Count == 2 && failure.InnerExceptions.All(e => e.InnerException is IOException), "A failed stdout hid the independent stderr failure."); } - Require(authority.Terminations == 1 && authority.JobWaits == 1 && authority.RootWaits == 1 && + int expectedProofCalls = id == "cleanup-descendant-unknown" ? 0 : 1; + Require(authority.Terminations == expectedProofCalls && authority.JobWaits == expectedProofCalls && authority.RootWaits == expectedProofCalls && ReferenceEquals(authority.JobDeadline, authority.RootDeadline), "Cleanup skipped a proof or refreshed its deadline."); bool closable = id is "cleanup-success" or "cleanup-only-error" or "cleanup-later-actions"; Require(authority.Closes == (closable ? 2 : 0), "UNKNOWN allowed close or a close error skipped a later action."); @@ -294,6 +293,26 @@ private static async Task ManagedAsync(string id, string root, string alpha, str Require(authority.RootRemainingMilliseconds < 50, "Root wait received a refreshed deadline after job wait."); return; } + if (id.StartsWith("signal-", StringComparison.Ordinal)) + { + int observations = 0; + var observer = new RetentionSignalObserver(() => observations++); + string marker = OwnershipRetention.Sentinel; + switch (id) + { + case "signal-fragmented": + foreach (char character in marker + "\n") observer.OnText(character.ToString()); + observer.OnText(marker + "\n"); + break; + case "signal-crlf": observer.OnText(marker + "\r\n"); break; + case "signal-eof": observer.OnText(marker); observer.OnEof(); break; + case "signal-exact-only": observer.OnText("prefix " + marker + "\n" + marker + " suffix\n"); break; + case "signal-overlong": observer.OnText(new string('x', 100000) + marker + "\n"); break; + } + Require(observations == (id is "signal-exact-only" or "signal-overlong" ? 0 : 1), + "Live UNKNOWN observer missed split/EOF signal or treated substring/overlong output as authority."); + return; + } if (id.StartsWith("json-", StringComparison.Ordinal)) { string text = id switch @@ -580,6 +599,7 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken private sealed class FakeCleanupAuthority : IOwnedCleanupAuthority { public string Identity => "controlled-fake-held-authority; nativeCalls=0"; + public bool OwnershipUnproven { get; set; } public Task? StandardOutput { get; set; } = Task.FromResult("stdout"); public Task? StandardError { get; set; } = Task.FromResult("stderr"); public bool RootExited = true, JobEmpty = true, CloseFails, ConsumeDeadline, Retained; diff --git a/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs b/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs index bb6b3690..0c28adb3 100644 --- a/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs +++ b/Tools/windows/ValidationRunner/Ownership/UiaOwnedStreamCapture.cs @@ -13,14 +13,16 @@ public static void RequireJobMember(IntPtr process, IntPtr job) throw new InvalidOperationException("Published UIA target is not a member of its owned launch job."); } - public static Task Read(StreamReader reader, string path) + public static Task Read(StreamReader reader, string path) => Read(reader, path, null); + + public static Task Read(StreamReader reader, string path, IOwnedStreamObserver? observer) { ArgumentNullException.ThrowIfNull(reader); var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite); - return Capture(reader, file); + return Capture(reader, file, observer); } - private static async Task Capture(StreamReader reader, FileStream file) + private static async Task Capture(StreamReader reader, FileStream file, IOwnedStreamObserver? observer) { // AutoFlush preserves encoder state across a split surrogate; FlushAsync does not. using var writer = new StreamWriter(file, new UTF8Encoding(false, true)) { AutoFlush = true }; @@ -31,7 +33,9 @@ private static async Task Capture(StreamReader reader, FileStream file) { text.Append(buffer, 0, count); await writer.WriteAsync(buffer, 0, count).ConfigureAwait(false); + observer?.OnText(new string(buffer, 0, count)); } + observer?.OnEof(); return text.ToString(); } } From f3133952b545412b7672fdecb8892fd9ac477496 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:06:40 -0700 Subject: [PATCH 16/46] Complete production-backed UI derived contracts and full case correspondence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Ui/UiAdapterCases.cs | 33 +- .../Ui/UiDerivedContracts.ps1 | 320 +++++++++++ ...son => derived-source-correspondence.json} | 495 ++++++++++++------ .../ValidationRunner/Ui/source-case-map.json | 80 ++- 4 files changed, 732 insertions(+), 196 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 rename Tools/windows/ValidationRunner/Ui/{pending-source-assertions.json => derived-source-correspondence.json} (52%) diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs index c3b74172..ae8804ec 100644 --- a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -203,11 +203,42 @@ internal static class UiAdapterCases "ui.root.source.line-7337", "ui.root.source.line-7344" }; + internal static readonly string[] DerivedIds = + { + "ui.host-info.diagnostics", + "ui.root.source.line-5501", + "ui.root.source.line-5514", + "ui.root.source.line-5524", + "ui.root.source.line-5537", + "ui.root.source.line-5897", + "ui.root.source.line-5905", + "ui.root.source.line-5914", + "ui.root.source.line-5918", + "ui.root.source.line-6057", + "ui.root.source.line-6093", + "ui.root.source.line-6157", + "ui.root.source.line-6182", + "ui.root.source.line-6276", + "ui.root.source.line-6292", + "ui.root.source.line-6298", + "ui.root.source.line-6305", + "ui.root.source.line-6314", + "ui.root.source.line-6326", + "ui.root.source.line-6352", + "ui.root.source.line-6412", + "ui.root.source.line-6467", + "ui.root.source.line-6591", + "ui.root.source.line-6654", + "ui.root.source.line-6754", + "ui.root.source.line-6770", + "ui.root.source.line-7027" + }; internal static IReadOnlyList CreateSuites() => new IValidationSuite[] { new StandaloneSuites.CaseScriptSuite("ui.powershell-contracts", @"Tools\windows\ValidationRunner\Ui\UiPowerShellContracts.ps1", "UI_CASE_RESULT=", PowerShellIds), new StandaloneSuites.CaseScriptSuite("ui.delete-contracts", @"Tools\windows\ValidationRunner\Ui\UiDeleteContracts.ps1", "UI_CASE_RESULT=", DeleteIds), new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds), - new StandaloneSuites.CaseScriptSuite("ui.functional-contracts", @"Tools\windows\ValidationRunner\Ui\UiFunctionalContracts.ps1", "UI_CASE_RESULT=", FunctionalIds) + new StandaloneSuites.CaseScriptSuite("ui.functional-contracts", @"Tools\windows\ValidationRunner\Ui\UiFunctionalContracts.ps1", "UI_CASE_RESULT=", FunctionalIds), + new StandaloneSuites.CaseScriptSuite("ui.derived-contracts", @"Tools\windows\ValidationRunner\Ui\UiDerivedContracts.ps1", "UI_CASE_RESULT=", DerivedIds) }; } diff --git a/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 new file mode 100644 index 00000000..4305c677 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 @@ -0,0 +1,320 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$bindings = & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) +$nativeSource = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs')) +$activationSource = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\UiaWindowActivation.cs')) +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) +if ($errors.Count) { throw 'Actual UIA gate does not parse' } +$results = [Collections.Generic.List[object]]::new() +function Assert-Derived([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Invoke-Derived([string] $Id, [scriptblock] $Assertion) { + $failure = $null + try { & $Assertion } catch { $failure = $_ } + $results.Add([ordered]@{ + id = $Id; status = $(if ($failure) { 'Failed' } else { 'Passed' }); executedCount = 1 + evidence = $(if ($failure) { $failure.ToString() } else { 'Exact current production PS/source/compiled binding derived contract' }) + }) +} +function Read-Function([string] $Name) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + Assert-Derived ($definitions.Count -eq 1) "Expected one actual production helper: $Name" + return $definitions[0].Extent.Text +} +function Get-Region([string] $Text, [string] $Start, [string] $End) { + $begin = $Text.IndexOf($Start, [StringComparison]::Ordinal) + $finish = if ($begin -ge 0) { $Text.IndexOf($End, $begin, [StringComparison]::Ordinal) } else { -1 } + Assert-Derived ($begin -ge 0 -and $finish -gt $begin) "Production region boundary missing: $Start" + return $Text.Substring($begin, $finish - $begin) +} +function Confirm-Order([string] $Text, [string[]] $Needles) { + $offset = 0 + foreach ($needle in $Needles) { + $next = $Text.IndexOf($needle, $offset, [StringComparison]::Ordinal) + if ($next -lt 0) { return $false } + $offset = $next + $needle.Length + } + return $true +} +function Assert-ContainsAll([string] $Text, [string[]] $Needles) { + Assert-Derived ($Needles.Count -gt 0) 'Source requirement list is empty' + foreach ($needle in $Needles) { Assert-Derived ($Text.Contains($needle)) "Production contract missing literal: $needle" } +} +$caption = $null +Invoke-Derived 'ui.root.source.line-5501' { + $script:caption = Get-Region $source '$laneProjectId = Get-MultiProjectAutomationId "overview-project" $fixtureProjectPath' ` + '$surfaceActionPatterns["quick-chats-destination"].Invoke()' +} +Invoke-Derived 'ui.root.source.line-5514' { + Assert-Derived ($null -ne $caption) 'Caption region unavailable' + Assert-ContainsAll $caption @('$captions = @($children | Where-Object', '$_.Current.AutomationId -ceq $laneProjectId', + 'lane caption snapshot must expose exactly one project caption', 'lane caption snapshot must not expose an uninspected worktree chip', + 'CaptionBounds = $caption', 'OpenX = [int][Math]::Round(($caption.Left + $caption.Right) / 2)', 'UIA_LANE_NO_WORKTREE_CHIP_EVIDENCE=') +} +Invoke-Derived 'ui.root.source.line-5524' { + Assert-Derived ($null -ne $caption) 'Caption region unavailable' + foreach ($obsolete in @('Worktrees not inspected', '$laneOpenNoticeOffset', '$laneWorktreesNoticeOffset', + '$laneActionsRightNoticeOffset', 'overview lane Worktrees click')) { + Assert-Derived (-not $caption.Contains($obsolete)) "Obsolete caption contract retained: $obsolete" + } +} +Invoke-Derived 'ui.root.source.line-5537' { + $diagnostic = $source.LastIndexOf('Get-UiaPrelaunchDiagnostics', [StringComparison]::Ordinal) + $launch = $source.IndexOf('$process = Start-UiaOwnedProcess $Shell', [StringComparison]::Ordinal) + Assert-Derived ($diagnostic -ge 0 -and $launch -ge 0 -and $diagnostic -lt $launch) 'Prelaunch diagnostics occur after shell launch' + foreach ($pattern in @('GetCurrentWindowStationName|WindowStation', 'GetCurrentDesktopName|DesktopName', 'desktopHeap', 'sessionId')) { + Assert-Derived ($source -match $pattern) 'Prelaunch diagnostics omit host evidence' + } +} +Invoke-Derived 'ui.root.source.line-5897' { + Assert-Derived ($null -ne $bindings.GetType('GraphCodeUiaHostInfo', $false)) 'Production host-info type missing' +} +Invoke-Derived 'ui.root.source.line-5905' { + $hostInfo = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaHostInfo.cs')) + Assert-Derived ($hostInfo.Contains('public static class GraphCodeUiaHostInfo') -and + $source.Contains('"GraphCodeUiaHostInfo"') -and $source.Contains('Import-CompiledValidationAssembly.ps1')) 'Ordinary host-info source is not required by production consumer' +} +Invoke-Derived 'ui.root.source.line-5914' { + Assert-Derived ([GraphCodeUiaHostInfo]::CurrentSessionId() -is [uint32]) 'Current Windows session type is not UInt32' +} +foreach ($name in @('Protect-UiaStartupDiagnosticText', 'Get-UiaStartupDiagnosticValue', 'Get-UiaPrelaunchDiagnostics')) { + . ([scriptblock]::Create((Read-Function $name))) +} +$hostContext = Get-UiaPrelaunchDiagnostics +foreach ($id in @('ui.root.source.line-5918', 'ui.host-info.diagnostics')) { + Invoke-Derived $id { + Assert-Derived ($hostContext.sessionId.state -eq 'available' -and $hostContext.currentProcessId -eq $PID -and + $hostContext.desktopHeap.state -eq 'usage_unavailable') 'Host diagnostics lost explicit session or heap state' + } +} +function Confirm-Ingress([string] $Gate, [string] $Native) { + $method = [regex]::Match($Native, '(?s)public static bool SendCommand\(IntPtr window, uint command\)\s*\{(?.*?)\n\s*\}') + $body = [regex]::Replace($method.Groups['body'].Value, '\s+', ' ').Trim() + $expected = 'if (window == IntPtr.Zero) return false; UIntPtr result; return SendMessageTimeout( window, 0x0111, (UIntPtr)command, IntPtr.Zero, 0x0001 | 0x0002, 5000, out result ) != IntPtr.Zero;' + if (-not $method.Success -or $body -cne $expected) { return $false } + try { $region = Get-Region $Gate 'foreach ($ingress in @(' 'foreach ($nativeForm in @(' } catch { return $false } + return (Confirm-Order $region @('action=submit-invalid phase=before', '[GraphCodeUiaGateState]::SendCommand(', 'action=submit-invalid phase=after')) -and + (Confirm-Order $region @('action=validation-enumeration phase=before', '$validationContent = @(', '.FindAll(', 'action=validation-enumeration phase=after')) -and + (Confirm-Order $region @('[GraphCodeUiaGateState]::PostClose(', 'action=close-wait phase=before', 'Wait-UiaOwnedSheetGoneByContentClass', 'action=close-wait phase=after')) +} +Invoke-Derived 'ui.root.source.line-6057' { + Assert-Derived (Confirm-Ingress $source $nativeSource) 'Actual ingress source lost bounded attribution' + Assert-Derived (-not (Confirm-Ingress $source ($nativeSource.Replace('return SendMessageTimeout(', 'if (false) return SendMessageTimeout(')))) 'Dead bounded call mutant accepted' + Assert-Derived (-not (Confirm-Ingress ($source.Replace('action=submit-invalid phase=after', 'action=submit-invalid phase=before')) $nativeSource)) 'Reordered ingress marker mutant accepted' +} +function Confirm-SheetRetry([string] $Gate) { + try { $body = Get-Region $Gate 'function Wait-UiaOwnedSheetByContentClass(' 'function Wait-UiaOwnedSheetGoneByContentClass(' } catch { return $false } + return [regex]::Matches($body, '(?s)if \(Test-MultiProjectOwnedSheetIdentity \$identity \$processId \$owner\s*`\s*[\r\n]+\s*\$expectedHostTitle \$contentClass\) \{\s*return \$element\s*\}').Count -eq 1 -and + [regex]::Matches($body, 'return \$element').Count -eq 1 -and $body -match 'Get-UiaOwnedSheetIdentity \$element' -and + $body -match 'UIA_OWNED_SHEET_RETRY' -and $body -match 'Start-Sleep -Milliseconds 50' -and + $body -match '\$watch\.ElapsedMilliseconds -lt \$timeoutMilliseconds' -and $body -notmatch 'Assert-UiaOwnedSheet \$element' +} +Invoke-Derived 'ui.root.source.line-6093' { + Assert-Derived (Confirm-SheetRetry $source) 'Actual owned-sheet retry proof failed' + $mutant = [regex]::Replace($source, + '(?s)if \(Test-MultiProjectOwnedSheetIdentity \$identity \$processId \$owner\s*`\s*[\r\n]+\s*\$expectedHostTitle \$contentClass\) \{\s*return \$element\s*\}', + 'if ($true) { $null = $element }; return $element', 1) + Assert-Derived (-not (Confirm-SheetRetry $mutant)) 'Unconditional owned-sheet return mutant accepted' +} +function Confirm-DeleteContext([string] $Gate) { + try { $region = Get-Region $Gate '$deleteLoopTitle =' 'Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 21))' } catch { return $false } + $region = [regex]::Replace($region, '\s+', ' ').Trim() + if ($region -match 'SendCommand\(\$shellWindow,\s*5103\)') { return $false } + return Confirm-Order $region @( + '$deleteTarget = Find-FragmentByIdWithRetry $root $renameGraphIdentity $rawWalker', '$deleteTargetSelection.Select()', + 'Require $deleteTargetSelection.Current.IsSelected', 'UIA_DELETE_LOOP_TARGET', + '$deleteLoopPopup = Open-CanvasCardContextMenu $process $root $shellWindow ` $renameGraphIdentity $rawWalker "Delete Loop"', + '$deleteLoopItems = @(Get-PopupMenuItems $deleteLoopPopup)', + '$_.Id -eq 5103 -and $_.Enabled', 'Require ($null -ne $deleteLoopItem)', + '$deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id', 'Require ($null -ne $deleteLoopClick)', + 'UIA_DELETE_LOOP_MENU', '"GraphCodeWindowsDialogContent" $deleteLoopTitle "Delete Loop custom sheet"', + '[IntPtr]$deleteLoopDialog.Current.NativeWindowHandle, 9808', + 'Wait-UiaOwnedSheetGoneByContentClass $process.Id $shellWindow ` "GraphCodeWindowsDialogContent") "Delete Loop custom sheet remained open after Cancel"') +} +Invoke-Derived 'ui.root.source.line-6157' { + $helper = [regex]::Replace((Read-Function 'Open-CanvasCardContextMenu'), '\s+', ' ') + Assert-Derived (Confirm-Order $helper @( + 'Require (Ensure-ShellForeground $ownerWindow', '$card = Find-FragmentByIdWithRetry $root $automationId $walker', + '$card.Current.AutomationId -ceq $automationId', '$card.Current.ProcessId -eq $process.Id -and -not $card.Current.IsOffscreen', + '$canvas = Find-FragmentByIdWithRetry $root "graph" $walker', '$bounds = $card.Current.BoundingRectangle', + '$canvasBounds = $canvas.Current.BoundingRectangle', '[double]::IsNaN($_) -or [double]::IsInfinity($_)', + '$visible = Get-MultiProjectClippedRectangle $cardRect $canvasRect', + '[GraphCodeUiaGateState]::PostOwnedScreenPoint($ownerWindow, $x, $y, $true)', + '$popup = Wait-ForPopupMenu $process $ownerWindow $label', 'Require ($popup -ne [IntPtr]::Zero)', 'return $popup')) ` + 'Actual owned stable-card context helper lost foreground/identity/geometry/popup proof' + Assert-Derived (Confirm-DeleteContext $source) 'Actual Delete context proof failed' + $mutants = @( + @('Open-CanvasCardContextMenu $process $root $shellWindow', '[GraphCodeUiaGateState]::SendCommand($shellWindow, 5103)'), + @('Require ($null -ne $deleteLoopItem)', '$null = ($null -ne $deleteLoopItem)'), + @('$deleteLoopPopup, $shellWindow, [int]$deleteLoopItem.Position, [int]$deleteLoopItem.Id', '$deleteLoopPopup, $shellWindow, 0, 5103'), + @('[IntPtr]$deleteLoopDialog.Current.NativeWindowHandle, 9808', '[IntPtr]$deleteLoopDialog.Current.NativeWindowHandle, 7'), + @('Wait-UiaOwnedSheetGoneByContentClass $process.Id $shellWindow', 'Wait-UiaOwnedSheetGoneDisabled $process.Id $shellWindow') + ) + $checked = 0 + foreach ($mutant in $mutants) { + Assert-Derived ($source.Contains($mutant[0])) 'Delete context mutation anchor missing' + Assert-Derived (-not (Confirm-DeleteContext ($source.Replace($mutant[0], $mutant[1])))) 'Delete context mutant accepted' + $checked++ + } + Assert-Derived ($checked -eq 5) 'Delete context mutation count changed' +} +Invoke-Derived 'ui.root.source.line-6182' { + foreach ($pattern in @('UIA_RENAME_DISPATCH', 'UIA_RENAME_INPUT', 'UIA_RENAME_OUTCOME', 'SetEditTextById', + 'Native Title edit control \(id 9904\)', 'Goal summary edit control \(id 9100\)', 'Update node dialog did not open', + 'Update node cancellation left the dialog open', 'UIA_UPDATE_NODE_SUBMIT_STATE', 'UIA_UPDATE_NODE_DISPATCH', + 'UIA_DELETE_LOOP_SHEET_EVIDENCE=', 'cancelId = 9808', 'deleteId = 9800')) { + Assert-Derived ($source -match $pattern) 'Rename/Delete/Edit Details source proof missing' + } + Assert-Derived ($source.Contains("Delete $([char]0x201c)UIA loop A$([char]0x201d)?") -and + $source.Contains("Its terminal session is ended and every edge touching it is removed. This can't be undone.")) 'Exact Delete copy missing' +} +foreach ($group in @( + @{ line = 6276; required = @('Test-MultiProjectRenameReceipt', 'Get-MultiProjectClippedRectangle', 'ClickOwnedScreenRectangle', + 'PostOwnedScreenPoint', '[int] $maximumAttempts = 100', 'postedFallback = $postedFallback', 'rightClickFallback = $rightClickFallback', + 'SetTextMessage', '$api.PSObject.Methods["SetText"]', 'messageFallbacks', 'LastFocusPostedFallback', 'LastFocusControlFallback', + 'renameButtonFallback', 'Wait-MultiProjectPeerSettled', 'Sketch-Field 9904 "rename stable immediately before submit"', + 'Invoke-MultiProjectTypeText 9904 $typedTitle $inputEvidence', 'source = "live-uia"', 'beforeSelection', 'afterSelection', + 'Windows unchanged-title rename was incorrectly treated as a no-op', 'observedOwnerCount', 'observedCardCount', + 'projectBounds', 'sidebarBounds', 'source-derived, not a UIA caption') }, + @{ line = 6292; required = @('Test-MultiProjectObservedRoster $surface $fragmentProjection $owners $selectedOwner $multiProcess.Id', + 'totalObservedCardCount', 'totalObservedProjectRowCount', 'expectedMatchedOwners', 'unexpectedCardIds', 'unexpectedProjectIds', + 'foreignProjectFragmentCount', 'static Graph destination is not an ordinary project row') }, + @{ line = 6298; required = @('Get-MultiProjectExpectedCanvasRoster', 'Get-MultiProjectCaptionGeometry', 'expectedNodeCardCount', + 'expectedProjectCaptionCount', 'projectCaptionCount', 'canvasFragmentClasses', 'overview-project', 'project-caption', + 'workspace navigation was not requested', + 'Get-MultiProjectCaptionGeometry $overview.canvasBounds $row.card.bounds (@($overview.canvasFragmentClasses | Where-Object { $_.classification -ceq "project-caption" -and $_.projectPath -ceq $owner.path })[0].bounds)') }, + @{ line = 6305; required = @('$pixelWidth = $card[2] - $card[0]', '$pixelHeight = $card[3] - $card[1]', + '$scaleSource = if ([Math]::Abs($pixelHeight - 96) -le 1)', '$pixelWidth / 250', '$normalizedHeight = $pixelHeight / $scale', + '$heightTolerance = if ($scaleSource -ceq "card-width-rounded" -and $scale -lt 1)', '[Math]::Max(1, $pixelWidth - 1) + 0.01', + 'scaleSource = $scaleSource', 'normalizedHeight = $normalizedHeight', 'heightTolerance = $heightTolerance') } +)) { + Invoke-Derived "ui.root.source.line-$($group.line)" { + foreach ($required in $group.required) { + if ($required -ceq 'SetTextMessage') { + Assert-Derived ($bindings.GetType('GraphCodeMultiProjectEditNative', $false) -ne $null -and + $source.Contains('"GraphCodeMultiProjectEditNative"')) 'Production consumer does not require the compiled edit component' + Assert-ContainsAll ([IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Host\Production\GraphCodeMultiProjectEditNative.cs'))) @($required) + } else { Assert-ContainsAll $source @($required) } + } + } +} +Invoke-Derived 'ui.root.source.line-6314' { + $body = Read-Function 'Get-MultiProjectObservation' + Assert-Derived (-not $body.Contains('.StartsWith(') -and -not $body.Contains('$projectSelected -eq $selected')) 'Observer accepts prefixes or obsolete selection' + Assert-ContainsAll $body @('Test-MultiProjectMetadataBatch', 'unresolvedMetadataCount', + 'Test-MultiProjectOwnerSelection $surface $owner $selectedOwner $projectSelected $loopSelected') + Assert-ContainsAll $source @('UIA_MULTIPROJECT_METADATA_REACQUIRE=') +} +Invoke-Derived 'ui.root.source.line-6326' { + Assert-ContainsAll $source @('function Complete-MultiProjectCapture', 'function Wait-MultiProjectCapture', + 'function Initialize-MultiProjectProcessJob', 'StandaloneProcessJob', 'rootStartUtcTicks', 'targetStartUtcTicks', + 'if (-not $capture.rootProcess.WaitForExit($remaining))', 'MULTIPROJECT_CAPTURE_DRAIN', 'MULTIPROJECT_CAPTURE_TEARDOWN=', + 'MultiProjectTeardown:', 'WaitAll', 'ownedJobEmpty', '$null = Complete-MultiProjectCapture $child $logDirectory 5000 $primaryError') + $streamAssembly = & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCode.Validation.Ownership.UiaOwnedStreamCapture' + Assert-Derived ([object]::ReferenceEquals($bindings, $streamAssembly)) 'Capture uses a foreign stream component' + Assert-ContainsAll ([IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ownership\UiaOwnedStreamCapture.cs'))) @('while ((count = await reader.ReadAsync', 'return text.ToString();') +} +Invoke-Derived 'ui.root.source.line-6352' { + Assert-ContainsAll $activationSource @('public interface IUiaWindowActivationOperations', + 'operations.ShowWindow(window, 9);', 'operations.IsForegroundWindow(window)', + 'operations.WindowProcessId(window) == expectedProcessId', 'operations.IsRetainedProcessAlive(expectedProcessId)', + 'broughtToTop.Succeeded ? 0 : broughtToTop.Error', 'foregroundSet.Succeeded ? 0 : foregroundSet.Error', + 'no synthetic keys or input-queue attachment') + Assert-Derived ($activationSource -notmatch '\b(DllImport|LibraryImport|SendInput|keybd_event|AttachThreadInput|Marshal)\b' -and + [regex]::Matches($nativeSource, 'int error = Marshal.GetLastWin32Error\(\);').Count -eq 2) 'Activation native errors or synthetic-input boundary changed' +} +function Confirm-Settings([string] $Gate) { + try { + $body = Get-Region $Gate '$backendFocus = Retain-FocusWithRetry' 'Require ([GraphCodeUiaGateState]::PostCommand($settingsShellWindow, 0x5002))' + } catch { return $false } + return Confirm-Order ([regex]::Replace($body, '\s+', ' ').Trim()) @( + '$backendFocus = Retain-FocusWithRetry', 'Require ($null -ne $backendFocus.Focused)', + 'Require ([GraphCodeUiaGateState]::IsForegroundWindow($settingsWindow))', + 'Product Settings window lost foreground ownership before accepting backend focus', + 'PostKeyboard( [IntPtr]$backendButton.Current.NativeWindowHandle, 0x0D', 'Require ($remainingSettings -eq $settingsHandle)', + '$savedSettings.defaultBackend -eq "copilotCLI"', '$savedSettings.gateSentinel -eq "preserve"', + '$savedSettingsBytes = [IO.File]::ReadAllBytes($settingsPath)', 'PostKeyboard( [IntPtr]$backendButton.Current.NativeWindowHandle, 0x1B', + 'Wait-UiaOwnedSheetGoneByContentClass $settingsProcess.Id $settingsShellWindow', '"GraphCodeProductSettings"', + '[Convert]::ToBase64String($savedSettingsBytes)', 'Escape changed already-persisted Product Settings') +} +Invoke-Derived 'ui.root.source.line-6412' { + Assert-Derived (Confirm-Settings $source) 'Actual immediate settings contract failed' + Assert-Derived (-not (Confirm-Settings ($source.Replace('Require ([GraphCodeUiaGateState]::IsForegroundWindow($settingsWindow))', + '$null = [GraphCodeUiaGateState]::IsForegroundWindow($settingsWindow)')))) 'Missing foreground mutant accepted' + Assert-Derived (-not (Confirm-Settings ($source.Replace('Require ($remainingSettings -eq $settingsHandle)', '$null = ($remainingSettings -eq $settingsHandle)')))) 'Closing Return mutant accepted' +} +Invoke-Derived 'ui.root.source.line-6467' { + $app = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'graphcode-windows\src\App.zig')) + foreach ($pattern in @('fn showUiaContextMenu', 'MainWindow\.wm_uia_context_menu => \{', '(?s)wparam == MainWindow\.menu_watchdog_timer_id.*?c\.EndMenu\(\)')) { + Assert-Derived ($app -match $pattern) 'Actual App context menu/watchdog contract missing' + } +} +Invoke-Derived 'ui.root.source.line-6591' { + $history = @( + [pscustomobject]@{ requestID = 'create-request'; command = '{"projectPath":"graphcode://stub/project","command":{"createNode":{"_0":{"id":"11111111-1111-4111-8111-111111111111","title":"UIA created timed loop","loopType":"timeBased","triggerPrompt":"Summarize new commits","heartbeatIntervalSeconds":3600,"backend":"copilotCLI","modelTier":"capable"}}}}' }, + [pscustomobject]@{ requestID = 'outcome-request'; command = '{"projectPath":"graphcode://stub/project","command":{"openNode":{"_0":"11111111-1111-4111-8111-111111111111"}}}' } + ) + $retained = @($history | Where-Object requestID -CEQ 'create-request') + $command = $retained[0].command | ConvertFrom-Json + Assert-Derived ($retained.Count -eq 1 -and [string]$command.projectPath -ceq 'graphcode://stub/project' -and + [string]$command.command.createNode._0.triggerPrompt -ceq 'Summarize new commits' -and + [double]$command.command.createNode._0.heartbeatIntervalSeconds -eq 3600) 'Retained request history selected wrong command' +} +Invoke-Derived 'ui.root.source.line-6654' { + $needles = @( + '$custodyMenuClick = Sketch-ClickMenu $custodyParent 5119', '$custodyCancel = Sketch-Cancel "New Child"', + '$custodyParent = Open-SketchNodeMenu $renameFinalTitle', '$custodyTurnTile = Assert-SketchControl 9600', + 'Sketch-Type 9104 $custodyFirstInstruction', '$null = Sketch-Combo 9112 1 "Claude Code"', + '$custodyInstructionAfterBackend = Sketch-Field 9104', + '(Edge-GraphCount $custodyAfter) -eq (Edge-GraphCount $custodyBefore) + 2', + '$custodyNewCommands = @($custodyAfter.receivedGraphCommands |', '$custodyExpectedOutcome = [ordered]@{', + '$custodyOutcomes = @($custodyNewCommands | Where-Object {', '[string]$custodyOutcomes[0].requestID -cne $custodyRequest -and', + '@($custodyAfter.unansweredRequests) -notcontains [string]$custodyOutcomes[0].requestID -and', + '$custodyRenderWait = Wait-SketchGraphCard', '$custodyWorkspaceId = Get-MultiProjectAutomationId "workspace-loop-bar" $custodyId', + '$custodyWorkspace = Wait-ForGraphChildren $renameRoot $rawWalker', + '$custodyShowGraph.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()', + '$custodyGraphReturn = Wait-ForGraphChildren $renameRoot $rawWalker', '$custodyRendered = Open-SketchNodeMenu "UIA custody child"') + Assert-Derived (Confirm-Order $source $needles) 'Custody reopen/type/backend/unique request/render order changed' + Assert-Derived ([regex]::Matches($source, [regex]::Escape('$custodyTurnTile = Assert-SketchControl 9600')).Count -eq 1) 'Custody Turn tile selected more than once' +} +Invoke-Derived 'ui.root.source.line-6754' { + Assert-Derived ($nativeSource.Contains('public static class GraphCodeUiaGateState') -and + $bindings.GetType('GraphCodeUiaGateState', $false)) 'Complete ordinary production gate helper missing' +} +Invoke-Derived 'ui.root.source.line-6770' { + $type = $bindings.GetType('GraphCodeUiaGateState', $true) + $calls = @([regex]::Matches($source, '\[GraphCodeUiaGateState\]::(?[\w]+)') | + ForEach-Object { $_.Groups['member'].Value } | Sort-Object -Unique) + Assert-Derived ($calls.Count -gt 0) 'No actual production gate member calls found' + foreach ($name in $calls) { + Assert-Derived ($type.GetMember($name, [Reflection.BindingFlags]'Public,Static').Count -gt 0) "Actual production gate calls undefined compiled member: $name" + } +} +Invoke-Derived 'ui.root.source.line-7027' { + Assert-Derived (Confirm-Order $source @('UIA_COMPOSITE_NAVIGATION_EVIDENCE=', + '$connectorActual = Find-FragmentByIdWithRetry $root "actual-size" $rawWalker', + '$connectorActual.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()', + '$root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker)', 'Get-DynamicProjectCardCapture $_', + '$connectorDecision = Get-ConnectorDragSnapshotDecision')) 'Connector evidence does not freeze after normalization/reconnect' + foreach ($pattern in @('\[GraphCodeUiaGateState\]::ClientScreenBounds\(\$shellWindow\)', 'FindVisibleOwnedSheetByContentClass\(', + 'UIA_CONNECTOR_DRAG_EVIDENCE=', 'coordinateSpace = "physical-screen"', 'targetCenterVisible = \$connectorDecision\.targetCenterVisible', + '\[GraphCodeUiaGateState\]::PostMouseDragMoveAt\(', '\$visibleTargetBounds = @\(')) { + Assert-Derived ($source -match $pattern) 'Connector physical-screen/clipped target contract missing' + } +} +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-derived-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ui/pending-source-assertions.json b/Tools/windows/ValidationRunner/Ui/derived-source-correspondence.json similarity index 52% rename from Tools/windows/ValidationRunner/Ui/pending-source-assertions.json rename to Tools/windows/ValidationRunner/Ui/derived-source-correspondence.json index 05e3e471..876cbf8b 100644 --- a/Tools/windows/ValidationRunner/Ui/pending-source-assertions.json +++ b/Tools/windows/ValidationRunner/Ui/derived-source-correspondence.json @@ -3,588 +3,747 @@ "id": "ui.root.source.line-5346", "sourceLines": "5346-5348", "diagnostic": "RED: shell validation does not exercise delayed correlated responses", - "reason": "Not a declared source resource: source", - "owner": "Host" + "owner": "Host", + "state": "Incomplete", + "adapterRationale": "Not a declared source resource: source" }, { "id": "ui.root.source.line-5366", "sourceLines": "5366-5368", "diagnostic": "RED: UIA live gate source does not parse", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5380", "sourceLines": "5380-5382", "diagnostic": "RED: multi-project observer lacks a focused source-supported chrome classifier", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5392", "sourceLines": "5392-5394", "diagnostic": "RED: multi-project observer rejects stable recovery control $identity", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5396", "sourceLines": "5396-5398", "diagnostic": "RED: multi-project observer weakens its census by accepting an unknown element", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5399", "sourceLines": "5399-5401", "diagnostic": "RED: multi-project observer accepts a foreign recovery control", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5407", "sourceLines": "5407-5409", "diagnostic": "RED: multi-project observer lacks a bounded workspace-marker reacquire", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5410", "sourceLines": "5410-5413", "diagnostic": "RED: workspace-marker reacquire replays navigation or input", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5432", "sourceLines": "5432-5435", "diagnostic": "RED: delayed exact workspace markers do not satisfy the bounded reacquire", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5449", "sourceLines": "5449-5451", "diagnostic": "RED: permanent or unknown workspace-marker absence no longer fails closed", - "reason": "Not a declared source resource: permanentFailure", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: permanentFailure", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5457", "sourceLines": "5457-5459", "diagnostic": "RED: multi-project loop activation lacks a recovery-control-safe card target", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5462", "sourceLines": "5462-5464", "diagnostic": "RED: card activation target does not retain the source-sized title strip", - "reason": "Unsupported assertion operator: Join", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5470", "sourceLines": "5470-5472", "diagnostic": "RED: multi-project card activation still lands on an unwired recovery control", - "reason": "Not a declared source resource: overlaps", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: overlaps", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5477", "sourceLines": "5477-5479", "diagnostic": "RED: invalid multi-project card activation bounds no longer fail closed", - "reason": "Not a declared source resource: invalidActivation", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: invalidActivation", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5485", "sourceLines": "5485-5488", "diagnostic": "RED: live multi-project loop navigation still clicks the whole card rectangle", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5501", "sourceLines": "5501-5503", "diagnostic": "RED: lane caption gate does not bind the source-derived overview-project identity", - "reason": "Not a declared source resource: laneCaptionStart", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: laneCaptionStart", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5514", "sourceLines": "5514-5516", "diagnostic": "RED: lane caption gate lacks the no-summary caption/open-project contract: $required", - "reason": "Not a declared source resource: laneCaptionSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: laneCaptionSource", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5524", "sourceLines": "5524-5526", "diagnostic": "RED: lane caption gate retains obsolete summary/button contract: $obsolete", - "reason": "Not a declared source resource: laneCaptionSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: laneCaptionSource", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5537", "sourceLines": "5537-5544", "diagnostic": "RED: UIA prelaunch diagnostics omit process, desktop heap, window station, or session evidence", - "reason": "Not a declared source resource: prelaunchDiagnostic", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: prelaunchDiagnostic", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5553", "sourceLines": "5553-5555", "diagnostic": "RED: UIA live gate no longer parses after startup diagnostic changes", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5561", "sourceLines": "5561-5563", "diagnostic": "RED: UIA live gate is missing its functional sidebar navigation snapshot oracle", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5569", "sourceLines": "5569-5571", "diagnostic": "RED: UIA live gate is missing its bounded sidebar scroll/reacquire proof", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5580", "sourceLines": "5580-5583", "diagnostic": "RED: UIA live gate retains mutable sidebar handles instead of freezing each reacquired capture", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5632", "sourceLines": "5632-5641", "diagnostic": "RED: Sidebar navigation oracle missed destination order, open owners, section names, or contained controls", - "reason": "Unsupported assertion operator: Join", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5659", "sourceLines": "5659-5662", "diagnostic": "RED: Sidebar capture retained mutable UIA handles instead of point-in-time evidence", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5683", "sourceLines": "5683-5687", "diagnostic": "RED: Sidebar bounds proof rejected exact DPI192 identities observed across bounded viewport captures", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5696", "sourceLines": "5696-5698", "diagnostic": "RED: Sidebar bounds proof accepted a missing owned row", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5713", "sourceLines": "5713-5715", "diagnostic": "RED: Sidebar bounds proof accepted a foreign-process row", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5724", "sourceLines": "5724-5726", "diagnostic": "RED: Sidebar bounds proof accepted a duplicate owned row", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5739", "sourceLines": "5739-5741", "diagnostic": "RED: Sidebar bounds proof accepted wrong open-owner order", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5752", "sourceLines": "5752-5754", "diagnostic": "RED: Sidebar navigation oracle did not isolate local owner collapse", - "reason": "Unsupported assertion operator: Join", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5761", "sourceLines": "5761-5763", "diagnostic": "RED: Sidebar navigation oracle did not isolate remote owner collapse", - "reason": "Unsupported assertion operator: Join", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5773", "sourceLines": "5773-5777", "diagnostic": "RED: Sidebar navigation oracle accepted a temporary remote owner after fixture restore", - "reason": "Unsupported assertion operator: Join", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5783", "sourceLines": "5783-5785", "diagnostic": "RED: Sidebar navigation oracle failed to detect a projected unopened recent folder", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5792", "sourceLines": "5792-5794", "diagnostic": "RED: Sidebar navigation oracle accepted a row control extending beyond its owner", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5807", "sourceLines": "5807-5809", "diagnostic": "RED: Sidebar navigation oracle failed to preserve the owner order evidence", - "reason": "Unsupported assertion operator: Join", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: Join", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5812", "sourceLines": "5812-5814", "diagnostic": "RED: Sidebar navigation fixture execution count changed unexpectedly", - "reason": "Not a declared source resource: sidebarFixtureCount", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: sidebarFixtureCount", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-5838", "sourceLines": "5838-5838", "diagnostic": "RED: UIA startup capture helper is missing: $helperName", - "reason": "Not a declared source resource: null", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Not a declared source resource: null" }, { "id": "ui.root.source.line-5845", "sourceLines": "5845-5850", "diagnostic": "RED: UIA startup capture does not retain bounded, redacted child output", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" }, { "id": "ui.root.source.line-5853", "sourceLines": "5853-5856", "diagnostic": "RED: UIA startup capture does not bound oversized diagnostics", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" }, { "id": "ui.root.source.line-5859", "sourceLines": "5859-5861", "diagnostic": "RED: UIA startup capture does not distinguish a missing child log", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" }, { "id": "ui.root.source.line-5882", "sourceLines": "5882-5888", "diagnostic": "RED: retained UIA startup report omits or exposes captured child and app output", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" }, { "id": "ui.root.source.line-5897", "sourceLines": "5897-5899", "diagnostic": "RED: UIA host context native diagnostics type is missing", - "reason": "Not a declared source resource: hostInfoClassAt", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: hostInfoClassAt", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5905", "sourceLines": "5905-5908", "diagnostic": "RED: UIA host context native diagnostics type is missing", - "reason": "Not a declared source resource: hostInfoAddTypeAt", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: hostInfoAddTypeAt", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5914", "sourceLines": "5914-5916", "diagnostic": "RED: UIA host context did not resolve the current Windows session", - "reason": "Unsupported assertion operator: IsNot", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported assertion operator: IsNot", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5918", "sourceLines": "5918-5922", "diagnostic": "RED: UIA host context diagnostics omitted explicit session or desktop-heap status", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-5945", "sourceLines": "5945-5945", "diagnostic": "RED: UIA owned-process traversal missed a controlled child", - "reason": "Not a declared source resource: treeObserved", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Not a declared source resource: treeObserved" }, { "id": "ui.root.source.line-5947", "sourceLines": "5947-5949", "diagnostic": "RED: UIA owned-process teardown returned before the controlled root exited", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst" }, { "id": "ui.root.source.line-5970", "sourceLines": "5970-5972", "diagnostic": "RED: UIA owned-process traversal adopts processes older than a reused parent PID: $($pidReuseAdopted -join ',')", - "reason": "Unsupported assertion operator: Join", - "owner": "Ownership" + "owner": "Ownership", + "state": "Incomplete", + "adapterRationale": "Unsupported assertion operator: Join" }, { "id": "ui.root.source.line-6057", "sourceLines": "6057-6061", "diagnostic": "RED: UIA ingress commands and scene transitions are not bounded and attributable", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6093", "sourceLines": "6093-6096", "diagnostic": "RED: UIA owned-sheet acquisition throws on stale handles instead of retrying exact identity", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6157", "sourceLines": "6157-6164", "diagnostic": "RED: UIA Delete Loop invocation does not re-establish the stable fixture selection after Rename", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6182", "sourceLines": "6182-6198", "diagnostic": "RED: UIA gate does not verify dynamic Rename, custom Delete, or Edit Details sheet behavior", - "reason": "Unsupported source observation", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported source observation", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6276", "sourceLines": "6276-6276", "diagnostic": "RED: multi-project live evidence lacks $required", - "reason": "Not a declared source resource: required", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6292", "sourceLines": "6292-6292", "diagnostic": "RED: unfiltered multi-project observer roster lacks $required", - "reason": "Not a declared source resource: required", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6298", "sourceLines": "6298-6298", "diagnostic": "RED: explicit complete overview taxonomy lacks $required", - "reason": "Not a declared source resource: required", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6305", "sourceLines": "6305-6307", "diagnostic": "RED: multi-project fitted card geometry lacks denominator-derived rounding budget: $required", - "reason": "Not a declared source resource: required", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6314", "sourceLines": "6314-6321", "diagnostic": "RED: new observer prefix/typed metadata/active-surface selection boundary remains unguarded or silently omitted", - "reason": "Not a declared source resource: metadataObserver", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: metadataObserver", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6326", "sourceLines": "6326-6326", "diagnostic": "RED: bounded multi-project capture lacks $required", - "reason": "Not a declared source resource: required", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: required", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6352", "sourceLines": "6352-6354", "diagnostic": "RED: UIA foreground failure hides native return values and last-error diagnostics", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6412", "sourceLines": "6412-6416", "diagnostic": "RED: UIA live gate does not prove immediate Product Settings persistence, foreground focus, Return retention, and Escape close", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6467", "sourceLines": "6467-6471", "diagnostic": "RED: the shell cannot open a gate-requested context menu, or an abandoned popup can block its message loop forever", - "reason": "Not a declared source resource: appSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: appSource", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6591", "sourceLines": "6591-6596", "diagnostic": "RED: retained New Loop request history cannot select the create after a later outcome command", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6654", "sourceLines": "6654-6676", "diagnostic": "RED: custody reopened path must select Turn once, type instruction, change backend and prove unchanged instruction", - "reason": "Not a declared source resource: firstCustodyOpen", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: firstCustodyOpen", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6754", "sourceLines": "6754-6756", "diagnostic": "RED: UIA gate embedded C# type source is missing", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6770", "sourceLines": "6770-6772", "diagnostic": "RED: UIA gate calls undefined embedded C# member(s): $($uiaGateMissingMembers -join ', ')", - "reason": "Nondeclarative assertion node: MemberExpressionAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: MemberExpressionAst", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-6853", "sourceLines": "6853-6855", "diagnostic": "RED: stable-but-wrong native text has no extracted retry-decision helper", - "reason": "Not a declared source resource: edgeTextHelperAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextHelperAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6864", "sourceLines": "6864-6864", "diagnostic": "Unexpected edge text retry decision '$decision", - "reason": "Not a declared source resource: decision", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: decision", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6866", "sourceLines": "6866-6868", "diagnostic": "RED: stable empty text did not retry exactly once before the expected second attempt", - "reason": "Not a declared source resource: edgeTextAttempts", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextAttempts", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6870", "sourceLines": "6870-6872", "diagnostic": "RED: edge retry-decision helper lost exact expected-text verification", - "reason": "Not a declared source resource: edgeTextHelperSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextHelperSource", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6877", "sourceLines": "6877-6879", "diagnostic": "RED: exact-match mutation negative did not find the expected helper branch", - "reason": "Not a declared source resource: mutatedEdgeTextHelperSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: mutatedEdgeTextHelperSource", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6892", "sourceLines": "6892-6894", "diagnostic": "RED: removing exact expected-text comparison evaded the stable-wrong retry regression", - "reason": "Not a declared source resource: mutationAttempts", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: mutationAttempts", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6905", "sourceLines": "6905-6908", "diagnostic": "RED: stable-wrong edge text did not exhaust and report exactly five attempts; calls=$edgeTextAttempts error=$($edgeTextExhaustion.Exception.Message)", - "reason": "Not a declared source resource: edgeTextAttempts", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: edgeTextAttempts", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6921", "sourceLines": "6921-6923", "diagnostic": "RED: custody graph-card wait helper is not extractable", - "reason": "Not a declared source resource: custodyCardWaitAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: custodyCardWaitAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6925", "sourceLines": "6925-6933", "diagnostic": "RED: custody card wait lacks a positive baseline, unique UUID-bound observation, or action-free polling", - "reason": "Not a declared source resource: custodyCardWaitSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: custodyCardWaitSource", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6939", "sourceLines": "6939-6941", "diagnostic": "RED: source-derived sketch-card AutomationId helper is missing", - "reason": "Not a declared source resource: sketchCardIdAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: sketchCardIdAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6958", "sourceLines": "6958-6960", "diagnostic": "RED: source-derived UIA card identity differs for $($expectedCard.NodeId)", - "reason": "Nondeclarative assertion node: CommandAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Nondeclarative assertion node: CommandAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-6967", "sourceLines": "6967-6973", "diagnostic": "RED: custody right-click can bypass fresh-fragment, unique-card, or hashed child-identity checks", - "reason": "Not a declared source resource: openSketchMenuAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: openSketchMenuAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-7027", "sourceLines": "7027-7041", "diagnostic": "RED: connector evidence does not freeze exact physical-screen endpoints and clamp the target to its visible card intersection", - "reason": "Not a declared source resource: compositeEvidenceIndex", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: compositeEvidenceIndex", + "implementation": "UiDerivedContracts.ps1" }, { "id": "ui.root.source.line-7136", "sourceLines": "7136-7138", "diagnostic": "RED: connector-card selector is not extractable for named functional cases", - "reason": "Not a declared source resource: connectorCardSelectorAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: connectorCardSelectorAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-7235", "sourceLines": "7235-7239", "diagnostic": "RED: edge connector functional case '$($case.Name)' failed: $($selectionError.Exception.Message)", - "reason": "Not a declared source resource: null", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: null", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-7326", "sourceLines": "7326-7328", "diagnostic": "RED: NativeForms.zig node_labels table not found for node creation sheet label contract", - "reason": "Not a declared source resource: nativeFormsSource", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: nativeFormsSource", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-7337", "sourceLines": "7337-7339", "diagnostic": "RED: UIA live gate node creation sheet label map is missing", - "reason": "Not a declared source resource: labelHashAst", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Not a declared source resource: labelHashAst", + "implementation": "UiFunctionalContracts.ps1" }, { "id": "ui.root.source.line-7344", "sourceLines": "7344-7346", "diagnostic": "RED: UIA live gate node creation sheet label lookup for $labelId returned '$actualLabel', expected '$expectedLabel' from NativeForms.zig", - "reason": "Unsupported source observation", - "owner": "Ui" + "owner": "Ui", + "state": "Implemented", + "adapterRationale": "Unsupported source observation", + "implementation": "UiFunctionalContracts.ps1" } ] diff --git a/Tools/windows/ValidationRunner/Ui/source-case-map.json b/Tools/windows/ValidationRunner/Ui/source-case-map.json index 8060cc83..a0a3ef3e 100644 --- a/Tools/windows/ValidationRunner/Ui/source-case-map.json +++ b/Tools/windows/ValidationRunner/Ui/source-case-map.json @@ -992,7 +992,7 @@ "id": "ui.host-info.diagnostics", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5896-5924", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -1573,28 +1573,28 @@ "id": "ui.root.source.line-5501", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5501-5503", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5514", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5514-5516", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5524", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5524-5526", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5537", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5537-5544", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -1762,28 +1762,28 @@ "id": "ui.root.source.line-5897", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5897-5899", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5905", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5905-5908", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5914", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5914-5916", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-5918", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "5918-5922", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -1811,119 +1811,119 @@ "id": "ui.root.source.line-6057", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6057-6061", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6093", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6093-6096", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6157", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6157-6164", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6182", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6182-6198", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6276", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6276-6276", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6292", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6292-6292", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6298", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6298-6298", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6305", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6305-6307", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6314", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6314-6321", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6326", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6326-6326", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6352", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6352-6354", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6412", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6412-6416", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6467", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6467-6471", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6591", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6591-6596", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6654", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6654-6676", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6754", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6754-6756", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { "id": "ui.root.source.line-6770", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "6770-6772", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -2014,7 +2014,7 @@ "id": "ui.root.source.line-7027", "source": "Tools/windows/Tests/ValidationRunner.Tests.ps1", "lines": "7027-7041", - "state": "Incomplete", + "state": "Implemented", "owner": "Ui" }, { @@ -2157,5 +2157,31 @@ "state": "Implemented", "owner": "Ui" } + ], + "sourceAdaptations": [ + { + "subject": "Activation", + "detail": "One actual Tools/windows/UiaWindowActivation.cs is linked by the shared production library. Test-only operations implement that same interface in a separate namespace." + }, + { + "subject": "Input and popup", + "detail": "Complete actual gate helper delegates to one UiaInputSafety instance. Only static-to-instance native receiver calls and shared interop types moved; policy bodies, diagnostics and observation order remain shared with typed operation-only fixtures." + }, + { + "subject": "Source guards", + "detail": "39 original literal observations target explicit current consumed production files. Legacy patterns and receiver-only changes are retained beside each assertion. Sleep ABI/policy and Unicode clear/read/call/record creation checks are split across explicit sources without relying on declaration order." + }, + { + "subject": "Delete context source correspondence", + "detail": "The frozen legacy source predicate expected direct PostContextMenu and Wait-ForPopupMenu calls. The actual frozen Cards gate already used Open-CanvasCardContextMenu. The migrated guard verifies its actual stable-identity callsite plus foreground, owner, visibility, finite clipped geometry, owned right-click, nonzero popup, enabled command ID, Cancel and teardown. All five original mutant obligations are retained against real current anchors." + }, + { + "subject": "No legacy execution", + "detail": "The exported blocked runner is a read-only case oracle, never an executed, extracted or embedded payload. Focused PS adapters are independently authored; only named actual production gate functions are invoked where PS scope, AST, ScriptProperty or ErrorRecord semantics are under test." + }, + { + "subject": "Evidence boundaries", + "detail": "Four activation,21 input,10 popup predicate,9 source,11 effect,14 hidden native lookup and6 hidden native hit cases remain explicit. No claim is made for native02, unrelated GUI walkthroughs, macOS parity, or a full host green while other mandatory families remain incomplete." + } ] } From dabe363ac59ab1d4e7933b8e6a78cfe82d127cd8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:17:08 -0700 Subject: [PATCH 17/46] Preserve hidden target standard handles and retain unknown gate authority Force explicit inherited stdout/stderr with a closed redirected stdin, without pumps or changing root-exit versus stream-EOF order. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index f6a7b896..a0a748fb 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -4251,9 +4251,11 @@ while (-not [IO.File]::Exists($launch.gate)) { $start = [Diagnostics.ProcessStartInfo]::new($launch.file) $start.UseShellExecute = $false $start.CreateNoWindow = -not $launch.visible +$start.RedirectStandardInput = $true $start.WorkingDirectory = $launch.workingDirectory foreach ($argument in $launch.arguments) { $start.ArgumentList.Add([string]$argument) } $target = [Diagnostics.Process]::Start($start) +$target.StandardInput.Close() try { $null = $target.Handle $temporaryIdentity = $launch.identity + ".pending" @@ -12100,5 +12102,15 @@ try { } } } + $ownershipUnknown = ($gateFailure -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $gateFailure)) -or + ($sandboxCleanupError -and (ShellProcessOwnership\Test-ShellOwnershipUnproven $sandboxCleanupError)) + if ($ownershipUnknown) { + try { + [Console]::Out.WriteLine("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") + [Console]::Out.Flush() + } finally { + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) + } + } if ($sandboxCleanupError -and $null -eq $gateFailure) { throw $sandboxCleanupError } } From 1083da5e27a9ff43ca882fbed42c3376371e0ae6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:19:30 -0700 Subject: [PATCH 18/46] Preserve original pipe bytes before strict protocol decoding Separate raw EOF proof from text decode failure and observe live retention markers independently of codec errors. Add safe controlled BOM/malformed/supplementary-byte regressions and execute the original eight gate capture modes plus native held-tree and real shared-linkage obligations. All165 selected ownership checks pass; the full mixed family remains mandatory incomplete. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ownership.json | 14 ++ .../Adapters/ControlledCapture.Contracts.ps1 | 129 ++++++++++++++++++ .../Adapters/ControlledCaptureChild.ps1 | 46 +++++++ .../Adapters/ProductionLinkage.Contracts.ps1 | 65 +++++++++ .../Fixtures/accepted-peer-91dc.json | 1 + .../Ownership/OwnedByteStreamCapture.cs | 76 +++++++++++ .../Ownership/OwnedChildSupervisor.cs | 27 ++-- .../Ownership/OwnedCleanupCoordinator.cs | 6 +- .../Ownership/OwnedFixture.cs | 22 +++ .../ValidationRunner/Ownership/README.md | 31 ++++- .../Ownership/RetentionSignalObserver.cs | 11 ++ .../Ownership/SuiteRegistration.cs | 60 ++++++-- 12 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/ProductionLinkage.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json create mode 100644 Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json index 32d7261d..9fddbf88 100644 --- a/Tools/windows/ValidationRunner/Manifests/ownership.json +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -38,6 +38,8 @@ { "id": "ownership.managed.signal-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.signal-exact-only", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.signal-overlong", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.raw-codec-malformed-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.managed.raw-codec-retention-after-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.managed.json-nested-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, @@ -81,6 +83,18 @@ { "id": "ownership.native.inherited-timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.native.arguments-environment", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.native.real-pipe", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.bytes-utf8-bom", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.bytes-utf16-bom", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.bytes-invalid-utf8", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.native.bytes-utf8-supplementary", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.normal", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.stop", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.inherited", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.abrupt", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.inherited-timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.primary-secondary", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, + { "id": "ownership.mixed-capture.cleanup-only", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, { "id": "ownership.legacy.ownership-receipts", "owner": "Ownership", "minimumCount": 7, "exactCount": 7 }, { "id": "ownership.legacy.retention-boundary", "owner": "Ownership", "minimumCount": 6, "exactCount": 6 }, { "id": "ownership.legacy.capture-ownership", "owner": "Ownership", "minimumCount": 14, "exactCount": 14 }, diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 new file mode 100644 index 00000000..9a23c3d1 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 @@ -0,0 +1,129 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath 'GraphCodeUiaGateState' +Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop +$gatePath = Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1' +$gate = Read-OwnershipProductionAst $gatePath +$windows = Join-Path $RepositoryRoot 'Tools\windows' +$sourceContext = @{ + '$PSScriptRoot' = ("'" + $windows.Replace("'", "''") + "'") + '$validationAssemblyPath' = ("'" + $AssemblyPath.Replace("'", "''") + "'") +} +. Import-OwnershipProductionFunctions $gate @('Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture') $sourceContext +. Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Protect-UiaStartupDiagnosticText', + 'Get-UiaOwnedProcessDescendants', 'Stop-UiaOwnedProcessTrees', 'Start-MultiProjectOwnedProcess', + 'Wait-MultiProjectCapture', 'Complete-MultiProjectCapture') +$childScript = Join-Path $PSScriptRoot 'ControlledCaptureChild.ps1' +$hostPath = Join-Path $PSHOME 'pwsh.exe' +$jobSource = Join-Path $windows 'Tests\Packaging.Standalone.Tests.ps1' +function Start-ControlledCapture([string] $Root, [string] $Mode, [switch] $Register) { + return Start-MultiProjectOwnedProcess -file $hostPath ` + -arguments @('-NoProfile', '-NonInteractive', '-File', $childScript, '-Root', $Root, '-Mode', $Mode) ` + -environment @{ TEMP = $Root; TMP = $Root } -stem 'controlled' -jobSourcePath $jobSource -RegisterForUia:$Register +} +function Wait-ControlledReady([string] $Root) { + $clock = [Diagnostics.Stopwatch]::StartNew() + while (-not [IO.File]::Exists((Join-Path $Root 'ready'))) { + if ($clock.ElapsedMilliseconds -ge 5000) { throw 'Controlled root did not become ready within its original deadline.' } + [Threading.Thread]::Sleep(10) + } +} +foreach ($mode in @('normal', 'stop', 'inherited', 'abrupt', 'timeout', 'inherited-timeout', 'primary-secondary', 'cleanup-only')) { + Invoke-OwnershipCase $mode { + $root = Join-Path $env:TEMP ('owned-capture-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root + $capture = $null + $lock = $null + $primary = $null + try { + $capture = Start-ControlledCapture $root $mode + Wait-ControlledReady $root + if ($mode -in @('timeout', 'inherited-timeout')) { + try { Wait-MultiProjectCapture $capture 500 } catch { $primary = $_ } + $expected = if ($mode -ceq 'timeout') { 'MULTIPROJECT_CAPTURE_EXIT' } else { 'MULTIPROJECT_CAPTURE_DRAIN' } + Assert-Ownership ($primary -and $primary.Exception.Message.StartsWith($expected, [StringComparison]::Ordinal)) "Actual $expected timeout path did not run." + } elseif ($mode -ceq 'inherited') { + Assert-Ownership ($capture.rootProcess.WaitForExit(5000) -and -not $capture.stdout.IsCompleted -and + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($capture.job) -gt 0) 'No actual inherited-open-pipe/job condition was exercised.' + } elseif ($mode -cne 'stop') { + Wait-MultiProjectCapture $capture 5000 + Assert-Ownership ($capture.rootProcess.ExitCode -eq $(if ($mode -ceq 'abrupt') { 23 } else { 0 })) 'Actual controlled root exit code differs.' + } + if ($mode -in @('primary-secondary', 'cleanup-only')) { + $lock = [IO.File]::Open((Join-Path $root 'controlled-stdout.log'), [IO.FileMode]::OpenOrCreate, + [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + if ($mode -ceq 'primary-secondary') { + try { throw [IO.IOException]::new('controlled original primary failure') } catch { $primary = $_ } + } + } + $original = $primary + $clock = [Diagnostics.Stopwatch]::StartNew() + if ($mode -ceq 'cleanup-only') { + $failure = $null + try { $null = Complete-MultiProjectCapture $capture $root 3000 } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Message.StartsWith('MULTIPROJECT_TEARDOWN:', [StringComparison]::Ordinal) -and + $failure.Exception.Message.Contains('stdout capture persistence') -and $failure.Exception.Message.Contains('pid=') -and + $failure.Exception.Message.Contains('start=') -and $failure.Exception.Message.Contains('job=') -and $capture.cleaned) 'Cleanup-only error lost exact identity, persistence failure, or completed ownership proof.' + } else { + $diagnostic = Complete-MultiProjectCapture $capture $root 3000 $primary + Assert-Ownership ($diagnostic.rootExited -and $diagnostic.ownedJobEmpty -and + $diagnostic.redirectedReadersDrained -and $clock.ElapsedMilliseconds -lt 5000 -and $capture.cleaned) 'Actual shared bounded teardown did not prove root/job/both reader completion.' + if ($mode -ceq 'primary-secondary') { + Assert-Ownership ([Object]::ReferenceEquals($primary, $original) -and $primary.Exception.Message -ceq 'controlled original primary failure' -and + $primary.Exception.Data['MultiProjectTeardown:controlled'].Contains('stdout capture persistence')) 'Cleanup replaced the actual primary ErrorRecord or lost explicit secondary persistence diagnostics.' + } elseif ($primary) { + Assert-Ownership ([Object]::ReferenceEquals($primary, $original) -and $diagnostic.failures.Count -eq 0 -and + $primary.Exception.Data['MultiProjectCapture:controlled'].redirectedReadersDrained) 'Timeout primary lost its identity or positive post-teardown drain diagnostics.' + } + } + [IO.File]::WriteAllText((Join-Path $root 'capture-task-text.json'), (@{ + stdoutType = $capture.stdout.GetType().FullName + stdoutResult = $capture.stdout.Result + stdoutAwait = $capture.stdout.GetAwaiter().GetResult() + stderrResult = $capture.stderr.Result + stderrAwait = $capture.stderr.GetAwaiter().GetResult() + } | ConvertTo-Json -Compress)) + $capture = $null + if ($mode -notin @('primary-secondary', 'cleanup-only')) { + Assert-Ownership ([IO.File]::ReadAllText((Join-Path $root 'controlled-stdout.log')).Contains('owned-capture-stdout') -and + [IO.File]::ReadAllText((Join-Path $root 'controlled-stderr.log')).Contains('owned-capture-stderr')) 'Actual independent stdout/stderr evidence was lost.' + } + } finally { + if ($lock) { $lock.Dispose() } + if ($capture -and -not $capture.cleaned) { $null = Complete-MultiProjectCapture $capture $root 3000 $primary } + } + } + Complete-OwnershipFamily ("ownership.mixed-capture." + $mode) 1 @('controlled-native', 'bounded-capture-eight-modes') +} +$treeRoot = Join-Path $env:TEMP ('owned-tree-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $treeRoot +$tree = $null +try { + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + Invoke-OwnershipCase 'controlled-child' { + $script:OwnershipControlledTree = Start-ControlledCapture $treeRoot 'tree' -Register + $tree = $script:OwnershipControlledTree + Wait-ControlledReady $treeRoot + $descendants = @(Get-UiaOwnedProcessDescendants @($tree.process.Id)) + Assert-Ownership ($descendants.Count -gt 0) 'Actual advisory traversal missed the controlled child.' + Assert-Ownership ([GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($tree.job, $tree.process.Handle) -and + [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($tree.job) -ge 2) 'Actual retained target lacks native job membership or a positive owned census.' + } + $tree = $script:OwnershipControlledTree + Complete-OwnershipFamily 'ownership.ui-process-tree.controlled-child' 1 @('controlled-native') + Invoke-OwnershipCase 'cleanup-complete' { + Assert-Ownership ($null -ne $tree) 'Controlled tree prerequisite did not launch.' + Stop-UiaOwnedProcessTrees @($tree.process) + Assert-Ownership $tree.cleaned 'Actual registered gate tree cleanup returned without completing root/job/readers.' + } + Complete-OwnershipFamily 'ownership.ui-process-tree.cleanup-complete' 1 @('controlled-native') +} finally { + if ($tree -and -not $tree.cleaned) { Stop-UiaOwnedProcessTrees @($tree.process) } +} +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 new file mode 100644 index 00000000..80188b8e --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCaptureChild.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Root, + [Parameter(Mandatory)][string] $Mode, + [switch] $Descendant +) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +if ($Descendant) { + [IO.File]::WriteAllText((Join-Path $Root 'descendant-ready'), 'ready') + [Threading.Thread]::Sleep(30000) + exit 0 +} +[Threading.Thread]::Sleep(300) +[IO.File]::WriteAllText((Join-Path $Root 'console-context.json'), (@{ + processId = $PID + stdoutRedirected = [Console]::IsOutputRedirected + stderrRedirected = [Console]::IsErrorRedirected + stdoutStreamType = [Console]::OpenStandardOutput().GetType().FullName + stderrStreamType = [Console]::OpenStandardError().GetType().FullName +} | ConvertTo-Json -Compress)) +[Console]::Out.WriteLine('owned-capture-stdout') +[Console]::Error.WriteLine('owned-capture-stderr') +if ($Mode -in @('inherited', 'inherited-timeout', 'tree')) { + $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in @('-NoProfile', '-NonInteractive', '-File', $PSCommandPath, '-Root', $Root, '-Mode', $Mode, '-Descendant')) { + $start.ArgumentList.Add($argument) + } + $child = [Diagnostics.Process]::Start($start) + try { + [IO.File]::WriteAllText((Join-Path $Root 'descendant.json'), (@{ + pid = $child.Id + startUtcTicks = $child.StartTime.ToUniversalTime().Ticks + } | ConvertTo-Json -Compress)) + $deadline = [Diagnostics.Stopwatch]::StartNew() + while (-not [IO.File]::Exists((Join-Path $Root 'descendant-ready'))) { + if ($deadline.ElapsedMilliseconds -ge 5000) { throw 'Controlled descendant was not ready within its original deadline.' } + [Threading.Thread]::Sleep(10) + } + } finally { $child.Dispose() } +} +[IO.File]::WriteAllText((Join-Path $Root 'ready'), 'ready') +if ($Mode -in @('stop', 'timeout', 'tree')) { [Threading.Thread]::Sleep(30000) } +if ($Mode -ceq 'abrupt') { exit 23 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/ProductionLinkage.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/ProductionLinkage.Contracts.ps1 new file mode 100644 index 00000000..c34e2572 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/ProductionLinkage.Contracts.ps1 @@ -0,0 +1,65 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$windows = Join-Path $RepositoryRoot 'Tools\windows' +$gatePath = Join-Path $windows 'uia-live-gate.ps1' +$gate = Read-OwnershipProductionAst $gatePath +$gateText = [IO.File]::ReadAllText($gatePath) +$packagingPath = Join-Path $windows 'Tests\Packaging.Standalone.Tests.ps1' +$packaging = Read-OwnershipProductionAst $packagingPath +$packagingText = [IO.File]::ReadAllText($packagingPath) +$bindingPath = Join-Path ([IO.Path]::GetDirectoryName($AssemblyPath)) 'WindowsValidationBindings.dll' +$context = @{ + '$PSScriptRoot' = ("'" + $windows.Replace("'", "''") + "'") + '$validationAssemblyPath' = ("'" + $AssemblyPath.Replace("'", "''") + "'") +} +. Import-OwnershipProductionFunctions $gate @('Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture') $context +function Assert-NoEmbeddedOwnedImplementation($Ast) { + $copies = @($Ast.FindAll({ param($node) + $node -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.Value -match 'public\s+(?:static\s+)?class\s+(StandaloneProcessJob|UiaOwnedStreamCapture)\b' + }, $true)) + Assert-Ownership ($copies.Count -eq 0) 'A legacy consumer retains an embedded duplicate owned implementation.' +} +Invoke-OwnershipCase 'packaging-shared-linkage' { + Assert-NoEmbeddedOwnedImplementation $packaging + Assert-Ownership ($packagingText.Contains('Import-CompiledValidationAssembly.ps1') -and + $packagingText.Contains('-RequiredType "GraphCode.Validation.Ownership.StandaloneProcessJob"')) 'Packaging does not require the exact file-backed shared production type.' + $setup = @($packaging.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Invoke-Setup' + }, $true)) + Assert-Ownership ($setup.Count -eq 1) 'Actual standalone setup consumer missing or ambiguous.' + foreach ($method in @('Create', 'Assign', 'Active', 'Summary', 'Processes', 'Terminate', 'WaitForEmpty', 'Close')) { + Assert-Ownership ($setup[0].Extent.Text.Contains("[GraphCode.Validation.Ownership.StandaloneProcessJob]::$method(")) "Actual packaging consumer does not call shared production method $method" + } + Assert-Ownership ([GraphCode.Validation.Ownership.StandaloneProcessJob].Assembly.Location -ceq $bindingPath) 'Packaging helper was loaded from another implementation.' +} +Complete-OwnershipFamily 'ownership.legacy.packaging-shared-linkage' 1 @('source-and-loader-linkage') +Invoke-OwnershipCase 'uia-shared-linkage' { + Assert-NoEmbeddedOwnedImplementation $gate + Initialize-MultiProjectProcessJob '' + Initialize-UiaOwnedStreamCapture + Assert-Ownership ([GraphCode.Validation.Ownership.UiaOwnedStreamCapture].Assembly.Location -ceq $bindingPath -and + [GraphCode.Validation.Ownership.StandaloneProcessJob].Assembly.Location -ceq $bindingPath) 'Actual UIA initializers loaded different copies of the shared production helpers.' + foreach ($call in @('[GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign(', + '[GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate(', + '[GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty(', + '[GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::Read(', + '[GraphCode.Validation.Ownership.UiaOwnedStreamCapture]::RequireJobMember(')) { + Assert-Ownership ($gateText.Contains($call)) "Actual gate is not routed through complete shared production call $call" + } + $jobSource = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\StandaloneProcessJob.cs')) + $streamSource = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '..\UiaOwnedStreamCapture.cs')) + Assert-Ownership ([regex]::Matches($jobSource, 'extern bool IsProcessInJob\(').Count -eq 1 -and + -not $streamSource.Contains('extern bool IsProcessInJob') -and + $streamSource.Contains('StandaloneProcessJob.Contains(job, process)') -and + $streamSource.Contains('AutoFlush = true') -and $streamSource.Contains('new UTF8Encoding(false, true)')) 'Native membership or corrected encoder state was duplicated or bypassed.' +} +Complete-OwnershipFamily 'ownership.legacy.uia-shared-linkage' 1 @('source-and-loader-linkage') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json b/Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json new file mode 100644 index 00000000..19d696c4 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Fixtures/accepted-peer-91dc.json @@ -0,0 +1 @@ +{"protocolConnected":true,"correlatedRequests":true,"connectionCount":2,"requestCount":11,"responseCount":11,"unansweredRequests":[],"unansweredCommands":[],"commands":["listRecentProjects","listQuickChats","listRecentProjects","listQuickChats","restoreOpenProjects","openProject","listRecentProjects","listQuickChats","restoreOpenProjects","graphCommand","graphCommand"],"error":null,"subscriptionSeen":true,"reconnectObserved":true,"graphSent":true,"busyObserved":false,"appliedRenames":[],"appliedCreates":[],"appliedCreateRequests":[],"appliedEdgeCreates":[],"appliedEdgeUpdates":[],"appliedEdgeCreateRequests":[],"appliedEdgeUpdateRequests":[],"appliedPromotions":[],"appliedPromotionRequests":[],"receivedGraphCommands":[{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","command":"{\"projectPath\":\"D:\\\\a\\\\_temp\\\\gu-23b076d47241480a960e6ea1f9980be0\\\\mp\\\\Alpha\",\"command\":{\"renameNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\",\"title\":\"Alpha renamed\"}}}"},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","command":"{\"projectPath\":\"D:\\\\a\\\\_temp\\\\gu-23b076d47241480a960e6ea1f9980be0\\\\mp\\\\Alpha\",\"command\":{\"renameNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\",\"title\":\"Alpha renamed\"}}}"}],"graphNodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Stub node A","loopType":"turnBased","createdBy":null},{"id":"22222222-2222-4222-8222-222222222222","title":"Stub node B","loopType":"turnBased","createdBy":null}],"edges":[],"graphSequence":1,"multiProjectPeer":{"receivedCount":11,"requestCount":11,"responseCount":11,"appliedCount":2,"publicationCount":7,"graphSequence":7,"received":[{"requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","command":{"listRecentProjects":{}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4dda29192","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4dda29192","command":{"listQuickChats":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb4e15cafdf","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4e15cafdf","command":{"listRecentProjects":{}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e15cafdf","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e33a0198","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4e33a0198","command":{"listQuickChats":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb4e9c908d9","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb4e9c908d9","command":{"restoreOpenProjects":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb501206706","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb501206706","command":{"openProject":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha"}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb50894205f","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb50894205f","command":{"listRecentProjects":{}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50894205f","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb50b6ab128","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb50b6ab128","command":{"listQuickChats":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb50f23ae55","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb50f23ae55","command":{"restoreOpenProjects":{}}},"expectedResponse":null},{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb6c7e0972e","command":{"graphCommand":{"projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","command":{"renameNode":{"_0":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed"}}}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb6c7e0972e","success":true}},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","frame":{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","command":{"graphCommand":{"projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","command":{"renameNode":{"_0":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed"}}}}},"expectedResponse":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","success":true}}],"applied":[{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","nodeID":"11111111-1111-4111-8111-111111111111","beforeTitle":"Alpha interleaved","title":"Alpha renamed"},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","nodeID":"11111111-1111-4111-8111-111111111111","beforeTitle":"Alpha renamed","title":"Alpha renamed"}],"answered":[{"requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4dbc64bc9","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4dda29192","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4dda29192","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e15cafdf","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e15cafdf","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e33a0198","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e33a0198","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}},{"requestID":"00000000-0000-4000-8000-5bb4e9c908d9","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb4e9c908d9","success":true}},{"requestID":"00000000-0000-4000-8000-5bb501206706","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb501206706","success":true}},{"requestID":"00000000-0000-4000-8000-5bb50894205f","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50894205f","event":{"recentProjectsListed":[{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false}]}}},{"requestID":"00000000-0000-4000-8000-5bb50b6ab128","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50b6ab128","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}},{"requestID":"00000000-0000-4000-8000-5bb50f23ae55","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb50f23ae55","success":true}},{"requestID":"00000000-0000-4000-8000-5bb6c7e0972e","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb6c7e0972e","success":true}},{"requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","response":{"version":2,"kind":"response","requestID":"00000000-0000-4000-8000-5bb8b7d8d3ef","success":true}}],"publications":[{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb4dbc64bc9","frame":{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb4dbc64bc9","frame":{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"bbbbbbbb-2222-4222-8222-222222222222","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Beta loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb50894205f","frame":{"version":2,"kind":"event","sequence":3,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"initial","correlationID":"00000000-0000-4000-8000-5bb50894205f","frame":{"version":2,"kind":"event","sequence":4,"event":{"graphChanged":{"id":"bbbbbbbb-2222-4222-8222-222222222222","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Beta loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"control","correlationID":"c80474d0-abb5-47bc-9aaf-a26aef79fe8e","frame":{"version":2,"kind":"event","sequence":5,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha interleaved","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"rename","correlationID":"00000000-0000-4000-8000-5bb6c7e0972e","frame":{"version":2,"kind":"event","sequence":6,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}},{"cause":"rename","correlationID":"00000000-0000-4000-8000-5bb8b7d8d3ef","frame":{"version":2,"kind":"event","sequence":7,"event":{"graphChanged":{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}}],"controls":[{"token":"c80474d0-abb5-47bc-9aaf-a26aef79fe8e","projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","nodeID":"11111111-1111-4111-8111-111111111111","beforeTitle":"Alpha loop","title":"Alpha interleaved","selection":{"projectPath":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","nodeID":"22222222-2222-4222-8222-222222222222","source":"live-uia"}}],"graphs":[{"id":"aaaaaaaa-1111-4111-8111-111111111111","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Alpha","name":"Alpha","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Alpha renamed","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]},{"id":"bbbbbbbb-2222-4222-8222-222222222222","project":{"path":"D:\\a\\_temp\\gu-23b076d47241480a960e6ea1f9980be0\\mp\\Beta","name":"Beta","remote":false},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Beta loop","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}],"unansweredRequests":[]}} \ No newline at end of file diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs b/Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs new file mode 100644 index 00000000..8dbbacaf --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/OwnedByteStreamCapture.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public sealed class OwnedByteStreamCapture +{ + private readonly Stream input; + private readonly FileStream evidence; + private readonly Decoder decoder; + private readonly RetentionSignalObserver observer; + private readonly StringBuilder text = new(); + private DecoderFallbackException? decodingFailure; + + public Task RawEof { get; } + public Task Text { get; } + + public OwnedByteStreamCapture(Stream input, string path, Encoding? encoding, RetentionSignalObserver observer) + { + this.input = input; + this.observer = observer; + var strict = (Encoding)(encoding ?? new UTF8Encoding(false, true)).Clone(); + strict.DecoderFallback = DecoderFallback.ExceptionFallback; + decoder = strict.GetDecoder(); + evidence = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite, 4096, true); + RawEof = PumpAsync(); + Text = DecodeResultAsync(); + } + + private async Task PumpAsync() + { + await using (evidence.ConfigureAwait(false)) + { + var bytes = new byte[4096]; + var characters = new char[16384]; + int count; + while ((count = await input.ReadAsync(bytes).ConfigureAwait(false)) != 0) + { + await evidence.WriteAsync(bytes.AsMemory(0, count)).ConfigureAwait(false); + await evidence.FlushAsync().ConfigureAwait(false); + observer.OnBytes(bytes.AsSpan(0, count)); + if (decodingFailure == null) + { + try + { + int decoded = decoder.GetChars(bytes, 0, count, characters, 0, false); + text.Append(characters, 0, decoded); + } + catch (DecoderFallbackException failure) { decodingFailure = failure; } + } + } + if (decodingFailure == null) + { + try + { + int decoded = decoder.GetChars([], 0, 0, characters, 0, true); + text.Append(characters, 0, decoded); + } + catch (DecoderFallbackException failure) { decodingFailure = failure; } + } + observer.OnEof(); + await evidence.FlushAsync().ConfigureAwait(false); + evidence.Flush(true); + } + } + + private async Task DecodeResultAsync() + { + await RawEof.ConfigureAwait(false); + if (decodingFailure != null) ExceptionDispatchInfo.Capture(decodingFailure).Throw(); + return text.ToString(); + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs index 000a24d7..f16c5c95 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedChildSupervisor.cs @@ -77,7 +77,8 @@ private static extern bool CreateProcessW(string file, StringBuilder command, In private bool descendantUnknown, retentionRegistered; private IntPtr root, thread, job; private bool assigned, resumed, completed; - private StreamReader? stdoutReader, stderrReader; + private Stream? stdoutPipe, stderrPipe; + private OwnedByteStreamCapture? stdoutCapture, stderrCapture; private Task? stdout, stderr; private long creationFileTime; public uint ProcessId { get; private set; } @@ -206,13 +207,15 @@ private void Launch(ProcessStartInfo info) StandaloneProcessJob.Assign(job, root); assigned = true; UiaOwnedStreamCapture.RequireJobMember(root, job); - stdoutReader = MakeReader(ref outRead, info.StandardOutputEncoding); - stderrReader = MakeReader(ref errRead, info.StandardErrorEncoding); + stdoutPipe = MakePipe(ref outRead); + stderrPipe = MakePipe(ref errRead); string stem = $"owned-{ProcessId}-{creationFileTime}"; - stdout = UiaOwnedStreamCapture.Read(stdoutReader, Path.Combine(artifactRoot, stem + "-stdout.log"), - new RetentionSignalObserver(ObserveDescendantUnknown)); - stderr = UiaOwnedStreamCapture.Read(stderrReader, Path.Combine(artifactRoot, stem + "-stderr.log"), - new RetentionSignalObserver(ObserveDescendantUnknown)); + stdoutCapture = new OwnedByteStreamCapture(stdoutPipe, Path.Combine(artifactRoot, stem + "-stdout.log"), + info.StandardOutputEncoding, new RetentionSignalObserver(ObserveDescendantUnknown)); + stdout = stdoutCapture.Text; + stderrCapture = new OwnedByteStreamCapture(stderrPipe, Path.Combine(artifactRoot, stem + "-stderr.log"), + info.StandardErrorEncoding, new RetentionSignalObserver(ObserveDescendantUnknown)); + stderr = stderrCapture.Text; if (deadline.Expired) throw new TimeoutException("Owned launch original deadline exceeded before resume."); uint previous = ResumeThread(thread); if (previous == uint.MaxValue) throw new Win32Exception(); @@ -243,11 +246,11 @@ private void Launch(ProcessStartInfo info) } } - private static StreamReader MakeReader(ref IntPtr handle, Encoding? encoding) + private static Stream MakePipe(ref IntPtr handle) { var safe = new SafeFileHandle(handle, true); handle = IntPtr.Zero; - try { return new StreamReader(new FileStream(safe, FileAccess.Read), encoding ?? new UTF8Encoding(false, true), true); } + try { return new FileStream(safe, FileAccess.Read); } catch { safe.Dispose(); throw; } } @@ -341,11 +344,11 @@ private sealed class NativeCleanupAuthority(OwnedChildSupervisor owner) : IOwned { public string Identity => $"pid={owner.ProcessId}; creation={owner.creationFileTime}; job={owner.job}"; public bool OwnershipUnproven { get { lock (owner.retentionGate) return owner.descendantUnknown; } } - public Task? StandardOutput => owner.stdout; - public Task? StandardError => owner.stderr; + public Task? StandardOutput => owner.stdoutCapture?.RawEof; + public Task? StandardError => owner.stderrCapture?.RawEof; public IReadOnlyList CloseActions => [ - () => CloseOwned(() => owner.stdoutReader?.Dispose()), () => CloseOwned(() => owner.stderrReader?.Dispose()), + () => CloseOwned(() => owner.stdoutPipe?.Dispose()), () => CloseOwned(() => owner.stderrPipe?.Dispose()), () => CloseOwned(() => Close(ref owner.thread)), () => CloseOwned(() => { if (owner.job != IntPtr.Zero) { StandaloneProcessJob.Close(owner.job); owner.job = IntPtr.Zero; } }), () => CloseOwned(() => Close(ref owner.root)), () => CloseOwned(owner.retentionCancellation.Dispose) diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs index 09cdad8f..b775d593 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedCleanupCoordinator.cs @@ -9,8 +9,8 @@ public interface IOwnedCleanupAuthority { string Identity { get; } bool OwnershipUnproven { get; } - Task? StandardOutput { get; } - Task? StandardError { get; } + Task? StandardOutput { get; } + Task? StandardError { get; } void Terminate(); Task WaitForJobEmptyAsync(Deadline deadline); Task WaitForRootExitAsync(Deadline deadline); @@ -30,7 +30,7 @@ public static async Task CompleteAsync(IOwnedCleanupAuthority var unknownProof = new OwnedCleanupProof(false, false, authority.StandardOutput?.IsCompletedSuccessfully ?? true, authority.StandardError?.IsCompletedSuccessfully ?? true, false); - foreach (Task? reader in new[] { authority.StandardOutput, authority.StandardError }) + foreach (Task? reader in new[] { authority.StandardOutput, authority.StandardError }) if (reader?.IsFaulted == true) failures.Add(reader.Exception!); var unknown = new AggregateException("OWNED_CLEANUP_UNPROVEN: live descendant retention signal; termination and close withheld.", failures); Exception reported = primary ?? unknown; diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs index dec74dca..b39d3a6a 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs @@ -39,12 +39,21 @@ public static ProcessStartInfo Command(string mode, string root, params string[] if (args.Length < 3) throw new ArgumentException("Owned fixture requires a mode and existing owned root."); string mode = args[1], root = Path.GetFullPath(args[2]); if (!Directory.Exists(root)) throw new DirectoryNotFoundException(root); + if (mode.StartsWith("bytes-", StringComparison.Ordinal)) + { + byte[] bytes = BytesForMode(mode); + using Stream output = Console.OpenStandardOutput(); + await output.WriteAsync(bytes).ConfigureAwait(false); + await output.FlushAsync().ConfigureAwait(false); + return 0; + } if (mode == "protocol") { if (args.Length != 6) throw new ArgumentException("Protocol fixture requires pipe name and both owner paths."); await ServeProtocolAsync(root, args[3], args[4], args[5]).ConfigureAwait(false); return 0; } + if (mode == "descendant") { File.WriteAllText(Path.Combine(root, "descendant-ready"), "ready"); @@ -84,6 +93,19 @@ public static ProcessStartInfo Command(string mode, string root, params string[] return mode == "abrupt" ? 23 : 0; } + public static byte[] BytesForMode(string mode) + { + const string json = "{\"value\":\"owned\"}"; + return mode switch + { + "bytes-utf8-bom" => [0xef, 0xbb, 0xbf, .. Encoding.UTF8.GetBytes(json)], + "bytes-utf16-bom" => [0xff, 0xfe, .. Encoding.Unicode.GetBytes(json)], + "bytes-invalid-utf8" => [0x7b, 0xc3, 0x28, 0x7d], + "bytes-utf8-supplementary" => Encoding.UTF8.GetBytes(new string('a', 4095) + "\uD83D\uDE80tail"), + _ => throw new ArgumentException("Unknown byte fixture mode.", nameof(mode)) + }; + } + private static ProcessStartInfo CommandForInheritedChild(string root) { ProcessStartInfo start = Command("descendant", root); diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md index f4d03f60..d907e690 100644 --- a/Tools/windows/ValidationRunner/Ownership/README.md +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -16,7 +16,7 @@ CIM observations nor reopening a PID supplies termination authority. `StandaloneProcessJob` is the complete native job implementation, including membership, accounting, bounded census and packaging diagnostics. -`UiaOwnedStreamCapture` uses it for native membership. Both must be linked by +`UiaOwnedStreamCapture` uses it for native membership. Both are linked by the host-owned packaging and UIA adapters; merely compiling these files does not establish that integration. Streaming uses `AutoFlush`, which flushes bytes without prematurely flushing an encoder containing half a surrogate pair. @@ -30,6 +30,13 @@ The cleanup coordinator refuses termination, proof waits and close on that latch; native kill/close boundaries also recheck it under the same lock. The host can therefore enter its terminal retention hold without waiting for the workload timeout to kill the very resources it is required to preserve. +The supervisor's `OwnedByteStreamCapture` persists original pipe bytes before +strict decoding and never autodetects or consumes a BOM. Raw EOF is a separate +proof from successful text decoding: invalid UTF-8 is rejected only after the +raw reader has continued to EOF and retained every byte. The live sentinel +observer reads the raw ASCII line boundary independently, including after a +decoder error. `UiaOwnedStreamCapture` remains the distinct production +text-reader API; its re-encoded text logs are not original-byte evidence. `OwnedFixture.TryRunAsync(args)` must run before ordinary host argument parsing. It recognizes only `--ownership-fixture`. The compiled fixture tests native @@ -45,7 +52,7 @@ and root-runner SHA-256 `202D260A846670E314163F9D2565D07B1E0AD27A4D61F41EA99D67624FF6C869`. The old runner is never executed, embedded or extracted for execution. -The 74 managed and seven controlled-native cases are individually registered +The 76 managed and eleven controlled-native cases are individually registered with exact-count manifest guards. The typed peer implements the complete request/response/publication/control core from `Stub-Daemon.ps1:29-294`; it is the implementation used by the compiled real-pipe fixture, not a passing fake. @@ -59,9 +66,8 @@ transport is the host's authoritative held-job supervisor. Scope, ScriptMethod, ErrorRecord identity, information streams and missing-versus-empty environment membership remain PowerShell assertions. -The full mixed family, two production linkage obligations and two actual -gate-owned controlled-tree obligations remain separately mandatory and -`Incomplete`; they cannot be replaced by compiled helper tests. +The full mixed family remains mandatory and `Incomplete`; it cannot be +replaced by the compiled helper tests. `source-correspondence.json` records every one of the immutable mixed source's 126 invocation templates and 192 assertion commands, with stable source IDs and zero claimed execution. Those source @@ -73,5 +79,16 @@ PowerShell source checks. Six newly reconciled startup/tree units also run: thirteen complete helper-presence checks (source evidence), three bounded file diagnostic cases, one retained report case, and the advisory PID-reuse negative control. These eighteen checks are not a native product startup -walkthrough. The two actual gate-owned controlled-tree obligations remain -mandatory until their linked-production integration runs. +walkthrough. The two actual gate-owned controlled-tree obligations now run +against the linked production gate, as do all eight original bounded-capture +modes, including inherited writers, original timeout classification, and +primary/cleanup-only persistence failures. Two separate source-and-loader +checks establish packaging/UIA linkage without executing the packaging suite +merely to obtain a type. + +The original-byte regression was first reproduced by two valid-BOM children: +the old reader consumed UTF-8 BOM bytes and autodetected UTF-16. In-memory +malformed-byte/raw-EOF separation was then established before executing the +actual malformed child. The four controlled byte fixtures now assert exact +original log bytes, strict UTF-8 rejection, positive owned cleanup, and a +supplementary scalar straddling a 4096-byte read boundary. diff --git a/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs b/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs index ce01283f..bd2f404a 100644 --- a/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs +++ b/Tools/windows/ValidationRunner/Ownership/RetentionSignalObserver.cs @@ -28,6 +28,17 @@ public void OnText(string text) public void OnEof() => FinishLine(); + public void OnBytes(ReadOnlySpan bytes) + { + foreach (byte value in bytes) + { + if (value == (byte)'\n') { FinishLine(); continue; } + if (overlong) continue; + if (line.Length > Signal.Length) { overlong = true; line.Clear(); continue; } + line.Append((char)value); + } + } + private void FinishLine() { if (!overlong) diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs index 573feea4..5e8ba705 100644 --- a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -31,7 +31,14 @@ public static IReadOnlyList CreateSuites() => "OWNERSHIP_CASE_RESULT=", ["ownership.ui-startup.helpers", "ownership.ui-startup.redacted-log", "ownership.ui-startup.truncated-log", "ownership.ui-startup.missing-log", "ownership.ui-startup.retained-report", "ownership.ui-process-tree.pid-reuse"]), new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-source", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedSource.Contracts.ps1", - "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.peer-emission", "ownership.legacy.rename-submit", "ownership.legacy.fresh-capture"]) + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.peer-emission", "ownership.legacy.rename-submit", "ownership.legacy.fresh-capture"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.controlled-capture", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\ControlledCapture.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.mixed-capture.normal", "ownership.mixed-capture.stop", "ownership.mixed-capture.inherited", + "ownership.mixed-capture.abrupt", "ownership.mixed-capture.timeout", "ownership.mixed-capture.inherited-timeout", + "ownership.mixed-capture.primary-secondary", "ownership.mixed-capture.cleanup-only", + "ownership.ui-process-tree.controlled-child", "ownership.ui-process-tree.cleanup-complete"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.production-linkage", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\ProductionLinkage.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.packaging-shared-linkage", "ownership.legacy.uia-shared-linkage"]) ]; } @@ -50,6 +57,7 @@ public sealed class OwnershipSuite : IValidationSuite "cleanup-success", "cleanup-root-survivor", "cleanup-job-survivor", "cleanup-stdout-fault", "cleanup-both-stream-faults", "cleanup-primary-error", "cleanup-only-error", "cleanup-original-deadline", "cleanup-later-actions", "cleanup-descendant-unknown", "signal-fragmented", "signal-crlf", "signal-eof", "signal-exact-only", "signal-overlong", + "raw-codec-malformed-eof", "raw-codec-retention-after-malformed", "json-malformed", "json-duplicate", "json-nested-duplicate", "json-array", "json-case-sensitive", "frame-fragmented-unicode", "frame-clean-eof", "frame-short-header", "frame-short-body", "frame-zero", "frame-over-limit", "frame-invalid-utf8", "peer-positive-rename", "peer-resume-unchanged", "peer-foreign-owner", "peer-foreign-node", @@ -59,14 +67,11 @@ public sealed class OwnershipSuite : IValidationSuite "peer-control-positive", "peer-control-owner", "peer-control-selection", "peer-control-extra", "peer-control-type", "peer-control-duplicate", "peer-control-second" ]; - private static readonly string[] nativeIds = ["normal", "abrupt", "timeout", "inherited", "inherited-timeout", "arguments-environment", "real-pipe"]; + private static readonly string[] nativeIds = ["normal", "abrupt", "timeout", "inherited", "inherited-timeout", "arguments-environment", "real-pipe", + "bytes-utf8-bom", "bytes-utf16-bom", "bytes-invalid-utf8", "bytes-utf8-supplementary"]; private static readonly (string Id, string Evidence)[] unported = [ - ("legacy.mixed-multiproject", "Complete mixed source 1672-4245 remains mandatory: UI predicates, native/fresh snapshot policies, receipts, strict protocol, owned real pipes, eight bounded-capture and primary/cleanup negative controls. Source invocation inventories are not executed counts."), - ("legacy.packaging-shared-linkage", "Packaging.Standalone.Tests.ps1 must load this complete StandaloneProcessJob implementation, not its embedded duplicate."), - ("legacy.uia-shared-linkage", "uia-live-gate.ps1 must load these complete UiaOwnedStreamCapture/StandaloneProcessJob implementations, including actual native membership."), - ("ui-process-tree.controlled-child", "Source 5945: actual gate registered held process/job must expose its controlled descendant."), - ("ui-process-tree.cleanup-complete", "Source 5947: actual gate Stop-UiaOwnedProcessTrees must finish retained root/job/readers before cleanup succeeds.") + ("legacy.mixed-multiproject", "Complete mixed source 1672-4245 remains mandatory: UI predicates, native/fresh snapshot policies, receipts, strict protocol, owned real pipes, eight bounded-capture and primary/cleanup negative controls. Source invocation inventories are not executed counts.") ]; public string Id => "ownership"; @@ -313,6 +318,21 @@ private static async Task ManagedAsync(string id, string root, string alpha, str "Live UNKNOWN observer missed split/EOF signal or treated substring/overlong output as authority."); return; } + if (id.StartsWith("raw-codec-", StringComparison.Ordinal)) + { + bool withSignal = id == "raw-codec-retention-after-malformed"; + byte[] bytes = withSignal ? [0xc3, 0x28, 10, .. Encoding.UTF8.GetBytes(OwnershipRetention.Sentinel + "\n")] : [0xc3, 0x28]; + int signals = 0; + string path = Path.Combine(root, id + ".log"); + using var memory = new MemoryStream(bytes); + var capture = new OwnedByteStreamCapture(memory, path, new UTF8Encoding(false, true), + new RetentionSignalObserver(() => signals++)); + await capture.RawEof.ConfigureAwait(false); + await RejectAsync(async () => { await capture.Text.ConfigureAwait(false); }).ConfigureAwait(false); + Require(capture.RawEof.IsCompletedSuccessfully && File.ReadAllBytes(path).SequenceEqual(bytes) && + signals == (withSignal ? 1 : 0), "Strict decoding failure lost original bytes, independent raw EOF, or a later live retention marker."); + return; + } if (id.StartsWith("json-", StringComparison.Ordinal)) { string text = id switch @@ -452,6 +472,28 @@ private static async Task NativeAsync(string id, string root, string alpha, stri { string childRoot = Path.Combine(root, "native-" + id); Directory.CreateDirectory(childRoot); + if (id.StartsWith("bytes-", StringComparison.Ordinal)) + { + var owned = OwnedChildSupervisor.Start(OwnedFixture.Command(id, childRoot), TimeSpan.FromSeconds(5), childRoot); + OwnedChildResult? result = null; + AggregateException? failure = null; + try { result = await owned.CompleteAsync(cancellationToken).ConfigureAwait(false); } + catch (AggregateException error) { failure = error; } + Require(result?.Cleanup.ResourcesClosed == true || + failure?.Data["OwnedCleanupProof"] is OwnedCleanupProof { CanRelease: true, ResourcesClosed: true }, + "Codec rejection did not independently prove raw EOF and safe owned cleanup."); + string rawPath = Path.Combine(childRoot, $"owned-{owned.ProcessId}-{owned.CreationFileTime}-stdout.log"); + Require(File.ReadAllBytes(rawPath).SequenceEqual(OwnedFixture.BytesForMode(id)), "Original pipe bytes were normalized or lost."); + if (id == "bytes-utf8-bom") + { + Require(result != null && result.StandardOutput.StartsWith('\uFEFF'), "UTF-8 BOM was consumed by capture."); + Reject(() => MultiProjectJson.Parse(result!.StandardOutput), "MULTIPROJECT_JSON"); + } + else if (id is "bytes-utf16-bom" or "bytes-invalid-utf8") + Require(failure != null, "Non-UTF-8 pipe bytes were accepted by BOM autodetection or replacement."); + else Require(result?.StandardOutput == new string('a', 4095) + "\uD83D\uDE80tail", "Supplementary scalar crossing the 4096-byte read boundary changed."); + return; + } if (id == "real-pipe") { await RealPipeAsync(childRoot, alpha, beta, cancellationToken).ConfigureAwait(false); @@ -600,8 +642,8 @@ private sealed class FakeCleanupAuthority : IOwnedCleanupAuthority { public string Identity => "controlled-fake-held-authority; nativeCalls=0"; public bool OwnershipUnproven { get; set; } - public Task? StandardOutput { get; set; } = Task.FromResult("stdout"); - public Task? StandardError { get; set; } = Task.FromResult("stderr"); + public Task? StandardOutput { get; set; } = Task.FromResult("stdout"); + public Task? StandardError { get; set; } = Task.FromResult("stderr"); public bool RootExited = true, JobEmpty = true, CloseFails, ConsumeDeadline, Retained; public int Terminations, JobWaits, RootWaits, Closes, RootRemainingMilliseconds; public Deadline? JobDeadline, RootDeadline; From d09b34974da7e157dc5176dfe04f86c065b391c3 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:27:01 -0700 Subject: [PATCH 19/46] Expose owned focus control primitive without duplicating focus policy Keep the production native delegate and existing fallback/refusal order; remove reliance on arbitrary preloaded type presence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index a0a748fb..8db246f2 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -3296,6 +3296,7 @@ function New-MultiProjectEditNativeApi([string] $expectedTitle = "") { $api | Add-Member ScriptMethod Clock {return $this.Watch.ElapsedMilliseconds} $api | Add-Member ScriptMethod Title {param($modal,$remaining) [GraphCodeMultiProjectEditNative]::Buffer($modal,$remaining)} -Force $api | Add-Member ScriptMethod FocusHandle {param($modal) [GraphCodeMultiProjectEditNative]::FocusOf($modal)} + $api | Add-Member ScriptMethod FocusControl {param($modal,$edit) [GraphCodeUiaGateState]::FocusControl($modal,$edit)} $api | Add-Member ScriptMethod FocusInsertion { param($modal,$point) $receipt = @([GraphCodeUiaGateState]::ClickOwnedScreenRectangle( @@ -3336,11 +3337,9 @@ function New-MultiProjectEditNativeApi([string] $expectedTitle = "") { } $null=Get-MultiProjectEditRemaining $this $started $receipt = @($this.FocusInsertion($modal,$point)) - if ("GraphCodeUiaGateState" -as [type]) { - $this.LastFocusControlFallback = [GraphCodeUiaGateState]::FocusControl($modal,$edit) - if (-not $this.LastFocusControlFallback) { - throw "MULTIPROJECT_EDIT_FOCUS: direct control focus fallback failed" - } + $this.LastFocusControlFallback = $this.FocusControl($modal,$edit) + if (-not $this.LastFocusControlFallback) { + throw "MULTIPROJECT_EDIT_FOCUS: direct control focus fallback failed" } return ,$receipt } From 0d4adb01d65569892fd378503c1db346ef4030b3 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:42:56 -0700 Subject: [PATCH 20/46] Derive composite coverage from current invocation and verify compiled source provenance Preserve root registry admission, source/ref fingerprints and loaded MVID checks, normal compiled fixtures, owned mock regression suites, and positive provider/source contract counts. Incomplete canonical coverage remains nonzero. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Import-CompiledValidationAssembly.ps1 | 69 + Tools/windows/ShellOwnedProcess.cs | 15 +- Tools/windows/ShellProcessOwnership.psm1 | 42 +- .../Tests/DaemonHandoff.Live.Tests.ps1 | 89 +- .../Tests/DaemonRoundTrip.Live.Tests.ps1 | 275 ++- .../Tests/GdiplusStartup.Live.Tests.ps1 | 323 +-- .../windows/Tests/OwnedLiveAdapter.Tests.ps1 | 432 ++++ .../Tests/OwnedLiveProcessCleanup.Tests.ps1 | 1918 +++++++++++++++++ .../Tests/ScrubbedShellStartup.Live.Tests.ps1 | 713 +++--- .../Tests/ShellProcessOwnership.Tests.ps1 | 878 ++++++++ .../Adapters/EvidenceParser.cs | 11 + .../Adapters/HostSourceContracts.ps1 | 129 ++ .../Adapters/LegacyOwnership.Contracts.ps1 | 62 + .../Adapters/PowerShellPayload.cs | 35 +- .../Adapters/Provenance.Contracts.ps1 | 36 + .../Adapters/ProviderBuild.Contracts.ps1 | 85 + .../Adapters/StandaloneSuites.cs | 59 +- .../Fixtures/ValidationTestFixtures.csproj | 15 + .../Bindings/ValidationFixtureBindings.csproj | 1 + .../Windows/WindowsValidationBindings.csproj | 4 +- .../ValidationRunner/BuildProvenance.targets | 19 + .../ValidationRunner/Host/ConsoleReport.cs | 4 +- .../windows/ValidationRunner/Host/Coverage.cs | 171 +- .../Host/Fixtures/ShellStreamFixtures.cs | 64 + .../ValidationRunner/Host/HostFixture.cs | 28 + .../ValidationRunner/Host/HostSelfTests.cs | 51 + .../Host/ProviderCacheSuite.cs | 168 ++ .../ValidationRunner/Manifests/host.json | 15 +- Tools/windows/ValidationRunner/Program.cs | 11 +- .../ValidationRunner/ValidationRunner.csproj | 3 +- 30 files changed, 5098 insertions(+), 627 deletions(-) create mode 100644 Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 create mode 100644 Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 create mode 100644 Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 create mode 100644 Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj create mode 100644 Tools/windows/ValidationRunner/BuildProvenance.targets create mode 100644 Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs create mode 100644 Tools/windows/ValidationRunner/Host/HostFixture.cs create mode 100644 Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs diff --git a/Tools/windows/Import-CompiledValidationAssembly.ps1 b/Tools/windows/Import-CompiledValidationAssembly.ps1 index da1263b8..3888c748 100644 --- a/Tools/windows/Import-CompiledValidationAssembly.ps1 +++ b/Tools/windows/Import-CompiledValidationAssembly.ps1 @@ -14,6 +14,9 @@ if (-not [IO.Path]::IsPathFullyQualified($AssemblyPath)) { $projectRoot = Join-Path $PSScriptRoot "ValidationRunner" $fileName = if ($RequiredType -ceq "GraphCode.ValidationFixtureDirectoryNative") { "ValidationFixtureBindings.dll" +} elseif ($RequiredType -ceq "GraphCode.Validation.Ownership.OwnershipJobFixture" -or + $RequiredType.StartsWith("GraphCode.Validation.TestFixtures.", [StringComparison]::Ordinal)) { + "ValidationTestFixtures.dll" } else { "WindowsValidationBindings.dll" } if ([IO.Path]::GetFileName($AssemblyPath) -ceq "ValidationRunner.dll") { $AssemblyPath = Join-Path ([IO.Path]::GetDirectoryName($AssemblyPath)) $fileName @@ -42,4 +45,70 @@ $sourceRoots = @($assembly.GetCustomAttributesData() | Where-Object { if ($sourceRoots.Count -ne 1 -or $sourceRoots[0].ConstructorArguments[1].Value -cne $projectRoot) { throw "Compiled validation assembly has no matching worktree build provenance" } +$inputs = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($attribute in $assembly.GetCustomAttributesData()) { + if ($attribute.AttributeType.FullName -cne "System.Reflection.AssemblyMetadataAttribute" -or + $attribute.ConstructorArguments.Count -ne 2 -or + $attribute.ConstructorArguments[0].Value -cne "GraphCode.Validation.Input") { continue } + $value = [string]$attribute.ConstructorArguments[1].Value + $parts = $value.Split('|') + if ($parts.Count -ne 2 -or -not [IO.Path]::IsPathFullyQualified($parts[0]) -or + $parts[1] -cnotmatch '^[0-9A-F]{64}$' -or -not $inputs.TryAdd($parts[0], $parts[1])) { + throw "Compiled validation assembly has invalid or duplicate input provenance" + } +} +$requiredInputs = [Collections.Generic.List[string]]::new() +$requiredInputs.Add((Join-Path $projectRoot "global.json")) +$requiredInputs.Add((Join-Path $projectRoot "BuildProvenance.targets")) +switch ($fileName) { + "ValidationFixtureBindings.dll" { + $requiredInputs.Add((Join-Path $projectRoot "Bindings\ValidationFixtureBindings.csproj")) + $requiredInputs.Add((Join-Path $PSScriptRoot "ValidationFixtureDirectoryNative.cs")) + } + "ValidationTestFixtures.dll" { + $requiredInputs.Add((Join-Path $projectRoot "Bindings\Fixtures\ValidationTestFixtures.csproj")) + $requiredInputs.Add((Join-Path $projectRoot "Ownership\OwnershipJobFixture.cs")) + foreach ($file in (Get-ChildItem -LiteralPath (Join-Path $projectRoot "Host\Fixtures") -Filter "*.cs" -File)) { + $requiredInputs.Add($file.FullName) + } + } + "WindowsValidationBindings.dll" { + $requiredInputs.Add((Join-Path $projectRoot "Bindings\Windows\WindowsValidationBindings.csproj")) + $requiredInputs.Add((Join-Path $PSScriptRoot "UiaWindowActivation.cs")) + $requiredInputs.Add((Join-Path $PSScriptRoot "ShellOwnedProcess.cs")) + foreach ($directory in @("Ui\Production", "Host\Production", "Ownership")) { + foreach ($file in (Get-ChildItem -LiteralPath (Join-Path $projectRoot $directory) -Filter "*.cs" -File)) { + if ($directory -ceq "Ownership" -and + ($file.Name -like "*Fixture.cs" -or $file.Name -ceq "SuiteRegistration.cs")) { continue } + $requiredInputs.Add($file.FullName) + } + } + } +} +foreach ($inputPath in $requiredInputs) { + if (-not $inputs.ContainsKey([IO.Path]::GetFullPath($inputPath))) { + throw "Compiled validation input manifest is incomplete; rebuild with Build-CompiledValidationRunner.ps1" + } +} +if ($inputs.Count -le $requiredInputs.Count) { + throw "Compiled validation input manifest omits compiler reference inputs" +} +foreach ($inputPath in $inputs.Keys) { + if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf) -or + (Get-FileHash -LiteralPath $inputPath -Algorithm SHA256).Hash -cne $inputs[$inputPath]) { + throw "Compiled validation inputs changed after build; rebuild with Build-CompiledValidationRunner.ps1" + } +} +$file = [IO.File]::Open($selected, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) +try { + $pe = [Reflection.PortableExecutable.PEReader]::new($file) + try { + $metadata = [Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($pe) + $fileMvid = $metadata.GetGuid($metadata.GetModuleDefinition().Mvid) + if ($fileMvid -ne $assembly.ManifestModule.ModuleVersionId) { + throw "An older validation assembly is already loaded at the current path; start a fresh PowerShell process" + } + } finally { $pe.Dispose() } +} finally { $file.Dispose() } Write-Output $assembly diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs index 333a5e7f..216614d4 100644 --- a/Tools/windows/ShellOwnedProcess.cs +++ b/Tools/windows/ShellOwnedProcess.cs @@ -1,4 +1,17 @@ +#nullable disable +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + public sealed class ShellOwnedLaunch { + public static string SelectWorkingDirectory(ProcessStartInfo info) { + return String.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory; + } [StructLayout(LayoutKind.Sequential)] struct Security { public int Length; public IntPtr Descriptor; public int Inherit; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] @@ -171,7 +184,7 @@ public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { uint flags = 0x4 | 0x400 | 0x80000; // suspended, Unicode environment, explicit handle list if (info.CreateNoWindow) flags |= 0x08000000; if (!CreateProcessW(info.FileName, command, IntPtr.Zero, IntPtr.Zero, true, flags, - environment, String.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory, ref startup, out created)) throw new Win32Exception(); + environment, SelectWorkingDirectory(info), ref startup, out created)) throw new Win32Exception(); run.Handle = created.Process; run.thread = created.Thread; run.Process = Process.GetProcessById(created.Pid); diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 index 76b57b8a..a5aa3ac6 100644 --- a/Tools/windows/ShellProcessOwnership.psm1 +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -1,4 +1,5 @@ Set-StrictMode -Version Latest +$script:CompiledOwnershipAssemblyPath = $null # Targets start suspended, join a private non-breakaway job, then resume. CIM # observations are never admitted into this scope; disposed managed objects do # not release the native launch handles retained until verified job teardown. @@ -17,34 +18,29 @@ Set-StrictMode -Version Latest # only. Actual suspended launch/job containment still requires native validation. function Initialize-ShellProcessOwnership { - if ("ShellOwnedLaunch" -as [type]) { return } - $path = Join-Path $PSScriptRoot "UiCaptureDriver.psm1" - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) - if ($errors.Count) { throw "Owned shell job source does not parse" } - $definitions = @($ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.StringConstantExpressionAst] -and - $node.Value.Contains("public static class CapProcessJob") - }, $true)) - if ($definitions.Count -ne 1) { throw "Owned shell requires exactly one existing CapProcessJob definition" } - if (-not ("CapProcessJob" -as [type])) { Add-Type -TypeDefinition $definitions[0].Value } - $source = "using System; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices;`n" + - "using System.IO; using System.Text; using System.Threading.Tasks; using System.Linq;`n" + - (Get-Content -LiteralPath (Join-Path $PSScriptRoot "ShellOwnedProcess.cs") -Raw) - Add-Type -TypeDefinition $source + $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY + if (-not $assemblyPath) { + if (-not $script:CompiledOwnershipAssemblyPath) { + $script:CompiledOwnershipAssemblyPath = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") + } + $assemblyPath = $script:CompiledOwnershipAssemblyPath + } + foreach ($requiredType in @("ShellOwnedLaunch", "GraphCode.Validation.Ownership.StandaloneProcessJob")) { + & (Join-Path $PSScriptRoot "Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assemblyPath -RequiredType $requiredType | Out-Null + } } function Invoke-ShellJob([string] $Operation, [IntPtr] $Job = [IntPtr]::Zero, [IntPtr] $Handle = [IntPtr]::Zero) { switch ($Operation) { - "Create" { [CapProcessJob]::Create() } - "Assign" { [CapProcessJob]::Assign($Job, $Handle) } - "Contains" { [CapProcessJob]::Contains($Job, $Handle) } - "Terminate" { [CapProcessJob]::Terminate($Job) } - "Wait" { [CapProcessJob]::WaitForEmpty($Job, 5000) } - "Active" { [CapProcessJob]::Active($Job) } - "Close" { [CapProcessJob]::Close($Job) } + "Create" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() } + "Assign" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($Job, $Handle) } + "Contains" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($Job, $Handle) } + "Terminate" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($Job) } + "Wait" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($Job, 5000) } + "Active" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($Job) } + "Close" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($Job) } default { throw "Unknown owned-job operation: $Operation" } } } diff --git a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 index c2d501e4..143a7e23 100644 --- a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 @@ -5,6 +5,8 @@ param( ) $ErrorActionPreference = "Stop" +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") +$processScope = $null $script:WM_QUIT = 0x0012 $daemonExecutable = Join-Path (Split-Path -Parent $Executable) "graphcoded.exe" $cliExecutable = Join-Path (Split-Path -Parent $Executable) "graphcode.exe" @@ -57,13 +59,7 @@ function Start-HandoffShell( $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_STATE"] = $daemonStatePath $startInfo.Environment["GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"] = "1" $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_USER"] = $userName - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start()) { - throw "could not start shell $userName" - } - [void] $process.Handle - return $process + Start-ShellOwnedProcess $processScope $startInfo } function Get-DaemonChildren([int[]] $parentIds) { @@ -125,6 +121,7 @@ function Stop-ShellNormally([Diagnostics.Process] $process, [string] $role) { if (-not $process.WaitForExit(7000)) { throw "$role shell did not exit normally" } + Complete-ShellOwnedProcess $processScope $process return } Start-Sleep -Milliseconds 100 @@ -143,16 +140,19 @@ function Assert-EndpointReachable { $startInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $supportDirectory $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe [void] $startInfo.ArgumentList.Add("projects") - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start() -or -not $process.WaitForExit(7000)) { - throw "daemon endpoint was not reachable through graphcode.exe" - } - $stderr = $process.StandardError.ReadToEnd() - if ($process.ExitCode -ne 0) { - throw "daemon endpoint rejected graphcode.exe: $stderr" + $process = Start-ShellOwnedProcess $processScope $startInfo + try { + if (-not $process.WaitForExit(7000)) { + throw "daemon endpoint was not reachable through graphcode.exe" + } + $stderr = Read-ShellOwnedOutput $processScope $process -StandardError + if ($process.ExitCode -ne 0) { + throw "daemon endpoint rejected graphcode.exe: $stderr" + } + } finally { + if (-not $process.HasExited) { Stop-ShellOwnedProcess $processScope $process } + $process.Dispose() } - $process.Dispose() } function Get-HandoffElapsed([Diagnostics.Stopwatch] $clock) { @@ -226,8 +226,13 @@ $shellA = $null $shellB = $null $daemonProcess = $null $handoffClock = [Diagnostics.Stopwatch]::StartNew() +$primaryFailure = $null +$supportCreated = $false try { - New-Item -ItemType Directory -Force -Path $supportDirectory | Out-Null + if (Test-Path -LiteralPath $supportDirectory) { throw "Handoff support directory already exists: $supportDirectory" } + New-Item -ItemType Directory -Path $supportDirectory | Out-Null + $supportCreated = $true + $processScope = New-ShellProcessScope $daemonStateA = Join-Path $supportDirectory "daemon-a.state" $daemonStateB = Join-Path $supportDirectory "daemon-b.state" $shellA = Start-HandoffShell ` @@ -244,7 +249,8 @@ try { ) $children = @(Get-DaemonChildren $parents) if ($children.Count -eq 1) { - $candidate = Get-Process -Id $children[0].ProcessId -ErrorAction SilentlyContinue + # CIM supplies a candidate only; membership of the retained job is authority. + $candidate = Get-ShellOwnedMember $processScope $children[0].ProcessId if ($candidate) { $daemonProcess = $candidate $selectedChild = $children[0] @@ -306,23 +312,38 @@ try { if (-not $daemonProcess.WaitForExit(7000)) { throw "owning shell exit did not stop its graphcoded child" } - Write-Output "Concurrent two-shell daemon handoff: PASS" +} catch { + $primaryFailure = $_ + throw } finally { - foreach ($process in @($shellA, $shellB)) { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + try { + if ($processScope) { Close-ShellProcessScope $processScope } + $expectedDaemonPath = [IO.Path]::GetFullPath($daemonExecutable) + $survivors = @(Get-CimInstance Win32_Process -ErrorAction Stop | + Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expectedDaemonPath + }) + if ($survivors.Count) { + throw "UNATTESTED_SURVIVORS: handoff daemon image matches after owned-job cleanup; no termination attempted: $($survivors.ProcessId -join ',')" } - if ($process) { $process.Dispose() } - } - if ($daemonProcess -and -not $daemonProcess.HasExited) { - Stop-Process -Id $daemonProcess.Id -Force -ErrorAction SilentlyContinue + if ($primaryFailure -and (Test-ShellOwnershipUnproven $primaryFailure)) { + throw "Earlier ownership refusal requires retained handoff support files" + } + if ($supportCreated) { + Remove-Item -LiteralPath $supportDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } catch { + Write-Host "HANDOFF_CLEANUP_UNPROVEN=$($_.Exception.Message); retained=$supportDirectory" + $cleanupFailure = Set-ShellOwnershipUnproven $_.Exception @($supportDirectory) + if ($primaryFailure) { + $failure = [InvalidOperationException]::new( + "Handoff failed: $($primaryFailure.Exception.Message); cleanup unproved: $($cleanupFailure.Message)", $primaryFailure.Exception) + $failure.Data["GraphCodeValidationRootRetentionRequired"] = $true + $failure.Data["GraphCodeValidationRetainedRoots"] = $cleanupFailure.Data["GraphCodeValidationRetainedRoots"] + throw $failure + } + throw $cleanupFailure } - $expectedDaemonPath = [IO.Path]::GetFullPath($daemonExecutable) - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and - [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expectedDaemonPath - } | - ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } - Remove-Item -LiteralPath $supportDirectory -Recurse -Force -ErrorAction SilentlyContinue } +Write-Output "Concurrent two-shell daemon handoff: PASS" diff --git a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 index ef70275a..4359cb42 100644 --- a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 @@ -11,6 +11,9 @@ param( ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\OwnedLiveProcess.ps1") +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path $shellRoot = Join-Path $repoRoot "graphcode-windows" $DaemonExecutable = (Resolve-Path -LiteralPath $DaemonExecutable).Path @@ -52,6 +55,7 @@ $defaultSupportDirectory = Join-Path ([Environment]::GetFolderPath("UserProfile" $successful = $false $shutdownEvent = $null $daemonProcess = $null +$processScope = $null $runLog = [Collections.Generic.List[string]]::new() function Get-DefaultSupportSignature { @@ -107,40 +111,66 @@ function Start-OwnedDaemon { $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe $startInfo.Environment["GRAPHCODE_DAEMON_SHUTDOWN_EVENT"] = $shutdownEventName - $process = [Diagnostics.Process]::new() - $process.StartInfo = $startInfo - if (-not $process.Start()) { - throw "could not start graphcoded.exe" - } - [void] $process.Handle - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - $deadline = [DateTime]::UtcNow.AddSeconds(15) - while ([DateTime]::UtcNow -lt $deadline) { - $process.Refresh() - if ($process.HasExited) { - throw "graphcoded.exe exited during startup: $($stderr.Result)" + $process = $null + $started = $false + $handleHeld = $false + try { + $process = Start-ShellOwnedProcess $processScope $startInfo + $started = $true + [void] $process.Handle + $handleHeld = $true + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline) { + $process.Refresh() + if ($process.HasExited) { + Complete-ShellOwnedProcess $processScope $process + $stderr = Read-ShellOwnedOutput $processScope $process -StandardError + throw "graphcoded.exe exited during startup: $stderr" + } + if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { + # A PID exists before the lifetime mutex and listener are initialized. This is + # especially observable on restart, when the Zig test executable is already built. + $readyPipe = [IO.Pipes.NamedPipeClientStream]::new(".", $daemonPipe.Substring(9), + [IO.Pipes.PipeDirection]::InOut) + try { + $readyPipe.Connect(15000) + } catch { + throw [InvalidOperationException]::new( + "Owned daemon did not publish its listener before the round-trip test.", $_.Exception) + } finally { + $readyPipe.Dispose() + } + return $process + } + Start-Sleep -Milliseconds 100 } - if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { - $script:daemonStdout = $stdout - $script:daemonStderr = $stderr - # A PID exists before the lifetime mutex and listener are initialized. This is - # especially observable on restart, when the Zig test executable is already built. - $readyPipe = [IO.Pipes.NamedPipeClientStream]::new(".", $daemonPipe.Substring(9), - [IO.Pipes.PipeDirection]::InOut) - try { - $readyPipe.Connect(15000) - } catch { - if (-not $process.HasExited) { $process.Kill(); [void]$process.WaitForExit(5000) } - throw "Owned daemon did not publish its listener before the round-trip test: $($_.Exception.Message)" - } finally { - $readyPipe.Dispose() + throw "graphcoded.exe did not appear as a child at its exact executable path" + } catch { + $startupError = $_ + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + try { + if ($started) { + if (-not $handleHeld) { + throw "The started daemon's original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $process -Name "round-trip daemon startup" ` + -TimeoutMilliseconds 5000 + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($process) { + try { $process.Dispose() } catch { $cleanupErrors.Add($_.Exception) } } - return $process } - Start-Sleep -Milliseconds 100 + if ($cleanupErrors.Count -gt 0) { + $cleanupErrors.Insert(0, $startupError.Exception) + throw (New-OwnedLiveProcessCleanupError ` + -Message "Round-trip daemon startup cleanup could not be verified; fixture root: $runRoot" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($runRoot)) + } + throw $startupError } - throw "graphcoded.exe did not appear as a child at its exact executable path" } function Stop-OwnedDaemon([Diagnostics.Process] $process) { @@ -156,9 +186,12 @@ function Stop-OwnedDaemon([Diagnostics.Process] $process) { if (-not $process.HasExited) { throw "graphcoded.exe is still running after shutdown" } + Complete-ShellOwnedProcess $processScope $process $script:runLog.Add("daemon-exit pid=$($process.Id) code=$($process.ExitCode)") - $script:runLog.Add("daemon-stdout=$($script:daemonStdout.Result.Trim())") - $script:runLog.Add("daemon-stderr=$($script:daemonStderr.Result.Trim())") + $stdout = Read-ShellOwnedOutput $processScope $process + $stderr = Read-ShellOwnedOutput $processScope $process -StandardError + $script:runLog.Add("daemon-stdout=$($stdout.Trim())") + $script:runLog.Add("daemon-stderr=$($stderr.Trim())") } function Invoke-ZigTest([string] $label, [string] $filter) { @@ -167,19 +200,21 @@ function Invoke-ZigTest([string] $label, [string] $filter) { $env:GRAPHCODE_LOCAL_TEST_SUPPORT = $supportDirectory $env:ZIG_LOCAL_CACHE_DIR = $zigLocalCache $env:ZIG_GLOBAL_CACHE_DIR = $zigGlobalCache - $output = @() - $exitCode = 0 - Push-Location $shellRoot - try { - $output = @(& $ZigExecutable test src\DaemonRoundTripTests.zig ` - -target x86_64-windows-msvc -lc -ladvapi32 "-I$WinghosttyInclude" ` - --cache-dir $zigLocalCache --global-cache-dir $zigGlobalCache ` - --test-filter $filter 2>&1) - $exitCode = $LASTEXITCODE - } finally { - Pop-Location - } - $text = $output -join "`n" + $testInfo = [Diagnostics.ProcessStartInfo]::new() + $testInfo.FileName = $ZigExecutable + $testInfo.WorkingDirectory = $shellRoot + $testInfo.UseShellExecute = $false + foreach ($argument in @( + "test", "src\DaemonRoundTripTests.zig", + "-target", "x86_64-windows-msvc", "-lc", "-ladvapi32", "-I$WinghosttyInclude", + "--cache-dir", $zigLocalCache, "--global-cache-dir", $zigGlobalCache, + "--test-filter", $filter)) { + [void]$testInfo.ArgumentList.Add($argument) + } + $result = Invoke-OwnedLiveCommand -Scope $processScope -StartInfo $testInfo + $exitCode = $result.ExitCode + $global:LASTEXITCODE = $exitCode + $text = @($result.Output, $result.Error) -join "`n" [Console]::WriteLine($text) $logPath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-$label.log" [IO.File]::WriteAllText($logPath, "$text`n", [Text.UTF8Encoding]::new($false)) @@ -259,8 +294,25 @@ function Assert-MutatedGraph([object] $graph, [int] $fireCount) { } $defaultSignatureBefore = Get-DefaultSupportSignature +if (Test-Path -LiteralPath $runRoot) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "Round-trip scratch already exists; ownership is unproven: $runRoot" ` + -Errors @([InvalidOperationException]::new("Existing scratch was not reused.")) ` + -RetainedRoots @($runRoot)) +} +try { + $null = New-ValidationFixtureDirectory -Path $runRoot +} catch { + $creationError = $_.Exception + if (Test-Path -LiteralPath $runRoot) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "Round-trip scratch appeared during admission; ownership is unproven: $runRoot" ` + -Errors @($creationError) -RetainedRoots @($runRoot)) + } + throw +} New-Item -ItemType Directory -Force -Path @( - $runRoot, $supportDirectory, $sessionDirectory, $projectPath, $spawnProjectPath, + $supportDirectory, $sessionDirectory, $projectPath, $spawnProjectPath, $evidenceDirectory, $tempDirectory, $zigLocalCache, $zigGlobalCache ) | Out-Null [IO.File]::WriteAllText($sessionMarkerPath, $sessionMarker, [Text.UTF8Encoding]::new($false)) @@ -277,7 +329,9 @@ $shutdownEvent = [Threading.EventWaitHandle]::new( $shutdownEventName ) +$roundtripError = $null try { + $processScope = New-ShellProcessScope $homePrefix = $defaultSupportDirectory.TrimEnd('\') + '\' if ($runRoot.StartsWith($homePrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "disposable daemon state unexpectedly resolves under the default support directory" @@ -354,36 +408,129 @@ try { throw "the user's default ~/.graphcode tree changed during the isolated daemon test" } $runLog.Add("default-support=unchanged") - $successful = $true $resultPath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-summary.txt" [IO.File]::WriteAllLines($resultPath, $runLog, [Text.UTF8Encoding]::new($false)) - Write-Output "REAL_DAEMON_ROUNDTRIP: PASS" - Write-Output "Accepted mutations: create node, update and rename node, create edge, edit edge, promote sketch" - Write-Output "Restart readback: node identities/configuration and edge id/endpoints/fireCount/transform/spawn/cycle guard" - Write-Output "Mutex: present in each live process and absent after each clean shutdown, using production-derived name" - Write-Output "State: disposable support directory; default ~/.graphcode tree unchanged" - Write-Output "Evidence: $resultPath" + $successful = $true +} catch { + $roundtripError = $_.Exception + throw } finally { - if ($daemonProcess -and -not $daemonProcess.HasExited) { + $shutdownError = $null + $cleanupError = $null + $retentionError = $null + if ($daemonProcess) { try { Stop-OwnedDaemon $daemonProcess } catch { - Stop-Process -Id $daemonProcess.Id -Force -ErrorAction SilentlyContinue + $shutdownError = $_.Exception + try { + Stop-OwnedLiveProcess -Process $daemonProcess -Name "round-trip daemon" + } catch { + $cleanupError = $_.Exception + $errors = @() + if ($roundtripError) { $errors += $roundtripError } + $errors += $shutdownError + $errors += $cleanupError + $retentionError = New-OwnedLiveProcessCleanupError ` + -Message "Round-trip daemon cleanup could not be verified; fixture root: $runRoot" ` + -Errors $errors -RetainedRoots @($runRoot) + } + } + try { $daemonProcess.Dispose() } catch { + $disposeError = $_.Exception + $errors = @() + if ($roundtripError) { $errors += $roundtripError } + if ($shutdownError) { $errors += $shutdownError } + if ($cleanupError) { $errors += $cleanupError } + $errors += $disposeError + $retentionError = New-OwnedLiveProcessCleanupError ` + -Message "Round-trip daemon handle cleanup could not be verified; fixture root: $runRoot" ` + -Errors $errors -RetainedRoots @($runRoot) } } - if ($daemonProcess) { $daemonProcess.Dispose() } - if ($shutdownEvent) { $shutdownEvent.Dispose() } + if ($processScope) { + try { Close-ShellProcessScope $processScope } catch { + $jobError = $_.Exception + $errors = @() + if ($retentionError) { + $errors += $retentionError + } elseif ($roundtripError) { + $errors += $roundtripError + } + if ($shutdownError -and -not $retentionError) { $errors += $shutdownError } + $errors += $jobError + $retentionError = New-OwnedLiveProcessCleanupError ` + -Message "Round-trip job cleanup could not be verified; fixture root: $runRoot" ` + -Errors $errors -RetainedRoots @($runRoot) + } + } + $auxiliaryErrors = [Collections.Generic.List[Exception]]::new() + if ($shutdownEvent) { + try { $shutdownEvent.Dispose() } catch { $auxiliaryErrors.Add($_.Exception) } + } foreach ($name in @( "GRAPHCODE_DAEMON_ROUNDTRIP_PHASE", "GRAPHCODE_DAEMON_ROUNDTRIP_PROJECT", "GRAPHCODE_DAEMON_ROUNDTRIP_SPAWN_PROJECT", "GRAPHCODE_DAEMON_ROUNDTRIP_EDGE_ID", "GRAPHCODE_LOCAL_TEST_SUPPORT")) { - Remove-Item -LiteralPath "env:$name" -ErrorAction SilentlyContinue + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + if ($environment.Contains($name)) { + try { Remove-Item -LiteralPath "env:$name" -ErrorAction Stop } catch { + $auxiliaryErrors.Add($_.Exception) + } + } + } + $artifactError = $null + try { + if ($successful -and -not $roundtripError -and -not $shutdownError -and -not $retentionError -and + $auxiliaryErrors.Count -eq 0) { + Remove-Item -LiteralPath $runRoot -Recurse -Force + } else { + $failurePath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-failure.txt" + [IO.File]::WriteAllLines($failurePath, $runLog, [Text.UTF8Encoding]::new($false)) + Write-Output "Failure artifacts written at $runRoot and $failurePath; enclosing isolation may remove them" + } + } catch { + $artifactError = $_.Exception } - if ($successful) { - Remove-Item -LiteralPath $runRoot -Recurse -Force - } else { - $failurePath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-failure.txt" - [IO.File]::WriteAllLines($failurePath, $runLog, [Text.UTF8Encoding]::new($false)) - Write-Output "Failure artifacts retained at $runRoot and $failurePath" + if ($artifactError -or $auxiliaryErrors.Count -gt 0) { + $errors = @() + if ($retentionError) { + $errors += $retentionError + } elseif ($roundtripError) { + $errors += $roundtripError + } + if ($shutdownError -and -not $retentionError) { $errors += $shutdownError } + foreach ($error in $auxiliaryErrors) { $errors += $error } + if ($artifactError) { $errors += $artifactError } + $combined = [AggregateException]::new( + "Round-trip failure reporting or fixture cleanup failed.", [Exception[]]$errors) + if (Test-OwnedLiveProcessRetentionRequired $combined) { + $combined.Data["GraphCodeValidationRootRetentionRequired"] = $true + $combined.Data["GraphCodeValidationRetainedRoots"] = [string[]]@( + Get-OwnedLiveProcessRetainedRoots -Errors $errors -RetainedRoots @($runRoot)) + } + throw $combined + } + if ($retentionError) { throw $retentionError } + if ($shutdownError) { + $errors = @() + if ($roundtripError) { $errors += $roundtripError } + $errors += $shutdownError + $combined = [AggregateException]::new( + "Round-trip daemon required forced cleanup after shutdown failed.", + [Exception[]]$errors) + if (Test-OwnedLiveProcessRetentionRequired $combined) { + $combined.Data["GraphCodeValidationRootRetentionRequired"] = $true + $combined.Data["GraphCodeValidationRetainedRoots"] = [string[]]@( + Get-OwnedLiveProcessRetainedRoots -Errors $errors -RetainedRoots @($runRoot)) + } + throw $combined } } +Write-Output "REAL_DAEMON_ROUNDTRIP: PASS" +Write-Output "Accepted mutations: create node, update and rename node, create edge, edit edge, promote sketch" +Write-Output "Restart readback: node identities/configuration and edge id/endpoints/fireCount/transform/spawn/cycle guard" +Write-Output "Mutex: present in each live process and absent after each clean shutdown, using production-derived name" +Write-Output "State: disposable support directory; default ~/.graphcode tree unchanged" +Write-Output "Evidence: $resultPath" diff --git a/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 b/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 index baad5c88..560b3aa8 100644 --- a/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 +++ b/Tools/windows/Tests/GdiplusStartup.Live.Tests.ps1 @@ -1,128 +1,195 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string] $ZigExecutable, - [Parameter(Mandatory)][string] $WinghosttyRoot, - [string] $ScratchRoot = (Join-Path $env:TEMP "graphcode-gdiplus-startup") -) - -$ErrorActionPreference = "Stop" - -if (-not ("GraphCodeGdiplusStartupNative" -as [type])) { - Add-Type @' -using System; -using System.Runtime.InteropServices; -using System.Text; - -public static class GraphCodeGdiplusStartupNative { - public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, StringBuilder value, int capacity); - [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); - - public static IntPtr Find(uint processId, string className) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId || !IsWindowVisible(window)) return true; - var actual = new StringBuilder(128); - GetClassName(window, actual, actual.Capacity); - if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } -} -'@ -} - -$shellRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..\graphcode-windows") -$scratch = [IO.Path]::GetFullPath($ScratchRoot) -$bin = Join-Path $scratch "bin" -$profile = Join-Path $scratch "profile" -$temp = Join-Path $scratch "temp" -$executable = Join-Path $bin "gdiplus-startup-live.exe" -if (Test-Path -LiteralPath $scratch) { - Remove-Item -LiteralPath $scratch -Recurse -Force -} -New-Item -ItemType Directory -Force -Path ` - $bin,$profile,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),$temp | - Out-Null - -$include = Join-Path $WinghosttyRoot "include" -Push-Location $shellRoot -try { - & $ZigExecutable build-exe src\GdiplusStartupLiveRunner.zig ` - -target x86_64-windows-msvc -OReleaseSafe -lc ` - -luser32 -lgdi32 -lgdiplus "-I$include" "-femit-bin=$executable" - if ($LASTEXITCODE -ne 0) { - throw "GDI+ startup live runner build failed with exit code $LASTEXITCODE" - } -} finally { - Pop-Location -} - -$system32 = Join-Path $env:SystemRoot "System32" -$powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" -$start = [Diagnostics.ProcessStartInfo]::new() -$start.FileName = $executable -$start.WorkingDirectory = $bin -$start.UseShellExecute = $false -$start.Environment.Clear() -$values = [ordered]@{ - SystemRoot = $env:SystemRoot - windir = $env:WINDIR - USERPROFILE = $profile - LOCALAPPDATA = Join-Path $profile "AppData\Local" - APPDATA = Join-Path $profile "AppData\Roaming" - TEMP = $temp - TMP = $temp - ProgramData = $env:ProgramData - HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") - HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) - PATH = "$system32;$powerShell;$bin" -} -foreach ($name in $values.Keys) { $start.Environment[$name] = [string]$values[$name] } - -$process = [Diagnostics.Process]::new() -$process.StartInfo = $start -$observations = 0 -$window = [IntPtr]::Zero -try { - if (-not $process.Start()) { throw "GDI+ startup live runner did not start" } - [void]$process.Handle - $deadline = [DateTime]::UtcNow.AddSeconds(5) - do { - $observations++ - $process.Refresh() - if ($process.HasExited) { - throw ("GDI+ startup live runner exited before creating a native window: " + - "exitCode=$($process.ExitCode); observations=$observations") - } - $window = [GraphCodeGdiplusStartupNative]::Find( - [uint32]$process.Id, - "GraphCodeGdiplusStartupTest" - ) - if ($window -ne [IntPtr]::Zero) { break } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - - if ($observations -le 0) { throw "GDI+ startup live runner made zero observations" } - if ($window -eq [IntPtr]::Zero) { - throw ("GDI+ startup live runner remained alive but no native window appeared: " + - "observations=$observations; environmentKeys=$( - @($start.Environment.Keys | Sort-Object) -join ',')") - } - Write-Output ("GDIPLUS_STARTUP_LIVE: PASS; executed=1; observations=$observations; " + - "window=$($window.ToInt64()); environmentKeys=$( - @($start.Environment.Keys | Sort-Object) -join ',')") -} finally { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force - [void]$process.WaitForExit(10000) - } - if ($process) { $process.Dispose() } - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue -} +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $ZigExecutable, + [Parameter(Mandatory)][string] $WinghosttyRoot, + [string] $ScratchRoot = (Join-Path $env:TEMP "graphcode-gdiplus-startup") +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\OwnedLiveProcess.ps1") +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force + +if (-not ("GraphCodeGdiplusStartupNative" -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class GraphCodeGdiplusStartupNative { + public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, StringBuilder value, int capacity); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + + public static IntPtr Find(uint processId, string className) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId || !IsWindowVisible(window)) return true; + var actual = new StringBuilder(128); + GetClassName(window, actual, actual.Capacity); + if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } +} +'@ +} + +$shellRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..\graphcode-windows") +$scratch = [IO.Path]::GetFullPath($ScratchRoot) +$bin = Join-Path $scratch "bin" +$profile = Join-Path $scratch "profile" +$temp = Join-Path $scratch "temp" +$executable = Join-Path $bin "gdiplus-startup-live.exe" +if (Test-Path -LiteralPath $scratch) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "GDI+ scratch already exists; ownership is unproven: $scratch" ` + -Errors @([InvalidOperationException]::new("Existing scratch was not removed.")) ` + -RetainedRoots @($scratch)) +} +try { + $scratchParent = [IO.Path]::GetDirectoryName($scratch) + if (-not [IO.Directory]::Exists($scratchParent)) { + [void][IO.Directory]::CreateDirectory($scratchParent) + } + $null = New-ValidationFixtureDirectory -Path $scratch +} catch { + $creationError = $_.Exception + if (Test-Path -LiteralPath $scratch) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "GDI+ scratch appeared during admission; ownership is unproven: $scratch" ` + -Errors @($creationError) -RetainedRoots @($scratch)) + } + throw +} +New-Item -ItemType Directory -Force -Path ` + $bin,$profile,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),$temp | + Out-Null + +$system32 = Join-Path $env:SystemRoot "System32" +$powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" +$start = [Diagnostics.ProcessStartInfo]::new() +$start.FileName = $executable +$start.WorkingDirectory = $bin +$start.UseShellExecute = $false +$start.Environment.Clear() +$values = [ordered]@{ + SystemRoot = $env:SystemRoot + windir = $env:WINDIR + USERPROFILE = $profile + LOCALAPPDATA = Join-Path $profile "AppData\Local" + APPDATA = Join-Path $profile "AppData\Roaming" + TEMP = $temp + TMP = $temp + ProgramData = $env:ProgramData + HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") + HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) + PATH = "$system32;$powerShell;$bin" +} +foreach ($name in $values.Keys) { $start.Environment[$name] = [string]$values[$name] } + +$process = $null +$processScope = $null +$observations = 0 +$window = [IntPtr]::Zero +$started = $false +$handleHeld = $false +$caseError = $null +try { + $processScope = New-ShellProcessScope + $buildInfo = [Diagnostics.ProcessStartInfo]::new() + $buildInfo.FileName = $ZigExecutable + $buildInfo.WorkingDirectory = $shellRoot.Path + $buildInfo.UseShellExecute = $false + $include = Join-Path $WinghosttyRoot "include" + foreach ($argument in @( + "build-exe", "src\GdiplusStartupLiveRunner.zig", + "-target", "x86_64-windows-msvc", "-OReleaseSafe", "-lc", + "-luser32", "-lgdi32", "-lgdiplus", "-I$include", "-femit-bin=$executable", + "--cache-dir", (Join-Path $scratch "zig-cache"), + "--global-cache-dir", (Join-Path $scratch "zig-global-cache"))) { + [void]$buildInfo.ArgumentList.Add($argument) + } + $build = Invoke-OwnedLiveCommand -Scope $processScope -StartInfo $buildInfo -StreamOutput + if ($build.ExitCode -ne 0) { + throw "GDI+ startup live runner build failed with exit code $($build.ExitCode)" + } + + $process = Start-ShellOwnedProcess $processScope $start + $started = $true + [void]$process.Handle + $handleHeld = $true + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + $observations++ + $process.Refresh() + if ($process.HasExited) { + throw ("GDI+ startup live runner exited before creating a native window: " + + "exitCode=$($process.ExitCode); observations=$observations") + } + $window = [GraphCodeGdiplusStartupNative]::Find( + [uint32]$process.Id, + "GraphCodeGdiplusStartupTest" + ) + if ($window -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + + if ($observations -le 0) { throw "GDI+ startup live runner made zero observations" } + if ($window -eq [IntPtr]::Zero) { + throw ("GDI+ startup live runner remained alive but no native window appeared: " + + "observations=$observations; environmentKeys=$( + @($start.Environment.Keys | Sort-Object) -join ',')") + } +} catch { + $caseError = $_.Exception + throw +} finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + try { + if ($started) { + if (-not $handleHeld) { + throw "The started GDI+ runner's original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $process -Name "GDI+ startup runner" + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($process) { + try { $process.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + if ($processScope) { + try { Close-ShellProcessScope $processScope } catch { $cleanupErrors.Add($_.Exception) } + } + if ($cleanupErrors.Count -gt 0) { + if ($caseError) { $cleanupErrors.Insert(0, $caseError) } + throw (New-OwnedLiveProcessCleanupError ` + -Message "GDI+ runner cleanup could not be verified; fixture root: $scratch" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($scratch)) + } + if ($caseError -and (Test-OwnedLiveProcessRetentionRequired $caseError)) { + Write-Output "GDIPLUS_FIXTURE_RETENTION_REQUESTED=$scratch" + } else { + try { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop + } catch { + if ($caseError) { + throw [AggregateException]::new( + "GDI+ fixture cleanup failed.", [Exception[]]@($caseError, $_.Exception)) + } + throw + } + } +} +$global:LASTEXITCODE = 0 +Write-Output ("GDIPLUS_STARTUP_LIVE: PASS; executed=1; observations=$observations; " + + "window=$($window.ToInt64()); environmentKeys=$( + @($start.Environment.Keys | Sort-Object) -join ',')") diff --git a/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 b/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 new file mode 100644 index 00000000..ecf2e95b --- /dev/null +++ b/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 @@ -0,0 +1,432 @@ +BeforeDiscovery { + Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force +} + +BeforeAll { + $hadValidationRoot = [Environment]::GetEnvironmentVariables("Process").Contains("GRAPHCODE_VALIDATION_ROOT") + $savedValidationRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", "Process") + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue +} + +AfterAll { + if ($hadValidationRoot) { + [Environment]::SetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", $savedValidationRoot, "Process") + } else { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } +} + +Describe "Owned live callers and shell adapter with mocked native operations" { + InModuleScope ShellProcessOwnership { + BeforeAll { + $moduleRoot = Split-Path -Parent (Get-Module ShellProcessOwnership).Path + . (Join-Path $moduleRoot "OwnedLiveProcess.ps1") + foreach ($item in @( + @{ File = "ScrubbedShellStartup.Live.Tests.ps1"; Name = "Invoke-ScrubbedCli" }, + @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Name = "Start-OwnedDaemon" }, + @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Name = "Stop-OwnedDaemon" })) { + $errors = $null + $source = Join-Path $moduleRoot "Tests\$($item.File)" + $ast = [Management.Automation.Language.Parser]::ParseFile($source, [ref]$null, [ref]$errors) + if ($errors.Count -ne 0) { throw "$source does not parse" } + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $item.Name + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one function named $($item.Name)." } + . ([scriptblock]::Create($matches[0].Extent.Text)) + } + + function New-StartInfo([string] $file, [string] $root) { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.UseShellExecute = $false + return $info + } + + function New-ControlledLaunch([string] $name, [bool] $exited = $false) { + $process = [Diagnostics.Process]::new() + $process | Add-Member NoteProperty MockName $name + $process | Add-Member NoteProperty HasExited $exited -Force + $process | Add-Member NoteProperty ExitCode 7 -Force + $process | Add-Member NoteProperty Id 61001 -Force + $process | Add-Member NoteProperty Handle ([IntPtr]91) -Force + $process | Add-Member ScriptMethod WaitForExit { + param($milliseconds = $null) + $script:events.Add("wait-root:$($this.MockName)") + $this.HasExited = $true + if ($null -ne $milliseconds) { return $true } + } -Force + $process | Add-Member ScriptMethod Refresh { } -Force + $process | Add-Member ScriptMethod Dispose { + $script:events.Add("dispose:$($this.MockName)") + } -Force + $run = [pscustomobject]@{ + Process = $process + Handle = [IntPtr]91 + Output = [Threading.Tasks.Task]::FromResult[string]("stdout-$name") + Error = [Threading.Tasks.Task]::FromResult[string]("stderr-$name") + PipeError = $false + OutputChunks = [Collections.Generic.Queue[string]]::new() + ErrorChunks = [Collections.Generic.Queue[string]]::new() + } + $run | Add-Member ScriptMethod Resume { $script:events.Add("resume:$($this.Process.MockName)") } + $run | Add-Member ScriptMethod Stop { + $script:events.Add("stop:$($this.Process.MockName)") + $this.Process.HasExited = $true + } + $run | Add-Member ScriptMethod RequireExited { + $script:events.Add("require-exited:$($this.Process.MockName)") + if (-not $this.Process.HasExited) { throw "mock root is still live" } + } + $run | Add-Member ScriptMethod Drain { + $script:events.Add("drain:$($this.Process.MockName)") + if ($this.PipeError) { throw "mock inherited pipe EOF unproved" } + } + $run | Add-Member ScriptMethod TakeOutput { + param([int] $maximumCharacters) + $script:events.Add("take-output:$($this.Process.MockName)") + if ($maximumCharacters -ne 65536) { throw "unexpected output bound" } + if ($this.OutputChunks.Count -gt 0) { return $this.OutputChunks.Dequeue() } + return "" + } + $run | Add-Member ScriptMethod TakeError { + param([int] $maximumCharacters) + $script:events.Add("take-error:$($this.Process.MockName)") + if ($maximumCharacters -ne 65536) { throw "unexpected error bound" } + if ($this.ErrorChunks.Count -gt 0) { return $this.ErrorChunks.Dequeue() } + return "" + } + $run | Add-Member ScriptMethod Close { $script:events.Add("close-run:$($this.Process.MockName)") } + $script:queue.Enqueue($run) + return $run + } + } + + BeforeEach { + $script:events = [Collections.Generic.List[string]]::new() + $script:queue = [Collections.Generic.Queue[object]]::new() + $script:starts = [Collections.Generic.List[Diagnostics.ProcessStartInfo]]::new() + $script:nextJob = 100 + $script:scope = $null + Mock Initialize-ShellProcessOwnership {} + Mock New-ShellSuspendedLaunch { + param($StartInfo) + $script:events.Add("suspended") + $script:starts.Add($StartInfo) + return $script:queue.Dequeue() + } + Mock Invoke-ShellJob { + param($Operation, $Job, $Handle) + $script:events.Add("${Operation}:$Job") + switch ($Operation) { + "Create" { $script:nextJob += 100; return [IntPtr]$script:nextJob } + "Active" { return 0 } + "Contains" { return $true } + "Terminate" { + if ($null -ne $script:scope -and $Job -eq $script:scope.Job) { + foreach ($run in $script:scope.Runs) { $run.Process.HasExited = $true } + } + } + } + } + Mock Write-Host {} + $script:scope = New-ShellProcessScope + $script:events.Clear() + $script:Cli = "C:\mock\graphcode.exe" + $script:runLog = [Collections.Generic.List[string]]::new() + $script:processScope = $script:scope + } + + It "runs the command helper through assignment, completion and both output reads" { + $run = New-ControlledLaunch "command" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = "C:\mock\zig.exe" + $info.UseShellExecute = $false + [void]$info.ArgumentList.Add("test") + + $result = Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info + + $result.ExitCode | Should -Be 7 + $result.Output | Should -BeExactly "stdout-command" + $result.Error | Should -BeExactly "stderr-command" + $info.RedirectStandardOutput | Should -BeTrue + $info.RedirectStandardError | Should -BeTrue + $script:starts.Count | Should -Be 1 + [object]::ReferenceEquals($script:starts[0], $info) | Should -BeTrue + $run.Finalized | Should -BeTrue + ($script:events -join ",") | Should -BeExactly ( + "suspended,Assign:200,Create:,Assign:300,resume:command,wait-root:command," + + "require-exited:command,Terminate:300,Wait:300,Active:300,drain:command,Close:300") + Close-ShellProcessScope $script:scope + $script:scope.Closed | Should -BeTrue + } + + It "does not finalize a live sibling when the command completes" { + $daemon = New-ControlledLaunch "daemon" + $command = New-ControlledLaunch "command" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = "C:\mock\fixture.exe" + $info.UseShellExecute = $false + [void](Start-ShellOwnedProcess $script:scope $info) + $script:events.Clear() + + $result = Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info + + $result.Output | Should -BeExactly "stdout-command" + $command.Finalized | Should -BeTrue + $daemon.Finalized | Should -BeFalse + $daemon.Process.HasExited | Should -BeFalse + $script:events | Should -Contain "Terminate:400" + $script:events | Should -Not -Contain "Terminate:200" + $script:events | Should -Not -Contain "Terminate:300" + $script:events | Should -Not -Contain "drain:daemon" + Close-ShellProcessScope $script:scope + $script:scope.Closed | Should -BeTrue + } + + It "propagates adapter EOF uncertainty before either command output read" { + $run = New-ControlledLaunch "command" + $run.PipeError = $true + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + Mock Read-ShellOwnedOutput { throw "unexpected output read" } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info } catch { $failure = $_.Exception } + + $failure | Should -Not -BeNullOrEmpty + Test-OwnedLiveProcessRetentionRequired $failure | Should -BeTrue + $run.Finalized | Should -BeFalse + $script:scope.Closed | Should -BeFalse + Should -Invoke Read-ShellOwnedOutput -Times 0 -Exactly + } + + It "forwards adapter chunks before exit and drains after completion without duplication" { + $run = New-ControlledLaunch "command" + foreach ($chunk in @("early out", "", "late out", "")) { $run.OutputChunks.Enqueue($chunk) } + foreach ($chunk in @("early err", "", "late err", "")) { $run.ErrorChunks.Enqueue($chunk) } + $run.Output = [Threading.Tasks.Task]::FromResult[string]("early outlate out") + $run.Error = [Threading.Tasks.Task]::FromResult[string]("early errlate err") + $run.Process | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $script:events.Add("wait-root:$($this.MockName):$milliseconds") + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $previousOutput = [Console]::Out + $previousError = [Console]::Error + $output = [IO.StringWriter]::new() + $error = [IO.StringWriter]::new() + try { + [Console]::SetOut($output) + [Console]::SetError($error) + $result = Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info -StreamOutput + $stdout = $output.ToString() + $stderr = $error.ToString() + } finally { + [Console]::SetOut($previousOutput) + [Console]::SetError($previousError) + $output.Dispose() + $error.Dispose() + } + + $stdout | Should -BeExactly "early outlate out" + $stderr | Should -BeExactly "early errlate err" + $result.Output | Should -BeExactly $stdout + $result.Error | Should -BeExactly $stderr + $script:starts[0].RedirectStandardOutput | Should -BeTrue + $script:starts[0].RedirectStandardError | Should -BeTrue + $run.Finalized | Should -BeTrue + $script:events.IndexOf("take-output:command") | Should -BeLessThan ` + $script:events.IndexOf("wait-root:command:100") + $script:events.IndexOf("wait-root:command:100") | Should -BeLessThan ` + $script:events.IndexOf("Terminate:300") + $run.OutputChunks.Count | Should -Be 0 + $run.ErrorChunks.Count | Should -Be 0 + Close-ShellProcessScope $script:scope + } + + It "propagates a tagged adapter pump fault without completing the launch" { + $run = New-ControlledLaunch "command" + $run | Add-Member ScriptMethod TakeOutput { throw "mock source pump failed" } -Force + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $failure = $null + try { Invoke-OwnedLiveCommand -Scope $script:scope -StartInfo $info -StreamOutput } catch { + $failure = $_.Exception + } + + $failure | Should -Not -BeNullOrEmpty + Test-OwnedLiveProcessRetentionRequired $failure | Should -BeTrue + $run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "Terminate:300" + $script:scope.Closed | Should -BeFalse + } + + It "runs the source-derived scrubbed CLI through the adapter before disposal" { + $daemon = New-ControlledLaunch "daemon" + $cli = New-ControlledLaunch "cli" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + [void](Start-ShellOwnedProcess $script:scope $info) + $script:events.Clear() + + $result = Invoke-ScrubbedCli $script:scope "case" "C:\mock\root" ` + "C:\mock\support" @("projects", "--json") + + $result.ExitCode | Should -Be 7 + $result.Output | Should -BeExactly "stdout-cli" + $result.Error | Should -BeExactly "stderr-cli" + $cli.Finalized | Should -BeTrue + $daemon.Finalized | Should -BeFalse + $daemon.Process.HasExited | Should -BeFalse + $script:starts[1].Environment["GRAPHCODE_SUPPORT_DIR"] | Should -BeExactly "C:\mock\support" + $script:starts[1].RedirectStandardOutput | Should -BeTrue + $script:starts[1].RedirectStandardError | Should -BeTrue + @($script:starts[1].ArgumentList) | Should -Be @("projects", "--json") + $script:events.IndexOf("drain:cli") | Should -BeLessThan $script:events.IndexOf("dispose:cli") + $script:events | Should -Not -Contain "Terminate:300" + Close-ShellProcessScope $script:scope + } + + It "preserves the unredirected startup stream policy through the owned launcher" -TestCases @( + @{ name = "daemon"; variable = "daemonInfo" }, + @{ name = "shell"; variable = "shellInfo" } + ) { + param($name, $variable) + $path = Join-Path $moduleRoot "Tests\ScrubbedShellStartup.Live.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $builders = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "New-StartInfo" + }, $true)) + $builders.Count | Should -Be 1 + . ([scriptblock]::Create($builders[0].Extent.Text)) + $commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq "Start-ShellOwnedProcess" -and + $node.Extent.Text -match ('\$processScope\s+\$' + $variable + '\b') + }, $true)) + $commands.Count | Should -Be 1 + $info = New-StartInfo "C:\mock\$name.exe" "C:\mock\root" + Set-Variable -Name $variable -Value $info + $processScope = $script:scope + $null = New-ControlledLaunch $name + [void](& ([scriptblock]::Create($commands[0].Extent.Text))) + + $script:starts.Count | Should -Be 1 + [object]::ReferenceEquals($script:starts[0], $info) | Should -BeTrue + $info.RedirectStandardOutput | Should -BeFalse + $info.RedirectStandardError | Should -BeFalse + Close-ShellProcessScope $script:scope + } + + It "preserves the unredirected GDI runner stream policy through the owned launcher" { + $path = Join-Path $moduleRoot "Tests\GdiplusStartup.Live.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $builders = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$start' -and + $node.Right.Extent.Text -ceq '[Diagnostics.ProcessStartInfo]::new()' + }, $true)) + $builders.Count | Should -Be 1 + $redirects = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [Management.Automation.Language.MemberExpressionAst] -and + $node.Left.Expression.Extent.Text -ceq '$start' -and + $node.Left.Member.Extent.Text -in @("RedirectStandardOutput", "RedirectStandardError") + }, $true)) + $redirects.Count | Should -Be 0 + $commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq "Start-ShellOwnedProcess" -and + $node.Extent.Text -match '\$processScope\s+\$start\b' + }, $true)) + $commands.Count | Should -Be 1 + . ([scriptblock]::Create($builders[0].Extent.Text)) + $processScope = $script:scope + $null = New-ControlledLaunch "gdiplus" + [void](& ([scriptblock]::Create($commands[0].Extent.Text))) + + $script:starts.Count | Should -Be 1 + [object]::ReferenceEquals($script:starts[0], $start) | Should -BeTrue + $start.RedirectStandardOutput | Should -BeFalse + $start.RedirectStandardError | Should -BeFalse + Close-ShellProcessScope $script:scope + } + + It "retains adapter completion failures across the scrubbed CLI cleanup" { + $cli = New-ControlledLaunch "cli" + $cli.PipeError = $true + $failure = $null + try { + Invoke-ScrubbedCli $script:scope "case" "C:\mock\root" "C:\mock\support" @("projects") + } catch { $failure = $_.Exception } + + $failure | Should -Not -BeNullOrEmpty + Test-OwnedLiveProcessRetentionRequired $failure | Should -BeTrue + $cli.Finalized | Should -BeFalse + $script:events | Should -Contain "dispose:cli" + $script:scope.Closed | Should -BeFalse + } + + It "uses the adapter after source-derived graceful daemon shutdown" { + $daemon = New-ControlledLaunch "daemon" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $process = Start-ShellOwnedProcess $script:scope $info + $script:shutdownEvent = [pscustomobject]@{} + $script:shutdownEvent | Add-Member ScriptMethod Set { + $script:events.Add("shutdown-event") + return $true + } + $script:events.Clear() + + Stop-OwnedDaemon $process + + $daemon.Finalized | Should -BeTrue + $script:events.IndexOf("shutdown-event") | Should -BeLessThan $script:events.IndexOf("wait-root:daemon") + $script:events.IndexOf("wait-root:daemon") | Should -BeLessThan $script:events.IndexOf("Terminate:300") + $script:runLog.Count | Should -Be 3 + $script:runLog[1] | Should -BeExactly "daemon-stdout=stdout-daemon" + $script:runLog[2] | Should -BeExactly "daemon-stderr=stderr-daemon" + Close-ShellProcessScope $script:scope + } + + It "preserves the source-derived startup-exit error after adapter completion" { + $daemon = New-ControlledLaunch "daemon" $true + $script:shutdownEvent = [pscustomobject]@{} + $script:shutdownEvent | Add-Member ScriptMethod Reset { return $true } + $script:expectedDaemonPath = "C:\mock\graphcoded.exe" + $script:supportDirectory = "C:\mock\support" + $script:daemonPipe = "\\.\pipe\mock" + $script:shutdownEventName = "Local\mock" + $script:runRoot = "C:\mock\root" + $failure = $null + try { Start-OwnedDaemon } catch { $failure = $_.Exception } + + $failure | Should -Not -BeNullOrEmpty + $failure.Message | Should -Match "graphcoded.exe exited during startup: stderr-daemon" + $script:starts[0].RedirectStandardOutput | Should -BeTrue + $script:starts[0].RedirectStandardError | Should -BeTrue + $daemon.Finalized | Should -BeTrue + $script:events.IndexOf("drain:daemon") | Should -BeLessThan $script:events.IndexOf("dispose:daemon") + Close-ShellProcessScope $script:scope + } + } +} diff --git a/Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 b/Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 new file mode 100644 index 00000000..2f0b41d8 --- /dev/null +++ b/Tools/windows/Tests/OwnedLiveProcessCleanup.Tests.ps1 @@ -0,0 +1,1918 @@ +[CmdletBinding()] +param([string] $SourceRoot = $PSScriptRoot) + +$ErrorActionPreference = "Stop" +$compiledValidationAssembly = $env:GRAPHCODE_VALIDATION_ASSEMBLY +if (-not $compiledValidationAssembly) { + $compiledValidationAssembly = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") +} +$testsRoot = $SourceRoot +$helper = Join-Path $testsRoot "..\OwnedLiveProcess.ps1" +if (Test-Path -LiteralPath $helper -PathType Leaf) { + . $helper +} + +$script:pidStops = [Collections.Generic.List[int]]::new() +$script:removedPaths = [Collections.Generic.List[string]]::new() +$script:mockRemoveFailure = $false +$script:cleanupEvents = [Collections.Generic.List[string]]::new() +$script:scopeCloseCalls = 0 +$script:scopeCloseFailure = $false +$processScope = $null +function Stop-Process { + [CmdletBinding()] + param([int] $Id, [switch] $Force) + $script:pidStops.Add($Id) +} +function Remove-Item { + [CmdletBinding()] + param([string] $LiteralPath, [switch] $Recurse, [switch] $Force) + $script:removedPaths.Add($LiteralPath) + $script:cleanupEvents.Add("remove") + if ($script:mockRemoveFailure) { throw "mock fixture removal failure" } +} +function Close-ShellProcessScope { + param($Scope) + $script:scopeCloseCalls++ + $script:cleanupEvents.Add("close-job") + if ($script:scopeCloseFailure) { throw "mock job cleanup failure" } +} + +function New-MockProcess([int] $id) { + $process = [Diagnostics.Process]::new() + $process | Add-Member NoteProperty Id $id -Force + $process | Add-Member NoteProperty HasExited $false -Force + $process | Add-Member NoteProperty MockKillCount 0 + $process | Add-Member NoteProperty MockWaitCount 0 + $process | Add-Member NoteProperty MockWaitResult $true + $process | Add-Member NoteProperty MockDisposed $false + $process | Add-Member ScriptMethod Kill { $this.MockKillCount++ } -Force + $process | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.MockWaitCount++ + return $this.MockWaitResult + } -Force + $process | Add-Member ScriptMethod Dispose { $this.MockDisposed = $true } -Force + return $process +} + +function Get-ParsedSource([string] $name) { + $path = Join-Path $testsRoot $name + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $path, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "$name has parser errors: $errors" } + return $ast +} + +function Get-BlockScript([Management.Automation.Language.StatementBlockAst] $block) { + $text = $block.Extent.Text + return [scriptblock]::Create($text.Substring(1, $text.Length - 2)) +} + +function Get-ScrubbedCleanup { + $ast = Get-ParsedSource "ScrubbedShellStartup.Live.Tests.ps1" + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-Case" + }, $true) + if (-not $function) { throw "Scrubbed startup case function not found" } + $matches = @($function.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text -match "onboardingObserved" + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one scrubbed case cleanup; found $($matches.Count)" } + return Get-BlockScript $matches[0].Finally +} + +function Get-GdiplusCleanup { + $ast = Get-ParsedSource "GdiplusStartup.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text -match "GraphCodeGdiplusStartupTest" + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one GDI+ runner cleanup; found $($matches.Count)" } + return Get-BlockScript $matches[0].Finally +} + +function Get-GdiplusScratchAdmission { + $ast = Get-ParsedSource "GdiplusStartup.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text -match 'Test-Path -LiteralPath \$scratch' + }, $true)) + if ($matches.Count -eq 0) { throw "GDI+ scratch admission was not found" } + $text = $ast.Extent.Text + $start = $matches[0].Extent.StartOffset + $end = $text.IndexOf('New-Item -ItemType Directory -Force -Path', $start, [StringComparison]::Ordinal) + if ($end -le $start) { throw "GDI+ scratch creation boundary was not found" } + return [scriptblock]::Create($text.Substring($start, $end - $start)) +} + +function Get-ScrubbedScratchAdmission { + $ast = Get-ParsedSource "ScrubbedShellStartup.Live.Tests.ps1" + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-Case" + }, $true) + $body = $function.Body.EndBlock.Extent.Text + $start = $body.IndexOf('$root = Join-Path $ScratchRoot $name', [StringComparison]::Ordinal) + $end = $body.IndexOf('if ($action -in', [StringComparison]::Ordinal) + if ($start -lt 0 -or $end -le $start) { throw "Scrubbed scratch admission not found" } + return [scriptblock]::Create($body.Substring($start, $end - $start)) +} + +function Get-RoundTripScratchAdmission { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $text = $ast.Extent.Text + $start = $text.IndexOf('if (Test-Path -LiteralPath $runRoot)', [StringComparison]::Ordinal) + $end = $text.IndexOf('New-Item -ItemType Directory -Force -Path @(', $start, [StringComparison]::Ordinal) + if ($start -lt 0 -or $end -le $start) { throw "Round-trip scratch admission was not found" } + return [scriptblock]::Create($text.Substring($start, $end - $start)) +} + +function Import-SourceFunction([string] $file, [string] $functionName) { + $ast = Get-ParsedSource $file + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $functionName + }, $true)) + if ($definitions.Count -ne 1) { throw "Expected one source function named $functionName" } + return [scriptblock]::Create($definitions[0].Extent.Text) +} + +function Get-RoundTripFallback { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $node.Body.Extent.Text -match '^\{\s*Stop-OwnedDaemon\s+\$daemonProcess\s*;?\s*\}$' -and + $node.CatchClauses.Count -eq 1 + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one round-trip cleanup fallback; found $($matches.Count)" } + return Get-BlockScript $matches[0].CatchClauses[0].Body +} + +function Get-RoundTripCleanup { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $matches = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text -match "Round-trip daemon cleanup could not be verified" + }, $true)) + if ($matches.Count -ne 1) { throw "Expected one round-trip cleanup; found $($matches.Count)" } + return Get-BlockScript $matches[0].Finally +} + +function Remove-ValidationRootEnvironment { + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + if ($environment.Contains("GRAPHCODE_VALIDATION_ROOT")) { + Microsoft.PowerShell.Management\Remove-Item ` + -LiteralPath "Env:\GRAPHCODE_VALIDATION_ROOT" -Force -ErrorAction Stop + } +} + +function Invoke-ReceiptFixture([scriptblock] $test) { + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + $hadRoot = $environment.Contains("GRAPHCODE_VALIDATION_ROOT") + $previousRoot = if ($hadRoot) { [string]$environment["GRAPHCODE_VALIDATION_ROOT"] } else { $null } + $root = Join-Path $env:TEMP ("owned-live-retention-" + [guid]::NewGuid().ToString("N")) + [void][IO.Directory]::CreateDirectory($root) + try { + & $test $root + } finally { + if ($hadRoot) { + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $previousRoot, [EnvironmentVariableTarget]::Process) + } else { + Remove-ValidationRootEnvironment + } + $receipt = Join-Path $root "ownership-unproven.json" + if ([IO.File]::Exists($receipt)) { [IO.File]::Delete($receipt) } + $target = Join-Path $root "target" + $targetReceipt = Join-Path $target "ownership-unproven.json" + if ([IO.File]::Exists($targetReceipt)) { [IO.File]::Delete($targetReceipt) } + $nested = Join-Path $target "nested" + $nestedReceipt = Join-Path $nested "ownership-unproven.json" + if ([IO.File]::Exists($nestedReceipt)) { [IO.File]::Delete($nestedReceipt) } + $link = Join-Path $root "link" + if ([IO.Directory]::Exists($link)) { + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $link -Force -ErrorAction Stop + } + if ([IO.Directory]::Exists($nested)) { [IO.Directory]::Delete($nested) } + if ([IO.Directory]::Exists($target)) { [IO.Directory]::Delete($target) } + $rootFile = Join-Path $root "not-directory" + if ([IO.File]::Exists($rootFile)) { [IO.File]::Delete($rootFile) } + $failureLog = Join-Path $root "daemon-roundtrip-test-failure.txt" + if ([IO.File]::Exists($failureLog)) { [IO.File]::Delete($failureLog) } + [IO.Directory]::Delete($root) + } +} + +function Invoke-CapturingConsole([scriptblock] $test) { + $previous = [Console]::Out + $writer = [IO.StringWriter]::new() + try { + [Console]::SetOut($writer) + $value = & $test + } finally { + [Console]::SetOut($previous) + } + $text = $writer.ToString() + $writer.Dispose() + return [pscustomobject]@{ Value = $value; Text = $text } +} + +$failures = [Collections.Generic.List[string]]::new() +$executed = 0 +function Invoke-Case([string] $name, [scriptblock] $test) { + $script:pidStops.Clear() + $script:removedPaths.Clear() + $script:mockRemoveFailure = $false + $script:cleanupEvents.Clear() + $script:scopeCloseCalls = 0 + $script:scopeCloseFailure = $false + $script:executed++ + $previousOutput = [Console]::Out + $capturedOutput = [IO.StringWriter]::new() + try { + [Console]::SetOut($capturedOutput) + $null = & $test + $outcome = "PASS $name" + } catch { + $script:failures.Add("${name}: $($_.Exception.Message)") + $outcome = "FAIL ${name}: $($_.Exception.Message); $($_.ScriptStackTrace)" + } finally { + [Console]::SetOut($previousOutput) + $capturedOutput.Dispose() + } + Write-Output $outcome +} + +$environment = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process) +$hadValidationRoot = $environment.Contains("GRAPHCODE_VALIDATION_ROOT") +$originalValidationRoot = if ($hadValidationRoot) { + [string]$environment["GRAPHCODE_VALIDATION_ROOT"] +} else { $null } +Remove-ValidationRootEnvironment +try { + Invoke-Case "scrubbed-cleanup-uses-held-processes" { + $shellProcess = New-MockProcess 91001 + $daemonProcess = New-MockProcess 91002 + $shellStarted = $daemonStarted = $true + $shellHandleHeld = $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = $null + & (Get-ScrubbedCleanup) + if ($script:pidStops.Count -ne 0 -or + $shellProcess.MockKillCount -ne 1 -or $daemonProcess.MockKillCount -ne 1 -or + $shellProcess.MockWaitCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1) { + throw "expected two held-process kills and waits, zero PID stops; pid=$($script:pidStops.Count) shell=$($shellProcess.MockKillCount) daemon=$($daemonProcess.MockKillCount)" + } + } + + Invoke-Case "gdiplus-cleanup-uses-held-process" { + $process = New-MockProcess 91003 + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = $null + & (Get-GdiplusCleanup) + if ($script:pidStops.Count -ne 0 -or + $process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 1) { + throw "expected a held-process kill and wait, zero PID stops; pid=$($script:pidStops.Count) kills=$($process.MockKillCount)" + } + } + + Invoke-Case "roundtrip-fallback-uses-held-process" { + $daemonProcess = New-MockProcess 91004 + try { + throw [TimeoutException]::new("mock graceful shutdown timed out") + } catch { + & (Get-RoundTripFallback) + } + if ($script:pidStops.Count -ne 0 -or + $daemonProcess.MockKillCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1) { + throw "expected a held-process kill and wait, zero PID stops; pid=$($script:pidStops.Count) kills=$($daemonProcess.MockKillCount)" + } + } + + Invoke-Case "scrubbed-unstarted-shell-does-not-block-daemon-cleanup" { + $shellProcess = [Diagnostics.Process]::new() + $shellProcess | Add-Member NoteProperty MockDisposed $false + $shellProcess | Add-Member ScriptMethod Dispose { $this.MockDisposed = $true } -Force + $daemonProcess = New-MockProcess 91006 + $shellStarted = $false + $shellHandleHeld = $false + $daemonStarted = $true + $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("shell did not start") + try { + & (Get-ScrubbedCleanup) + } catch { + throw "unstarted shell blocked daemon cleanup: $($_.Exception.Message)" + } + if (($daemonProcess.MockKillCount + $script:pidStops.Count) -ne 1 -or + $daemonProcess.MockWaitCount -ne 1) { + throw "expected the launched daemon to be stopped and waited after shell start failed; pidStops=$($script:pidStops.Count) kills=$($daemonProcess.MockKillCount) waits=$($daemonProcess.MockWaitCount)" + } + } + + Invoke-Case "gdiplus-unverified-exit-retains-fixture" { + $process = New-MockProcess 91007 + $process.MockWaitResult = $false + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = $null + $failure = $null + try { + & (Get-GdiplusCleanup) + } catch { + $failure = $_.Exception + } + if (-not $failure) { + throw "cleanup accepted an unverified exit; removed=$($script:removedPaths.Count)" + } + if ($script:removedPaths.Count -ne 0 -or + -not (Test-OwnedLiveProcessRetentionRequired $failure)) { + throw "unverified exit did not preserve the fixture and tag the failure" + } + } + + Invoke-Case "gdiplus-refuses-existing-scratch-without-removing" { + Invoke-ReceiptFixture { + param($root) + $scratch = Join-Path $root "existing-scratch" + [void][IO.Directory]::CreateDirectory($scratch) + try { + $failure = $null + try { & (Get-GdiplusScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or $script:removedPaths.Count -ne 0 -or + -not [IO.Directory]::Exists($scratch)) { + throw "existing GDI+ scratch was reused or removal was attempted" + } + } finally { + [IO.Directory]::Delete($scratch) + } + } + } + + Invoke-Case "scrubbed-refuses-existing-scratch-without-reusing" { + Invoke-ReceiptFixture { + param($root) + $ScratchRoot = $root + $name = "existing-case" + $action = "escape" + $existing = Join-Path $root $name + [void][IO.Directory]::CreateDirectory($existing) + $script:exclusiveCalls = 0 + function New-ValidationFixtureDirectory { $script:exclusiveCalls++ } + try { + $failure = $null + try { & (Get-ScrubbedScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or $script:exclusiveCalls -ne 0 -or + -not [IO.Directory]::Exists($existing)) { + throw "existing scrubbed-case scratch was reused" + } + } finally { + [IO.Directory]::Delete($existing) + } + } + } + + Invoke-Case "gdiplus-refuses-scratch-admission-collision" { + Invoke-ReceiptFixture { + param($root) + $scratch = Join-Path $root "collision" + function New-ValidationFixtureDirectory { + param($Path) + [void][IO.Directory]::CreateDirectory($Path) + throw "mock concurrent directory creation" + } + try { + $failure = $null + try { & (Get-GdiplusScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or -not [IO.Directory]::Exists($scratch) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $scratch) { + throw "GDI+ admission collision was not retained and tagged" + } + } finally { + if ([IO.Directory]::Exists($scratch)) { [IO.Directory]::Delete($scratch) } + } + } + } + + Invoke-Case "scrubbed-refuses-scratch-admission-collision" { + Invoke-ReceiptFixture { + param($root) + $ScratchRoot = $root + $name = "collision" + $action = "escape" + $scratch = Join-Path $root $name + function New-ValidationFixtureDirectory { + param($Path) + [void][IO.Directory]::CreateDirectory($Path) + throw "mock concurrent directory creation" + } + try { + $failure = $null + try { & (Get-ScrubbedScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or -not [IO.Directory]::Exists($scratch) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $scratch) { + throw "scrubbed admission collision was not retained and tagged" + } + } finally { + if ([IO.Directory]::Exists($scratch)) { [IO.Directory]::Delete($scratch) } + } + } + } + + foreach ($collision in @($false, $true)) { + Invoke-Case "roundtrip-refuses-existing-or-racing-scratch-$collision" { + Invoke-ReceiptFixture { + param($root) + $runRoot = Join-Path $root "roundtrip" + if (-not $collision) { [void][IO.Directory]::CreateDirectory($runRoot) } + function New-ValidationFixtureDirectory { + param($Path) + [void][IO.Directory]::CreateDirectory($Path) + throw "mock concurrent directory creation" + } + try { + $failure = $null + try { & (Get-RoundTripScratchAdmission) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or -not [IO.Directory]::Exists($runRoot) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $runRoot) { + throw "round-trip scratch collision was not retained and tagged" + } + } finally { + if ([IO.Directory]::Exists($runRoot)) { [IO.Directory]::Delete($runRoot) } + } + } + } + } + + foreach ($kind in @("gdiplus", "scrubbed", "roundtrip")) { + Invoke-Case "$kind-scratch-uses-exclusive-admission" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $ScratchRoot = $fixtureRoot + $name = "exclusive-collision" + $action = "escape" + $scratch = Join-Path $fixtureRoot $name + $runRoot = $scratch + $marker = Join-Path $scratch "foreign.txt" + $script:exclusiveCalls = 0 + $script:exclusiveError = [ComponentModel.Win32Exception]::new( + 183, "controlled exclusive directory collision") + function New-ValidationFixtureDirectory { + param([string] $Path) + $script:exclusiveCalls++ + if ($Path -cne $scratch) { throw "exclusive creator received the wrong path" } + [void][IO.Directory]::CreateDirectory($Path) + [IO.File]::WriteAllText($marker, "foreign content") + throw $script:exclusiveError + } + function New-Item { + [CmdletBinding()] + param([string] $ItemType, [string] $Path) + throw "nonexclusive directory creation was attempted" + } + try { + $failure = $null + try { + switch ($kind) { + "gdiplus" { & (Get-GdiplusScratchAdmission) } + "scrubbed" { & (Get-ScrubbedScratchAdmission) } + "roundtrip" { & (Get-RoundTripScratchAdmission) } + } + } catch { + $failure = $_.Exception + } + if ($script:exclusiveCalls -ne 1) { + throw "scratch admission did not call the exclusive creator exactly once" + } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:removedPaths.Count -ne 0 -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $scratch) { + throw "exclusive admission failure was not retained and tagged" + } + $originalErrors = @($failure.InnerExceptions | Where-Object { + [object]::ReferenceEquals($_, $script:exclusiveError) + }) + if ($originalErrors.Count -ne 1 -or + [IO.File]::ReadAllText($marker) -cne "foreign content") { + throw "exclusive admission lost its error or changed the foreign fixture" + } + } finally { + if ([IO.File]::Exists($marker)) { [IO.File]::Delete($marker) } + if ([IO.Directory]::Exists($scratch)) { [IO.Directory]::Delete($scratch) } + } + } + } + + Invoke-Case "$kind-scratch-continues-after-exclusive-admission" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $ScratchRoot = $fixtureRoot + $name = "exclusive-success" + $action = "escape" + $scratch = Join-Path $fixtureRoot $name + $runRoot = $scratch + $script:exclusiveCalls = 0 + $script:nestedCalls = 0 + function New-ValidationFixtureDirectory { + param([string] $Path) + if ($Path -cne $scratch) { throw "exclusive creator received the wrong path" } + $script:exclusiveCalls++ + return [pscustomobject]@{ Path = $Path } + } + function New-Item { + [CmdletBinding()] + param([string] $ItemType, [string[]] $Path, [switch] $Force) + if ($script:exclusiveCalls -ne 1) { + throw "nested fixture setup began before exclusive admission" + } + $script:nestedCalls++ + } + switch ($kind) { + "gdiplus" { & (Get-GdiplusScratchAdmission) } + "scrubbed" { & (Get-ScrubbedScratchAdmission) } + "roundtrip" { & (Get-RoundTripScratchAdmission) } + } + $expectedNestedCalls = if ($kind -eq "scrubbed") { 1 } else { 0 } + if ($script:exclusiveCalls -ne 1 -or $script:nestedCalls -ne $expectedNestedCalls -or + $script:removedPaths.Count -ne 0) { + throw "successful exclusive admission did not continue to fixture setup" + } + } + } + + Invoke-Case "$kind-scratch-preserves-exclusive-creation-error" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $ScratchRoot = $fixtureRoot + $name = "exclusive-failure" + $action = "escape" + $scratch = Join-Path $fixtureRoot $name + $runRoot = $scratch + $script:exclusiveCalls = 0 + $script:exclusiveError = [UnauthorizedAccessException]::new( + "controlled exclusive directory creation failure") + function New-ValidationFixtureDirectory { + param([string] $Path) + if ($Path -cne $scratch) { throw "exclusive creator received the wrong path" } + $script:exclusiveCalls++ + throw $script:exclusiveError + } + function New-Item { + [CmdletBinding()] + param([string] $ItemType, [string] $Path) + throw "nonexclusive directory creation was attempted" + } + $failure = $null + try { + switch ($kind) { + "gdiplus" { & (Get-GdiplusScratchAdmission) } + "scrubbed" { & (Get-ScrubbedScratchAdmission) } + "roundtrip" { & (Get-RoundTripScratchAdmission) } + } + } catch { + $failure = $_.Exception + } + if ($script:exclusiveCalls -ne 1 -or + -not [object]::ReferenceEquals($failure, $script:exclusiveError) -or + $script:removedPaths.Count -ne 0 -or (Test-Path -LiteralPath $scratch)) { + throw "exclusive creation failure was changed or followed by fixture cleanup" + } + } + } + } + + Invoke-Case "scratch-consumers-import-exclusive-admission-helper" { + foreach ($file in @( + "GdiplusStartup.Live.Tests.ps1", + "ScrubbedShellStartup.Live.Tests.ps1", + "DaemonRoundTrip.Live.Tests.ps1" + )) { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot $file)) + if (-not $source.Contains( + '. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1")')) { + throw "$file does not import the exclusive admission helper" + } + } + } + + Invoke-Case "gdiplus-creates-missing-scratch-parent" { + Invoke-ReceiptFixture { + param($fixtureRoot) + $parent = Join-Path $fixtureRoot "parent" + $scratch = Join-Path $parent "child" + $script:exclusiveCalls = 0 + function New-ValidationFixtureDirectory { + param([string] $Path) + if ($Path -cne $scratch -or -not [IO.Directory]::Exists($parent)) { + throw "exclusive creation was attempted before the scratch parent existed" + } + $script:exclusiveCalls++ + return [pscustomobject]@{ Path = $Path } + } + try { + & (Get-GdiplusScratchAdmission) + if ($script:exclusiveCalls -ne 1 -or $script:removedPaths.Count -ne 0) { + throw "GDI+ did not create its parent before exclusive scratch admission" + } + } finally { + if ([IO.Directory]::Exists($parent)) { [IO.Directory]::Delete($parent) } + } + } + } + + Invoke-Case "scrubbed-cli-completes-before-output-read" { + $script:cliProcess = New-MockProcess 91033 + $script:cliProcess | Add-Member NoteProperty Handle ([IntPtr]1) -Force + $script:cliProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:cliProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.MockWaitCount++ + $this.HasExited = $true + return $true + } -Force + $script:cliCompleted = $false + $script:cliInfo = $null + $jobScope = [pscustomobject]@{ Name = "mock job" } + $Cli = "C:\mock\graphcode.exe" + function New-StartInfo([string] $file, [string] $root) { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.UseShellExecute = $false + return $info + } + function Start-ShellOwnedProcess($Scope, $StartInfo) { + if (-not [object]::ReferenceEquals($Scope, $jobScope)) { throw "wrong CLI scope" } + $script:cliInfo = $StartInfo + return $script:cliProcess + } + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:cliProcess) -or + -not $Process.HasExited) { throw "CLI completed before its root exited" } + $script:cliCompleted = $true + } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not $script:cliCompleted) { throw "CLI output read preceded completion" } + if ($StandardError) { return "cli stderr" } + return "cli stdout" + } + . (Import-SourceFunction "ScrubbedShellStartup.Live.Tests.ps1" "Invoke-ScrubbedCli") + $result = Invoke-ScrubbedCli $jobScope "case" "C:\mock\root" "C:\mock\support" @("projects") + if (-not $script:cliCompleted -or -not $script:cliProcess.MockDisposed -or + $script:cliProcess.MockKillCount -ne 0 -or $result.ExitCode -ne 0 -or + $result.Output -cne "cli stdout" -or $result.Error -cne "cli stderr" -or + $script:cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] -cne "C:\mock\support" -or + @($script:cliInfo.ArgumentList).Count -ne 1 -or + $script:cliInfo.ArgumentList[0] -cne "projects") { + throw "scrubbed CLI lost launch arguments, finalization, output or cleanup" + } + } + + Invoke-Case "roundtrip-daemon-completes-after-graceful-exit" { + $daemon = New-MockProcess 91034 + $daemon | Add-Member NoteProperty ExitCode 0 -Force + $daemon | Add-Member ScriptMethod Refresh { } -Force + $daemon | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.MockWaitCount++ + $this.HasExited = $true + return $true + } -Force + $shutdownEvent = [pscustomobject]@{} + $shutdownEvent | Add-Member ScriptMethod Set { return $true } + $processScope = [pscustomobject]@{ Name = "mock job" } + $script:runLog = [Collections.Generic.List[string]]::new() + $script:daemonCompleted = $false + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not [object]::ReferenceEquals($Scope, $processScope) -or + -not $Process.HasExited -or $Process.MockWaitCount -ne 1) { + throw "daemon completion did not follow graceful root exit" + } + $script:daemonCompleted = $true + } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not $script:daemonCompleted) { throw "daemon output read preceded completion" } + if ($StandardError) { return " stderr " } + return " stdout " + } + . (Import-SourceFunction "DaemonRoundTrip.Live.Tests.ps1" "Stop-OwnedDaemon") + Stop-OwnedDaemon $daemon + if (-not $script:daemonCompleted -or $daemon.MockKillCount -ne 0 -or + $script:runLog.Count -ne 3 -or $script:runLog[1] -cne "daemon-stdout=stdout" -or + $script:runLog[2] -cne "daemon-stderr=stderr") { + throw "round-trip daemon finalization lost graceful exit or output" + } + } + + Invoke-Case "owned-command-retains-scope-arguments-and-output" { + $jobScope = [pscustomobject]@{ Name = "mock job" } + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = "C:\mock\zig.exe" + $info.UseShellExecute = $false + [void]$info.ArgumentList.Add("test") + $script:commandProcess = New-MockProcess 91030 + $script:commandProcess | Add-Member NoteProperty ExitCode 7 -Force + $script:commandScope = $null + $script:commandInfo = $null + function Start-ShellOwnedProcess($Scope, $StartInfo) { + $script:commandScope = $Scope + $script:commandInfo = $StartInfo + return $script:commandProcess + } + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:commandProcess) -or + $Process.MockWaitCount -ne 1) { + throw "completion did not follow the owned root exit" + } + $script:commandCompleted = $true + } + function Receive-ShellOwnedOutput { throw "buffered command unexpectedly requested a stream chunk" } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:commandProcess) -or + -not $script:commandCompleted) { + throw "output read was not bound to the owned launch" + } + if ($StandardError) { return "mock stderr" } + return "mock stdout" + } + $script:commandCompleted = $false + $result = Invoke-OwnedLiveCommand -Scope $jobScope -StartInfo $info + if (-not [object]::ReferenceEquals($script:commandScope, $jobScope) -or + -not [object]::ReferenceEquals($script:commandInfo, $info) -or + $script:commandProcess.MockWaitCount -ne 1 -or $result.ExitCode -ne 7 -or + $result.Output -cne "mock stdout" -or $result.Error -cne "mock stderr" -or + -not $info.RedirectStandardOutput -or -not $info.RedirectStandardError) { + throw "owned command lost its launch, output, or exit status" + } + } + + Invoke-Case "owned-command-streams-before-exit-and-drains-after-completion" { + $jobScope = [pscustomobject]@{ Name = "mock job" } + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91036 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandEvents = [Collections.Generic.List[string]]::new() + $script:receiveCount = 0 + $script:commandCompleted = $false + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param($milliseconds = $null) + if ($null -eq $milliseconds) { + $script:commandEvents.Add("wait:blocking") + $this.HasExited = $true + return + } + $script:commandEvents.Add("wait:$milliseconds") + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess($Scope, $Process) { + if (-not $Process.HasExited) { throw "completion preceded root exit" } + $script:commandEvents.Add("complete") + $script:commandCompleted = $true + } + function Receive-ShellOwnedOutput($Scope, $Process, [int] $MaximumCharacters) { + if (-not [object]::ReferenceEquals($Scope, $jobScope) -or + -not [object]::ReferenceEquals($Process, $script:commandProcess) -or + $MaximumCharacters -ne 65536) { + throw "stream read lost its scope, process or bound" + } + $script:receiveCount++ + $script:commandEvents.Add("receive:$script:receiveCount") + switch ($script:receiveCount) { + 1 { return [pscustomobject]@{ Output = "early stdout"; Error = "early stderr" } } + 2 { return [pscustomobject]@{ Output = ""; Error = "" } } + 3 { + if (-not $script:commandCompleted) { throw "late drain preceded completion" } + return [pscustomobject]@{ Output = "late stdout"; Error = "late stderr" } + } + 4 { return [pscustomobject]@{ Output = ""; Error = "" } } + default { throw "unexpected extra stream read" } + } + } + function Read-ShellOwnedOutput($Scope, $Process, [switch] $StandardError) { + if (-not $script:commandCompleted -or $script:receiveCount -ne 4) { + throw "final output preceded completed stream drainage" + } + if ($StandardError) { return "complete stderr" } + return "complete stdout" + } + $previousOutput = [Console]::Out + $previousError = [Console]::Error + $output = [IO.StringWriter]::new() + $error = [IO.StringWriter]::new() + try { + [Console]::SetOut($output) + [Console]::SetError($error) + $result = Invoke-OwnedLiveCommand -Scope $jobScope -StartInfo $info -StreamOutput + $outputText = $output.ToString() + $errorText = $error.ToString() + } finally { + [Console]::SetOut($previousOutput) + [Console]::SetError($previousError) + $output.Dispose() + $error.Dispose() + } + if ($outputText -cne "early stdoutlate stdout" -or + $errorText -cne "early stderrlate stderr" -or + $result.Output -cne "complete stdout" -or $result.Error -cne "complete stderr" -or + ($script:commandEvents -join ",") -cne + "receive:1,wait:0,receive:2,wait:100,complete,receive:3,receive:4") { + throw "streamed output was delayed, duplicated, reordered or incompletely drained" + } + } + + Invoke-Case "owned-command-stream-surfaces-pump-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91037 + $script:completionCalls = 0 + $script:finalReads = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Receive-ShellOwnedOutput { throw "mock incremental output pump failed" } + function Complete-ShellOwnedProcess { $script:completionCalls++ } + function Read-ShellOwnedOutput { $script:finalReads++ } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock incremental output pump failed" -or + $script:completionCalls -ne 0 -or $script:finalReads -ne 0) { + throw "incremental pump failure was hidden or treated as finalized output" + } + } + + Invoke-Case "owned-command-stream-preserves-split-surrogate-pairs" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91041 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + $script:receiveCount = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess { } + function Receive-ShellOwnedOutput { + $script:receiveCount++ + switch ($script:receiveCount) { + 1 { return [pscustomobject]@{ Output = [string][char]0xD83D; Error = [string][char]0xD83D } } + 2 { return [pscustomobject]@{ Output = ""; Error = "" } } + 3 { return [pscustomobject]@{ Output = [string][char]0xDE80; Error = [string][char]0xDE80 } } + 4 { return [pscustomobject]@{ Output = ""; Error = "" } } + default { throw "unexpected extra stream read" } + } + } + function Read-ShellOwnedOutput { return "complete capture" } + $previousOutput = [Console]::Out + $previousError = [Console]::Error + $outputBytes = [IO.MemoryStream]::new() + $errorBytes = [IO.MemoryStream]::new() + $encoding = [Text.UTF8Encoding]::new($false) + $output = [IO.StreamWriter]::new($outputBytes, $encoding, 1024, $true) + $error = [IO.StreamWriter]::new($errorBytes, $encoding, 1024, $true) + try { + [Console]::SetOut($output) + [Console]::SetError($error) + [void](Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput) + $output.Flush() + $error.Flush() + $stdout = $encoding.GetString($outputBytes.ToArray()) + $stderr = $encoding.GetString($errorBytes.ToArray()) + } finally { + [Console]::SetOut($previousOutput) + [Console]::SetError($previousError) + $output.Dispose() + $error.Dispose() + $outputBytes.Dispose() + $errorBytes.Dispose() + } + $expected = [string][char]0xD83D + [string][char]0xDE80 + if ($stdout -cne $expected -or $stderr -cne $expected) { + throw "a split UTF-16 surrogate pair was corrupted while forwarding output" + } + } + + Invoke-Case "owned-command-stream-flushes-final-unpaired-surrogate" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91042 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + if ($milliseconds -eq 100) { + $this.HasExited = $true + return $true + } + return $false + } -Force + $script:receiveCount = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess { } + function Receive-ShellOwnedOutput { + $script:receiveCount++ + if ($script:receiveCount -eq 1) { + return [pscustomobject]@{ Output = [string][char]0xD83D; Error = "" } + } + return [pscustomobject]@{ Output = ""; Error = "" } + } + function Read-ShellOwnedOutput { return "complete capture" } + $previousOutput = [Console]::Out + $outputBytes = [IO.MemoryStream]::new() + $encoding = [Text.UTF8Encoding]::new($false) + $output = [IO.StreamWriter]::new($outputBytes, $encoding, 1024, $true) + try { + [Console]::SetOut($output) + [void](Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput) + $output.Flush() + $stdout = $encoding.GetString($outputBytes.ToArray()) + } finally { + [Console]::SetOut($previousOutput) + $output.Dispose() + $outputBytes.Dispose() + } + if ($stdout -cne [string][char]0xFFFD) { + throw "a final unpaired surrogate was silently discarded" + } + } + + Invoke-Case "owned-command-stream-surfaces-final-drain-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91038 + $script:commandProcess | Add-Member ScriptMethod WaitForExit { + param([int] $milliseconds) + $this.HasExited = $true + return $true + } -Force + $script:completed = $false + $script:finalReads = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Receive-ShellOwnedOutput { + if ($script:completed) { throw "mock final incremental drain failed" } + return [pscustomobject]@{ Output = ""; Error = "" } + } + function Complete-ShellOwnedProcess { $script:completed = $true } + function Read-ShellOwnedOutput { $script:finalReads++ } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info -StreamOutput } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock final incremental drain failed" -or + -not $script:completed -or $script:finalReads -ne 0) { + throw "final incremental drain failure was hidden or followed by a full output read" + } + } + + foreach ($invalid in @( + @{ Name = "missing"; Chunk = $null }, + @{ Name = "null-output"; Chunk = [pscustomobject]@{ Output = $null; Error = "" } }, + @{ Name = "nonstring-error"; Chunk = [pscustomobject]@{ Output = ""; Error = 1 } }, + @{ Name = "oversized-output"; Chunk = [pscustomobject]@{ Output = [string]::new([char]"x", 65537); Error = "" } }, + @{ Name = "oversized-error"; Chunk = [pscustomobject]@{ Output = ""; Error = [string]::new([char]"x", 65537) } })) { + Invoke-Case "owned-stream-rejects-invalid-chunk-$($invalid.Name)" { + $script:streamChunk = $invalid.Chunk + $process = New-MockProcess 91039 + $pending = @{ Output = ""; Error = "" } + function Receive-ShellOwnedOutput { return $script:streamChunk } + $result = Invoke-CapturingConsole { + try { + [void](Write-OwnedLiveOutputChunk -Scope ([pscustomobject]@{}) -Process $process -Pending $pending) + return $null + } catch { return $_.Exception } + } + if (-not $result.Value -or + $result.Value.Message -notmatch "invalid or oversized chunk" -or + $result.Text.Length -ne 0) { + throw "invalid incremental output was accepted or partially written" + } + } + } + + Invoke-Case "owned-stream-accepts-exact-chunk-limit" { + $script:streamChunk = [pscustomobject]@{ Output = [string]::new([char]"x", 65536); Error = "" } + $process = New-MockProcess 91040 + $pending = @{ Output = ""; Error = "" } + function Receive-ShellOwnedOutput { return $script:streamChunk } + $result = Invoke-CapturingConsole { + Write-OwnedLiveOutputChunk -Scope ([pscustomobject]@{}) -Process $process -Pending $pending + } + if ($result.Value -ne $true -or $result.Text.Length -ne 65536 -or + $result.Text -cne $script:streamChunk.Output) { + throw "a chunk at the exact limit was not forwarded intact" + } + } + + Invoke-Case "gdiplus-build-selects-streaming-without-reprinting-final-output" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot "GdiplusStartup.Live.Tests.ps1")) + if ($source -notmatch '\$build\s*=\s*Invoke-OwnedLiveCommand\b[^\r\n]*-StreamOutput' -or + $source -match '\[Console\]::(?:Out|Error)\.Write\(\$build\.(?:Output|Error)\)') { + throw "GDI+ build must stream during the owned run without reprinting its final capture" + } + } + + Invoke-Case "owned-command-surfaces-output-read-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91031 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess($Scope, $Process) { } + function Read-ShellOwnedOutput { throw "mock unproved output EOF" } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock unproved output EOF" -or + $script:commandProcess.MockWaitCount -ne 1) { + throw "owned command did not surface the output read failure" + } + } + + Invoke-Case "owned-command-surfaces-launch-failure" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + function Start-ShellOwnedProcess { throw "mock refused owned launch" } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock refused owned launch") { + throw "owned command did not surface the launch failure" + } + } + + Invoke-Case "owned-command-surfaces-completion-failure-before-output" { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.UseShellExecute = $false + $script:commandProcess = New-MockProcess 91032 + $script:commandProcess | Add-Member NoteProperty ExitCode 0 -Force + $script:commandReads = 0 + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:commandProcess } + function Complete-ShellOwnedProcess { throw "mock unproved descendant cleanup" } + function Read-ShellOwnedOutput { $script:commandReads++ } + $failure = $null + try { Invoke-OwnedLiveCommand -Scope ([pscustomobject]@{}) -StartInfo $info } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock unproved descendant cleanup" -or + $script:commandProcess.MockWaitCount -ne 1 -or $script:commandReads -ne 0) { + throw "owned command read output after an unproved launch completion" + } + } + + foreach ($fileName in @( + "ScrubbedShellStartup.Live.Tests.ps1", + "GdiplusStartup.Live.Tests.ps1", + "DaemonRoundTrip.Live.Tests.ps1")) { + Invoke-Case "job-owned-live-launches-$fileName" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot $fileName)) + if ($source -notmatch 'ShellProcessOwnership\.psm1' -or + $source -notmatch 'New-ShellProcessScope' -or + $source -notmatch 'Start-ShellOwnedProcess' -or + $source -notmatch 'Close-ShellProcessScope') { + throw "live runner does not use a pre-launch owned job through cleanup" + } + } + } + + Invoke-Case "job-contains-gdiplus-build" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot "GdiplusStartup.Live.Tests.ps1")) + if ($source -match '&\s*\$ZigExecutable\s+build-exe' -or + $source -notmatch 'Invoke-OwnedLiveCommand') { + throw "GDI+ build is not launched through the owned job" + } + } + + Invoke-Case "job-contains-roundtrip-tests" { + $source = [IO.File]::ReadAllText((Join-Path $testsRoot "DaemonRoundTrip.Live.Tests.ps1")) + if ($source -match '&\s*\$ZigExecutable\s+test' -or + $source -notmatch 'Invoke-OwnedLiveCommand') { + throw "daemon round-trip Zig tests are not launched through the owned job" + } + } + + foreach ($successBoundary in @( + @{ File = "GdiplusStartup.Live.Tests.ps1"; Cleanup = "GraphCodeGdiplusStartupTest"; Pass = "GDIPLUS_STARTUP_LIVE: PASS" }, + @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Cleanup = "Round-trip daemon cleanup could not be verified"; Pass = "REAL_DAEMON_ROUNDTRIP: PASS" })) { + Invoke-Case "pass-follows-job-cleanup-$($successBoundary.File)" { + $ast = Get-ParsedSource $successBoundary.File + $blocks = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.TryStatementAst] -and + $null -ne $node.Finally -and + $node.Extent.Text.Contains($successBoundary.Cleanup) + }, $true)) + $passOffset = $ast.Extent.Text.IndexOf($successBoundary.Pass, [StringComparison]::Ordinal) + if ($blocks.Count -ne 1 -or $passOffset -le $blocks[0].Extent.EndOffset) { + throw "live PASS was reported before cleanup finished" + } + } + } + + Invoke-Case "scrubbed-closes-job-before-root-removal" { + $shellProcess = New-MockProcess 91027 + $daemonProcess = New-MockProcess 91028 + $shellStarted = $daemonStarted = $false + $shellHandleHeld = $daemonHandleHeld = $false + $processScope = [pscustomobject]@{ Name = "mock job" } + $root = "C:\mock-scrubbed-case" + $caseError = $null + & (Get-ScrubbedCleanup) + if ($script:scopeCloseCalls -ne 1 -or + ($script:cleanupEvents -join ",") -cne "close-job,remove") { + throw "scrubbed fixture was removed before verified job cleanup" + } + } + + Invoke-Case "gdiplus-job-close-failure-retains-root" { + $process = New-MockProcess 91029 + $process.HasExited = $true + $started = $true + $handleHeld = $true + $processScope = [pscustomobject]@{ Name = "mock job" } + $scratch = "C:\mock-gdiplus-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $script:scopeCloseFailure = $true + $failure = $null + try { & (Get-GdiplusCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:scopeCloseCalls -ne 1 -or $script:removedPaths.Count -ne 0 -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $caseError)) { + throw "GDI+ job failure did not preserve the primary error and root" + } + } + + Invoke-Case "roundtrip-job-close-failure-retains-root" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($target) + $daemonProcess = $null + $processScope = [pscustomobject]@{ Name = "mock job" } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $successful = $true + $runRoot = $target + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $script:scopeCloseFailure = $true + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $script:scopeCloseCalls -ne 1 -or + @($script:removedPaths | Where-Object { $_ -ceq $runRoot }).Count -ne 0 -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError)) { + throw "round-trip job failure did not preserve the primary error and root" + } + } + } + + Invoke-Case "roundtrip-fallback-waits-for-exit" { + $daemonProcess = New-MockProcess 91008 + try { + throw [TimeoutException]::new("mock graceful shutdown timed out") + } catch { + & (Get-RoundTripFallback) + } + if ($daemonProcess.MockWaitCount -ne 1) { + throw "forced fallback did not wait for the launched daemon to exit" + } + } + + Invoke-Case "roundtrip-startup-failure-stops-launched-daemon" { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Start-OwnedDaemon" + }, $true) + if (-not $function) { throw "Round-trip daemon startup function not found" } + $source = $function.Extent.Text + if ($source -notmatch 'Start-ShellOwnedProcess') { + throw "Expected an owned daemon launch in the startup function" + } + $script:startupMockProcess = New-MockProcess 91009 + $script:startupMockProcess | Add-Member ScriptMethod Start { return $true } -Force + $script:startupMockProcess | Add-Member ScriptMethod Refresh { } -Force + $script:startupMockProcess | Add-Member NoteProperty Handle ([IntPtr]1) -Force + $script:startupMockProcess | Add-Member NoteProperty StandardOutput ( + [IO.StringReader]::new("")) -Force + $script:startupMockProcess | Add-Member NoteProperty StandardError ( + [IO.StringReader]::new("")) -Force + $script:shutdownEvent = [pscustomobject]@{} + $script:shutdownEvent | Add-Member ScriptMethod Reset { return $true } + $expectedDaemonPath = "C:\mock\graphcoded.exe" + $supportDirectory = "C:\mock\support" + $daemonPipe = "\\.\pipe\mock-daemon" + $shutdownEventName = "mock-shutdown" + $runRoot = "C:\mock-roundtrip-case" + $processScope = [pscustomobject]@{ Name = "mock job" } + function Start-ShellOwnedProcess($Scope, $StartInfo) { return $script:startupMockProcess } + function Get-OwnedDaemon([int] $processId) { throw "mock process discovery failure" } + . ([scriptblock]::Create($source)) + $failure = $null + try { + Start-OwnedDaemon + } catch { + $failure = $_.Exception + } + if (-not $failure -or $failure.Message -notmatch "mock process discovery failure") { + throw "startup did not preserve the discovery failure" + } + if ($script:startupMockProcess.MockKillCount -ne 1 -or + $script:startupMockProcess.MockWaitCount -ne 1 -or + -not $script:startupMockProcess.MockDisposed) { + throw "launched daemon was not stopped, waited, and disposed after startup failure" + } + } + + Invoke-Case "helper-does-not-kill-exited-process" { + $process = New-MockProcess 91010 + $process.HasExited = $true + Stop-OwnedLiveProcess -Process $process -Name "mock" + if ($process.MockKillCount -ne 0 -or $process.MockWaitCount -ne 0) { + throw "already-exited process was killed or waited" + } + } + + Invoke-Case "helper-surfaces-kill-failure-while-live" { + $process = New-MockProcess 91011 + $process | Add-Member ScriptMethod Kill { + $this.MockKillCount++ + throw "mock kill failure" + } -Force + $failure = $null + try { Stop-OwnedLiveProcess -Process $process -Name "mock" } catch { $failure = $_.Exception } + if (-not $failure -or $failure.Message -notmatch "mock kill failure" -or + $process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 0) { + throw "live process kill failure was not preserved" + } + } + + Invoke-Case "helper-allows-confirmed-concurrent-exit" { + $process = New-MockProcess 91012 + $process | Add-Member ScriptMethod Kill { + $this.MockKillCount++ + $this.HasExited = $true + throw "mock process exited during kill" + } -Force + Stop-OwnedLiveProcess -Process $process -Name "mock" + if ($process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 1) { + throw "confirmed concurrent exit was not waited" + } + } + + Invoke-Case "helper-rejects-wait-timeout" { + $process = New-MockProcess 91013 + $process.MockWaitResult = $false + $failure = $null + try { Stop-OwnedLiveProcess -Process $process -Name "mock" } catch { $failure = $_.Exception } + if (-not $failure -or $failure.Message -notmatch "did not exit" -or + $process.MockKillCount -ne 1 -or $process.MockWaitCount -ne 1) { + throw "wait timeout was not surfaced" + } + } + + Invoke-Case "scrubbed-unattested-shell-leaves-it-alone-and-cleans-daemon" { + $shellProcess = New-MockProcess 91014 + $daemonProcess = New-MockProcess 91015 + $shellStarted = $daemonStarted = $true + $shellHandleHeld = $false + $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("mock initial case failure") + $failure = $null + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $shellProcess.MockKillCount -ne 0 -or $daemonProcess.MockKillCount -ne 1 -or + $daemonProcess.MockWaitCount -ne 1 -or $script:removedPaths.Count -ne 0) { + throw "unattested shell was acted on or daemon/retention cleanup was skipped" + } + if ($failure.InnerExceptions[0].Message -ne "mock initial case failure") { + throw "initial case failure was not preserved first" + } + } + + Invoke-Case "scrubbed-continues-after-first-cleanup-failure" { + $shellProcess = New-MockProcess 91016 + $shellProcess | Add-Member ScriptMethod Kill { + $this.MockKillCount++ + throw "mock shell kill failure" + } -Force + $daemonProcess = New-MockProcess 91017 + $shellStarted = $daemonStarted = $true + $shellHandleHeld = $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $failure = $null + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $daemonProcess.MockKillCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1 -or + -not $shellProcess.MockDisposed -or -not $daemonProcess.MockDisposed -or + $script:removedPaths.Count -ne 0) { + throw "first cleanup failure prevented daemon cleanup or root retention" + } + if ($failure.InnerExceptions[0].Message -ne "mock primary failure") { + throw "primary failure was not preserved first" + } + } + + Invoke-Case "scrubbed-tagged-inner-failure-retains-fixture" { + $shellProcess = New-MockProcess 91020 + $daemonProcess = New-MockProcess 91021 + $shellStarted = $false + $shellHandleHeld = $false + $daemonStarted = $true + $daemonHandleHeld = $true + $root = "C:\mock-scrubbed-case" + $caseError = New-OwnedLiveProcessCleanupError ` + -Message "mock CLI ownership failure" -Errors @([InvalidOperationException]::new("mock")) + $null = & (Get-ScrubbedCleanup) + if ($daemonProcess.MockKillCount -ne 1 -or $daemonProcess.MockWaitCount -ne 1 -or + $script:removedPaths.Count -ne 0) { + throw "tagged CLI failure did not preserve the case root after daemon cleanup" + } + } + + Invoke-Case "gdiplus-dispose-failure-preserves-primary" { + $process = New-MockProcess 91022 + $process | Add-Member ScriptMethod Dispose { + throw "mock disposal failure" + } -Force + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $failure = $null + try { & (Get-GdiplusCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $failure.InnerExceptions[0].Message -ne "mock primary failure" -or + $process.MockWaitCount -ne 1 -or $script:removedPaths.Count -ne 0) { + throw "disposal failure masked the primary error or removed the fixture" + } + } + + Invoke-Case "scrubbed-fixture-removal-failure-preserves-primary" { + $shellProcess = New-MockProcess 91023 + $daemonProcess = New-MockProcess 91024 + $shellStarted = $daemonStarted = $false + $shellHandleHeld = $daemonHandleHeld = $false + $root = "C:\mock-scrubbed-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $script:mockRemoveFailure = $true + $failure = $null + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + if ($failure -isnot [AggregateException] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $caseError) -or + $failure.InnerExceptions.Count -lt 2) { + throw "fixture removal failure masked the primary scrubbed-case error" + } + } + + Invoke-Case "gdiplus-fixture-removal-failure-preserves-primary" { + $process = New-MockProcess 91025 + $process.HasExited = $true + $started = $true + $handleHeld = $true + $scratch = "C:\mock-gdiplus-case" + $caseError = [InvalidOperationException]::new("mock primary failure") + $script:mockRemoveFailure = $true + $failure = $null + try { & (Get-GdiplusCleanup) } catch { $failure = $_.Exception } + if ($failure -isnot [AggregateException] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $caseError) -or + $failure.InnerExceptions.Count -lt 2) { + throw "fixture removal failure masked the primary GDI+ error" + } + } + + Invoke-Case "roundtrip-unverified-fallback-tags-failure" { + $daemonProcess = New-MockProcess 91018 + $daemonProcess.MockWaitResult = $false + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $shutdownError = $cleanupError = $retentionError = $null + $runRoot = "C:\mock-roundtrip-case" + try { + throw [TimeoutException]::new("mock shutdown failure") + } catch { + . (Get-RoundTripFallback) + } + if (-not $retentionError -or + -not (Test-OwnedLiveProcessRetentionRequired $retentionError) -or + $daemonProcess.MockWaitCount -ne 1 -or + $retentionError.InnerExceptions[0].Message -ne "mock primary failure") { + throw "failed fallback did not retain the primary failure and request root preservation" + } + } + + Invoke-Case "roundtrip-initializes-primary-error-in-outer-scope" { + $ast = Get-ParsedSource "DaemonRoundTrip.Live.Tests.ps1" + $zig = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-ZigTest" + }, $true) + if (-not $zig -or $zig.Extent.Text -match '\$roundtripError\s*=\s*\$null' -or + $ast.Extent.Text -notmatch '(?s)\$roundtripError\s*=\s*\$null\s*try\s*\{\s*\$processScope\s*=\s*New-ShellProcessScope') { + throw "round-trip primary error was not initialized in the outer scope" + } + } + + Invoke-Case "roundtrip-dispose-failure-preserves-primary-and-retains" { + Invoke-ReceiptFixture { + param($root) + $daemonProcess = New-MockProcess 91019 + $daemonProcess | Add-Member ScriptMethod Dispose { + throw "mock handle disposal failure" + } -Force + function Stop-OwnedDaemon([Diagnostics.Process] $process) { } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $successful = $false + $runRoot = Join-Path $root "run" + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $failure.InnerExceptions[0].Message -ne "mock primary failure" -or + @($script:removedPaths | Where-Object { $_ -ceq $runRoot }).Count -ne 0) { + throw "handle disposal failure masked the primary error or failed to retain the run" + } + } + } + + Invoke-Case "roundtrip-report-failure-preserves-retention-tag" { + Invoke-ReceiptFixture { + param($root) + $daemonProcess = $null + $shutdownEvent = $null + $roundtripError = New-OwnedLiveProcessCleanupError ` + -Message "mock ownership failure" -Errors @([InvalidOperationException]::new("mock primary")) + $successful = $false + $runRoot = Join-Path $root "run" + $evidenceDirectory = Join-Path $root "missing-evidence" + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + $failure.InnerExceptions.Count -lt 2) { + $detail = if ($failure) { + "type=$($failure.GetType().FullName); tag=$(Test-OwnedLiveProcessRetentionRequired $failure); inner=$($failure.InnerExceptions.Count); first=$($failure.InnerExceptions[0].Message); message=$($failure.Message)" + } else { "no failure" } + throw "failure reporting masked the tagged original error: $detail" + } + } + } + + foreach ($reportFailure in @($false, $true)) { + Invoke-Case "roundtrip-tagged-shutdown-failure-retains-root-$reportFailure" { + Invoke-ReceiptFixture { + param($root) + $runRoot = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($runRoot) + $daemonProcess = New-MockProcess 91035 + $daemonProcess.HasExited = $true + $processScope = [pscustomobject]@{ Name = "mock job" } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $taggedShutdown = [InvalidOperationException]::new("mock ownership-unproven shutdown") + $taggedShutdown.Data["GraphCodeValidationRootRetentionRequired"] = $true + $taggedShutdown.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($runRoot) + function Stop-OwnedDaemon([Diagnostics.Process] $process) { throw $taggedShutdown } + $successful = $false + $evidenceDirectory = if ($reportFailure) { Join-Path $root "missing-evidence" } else { $root } + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or + $failure.Data["GraphCodeValidationRootRetentionRequired"] -isnot [bool] -or + -not $failure.Data["GraphCodeValidationRootRetentionRequired"] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $runRoot -or + @($script:removedPaths | Where-Object { $_ -ceq $runRoot }).Count -ne 0) { + throw "tagged shutdown was lost during aggregate failure propagation" + } + } + } + } + + Invoke-Case "roundtrip-event-dispose-failure-preserves-primary" { + Invoke-ReceiptFixture { + param($root) + $daemonProcess = $null + $shutdownEvent = [pscustomobject]@{} + $shutdownEvent | Add-Member ScriptMethod Dispose { throw "mock event disposal failure" } + $roundtripError = [InvalidOperationException]::new("mock primary failure") + $successful = $false + $runRoot = Join-Path $root "run" + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if ($failure -isnot [AggregateException] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + $failure.InnerExceptions.Count -lt 2) { + throw "event disposal failure masked the primary error" + } + } + } + + Invoke-Case "roundtrip-forced-cleanup-preserves-tagged-primary" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($target) + $daemonProcess = New-MockProcess 91026 + function Stop-OwnedDaemon([Diagnostics.Process] $process) { + throw "mock graceful shutdown failure" + } + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock ownership failure") + $roundtripError.Data["GraphCodeValidationRootRetentionRequired"] = $true + $roundtripError.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($target) + $successful = $false + $runRoot = $target + $evidenceDirectory = $root + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or + $failure.Data["GraphCodeValidationRootRetentionRequired"] -isnot [bool] -or + -not $failure.Data["GraphCodeValidationRootRetentionRequired"] -or + -not [object]::ReferenceEquals($failure.InnerExceptions[0], $roundtripError) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]).Count -ne 1 -or + $failure.Data["GraphCodeValidationRetainedRoots"][0] -cne $target) { + throw "forced cleanup failed to propagate the primary retention tag and root list" + } + } + } + + Invoke-Case "roundtrip-report-failure-preserves-retained-roots" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + [void][IO.Directory]::CreateDirectory($target) + $daemonProcess = $null + $shutdownEvent = $null + $roundtripError = [InvalidOperationException]::new("mock ownership failure") + $roundtripError.Data["GraphCodeValidationRootRetentionRequired"] = $true + $roundtripError.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($target) + $successful = $false + $runRoot = $target + $evidenceDirectory = Join-Path $root "missing-evidence" + $nonce = "test" + $runLog = [Collections.Generic.List[string]]::new() + $failure = $null + try { $null = & (Get-RoundTripCleanup) } catch { $failure = $_.Exception } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + @($failure.Data["GraphCodeValidationRetainedRoots"]).Count -ne 1 -or + $failure.Data["GraphCodeValidationRetainedRoots"][0] -cne $target) { + throw "failure reporting discarded the observed retained-root list" + } + } + } + + Invoke-Case "retained-root-list-records-observed-only" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + $nested = Join-Path $target "nested" + [void][IO.Directory]::CreateDirectory($nested) + $inner = [InvalidOperationException]::new("mock inner") + $inner.Data["GraphCodeValidationRootRetentionRequired"] = $true + $inner.Data["GraphCodeValidationRetainedRoots"] = [string[]]@($nested) + $missing = Join-Path $root "missing" + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @($inner) ` + -RetainedRoots @($target, $missing, $target) + $observed = @($error.Data["GraphCodeValidationRetainedRoots"]) + if ($observed.Count -ne 2 -or $observed -cnotcontains $target -or + $observed -cnotcontains $nested -or $observed -ccontains $missing) { + throw "retained-root list did not preserve distinct observed directories" + } + } + } + + Invoke-Case "absent-receipt-root-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + Remove-ValidationRootEnvironment + if ([Environment]::GetEnvironmentVariables("Process").Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "test did not establish an absent validation-root variable" + } + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + $result.Value.InnerExceptions.Count -ne 2) { + throw "absent root did not emit a tagged receipt-failure sentinel" + } + } + } + + Invoke-Case "failed-sentinel-write-preserves-retention-and-errors" { + Invoke-ReceiptFixture { + param($root) + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $compiledValidationAssembly ` + -RequiredType "GraphCode.Validation.TestFixtures.FailingRetentionSentinelWriter" | Out-Null + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "relative-root", [EnvironmentVariableTarget]::Process) + $previous = [Console]::Out + $writer = [GraphCode.Validation.TestFixtures.FailingRetentionSentinelWriter]::new() + try { + [Console]::SetOut($writer) + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("primary cleanup failure")) ` + -RetainedRoots @($root) + } finally { + [Console]::SetOut($previous) + $writer.Dispose() + } + if (-not (Test-OwnedLiveProcessRetentionRequired $error) -or + $error.InnerExceptions.Count -ne 3 -or + $error.ToString() -notmatch "primary cleanup failure" -or + $error.ToString() -notmatch "fully qualified path" -or + $error.ToString() -notmatch "controlled sentinel write failure" -or + @($error.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $root) { + throw "sentinel failure lost the retention tag, observed root, or contributing errors" + } + + $shellProcess = New-MockProcess 91099 + $daemonProcess = $null + $shellStarted = $true + $shellHandleHeld = $false + $daemonStarted = $daemonHandleHeld = $false + $processScope = $null + $caseError = [InvalidOperationException]::new("source cleanup primary") + $failure = $null + $writer = [GraphCode.Validation.TestFixtures.FailingRetentionSentinelWriter]::new() + try { + [Console]::SetOut($writer) + try { & (Get-ScrubbedCleanup) } catch { $failure = $_.Exception } + } finally { + [Console]::SetOut($previous) + $writer.Dispose() + } + if (-not $failure -or -not (Test-OwnedLiveProcessRetentionRequired $failure) -or + $failure.ToString() -notmatch "source cleanup primary" -or + $failure.ToString() -notmatch "controlled sentinel write failure" -or + @($failure.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $root -or + $shellProcess.MockKillCount -ne 0 -or $script:removedPaths.Count -ne 0) { + throw "source cleanup lost the retention tag or acted on an unattested process or fixture" + } + } + } + + Invoke-Case "failed-sentinel-flush-preserves-retention-and-errors" { + Invoke-ReceiptFixture { + param($root) + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $compiledValidationAssembly ` + -RequiredType "GraphCode.Validation.TestFixtures.FailingRetentionSentinelFlush" | Out-Null + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "relative-root", [EnvironmentVariableTarget]::Process) + $previous = [Console]::Out + $writer = [GraphCode.Validation.TestFixtures.FailingRetentionSentinelFlush]::new() + try { + [Console]::SetOut($writer) + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("primary cleanup failure")) ` + -RetainedRoots @($root) + } finally { + [Console]::SetOut($previous) + $writer.Dispose() + } + if (-not (Test-OwnedLiveProcessRetentionRequired $error) -or + $error.InnerExceptions.Count -ne 3 -or + $error.ToString() -notmatch "primary cleanup failure" -or + $error.ToString() -notmatch "fully qualified path" -or + $error.ToString() -notmatch "controlled sentinel flush failure" -or + @($error.Data["GraphCodeValidationRetainedRoots"]) -cnotcontains $root) { + throw "sentinel flush failure lost the retention tag, observed root, or contributing errors" + } + } + } + + Invoke-Case "receipt-preserves-exact-root-and-tag" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $root, [EnvironmentVariableTarget]::Process) + $error = New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + $path = Join-Path $root "ownership-unproven.json" + if (-not [IO.File]::Exists($path)) { throw "receipt was not created" } + $bytes = [IO.File]::ReadAllBytes($path) + $receipt = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | + ConvertFrom-Json + if ($bytes.Length -gt 4096 -or $receipt.schemaVersion -ne 1 -or + $receipt.validationRoot -cne $root -or $receipt.reason -cne "ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $error)) { + throw "receipt content or Boolean retention tag was incorrect" + } + } + } + + Invoke-Case "existing-receipt-is-not-overwritten" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $root, [EnvironmentVariableTarget]::Process) + $path = Join-Path $root "ownership-unproven.json" + [IO.File]::WriteAllText($path, "preexisting evidence") + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ([IO.File]::ReadAllText($path) -cne "preexisting evidence" -or + $result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + $result.Value.InnerExceptions.Count -ne 2) { + throw "existing receipt was overwritten or its write failure was not surfaced" + } + } + } + + Invoke-Case "invalid-receipt-root-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "relative-root", [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + $result.Value.InnerExceptions.Count -ne 2) { + throw "invalid receipt root did not emit a tagged sentinel failure" + } + } + } + + Invoke-Case "empty-receipt-root-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", "", [EnvironmentVariableTarget]::Process) + $environment = [Environment]::GetEnvironmentVariables( + [EnvironmentVariableTarget]::Process) + if (-not $environment.Contains("GRAPHCODE_VALIDATION_ROOT")) { + throw "test did not establish a present empty validation-root variable" + } + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value)) { + throw "present empty root did not emit a tagged sentinel failure" + } + } + } + + Invoke-Case "receipt-root-file-emits-sentinel" { + Invoke-ReceiptFixture { + param($root) + $file = Join-Path $root "not-directory" + [IO.File]::WriteAllText($file, "existing file") + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $file, [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + [IO.File]::ReadAllText($file) -cne "existing file") { + throw "a file-valued root was not refused without modification" + } + } + } + + Invoke-Case "reparse-root-emits-sentinel-without-writing-target" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + $link = Join-Path $root "link" + [void][IO.Directory]::CreateDirectory($target) + [void](New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $link, [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + [IO.File]::Exists((Join-Path $target "ownership-unproven.json"))) { + throw "reparse root was not refused without writing through it" + } + } + } + + Invoke-Case "reparse-ancestor-emits-sentinel-without-writing-target" { + Invoke-ReceiptFixture { + param($root) + $target = Join-Path $root "target" + $nested = Join-Path $target "nested" + $link = Join-Path $root "link" + [void][IO.Directory]::CreateDirectory($nested) + [void](New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop) + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", (Join-Path $link "nested"), + [EnvironmentVariableTarget]::Process) + $result = Invoke-CapturingConsole { + New-OwnedLiveProcessCleanupError ` + -Message "mock cleanup failure" -Errors @([InvalidOperationException]::new("mock")) + } + if ($result.Text.Trim() -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" -or + -not (Test-OwnedLiveProcessRetentionRequired $result.Value) -or + [IO.File]::Exists((Join-Path $nested "ownership-unproven.json"))) { + throw "reparse ancestor was not refused without writing through it" + } + } + } + + Invoke-Case "receipt-writer-uses-create-new-and-flush" { + $source = [IO.File]::ReadAllText($helper) + if ($source -notmatch '\[IO\.FileMode\]::CreateNew' -or + $source -notmatch '\$stream\.Flush\(\$true\)') { + throw "receipt writer must create exclusively and flush the file stream" + } + } + + Invoke-Case "nested-retention-tag-is-observed" { + $tagged = [InvalidOperationException]::new("tagged") + $tagged.Data["GraphCodeValidationRootRetentionRequired"] = $true + $wrapped = [AggregateException]::new( + "wrapped", [Exception[]]@([InvalidOperationException]::new("untagged"), $tagged)) + $stringTag = [InvalidOperationException]::new("string tag") + $stringTag.Data["GraphCodeValidationRootRetentionRequired"] = "true" + if (-not (Test-OwnedLiveProcessRetentionRequired $wrapped) -or + (Test-OwnedLiveProcessRetentionRequired ([InvalidOperationException]::new("ordinary"))) -or + (Test-OwnedLiveProcessRetentionRequired $stringTag)) { + throw "nested Boolean retention tag was not distinguished from an ordinary error" + } + } + + Write-Output "OWNED_LIVE_PROCESS_CLEANUP_CASES=$executed FAILURES=$($failures.Count)" + if ($executed -ne 84 -or $failures.Count -ne 0) { + throw "Owned live process cleanup contract failed: $($failures -join '; ')" + } +} finally { + if ($hadValidationRoot) { + [Environment]::SetEnvironmentVariable( + "GRAPHCODE_VALIDATION_ROOT", $originalValidationRoot, [EnvironmentVariableTarget]::Process) + } else { + Remove-ValidationRootEnvironment + } +} diff --git a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 index 35591fae..c2f4808f 100644 --- a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 +++ b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 @@ -1,310 +1,403 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string] $Shell, - [Parameter(Mandatory)][string] $Daemon, - [Parameter(Mandatory)][string] $Cli, - [Parameter(Mandatory)][string] $ScratchRoot -) - -$ErrorActionPreference = "Stop" -Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes -Add-Type @' -using System; -using System.Runtime.InteropServices; -public static class ScrubbedStartupNative { - public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); - [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); - [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, System.Text.StringBuilder value, int capacity); - [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); - [DllImport("user32.dll")] static extern bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); - public static IntPtr Find(uint processId, string className) { - IntPtr result = IntPtr.Zero; - EnumWindows(delegate(IntPtr window, IntPtr parameter) { - uint owner; - GetWindowThreadProcessId(window, out owner); - if (owner != processId || !IsWindowVisible(window)) return true; - var actual = new System.Text.StringBuilder(128); - GetClassName(window, actual, actual.Capacity); - if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; - result = window; - return false; - }, IntPtr.Zero); - return result; - } - public static bool Escape(IntPtr window) { - return PostMessage(window, 0x0100, (UIntPtr)0x1B, IntPtr.Zero) && - PostMessage(window, 0x0101, (UIntPtr)0x1B, IntPtr.Zero); - } - public static bool Command(IntPtr window, uint action) { - return PostMessage(window, 0x0111, (UIntPtr)action, IntPtr.Zero); - } -} -'@ - -$allowedKeys = @( - "SystemRoot", "windir", "USERPROFILE", "LOCALAPPDATA", "APPDATA", - "TEMP", "TMP", "ProgramData", "HOMEDRIVE", "HOMEPATH", "PATH" -) -$forbiddenPathFragments = @( - ".graphcode-tools", "Visual Studio", "Windows Kits", "Swift" -) - -function New-StartInfo([string] $file, [string] $root) { - $profile = Join-Path $root "profile" - $system32 = Join-Path $env:SystemRoot "System32" - $powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" - $info = [Diagnostics.ProcessStartInfo]::new() - $info.FileName = $file - $info.WorkingDirectory = Split-Path -Parent $file - $info.UseShellExecute = $false - $info.Environment.Clear() - $values = [ordered]@{ - SystemRoot = $env:SystemRoot - windir = $env:WINDIR - USERPROFILE = $profile - LOCALAPPDATA = Join-Path $profile "AppData\Local" - APPDATA = Join-Path $profile "AppData\Roaming" - TEMP = Join-Path $root "temp" - TMP = Join-Path $root "temp" - ProgramData = $env:ProgramData - HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") - HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) - PATH = "$system32;$powerShell;$(Split-Path -Parent $file)" - } - foreach ($name in $values.Keys) { $info.Environment[$name] = [string]$values[$name] } - return $info -} - -function Wait-NativeWindow([int] $processId, [string] $className, [int] $seconds) { - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - $window = [ScrubbedStartupNative]::Find([uint32]$processId, $className) - if ($window -ne [IntPtr]::Zero) { return $window } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - return [IntPtr]::Zero -} - -function Wait-NativeWindowClosed([int] $processId, [string] $className, [int] $seconds) { - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - if ([ScrubbedStartupNative]::Find([uint32]$processId, $className) -eq [IntPtr]::Zero) { - return $true - } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - return $false -} - -function Wait-File([string] $path, [int] $seconds) { - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - if (Test-Path -LiteralPath $path -PathType Leaf) { return $true } - Start-Sleep -Milliseconds 50 - } while ([DateTime]::UtcNow -lt $deadline) - return $false -} - -function Invoke-ScrubbedCli([string] $name, [string] $root, [string] $support, [string[]] $arguments) { - $cliInfo = New-StartInfo $Cli $root - $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support - foreach ($argument in $arguments) { [void]$cliInfo.ArgumentList.Add($argument) } - $cliInfo.RedirectStandardOutput = $true - $cliInfo.RedirectStandardError = $true - $cliProcess = [Diagnostics.Process]::new() - $cliProcess.StartInfo = $cliInfo - try { - if (-not $cliProcess.Start()) { throw "$name CLI '$($arguments[0])' could not start" } - $stdout = $cliProcess.StandardOutput.ReadToEndAsync() - $stderr = $cliProcess.StandardError.ReadToEndAsync() - if (-not $cliProcess.WaitForExit(10000)) { throw "$name CLI '$($arguments[0])' timed out" } - return [pscustomobject]@{ - ExitCode = $cliProcess.ExitCode - Output = $stdout.GetAwaiter().GetResult() - Error = $stderr.GetAwaiter().GetResult() - } - } finally { - if (-not $cliProcess.HasExited) { $cliProcess.Kill() } - $cliProcess.Dispose() - } -} - -function Wait-ProjectRow([int] $processId, [string] $projectName, [int] $seconds) { - $processCondition = [Windows.Automation.PropertyCondition]::new( - [Windows.Automation.AutomationElement]::ProcessIdProperty, $processId) - $rowCondition = [Windows.Automation.AndCondition]::new( - [Windows.Automation.PropertyCondition]::new( - [Windows.Automation.AutomationElement]::ControlTypeProperty, - [Windows.Automation.ControlType]::ListItem), - [Windows.Automation.PropertyCondition]::new( - [Windows.Automation.AutomationElement]::NameProperty, $projectName)) - $deadline = [DateTime]::UtcNow.AddSeconds($seconds) - do { - $window = [Windows.Automation.AutomationElement]::RootElement.FindFirst( - [Windows.Automation.TreeScope]::Children, $processCondition) - if ($window) { - $row = $window.FindFirst([Windows.Automation.TreeScope]::Descendants, $rowCondition) - if ($row -and $row.Current.AutomationId.StartsWith("open-project-", [StringComparison]::Ordinal)) { - return $row.Current.AutomationId - } - } - Start-Sleep -Milliseconds 200 - } while ([DateTime]::UtcNow -lt $deadline) - return $null -} - -function Invoke-Case( - [string] $name, - [ValidateSet("escape", "skip", "complete", "marker", "project")] - [string] $action -) { - $root = Join-Path $ScratchRoot $name - $profile = Join-Path $root "profile" - $support = Join-Path $profile ".graphcode" - New-Item -ItemType Directory -Force -Path ` - $support,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),(Join-Path $root "temp") | - Out-Null - if ($action -in @("marker", "project")) { - Set-Content -LiteralPath (Join-Path $support "onboarding-seen") -Value "seen" -NoNewline - } - # A plain folder, not a Git repository: startup must not need Git on PATH. - $projectPath = Join-Path $root "Core" - $projectRow = $null - if ($action -eq "project") { - New-Item -ItemType Directory -Force -Path $projectPath | Out-Null - Set-Content -LiteralPath (Join-Path $projectPath "README.md") -Value "fixture" -NoNewline - } - - $daemonProcess = [Diagnostics.Process]::new() - $daemonInfo = New-StartInfo $Daemon $root - $daemonInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support - $daemonProcess.StartInfo = $daemonInfo - $shellProcess = [Diagnostics.Process]::new() - $shellProcess.StartInfo = New-StartInfo $Shell $root - try { - if (-not $daemonProcess.Start()) { throw "$name could not start the production daemon" } - if ($action -eq "project") { - # Register through the production daemon before the shell starts, as the - # native folder picker does, so the shell restores a real daemon graph. - $deadline = [DateTime]::UtcNow.AddSeconds(15) - do { - $status = Invoke-ScrubbedCli $name $root $support @("status", $projectPath) - if ($status.ExitCode -eq 0) { break } - Start-Sleep -Milliseconds 250 - } while ([DateTime]::UtcNow -lt $deadline) - if ($status.ExitCode -ne 0) { throw "$name could not register the project: $($status.Error)" } - } - if (-not $shellProcess.Start()) { throw "$name could not start the production shell" } - [void]$daemonProcess.Handle - [void]$shellProcess.Handle - $keys = @($shellProcess.StartInfo.Environment.Keys | Sort-Object) - if (($keys -join "|") -cne (($allowedKeys | Sort-Object) -join "|")) { - throw "$name environment keys differ: $($keys -join ',')" - } - foreach ($fragment in $forbiddenPathFragments) { - if ($shellProcess.StartInfo.Environment["PATH"].IndexOf( - $fragment, [StringComparison]::OrdinalIgnoreCase) -ge 0) { - throw "$name PATH contains forbidden developer fragment '$fragment'" - } - } - - $onboarding = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsOnboarding" 10 - if ($action -notin @("marker", "project")) { - if ($onboarding -eq [IntPtr]::Zero) { throw "$name onboarding was not shown" } - switch ($action) { - "escape" { - if (-not [ScrubbedStartupNative]::Escape($onboarding)) { - throw "$name onboarding rejected Escape" - } - } - "skip" { - if (-not [ScrubbedStartupNative]::Command($onboarding, 2)) { - throw "$name onboarding rejected Skip" - } - } - "complete" { - foreach ($page in 1..5) { - if (-not [ScrubbedStartupNative]::Command($onboarding, 4)) { - throw "$name onboarding rejected primary action on page $page" - } - Start-Sleep -Milliseconds 50 - } - } - } - if (-not (Wait-NativeWindowClosed $shellProcess.Id "GraphCodeWindowsOnboarding" 10)) { - throw "$name onboarding did not close" - } - } elseif ($onboarding -ne [IntPtr]::Zero) { - throw "$name onboarding was shown despite the marker" - } - - $main = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsShell" 10 - $shellProcess.Refresh() - $daemonProcess.Refresh() - if ($main -eq [IntPtr]::Zero -or $shellProcess.HasExited -or $daemonProcess.HasExited) { - throw "$name did not retain live shell/daemon after onboarding" - } - if ($action -eq "project") { - $projectRow = Wait-ProjectRow $shellProcess.Id "Core" 20 - if (-not $projectRow) { throw "$name did not show the registered daemon project row" } - $survivalDeadline = [DateTime]::UtcNow.AddSeconds(5) - do { - $shellProcess.Refresh() - if ($shellProcess.HasExited) { - throw "$name shell exited with 0x$($shellProcess.ExitCode.ToString('X8')) after showing the project" - } - Start-Sleep -Milliseconds 100 - } while ([DateTime]::UtcNow -lt $survivalDeadline) - } - $markerPath = Join-Path $support "onboarding-seen" - if (-not (Wait-File $markerPath 10)) { - throw "$name did not persist the onboarding marker" - } - $logPath = Join-Path $support "graphcode-windows.log" - $log = if (Test-Path $logPath) { Get-Content $logPath -Raw } else { "" } - if ($log -match 'event=fatal') { throw "$name logged a fatal startup event: $log" } - - $projects = Invoke-ScrubbedCli $name $root $support @("projects") - if ($projects.ExitCode -ne 0) { - throw "$name CLI endpoint rejected the request: $($projects.Error)" - } - $stdout = $projects.Output - if ($action -eq "project" -and $stdout -notmatch "(?m)^Core\s") { - throw "$name CLI does not list the registered project" - } - return [ordered]@{ - name = $name - onboardingObserved = $onboarding -ne [IntPtr]::Zero - action = $action - mainWindow = $main.ToInt64() - shellProcessId = $shellProcess.Id - daemonProcessId = $daemonProcess.Id - cliOutput = $stdout.Trim() - projectRow = $projectRow - environmentKeys = $keys - fatalLogAbsent = $true - } - } finally { - foreach ($process in @($shellProcess,$daemonProcess)) { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force - [void]$process.WaitForExit(10000) - } - if ($process) { $process.Dispose() } - } - Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue - } -} - -New-Item -ItemType Directory -Force -Path $ScratchRoot | Out-Null -$results = @( - Invoke-Case "first-run-escape" "escape" - Invoke-Case "first-run-skip" "skip" - Invoke-Case "first-run-complete" "complete" - Invoke-Case "onboarding-marker" "marker" - Invoke-Case "registered-project" "project" -) -if ($results.Count -ne 5) { throw "Scrubbed startup executed $($results.Count)/5 cases" } -Write-Output ("SCRUBBED_SHELL_STARTUP: PASS; executed=$($results.Count); " + - "developerToolsExcluded=true; results=" + ($results | ConvertTo-Json -Compress -Depth 5)) +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Shell, + [Parameter(Mandatory)][string] $Daemon, + [Parameter(Mandatory)][string] $Cli, + [Parameter(Mandatory)][string] $ScratchRoot +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\OwnedLiveProcess.ps1") +. (Join-Path $PSScriptRoot "ValidationFixtureDirectory.ps1") +Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ScrubbedStartupNative { + public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, System.Text.StringBuilder value, int capacity); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] static extern bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + public static IntPtr Find(uint processId, string className) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId || !IsWindowVisible(window)) return true; + var actual = new System.Text.StringBuilder(128); + GetClassName(window, actual, actual.Capacity); + if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static bool Escape(IntPtr window) { + return PostMessage(window, 0x0100, (UIntPtr)0x1B, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)0x1B, IntPtr.Zero); + } + public static bool Command(IntPtr window, uint action) { + return PostMessage(window, 0x0111, (UIntPtr)action, IntPtr.Zero); + } +} +'@ + +$allowedKeys = @( + "SystemRoot", "windir", "USERPROFILE", "LOCALAPPDATA", "APPDATA", + "TEMP", "TMP", "ProgramData", "HOMEDRIVE", "HOMEPATH", "PATH" +) +$forbiddenPathFragments = @( + ".graphcode-tools", "Visual Studio", "Windows Kits", "Swift" +) + +function New-StartInfo([string] $file, [string] $root) { + $profile = Join-Path $root "profile" + $system32 = Join-Path $env:SystemRoot "System32" + $powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.WorkingDirectory = Split-Path -Parent $file + $info.UseShellExecute = $false + $info.Environment.Clear() + $values = [ordered]@{ + SystemRoot = $env:SystemRoot + windir = $env:WINDIR + USERPROFILE = $profile + LOCALAPPDATA = Join-Path $profile "AppData\Local" + APPDATA = Join-Path $profile "AppData\Roaming" + TEMP = Join-Path $root "temp" + TMP = Join-Path $root "temp" + ProgramData = $env:ProgramData + HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") + HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) + PATH = "$system32;$powerShell;$(Split-Path -Parent $file)" + } + foreach ($name in $values.Keys) { $info.Environment[$name] = [string]$values[$name] } + return $info +} + +function Wait-NativeWindow([int] $processId, [string] $className, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + $window = [ScrubbedStartupNative]::Find([uint32]$processId, $className) + if ($window -ne [IntPtr]::Zero) { return $window } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return [IntPtr]::Zero +} + +function Wait-NativeWindowClosed([int] $processId, [string] $className, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + if ([ScrubbedStartupNative]::Find([uint32]$processId, $className) -eq [IntPtr]::Zero) { + return $true + } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Wait-File([string] $path, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + if (Test-Path -LiteralPath $path -PathType Leaf) { return $true } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Invoke-ScrubbedCli($processScope, [string] $name, [string] $root, [string] $support, [string[]] $arguments) { + $cliInfo = New-StartInfo $Cli $root + $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + foreach ($argument in $arguments) { [void]$cliInfo.ArgumentList.Add($argument) } + $cliInfo.RedirectStandardOutput = $true + $cliInfo.RedirectStandardError = $true + $cliProcess = $null + $cliStarted = $false + $cliHandleHeld = $false + $cliError = $null + try { + $cliProcess = Start-ShellOwnedProcess $processScope $cliInfo + $cliStarted = $true + [void]$cliProcess.Handle + $cliHandleHeld = $true + if (-not $cliProcess.WaitForExit(10000)) { throw "$name CLI '$($arguments[0])' timed out" } + Complete-ShellOwnedProcess $processScope $cliProcess + return [pscustomobject]@{ + ExitCode = $cliProcess.ExitCode + Output = Read-ShellOwnedOutput $processScope $cliProcess + Error = Read-ShellOwnedOutput $processScope $cliProcess -StandardError + } + } catch { + $cliError = $_.Exception + throw + } finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + try { + if ($cliStarted) { + if (-not $cliHandleHeld) { + throw "The started CLI's original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $cliProcess -Name "$name CLI" + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($cliProcess) { + try { $cliProcess.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + if ($cleanupErrors.Count -gt 0) { + if ($cliError) { $cleanupErrors.Insert(0, $cliError) } + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name CLI cleanup could not be verified; fixture root: $root" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($root)) + } + } +} + +function Wait-ProjectRow([int] $processId, [string] $projectName, [int] $seconds) { + $processCondition = [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ProcessIdProperty, $processId) + $rowCondition = [Windows.Automation.AndCondition]::new( + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ControlTypeProperty, + [Windows.Automation.ControlType]::ListItem), + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::NameProperty, $projectName)) + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + $window = [Windows.Automation.AutomationElement]::RootElement.FindFirst( + [Windows.Automation.TreeScope]::Children, $processCondition) + if ($window) { + $row = $window.FindFirst([Windows.Automation.TreeScope]::Descendants, $rowCondition) + if ($row -and $row.Current.AutomationId.StartsWith("open-project-", [StringComparison]::Ordinal)) { + return $row.Current.AutomationId + } + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + return $null +} + +function Invoke-Case( + [string] $name, + [ValidateSet("escape", "skip", "complete", "marker", "project")] + [string] $action +) { + $root = Join-Path $ScratchRoot $name + if (Test-Path -LiteralPath $root) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name scratch already exists; ownership is unproven: $root" ` + -Errors @([InvalidOperationException]::new("Existing scratch was not reused.")) ` + -RetainedRoots @($root)) + } + try { + $null = New-ValidationFixtureDirectory -Path $root + } catch { + $creationError = $_.Exception + if (Test-Path -LiteralPath $root) { + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name scratch appeared during admission; ownership is unproven: $root" ` + -Errors @($creationError) -RetainedRoots @($root)) + } + throw + } + $profile = Join-Path $root "profile" + $support = Join-Path $profile ".graphcode" + New-Item -ItemType Directory -Force -Path ` + $support,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),(Join-Path $root "temp") | + Out-Null + if ($action -in @("marker", "project")) { + Set-Content -LiteralPath (Join-Path $support "onboarding-seen") -Value "seen" -NoNewline + } + # A plain folder, not a Git repository: startup must not need Git on PATH. + $projectPath = Join-Path $root "Core" + $projectRow = $null + if ($action -eq "project") { + New-Item -ItemType Directory -Force -Path $projectPath | Out-Null + Set-Content -LiteralPath (Join-Path $projectPath "README.md") -Value "fixture" -NoNewline + } + + $daemonProcess = $null + $daemonInfo = New-StartInfo $Daemon $root + $daemonInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + $shellProcess = $null + $shellInfo = New-StartInfo $Shell $root + $processScope = $null + $daemonStarted = $false + $daemonHandleHeld = $false + $shellStarted = $false + $shellHandleHeld = $false + $caseError = $null + try { + $processScope = New-ShellProcessScope + $daemonProcess = Start-ShellOwnedProcess $processScope $daemonInfo + $daemonStarted = $true + [void]$daemonProcess.Handle + $daemonHandleHeld = $true + if ($action -eq "project") { + # Register through the production daemon before the shell starts, as the + # native folder picker does, so the shell restores a real daemon graph. + $deadline = [DateTime]::UtcNow.AddSeconds(15) + do { + $status = Invoke-ScrubbedCli $processScope $name $root $support @("status", $projectPath) + if ($status.ExitCode -eq 0) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if ($status.ExitCode -ne 0) { throw "$name could not register the project: $($status.Error)" } + } + $shellProcess = Start-ShellOwnedProcess $processScope $shellInfo + $shellStarted = $true + [void]$shellProcess.Handle + $shellHandleHeld = $true + $keys = @($shellInfo.Environment.Keys | Sort-Object) + if (($keys -join "|") -cne (($allowedKeys | Sort-Object) -join "|")) { + throw "$name environment keys differ: $($keys -join ',')" + } + foreach ($fragment in $forbiddenPathFragments) { + if ($shellInfo.Environment["PATH"].IndexOf( + $fragment, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + throw "$name PATH contains forbidden developer fragment '$fragment'" + } + } + + $onboarding = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsOnboarding" 10 + if ($action -notin @("marker", "project")) { + if ($onboarding -eq [IntPtr]::Zero) { throw "$name onboarding was not shown" } + switch ($action) { + "escape" { + if (-not [ScrubbedStartupNative]::Escape($onboarding)) { + throw "$name onboarding rejected Escape" + } + } + "skip" { + if (-not [ScrubbedStartupNative]::Command($onboarding, 2)) { + throw "$name onboarding rejected Skip" + } + } + "complete" { + foreach ($page in 1..5) { + if (-not [ScrubbedStartupNative]::Command($onboarding, 4)) { + throw "$name onboarding rejected primary action on page $page" + } + Start-Sleep -Milliseconds 50 + } + } + } + if (-not (Wait-NativeWindowClosed $shellProcess.Id "GraphCodeWindowsOnboarding" 10)) { + throw "$name onboarding did not close" + } + } elseif ($onboarding -ne [IntPtr]::Zero) { + throw "$name onboarding was shown despite the marker" + } + + $main = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsShell" 10 + $shellProcess.Refresh() + $daemonProcess.Refresh() + if ($main -eq [IntPtr]::Zero -or $shellProcess.HasExited -or $daemonProcess.HasExited) { + throw "$name did not retain live shell/daemon after onboarding" + } + if ($action -eq "project") { + $projectRow = Wait-ProjectRow $shellProcess.Id "Core" 20 + if (-not $projectRow) { throw "$name did not show the registered daemon project row" } + $survivalDeadline = [DateTime]::UtcNow.AddSeconds(5) + do { + $shellProcess.Refresh() + if ($shellProcess.HasExited) { + throw "$name shell exited with 0x$($shellProcess.ExitCode.ToString('X8')) after showing the project" + } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $survivalDeadline) + } + $markerPath = Join-Path $support "onboarding-seen" + if (-not (Wait-File $markerPath 10)) { + throw "$name did not persist the onboarding marker" + } + $logPath = Join-Path $support "graphcode-windows.log" + $log = if (Test-Path $logPath) { Get-Content $logPath -Raw } else { "" } + if ($log -match 'event=fatal') { throw "$name logged a fatal startup event: $log" } + + $projects = Invoke-ScrubbedCli $processScope $name $root $support @("projects") + if ($projects.ExitCode -ne 0) { + throw "$name CLI endpoint rejected the request: $($projects.Error)" + } + $stdout = $projects.Output + if ($action -eq "project" -and $stdout -notmatch "(?m)^Core\s") { + throw "$name CLI does not list the registered project" + } + return [ordered]@{ + name = $name + onboardingObserved = $onboarding -ne [IntPtr]::Zero + action = $action + mainWindow = $main.ToInt64() + shellProcessId = $shellProcess.Id + daemonProcessId = $daemonProcess.Id + cliOutput = $stdout.Trim() + projectRow = $projectRow + environmentKeys = $keys + fatalLogAbsent = $true + } + } catch { + $caseError = $_.Exception + throw + } finally { + $cleanupErrors = [Collections.Generic.List[Exception]]::new() + foreach ($entry in @( + @{ Process = $shellProcess; Started = $shellStarted; HandleHeld = $shellHandleHeld; Name = "shell" }, + @{ Process = $daemonProcess; Started = $daemonStarted; HandleHeld = $daemonHandleHeld; Name = "daemon" } + )) { + try { + if ($entry.Started) { + if (-not $entry.HandleHeld) { + throw "The started $($entry.Name)'s original process handle was not acquired; no termination attempted." + } + Stop-OwnedLiveProcess -Process $entry.Process -Name "$name $($entry.Name)" + } + } catch { + $cleanupErrors.Add($_.Exception) + } finally { + if ($entry.Process) { + try { $entry.Process.Dispose() } catch { $cleanupErrors.Add($_.Exception) } + } + } + } + if ($processScope) { + try { Close-ShellProcessScope $processScope } catch { $cleanupErrors.Add($_.Exception) } + } + if ($cleanupErrors.Count -gt 0) { + if ($caseError) { $cleanupErrors.Insert(0, $caseError) } + throw (New-OwnedLiveProcessCleanupError ` + -Message "$name process cleanup could not be verified; fixture root: $root" ` + -Errors ([Exception[]]$cleanupErrors.ToArray()) -RetainedRoots @($root)) + } + if ($caseError -and (Test-OwnedLiveProcessRetentionRequired $caseError)) { + Write-Output "SCRUBBED_STARTUP_FIXTURE_RETENTION_REQUESTED=$root" + } else { + try { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop + } catch { + if ($caseError) { + throw [AggregateException]::new( + "$name fixture cleanup failed.", [Exception[]]@($caseError, $_.Exception)) + } + throw + } + } + } +} + +New-Item -ItemType Directory -Force -Path $ScratchRoot | Out-Null +$results = @( + Invoke-Case "first-run-escape" "escape" + Invoke-Case "first-run-skip" "skip" + Invoke-Case "first-run-complete" "complete" + Invoke-Case "onboarding-marker" "marker" + Invoke-Case "registered-project" "project" +) +if ($results.Count -ne 5) { throw "Scrubbed startup executed $($results.Count)/5 cases" } +Write-Output ("SCRUBBED_SHELL_STARTUP: PASS; executed=$($results.Count); " + + "developerToolsExcluded=true; results=" + ($results | ConvertTo-Json -Compress -Depth 5)) diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 new file mode 100644 index 00000000..c1779e42 --- /dev/null +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -0,0 +1,878 @@ +BeforeDiscovery { + Import-Module (Join-Path $PSScriptRoot "..\ShellProcessOwnership.psm1") -Force +} + +BeforeAll { + $repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path + $hadValidationAssembly = [Environment]::GetEnvironmentVariables("Process").Contains("GRAPHCODE_VALIDATION_ASSEMBLY") + $savedValidationAssembly = $env:GRAPHCODE_VALIDATION_ASSEMBLY + if (-not $hadValidationAssembly) { + $env:GRAPHCODE_VALIDATION_ASSEMBLY = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") + } + $shellPath = Join-Path $repo "Tools\windows\windows-shell.ps1" + $hadValidationRoot = [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") + $savedValidationRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") | Should -BeFalse +} + +AfterAll { + if ($hadValidationAssembly) { $env:GRAPHCODE_VALIDATION_ASSEMBLY = $savedValidationAssembly } + else { Remove-Item Env:\GRAPHCODE_VALIDATION_ASSEMBLY -ErrorAction SilentlyContinue } + if ($hadValidationRoot) { + [Environment]::SetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", $savedValidationRoot, "Process") + } else { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } +} + +Describe "Owned shell scope mock-only lifecycle" { + InModuleScope ShellProcessOwnership { + BeforeEach { + $script:events = [Collections.Generic.List[string]]::new() + $script:process = [Diagnostics.Process]::new() + $script:run = [pscustomobject]@{ Process = $script:process; Handle = [IntPtr]123 } + $script:run | Add-Member ScriptMethod Resume { $script:events.Add("resume") } + $script:run | Add-Member ScriptMethod Stop { $script:events.Add("stop-held") } + $script:run | Add-Member ScriptMethod Close { $script:events.Add("close-launch") } + $script:run | Add-Member ScriptMethod RequireExited { $script:events.Add("root-exited") } + $script:run | Add-Member ScriptMethod Drain { $script:events.Add("drain") } + $script:run | Add-Member NoteProperty LaunchJob ([IntPtr]567) + $script:run | Add-Member NoteProperty ScopeAssigned $true + $script:run | Add-Member NoteProperty LaunchAssigned $true + $script:run | Add-Member NoteProperty Finalized $false + $script:run | Add-Member NoteProperty LaunchJobClosed $false + $script:nextJob = 345 + Mock Initialize-ShellProcessOwnership {} + Mock New-ShellSuspendedLaunch { $script:events.Add("suspended"); $script:run } + Mock Invoke-ShellJob { + param($Operation) + $script:events.Add($Operation) + switch ($Operation) { + "Create" { $script:nextJob += 111; [IntPtr]$script:nextJob } + "Contains" { $true } + "Active" { 0 } + } + } + Mock Write-Host {} + $script:scope = New-ShellProcessScope + $script:events.Clear() + } + AfterEach { $script:process.Dispose() } + + It "assigns the suspended launch before releasing any target code" { + $actual = Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) + [object]::ReferenceEquals($actual, $script:process) | Should -BeTrue + ($script:events -join ",") | Should -Be "suspended,Assign,Create,Assign,resume" + $script:scope.Runs.Count | Should -Be 1 + } + It "retains and stops only the suspended original when assignment fails" { + Mock Invoke-ShellJob { throw "assignment refused" } -ParameterFilter { $Operation -eq "Assign" } + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*assignment refused*" + ($script:events -join ",") | Should -Be "suspended,stop-held" + $script:scope.Runs.Count | Should -Be 1 + } + It "does not start a target in a closed scope" { + $script:scope.Closed = $true + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*closed*" + Should -Invoke New-ShellSuspendedLaunch -Times 0 -Exactly + } + It "retains the owned launch and stops it when thread release fails" { + $script:run | Add-Member ScriptMethod Resume { throw "resume refused" } -Force + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*resume refused*" + ($script:events -join ",") | Should -Be "suspended,Assign,Create,Assign,stop-held" + $script:scope.Runs.Count | Should -Be 1 + } + It "propagates a changed-suspension retention tag after stopping the original launch" { + $script:run | Add-Member ScriptMethod Resume { + $error = [InvalidOperationException]::new("suspension changed") + $error.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $error + } -Force + $failure = $null + try { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:events | Should -Contain "stop-held" + } + It "propagates pre-return native cleanup uncertainty without inventing a launch record" { + Mock New-ShellSuspendedLaunch { + $error = [InvalidOperationException]::new("native cleanup unproved") + $error.Data["GraphCodeValidationRootRetentionRequired"] = $true + throw $error + } + $failure = $null + try { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:scope.Runs.Count | Should -Be 0 + $script:events | Should -Not -Contain "Assign" + } + It "refuses a newly reopened or stale PID object" { + $script:scope.Runs.Add($script:run) + $other = [Diagnostics.Process]::new() + try { + { Stop-ShellOwnedProcess $script:scope $other } | Should -Throw "*UNATTESTED_PROCESS*" + $script:events.Count | Should -Be 0 + } finally { $other.Dispose() } + } + It "uses the retained native launch even after its managed Process is disposed" { + $script:scope.Runs.Add($script:run) + $script:process.Dispose() + Stop-ShellOwnedProcess $script:scope $script:process + ($script:events -join ",") | Should -Be "Contains,stop-held" + } + It "refuses a lost job-membership attestation without termination" { + $script:scope.Runs.Add($script:run) + Mock Invoke-ShellJob { $false } -ParameterFilter { $Operation -eq "Contains" } + { Stop-ShellOwnedProcess $script:scope $script:process } | Should -Throw "*UNATTESTED_PROCESS*" + $script:events | Should -Not -Contain "stop-held" + } + It "waits for an empty job before closing launch and job handles" { + $script:scope.Runs.Add($script:run) + Close-ShellProcessScope $script:scope + ($script:events -join ",") | Should -Be "Terminate,Wait,Active,root-exited,Terminate,Wait,Active,drain,Close,close-launch,Close" + $script:scope.Closed | Should -BeTrue + Should -Invoke Write-Host -Times 1 -Exactly + } + It "does not release handles or claim success when the job remains active" { + $script:scope.Runs.Add($script:run) + Mock Invoke-ShellJob { 1 } -ParameterFilter { $Operation -eq "Active" } + { Close-ShellProcessScope $script:scope } | Should -Throw "*OWNED_CLEANUP_UNPROVEN*" + $script:scope.Closed | Should -BeFalse + $script:events | Should -Not -Contain "close-launch" + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } + } + It "does not claim cleanup when output drainage fails" { + $script:run | Add-Member ScriptMethod Drain { throw "output EOF unproved" } -Force + $script:scope.Runs.Add($script:run) + { Close-ShellProcessScope $script:scope } | Should -Throw "*EOF unproved*" + $script:scope.Closed | Should -BeFalse + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } + } + It "does not report success when native job termination fails" { + Mock Invoke-ShellJob { throw "termination refused" } -ParameterFilter { $Operation -eq "Terminate" } + { Close-ShellProcessScope $script:scope } | Should -Throw "*termination refused*" + $script:scope.Closed | Should -BeFalse + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } + } + It "does not reopen a candidate after the scope has closed" { + $script:scope.Closed = $true + { Get-ShellOwnedMember $script:scope 99999999 } | Should -Throw "*scope is closed*" + $script:events.Count | Should -Be 0 + } + It "refuses an unfinalized output read without waiting for inherited pipe EOF" -Tag PipeFinalization { + $script:task = [pscustomobject]@{} + $script:task | Add-Member ScriptMethod Wait { $script:events.Add("EOF-wait"); throw "descendant pipe still open" } + $script:run | Add-Member NoteProperty Output $script:task + $script:scope.Runs.Add($script:run) + { Read-ShellOwnedOutput $script:scope $script:process } | Should -Throw "*explicit launch finalization*" + $script:events | Should -Not -Contain "EOF-wait" + } + It "finalizes only one private launch job before draining its inherited pipes" { + $script:scope.Runs.Add($script:run) + Complete-ShellOwnedProcess $script:scope $script:process + ($script:events -join ",") | Should -Be "root-exited,Terminate,Wait,Active,drain,Close" + $script:run.Finalized | Should -BeTrue + Should -Invoke Invoke-ShellJob -Times 0 -Exactly -ParameterFilter { + $Operation -eq "Terminate" -and $Job -eq [IntPtr]456 + } + Should -Invoke Invoke-ShellJob -Times 1 -Exactly -ParameterFilter { + $Operation -eq "Terminate" -and $Job -eq [IntPtr]567 + } + $script:events | Should -Not -Contain "close-launch" + Complete-ShellOwnedProcess $script:scope $script:process + Should -Invoke Invoke-ShellJob -Times 1 -Exactly -ParameterFilter { $Operation -eq "Terminate" } + } + It "refuses finalization of a still-live root without terminating any job" { + $script:run | Add-Member ScriptMethod RequireExited { throw "original root is still live" } -Force + $script:scope.Runs.Add($script:run) + { Complete-ShellOwnedProcess $script:scope $script:process } | Should -Throw "*still live*" + Should -Invoke Invoke-ShellJob -Times 0 -Exactly -ParameterFilter { $Operation -eq "Terminate" } + $script:run.Finalized | Should -BeFalse + } + It "retains the launch when its private job remains active" { + $script:scope.Runs.Add($script:run) + Mock Invoke-ShellJob { 1 } -ParameterFilter { $Operation -eq "Active" } + $failure = $null + try { Complete-ShellOwnedProcess $script:scope $script:process } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "drain" + $script:events | Should -Not -Contain "Close" + } + It "propagates EOF uncertainty after private-job emptiness without releasing handles" { + $script:scope.Runs.Add($script:run) + $script:run | Add-Member ScriptMethod Drain { throw "inherited pipe EOF unproved" } -Force + $failure = $null + try { Complete-ShellOwnedProcess $script:scope $script:process } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "Close" + $script:events | Should -Not -Contain "close-launch" + } + It "cleans a partially assigned suspended launch through its original handle" { + $script:run.ScopeAssigned = $false + $script:run.LaunchAssigned = $false + $script:run.LaunchJob = [IntPtr]::Zero + $script:scope.Runs.Add($script:run) + Close-ShellProcessScope $script:scope + $script:events | Should -Contain "stop-held" + $script:run.Finalized | Should -BeTrue + $script:scope.Closed | Should -BeTrue + } + It "closes a partially finalized scope idempotently without re-finalizing completed jobs" { + $script:scope.Runs.Add($script:run) + Complete-ShellOwnedProcess $script:scope $script:process + $script:events.Clear() + Close-ShellProcessScope $script:scope + ($script:events -join ",") | Should -Be "Terminate,Wait,Active,close-launch,Close" + $script:events.Clear() + Close-ShellProcessScope $script:scope + $script:events.Count | Should -Be 0 + } + It "leaves a concurrently live sibling launch untouched during explicit finalization" { + $other = [Diagnostics.Process]::new() + try { + $sibling = [pscustomobject]@{ Process = $other; LaunchJob = [IntPtr]678; Finalized = $false } + $sibling | Add-Member ScriptMethod RequireExited { throw "sibling root is still live" } + $script:scope.Runs.Add($script:run) + $script:scope.Runs.Add($sibling) + Complete-ShellOwnedProcess $script:scope $script:process + $sibling.Finalized | Should -BeFalse + Should -Invoke Invoke-ShellJob -Times 0 -Exactly -ParameterFilter { + $Operation -eq "Terminate" -and $Job -ne [IntPtr]567 + } + } finally { $other.Dispose() } + } + It "cleans a retained private job whose suspended launch assignment failed" { + Mock Invoke-ShellJob { throw "private assignment refused" } -ParameterFilter { + $Operation -eq "Assign" -and $Job -eq [IntPtr]567 + } + { Start-ShellOwnedProcess $script:scope ([Diagnostics.ProcessStartInfo]::new()) } | + Should -Throw "*private assignment refused*" + $script:run.ScopeAssigned | Should -BeTrue + $script:run.LaunchAssigned | Should -BeFalse + $script:events | Should -Not -Contain "resume" + Close-ShellProcessScope $script:scope + $script:run.Finalized | Should -BeTrue + $script:run.LaunchJobClosed | Should -BeTrue + $script:scope.Closed | Should -BeTrue + } + It "returns complete stdout and stderr only after explicit finalization" { + $script:run | Add-Member NoteProperty Output ([Threading.Tasks.Task]::FromResult[string]("stdout")) + $script:run | Add-Member NoteProperty Error ([Threading.Tasks.Task]::FromResult[string]("stderr")) + $script:scope.Runs.Add($script:run) + Complete-ShellOwnedProcess $script:scope $script:process + Read-ShellOwnedOutput $script:scope $script:process | Should -BeExactly "stdout" + Read-ShellOwnedOutput $script:scope $script:process -StandardError | Should -BeExactly "stderr" + } + It "receives bounded incremental text without finalizing a live launch" -Tag IncrementalOutput { + $script:run | Add-Member ScriptMethod TakeOutput { param($maximum) "out:$maximum" } + $script:run | Add-Member ScriptMethod TakeError { param($maximum) "err:$maximum" } + $script:scope.Runs.Add($script:run) + $chunk = Receive-ShellOwnedOutput $script:scope $script:process -MaximumCharacters 8 + $chunk.Output | Should -BeExactly "out:8" + $chunk.Error | Should -BeExactly "err:8" + $script:run.Finalized | Should -BeFalse + $script:events.Count | Should -Be 0 + } + It "refuses incremental reads through a different process object" { + $script:scope.Runs.Add($script:run) + $other = [Diagnostics.Process]::new() + try { + { Receive-ShellOwnedOutput $script:scope $other } | Should -Throw "*original launch record*" + $script:events.Count | Should -Be 0 + } finally { $other.Dispose() } + } + It "rejects invalid incremental batch limits before accessing any stream or job" { + $script:scope.Runs.Add($script:run) + { Receive-ShellOwnedOutput $script:scope $script:process -MaximumCharacters 1 } | Should -Throw + { Receive-ShellOwnedOutput $script:scope $script:process -MaximumCharacters 65537 } | Should -Throw + $script:events.Count | Should -Be 0 + } + It "propagates incremental pump faults without returning success-shaped chunks" { + $script:run | Add-Member ScriptMethod TakeOutput { throw [IO.IOException]::new("stdout pump failed") } + $script:run | Add-Member ScriptMethod TakeError { throw "stderr must not be accessed" } + $script:scope.Runs.Add($script:run) + $failure = $null + try { Receive-ShellOwnedOutput $script:scope $script:process } catch { $failure = $_ } + $failure.Exception.Message | Should -Match "stdout pump failed" + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:events.Count | Should -Be 0 + } + } +} + +Describe "Incremental capture before EOF (managed reader only)" { + BeforeAll { + InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } + $captureType = ("ShellOwnedLaunch" -as [type]).GetNestedType("OutputCapture", [Reflection.BindingFlags]::NonPublic) + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $env:GRAPHCODE_VALIDATION_ASSEMBLY ` + -RequiredType "GraphCode.Validation.TestFixtures.ControlledOutputReader" | Out-Null + $readerType = [GraphCode.Validation.TestFixtures.ControlledOutputReader] + $writerType = [GraphCode.Validation.TestFixtures.FailingForwardWriter] + } + BeforeEach { + $expectReaderFault = $false + $text = "a" + [char]::ConvertFromUtf32(0x1F600) + "b" + $reader = [Activator]::CreateInstance($readerType, [object[]]@($text)) + $capture = [Activator]::CreateInstance($captureType, [object[]]@($reader)) + } + AfterEach { + [void] $reader.End.TrySetResult(0) + try { + if (-not $capture.Completion.Wait(5000)) { throw "Managed capture did not stop" } + } catch { + if (-not $expectReaderFault -or -not $capture.Completion.IsFaulted) { throw } + } + $reader.Dispose() + } + It "forwards bounded characters before EOF without splitting surrogate pairs or losing final text" { + $capture.Completion.IsCompleted | Should -BeFalse + $capture.Take(2) | Should -BeExactly "a" + $capture.Take(2) | Should -BeExactly ([char]::ConvertFromUtf32(0x1F600)) + $capture.Take(2) | Should -BeExactly "b" + $capture.Take(2) | Should -BeExactly "" + ($null -eq $capture.Take(2)) | Should -BeFalse + $capture.Completion.IsCompleted | Should -BeFalse + [void] $reader.End.TrySetResult(0) + $capture.Completion.Wait(5000) | Should -BeTrue + $capture.Completion.GetAwaiter().GetResult() | Should -BeExactly $text + } + It "surfaces a reader fault instead of reporting an empty successful stream" { + $expectReaderFault = $true + [void] $reader.End.TrySetException([IO.IOException]::new("managed reader failed")) + { $capture.Completion.Wait(5000) } | Should -Throw "*managed reader failed*" + { $capture.Take(8) } | Should -Throw "*managed reader failed*" + } + It "enforces the same hard character bounds in the actual managed capture" { + { $capture.Take(1) } | Should -Throw + { $capture.Take(65537) } | Should -Throw + $capture.Take(4) | Should -BeExactly $text + } + It "preserves text that arrives between empty non-EOF polls" { + $capture.Take(4) | Should -BeExactly $text + $capture.Take(4) | Should -BeExactly "" + $capture.Completion.IsCompleted | Should -BeFalse + $reader.Tail = "later" + [void] $reader.End.TrySetResult(5) + $received = "" + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($received.Length -lt 5 -and $clock.ElapsedMilliseconds -lt 5000) { + $chunk = $capture.Take(2) + $chunk.Length | Should -BeLessOrEqual 2 + $received += $chunk + [Threading.Thread]::Sleep(1) + } + $received | Should -BeExactly "later" + $capture.Completion.IsCompleted | Should -BeFalse + [void] $reader.End.TrySetResult(0) + $capture.Completion.Wait(5000) | Should -BeTrue + $capture.Completion.GetAwaiter().GetResult() | Should -BeExactly ($text + "later") + } + It "forwards exactly the 65536-character ceiling and retains the complete capture" { + $large = "x" * 70000 + $largeReader = [Activator]::CreateInstance($readerType, [object[]]@($large)) + $largeCapture = [Activator]::CreateInstance($captureType, [object[]]@($largeReader)) + try { + $largeCapture.Take(65536).Length | Should -Be 65536 + $largeCapture.Take(65536).Length | Should -Be 4464 + $largeCapture.Take(65536) | Should -BeExactly "" + $largeCapture.Completion.IsCompleted | Should -BeFalse + [void] $largeReader.End.TrySetResult(0) + $largeCapture.Completion.Wait(5000) | Should -BeTrue + $largeCapture.Completion.GetAwaiter().GetResult() | Should -BeExactly $large + } finally { + [void] $largeReader.End.TrySetResult(0) + if (-not $largeCapture.Completion.Wait(5000)) { throw "Large managed capture did not stop" } + $largeReader.Dispose() + } + } + It "redirected=: forwards only inherited-style streams before EOF" -TestCases @( + @{ redirected = $false }, @{ redirected = $true } + ) { + param($redirected) + $sink = [IO.StringWriter]::new() + $select = ("ShellOwnedLaunch" -as [type]).GetMethod("ForwardWriter", + [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::NonPublic) + $forward = $select.Invoke($null, [object[]]@($redirected, $sink)) + $liveReader = [Activator]::CreateInstance($readerType, [object[]]@($text)) + $liveCapture = [Activator]::CreateInstance($captureType, [object[]]@($liveReader, $forward)) + try { + $liveCapture.Completion.IsCompleted | Should -BeFalse + $sink.ToString() | Should -BeExactly $(if ($redirected) { "" } else { $text }) + $liveCapture.Take(4) | Should -BeExactly $text + [void] $liveReader.End.TrySetResult(0) + $liveCapture.Completion.Wait(5000) | Should -BeTrue + $liveCapture.Completion.GetAwaiter().GetResult() | Should -BeExactly $text + $sink.ToString() | Should -BeExactly $(if ($redirected) { "" } else { $text }) + } finally { + [void] $liveReader.End.TrySetResult(0) + if (-not $liveCapture.Completion.Wait(5000)) { throw "Live-forward capture did not stop" } + $liveReader.Dispose() + $sink.Dispose() + } + } + It "flushFailure=: surfaces forwarding sink failures in the capture task" -TestCases @( + @{ flushFailure = $false }, @{ flushFailure = $true } + ) { + param($flushFailure) + $sink = [Activator]::CreateInstance($writerType) + $sink.FailFlush = $flushFailure + $liveReader = [Activator]::CreateInstance($readerType, [object[]]@($text)) + $liveCapture = [Activator]::CreateInstance($captureType, [object[]]@($liveReader, $sink)) + try { + $expected = if ($flushFailure) { "*forward flush failed*" } else { "*forward write failed*" } + { $liveCapture.Completion.Wait(5000) } | Should -Throw $expected + { $liveCapture.Take(4) } | Should -Throw $expected + } finally { + [void] $liveReader.End.TrySetResult(0) + $liveReader.Dispose() + $sink.Dispose() + } + } + It "preserves UTF-8 forwarding when a surrogate pair crosses pump read boundaries" -Tag ForwardUnicode { + $large = ("x" * 4095) + [char]::ConvertFromUtf32(0x1F600) + "b" + $bytes = [IO.MemoryStream]::new() + $sink = [IO.StreamWriter]::new($bytes, [Text.UTF8Encoding]::new($false), 1024, $true) + $sink.AutoFlush = $true + $liveReader = [Activator]::CreateInstance($readerType, [object[]]@($large)) + $liveCapture = [Activator]::CreateInstance($captureType, [object[]]@($liveReader, $sink)) + try { + [void] $liveReader.End.TrySetResult(0) + $liveCapture.Completion.Wait(5000) | Should -BeTrue + $liveCapture.Completion.GetAwaiter().GetResult() | Should -BeExactly $large + $forwarded = [Text.UTF8Encoding]::new($false, $true).GetString($bytes.ToArray()) + ($forwarded -ceq $large) | Should -BeTrue -Because "forwarding must not flush half a surrogate pair" + } finally { + [void] $liveReader.End.TrySetResult(0) + if (-not $liveCapture.Completion.Wait(5000)) { throw "UTF-8 forwarding capture did not stop" } + $liveReader.Dispose() + $sink.Dispose() + $bytes.Dispose() + } + } +} + +Describe "Ownership source wiring (not native execution)" { + It "selects live forwarding independently for originally unredirected stdout and stderr" -Tag ForwardPolicy { + InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } + $select = ("ShellOwnedLaunch" -as [type]).GetMethod("ForwardWriter", + [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::NonPublic) + $select | Should -Not -BeNullOrEmpty + $sink = [IO.StringWriter]::new() + try { + ($null -eq $select.Invoke($null, [object[]]@($true, $sink))) | Should -BeTrue + [object]::ReferenceEquals($sink, $select.Invoke($null, [object[]]@($false, $sink))) | Should -BeTrue + } finally { $sink.Dispose() } + $native = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellOwnedProcess.cs") -Raw + $native | Should -Match 'ForwardWriter\(info.RedirectStandardOutput, Console.Out\)' + $native | Should -Match 'ForwardWriter\(info.RedirectStandardError, Console.Error\)' + } + It "preserves each smoke caller's original per-stream capture intent" { + $ast = [Management.Automation.Language.Parser]::ParseFile($shellPath, [ref]$null, [ref]$null) + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq "Start-SmokeProcess" + }, $true) + . ([scriptblock]::Create($function.Extent.Text)) + $repoRoot = $repo + Mock Get-Command { [pscustomobject]@{ Source = "D:\synthetic.exe" } } + Mock Start-ShellOwnedProcess { param($Scope, $StartInfo) $script:observedStart = $StartInfo } + foreach ($case in @( + @{ output = $false; error = $false }, + @{ output = $true; error = $false }, + @{ output = $false; error = $true }, + @{ output = $true; error = $true } + )) { + Start-SmokeProcess "synthetic" @() -scope ([pscustomobject]@{}) ` + -CaptureOutput:$case.output -CaptureError:$case.error + $script:observedStart.RedirectStandardOutput | Should -Be $case.output + $script:observedStart.RedirectStandardError | Should -Be $case.error + } + $text = Get-Content -LiteralPath $shellPath -Raw + $text | Should -Match '\$versionProcess = Start-SmokeProcess \$app @\("--version"\) -CaptureOutput -CaptureError' + $text | Should -Match '\$busyApp = Start-SmokeProcess \$app @\("--smoke"\) -CaptureError' + $text | Should -Match '\$inputApp = Start-SmokeProcess \$app @\("--smoke"\) -CaptureError' + } + It "samples surviving bare-name descendants before finalization and reads only afterwards" { + $text = Get-Content -LiteralPath $shellPath -Raw + $text | Should -Match '(?s)\$process.WaitForExit\(\).*?& \$sample\s+Record-TestOwnedSessions\s+Complete-ShellOwnedProcess \$bareProcessScope \$process\s+\$stderr = Read-ShellOwnedOutput \$bareProcessScope \$process -StandardError' + } + It "inherits the current directory for default or empty settings and preserves explicit directories" -Tag WorkingDirectory { + $native = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellOwnedProcess.cs") -Raw + $argument = [regex]::Match($native, 'environment, (.*?), ref startup, out created') + $argument.Success | Should -BeTrue + $argument.Groups[1].Value | Should -BeExactly "SelectWorkingDirectory(info)" + $selector = "ShellOwnedLaunch" -as [type] + $default = [Diagnostics.ProcessStartInfo]::new() + $default.WorkingDirectory | Should -BeExactly "" + ($null -eq $selector::SelectWorkingDirectory($default)) | Should -BeTrue + $default.WorkingDirectory = "" + ($null -eq $selector::SelectWorkingDirectory($default)) | Should -BeTrue + $default.WorkingDirectory = "D:\explicit directory\" + $selector::SelectWorkingDirectory($default) | Should -BeExactly "D:\explicit directory\" + } + It "compiles the existing job mechanism and suspended-launch interop without invoking native APIs" { + InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } + "ShellOwnedLaunch" -as [type] | Should -Not -BeNullOrEmpty + } + It "removes CIM-derived and named zmx termination from both reachable scripts" { + foreach ($path in @($shellPath, (Join-Path $repo "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1"))) { + $text = Get-Content -LiteralPath $path -Raw + $text | Should -Not -Match 'Stop-Process|\.Kill\(|kill --force' + $text | Should -Match 'Close-ShellProcessScope' + $text | Should -Match 'UNATTESTED_SURVIVORS|Assert-NoOrphanShellProcesses' + } + } + It "requires held job membership before retaining a CIM-discovered handoff candidate" { + $text = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1") -Raw + $text | Should -Match 'Get-ShellOwnedMember \$processScope \$children\[0\]\.ProcessId' + $module = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellProcessOwnership.psm1") -Raw + $module | Should -Match '(?s)function Get-ShellOwnedMember.*?Contains.*?Members.Add' + } + It "preserves both registered large-paste sampling contracts with the owned launcher" { + $shellSource = Get-Content -LiteralPath $shellPath -Raw + $path = Join-Path $repo "Tools\windows\Tests\WindowsShell.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq "Assert-Contract" -and + ($node.Extent.Text.Contains("large-paste sampling") -or + $node.Extent.Text.Contains("large-paste metric is not tied")) + }, $true)) + $commands.Count | Should -Be 2 + function Assert-Contract($condition, $message) { + if (-not $condition) { throw $message } + } + foreach ($command in $commands) { & ([scriptblock]::Create($command.Extent.Text)) } + } + It "requires original/managed generation comparison and an explicit inherited handle list" { + $native = Get-Content -LiteralPath (Join-Path $repo "Tools\windows\ShellOwnedProcess.cs") -Raw + $native | Should -Match 'nativeCreated != managedCreated' + $native | Should -Match 'new IntPtr\(0x20002\)' + $native | Should -Match 'uint flags = 0x4' + } + It "preserves empty, spaced, quoted and trailing-backslash arguments without native execution" { + $quote = ("ShellOwnedLaunch" -as [type]).GetMethod("Quote", + [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::NonPublic) + $fixtures = @( + @{ value = ""; expected = '""' }, + @{ value = "plain"; expected = '"plain"' }, + @{ value = "two words"; expected = '"two words"' }, + @{ value = 'a"b'; expected = '"a\"b"' }, + @{ value = 'D:\two words\'; expected = '"D:\two words\\"' } + ) + foreach ($fixture in $fixtures) { + $quote.Invoke($null, [object[]]@($fixture.value)) | Should -BeExactly $fixture.expected + } + $fixtures.Count | Should -Be 5 + } +} + +Describe "Shell cleanup never promotes advisory process observations" { + It "refuses a disposed launch PID and a same-session CIM impostor without terminating either" { + $source = Get-Content -LiteralPath $shellPath -Raw + $legacy = [regex]::Match($source, + '(?s)if \(\$env:GRAPHCODE_ZMX -and \(Test-Path -LiteralPath \$env:GRAPHCODE_ZMX\)\) \{.*?\r?\n \}') + if (-not $legacy.Success) { + $source | Should -Match 'Close-ShellProcessScope' + $source | Should -Not -Match 'Stop-Process' + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Assert-NoOrphanShellProcesses" + }, $true) + . ([scriptblock]::Create($function.Extent.Text)) + function Test-TestSessionProcess { $true } + Mock Get-CimInstance { [pscustomobject]@{ ProcessId = 202; CommandLine = "forged-session" } } + Mock Stop-Process {} + { Assert-NoOrphanShellProcesses } | Should -Throw "*UNATTESTED_SURVIVORS*" + Should -Invoke Stop-Process -Times 0 -Exactly + return + } + + $ownedProcessIds = [Collections.Generic.HashSet[int]]::new() + [void]$ownedProcessIds.Add(101) + $ownedSessionNames = @() + $saved = $env:GRAPHCODE_ZMX + $env:GRAPHCODE_ZMX = "synthetic-zmx" + Mock Test-Path { $true } + Mock Get-Process { [pscustomobject]@{ Id = 101 } } + Mock Get-CimInstance { [pscustomobject]@{ Name = "zmx.exe"; ProcessId = 202 } } + Mock Stop-Process {} + function Get-ProcessTreeIds { @(101) } + function Test-TestSessionProcess { $true } + try { + & ([scriptblock]::Create($legacy.Value)) + Should -Invoke Stop-Process -Times 0 -Exactly + } finally { + $env:GRAPHCODE_ZMX = $saved + } + } +} + +Describe "Handoff teardown refuses unattested image matches" { + It ": only verified owned-job cleanup can release support files" -TestCases @( + @{ case = "same-path impostor"; hasSurvivor = $true }, + @{ case = "normal owned cleanup"; hasSurvivor = $false } + ) { + param($hasSurvivor) + $path = Join-Path $repo "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1" + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $main = @($ast.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] + $processScope = [pscustomobject]@{ Closed = $false } + $daemonExecutable = "D:\synthetic-handoff\graphcoded.exe" + $supportDirectory = "D:\synthetic-handoff\support" + $supportCreated = $true + $primaryFailure = $null + function Close-ShellProcessScope { $processScope.Closed = $true } + Mock Get-CimInstance { + if ($hasSurvivor) { [pscustomobject]@{ + Name = "graphcoded.exe"; ProcessId = 303; ExecutablePath = "D:\synthetic-handoff\graphcoded.exe" + } } + } + + Mock Stop-Process {} + Mock Remove-Item {} + Mock Write-Host {} + Mock Write-Host {} -ModuleName ShellProcessOwnership + $body = ($main.Finally.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + if ($hasSurvivor) { + { & ([scriptblock]::Create($body)) } | Should -Throw "*UNATTESTED_SURVIVORS*" + Should -Invoke Remove-Item -Times 0 -Exactly + } else { + & ([scriptblock]::Create($body)) + Should -Invoke Remove-Item -Times 1 -Exactly + } + $processScope.Closed | Should -BeTrue + Should -Invoke Stop-Process -Times 0 -Exactly + } +} + +Describe "Late bare-stage cleanup preserves earlier failures (mock only)" { + It " preserves primary failure and cleanup evidence" -Tag LateCleanup -TestCases @( + @{ boundary = "outer"; kind = "tagged" }, + @{ boundary = "outer"; kind = "CIM" }, + @{ boundary = "bare"; kind = "tagged" }, + @{ boundary = "bare"; kind = "CIM" }, + @{ boundary = "outer"; kind = "earlier tagged" }, + @{ boundary = "bare"; kind = "earlier tagged" } + ) { + param($boundary, $kind) + $ast = [Management.Automation.Language.Parser]::ParseFile($shellPath, [ref]$null, [ref]$null) + $helper = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "New-ShellCleanupFailure" + }, $true) + if ($helper) { . ([scriptblock]::Create($helper.Extent.Text)) } + if ($boundary -eq "bare") { + $function = $ast.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "Invoke-BareNameZmxShell" + }, $true) + $main = @($function.Body.EndBlock.Statements | + Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] + } else { + $main = @($ast.EndBlock.Statements | + Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] + } + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + $observedRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $observedRoot | Out-Null + $primary = [InvalidOperationException]::new("first failure") + $late = [InvalidOperationException]::new("late $kind refusal") + if ($kind -eq "tagged") { + $late.Data["GraphCodeValidationRootRetentionRequired"] = $true + $late.Data["GraphCodeValidationRetainedRoots"] = @($observedRoot) + } + if ($kind -eq "earlier tagged") { + $primary.Data["GraphCodeValidationRootRetentionRequired"] = $true + $primary.Data["GraphCodeValidationRetainedRoots"] = @($observedRoot) + } + function Remove-BareNameZmxStage {} + function Assert-NoOrphanShellProcesses {} + Mock Remove-BareNameZmxStage { throw $late } + Mock Close-ShellProcessScope { param($scope) $scope.Closed = $true } + Mock Assert-NoOrphanShellProcesses {} + Mock Remove-Item {} + Mock Get-ChildItem {} + Mock Write-Host {} + Mock Write-Host {} -ModuleName ShellProcessOwnership + $failure = $null + try { + & { + foreach ($variable in @($main.Finally.FindAll({ + param($node) + $node -is [Management.Automation.Language.VariableExpressionAst] -and + -not $node.VariablePath.IsDriveQualified + }, $true).VariablePath.UserPath | Sort-Object -Unique)) { + if ($variable -notin @("_", "null", "true", "false")) { Set-Variable $variable $null } + } + $primaryFailure = [Management.Automation.ErrorRecord]::new($primary, "primary", "NotSpecified", $null) + $barePrimaryFailure = $primaryFailure + $processScope = [pscustomobject]@{ Closed = $false } + $bareProcessScope = [pscustomobject]@{ Closed = $false } + $stubSessionHosts = @{} + $shellRoot = $root + $workspaceLayoutBase = "D:\synthetic-layout.json" + $bareZmxStage = $root + $bareZmxProject = $root + $smokeZmxDir = "$root\smoke" + $bareStubResult = "$root\bare-stub.json" + $stubResult = "$root\stub.json" + $busyResult = "$root\busy.json" + $busyError = "$root\busy.err" + $inputError = "$root\input.err" + $body = ($main.Finally.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + & ([scriptblock]::Create($body)) + } + } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + $expected = if ($kind -eq "earlier tagged") { "Earlier ownership refusal" } else { "late" } + $failure.Exception.Message | Should -Match "^Shell failed: first failure; cleanup unproved: .*$expected" + [object]::ReferenceEquals($failure.Exception.InnerException, $primary) | Should -BeTrue + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $failure.Exception.Data["GraphCodeValidationRetainedRoots"] | Should -Contain $root + if ($kind -ne "CIM") { + $failure.Exception.Data["GraphCodeValidationRetainedRoots"] | Should -Contain $observedRoot + } + Should -Invoke Remove-BareNameZmxStage -Times $(if ($kind -eq "earlier tagged") { 0 } else { 1 }) -Exactly + Should -Invoke Close-ShellProcessScope -Times $(if ($boundary -eq "outer") { 2 } else { 1 }) -Exactly + Should -Invoke Remove-Item -Times 0 -Exactly -ParameterFilter { $PesterBoundParameters.ContainsKey("LiteralPath") } + Remove-Item -LiteralPath "$root\filter-positive-control" + Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { $PesterBoundParameters.ContainsKey("LiteralPath") } + } +} + +Describe "Ownership-unproven root retention contract (filesystem only)" { + BeforeEach { + $hadRoot = [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") + $savedRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + $env:GRAPHCODE_VALIDATION_ROOT = $root + Mock Write-Host {} -ModuleName ShellProcessOwnership + } + AfterEach { + if ($hadRoot) { + [Environment]::SetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT", $savedRoot, "Process") + } else { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + } + [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") | Should -Be $hadRoot + if ($hadRoot) { + [Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ROOT") | Should -BeExactly $savedRoot + } + } + It "publishes a bounded CreateNew UTF-8 receipt and an actual retained-root list" { + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) @($root, "$root\absent") + $error.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + @($error.Data["GraphCodeValidationRetainedRoots"]).Count | Should -Be 1 + $receipt = Join-Path $root "ownership-unproven.json" + $bytes = [IO.File]::ReadAllBytes($receipt) + $bytes.Length | Should -BeLessOrEqual 4096 + $payload = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json + $payload.schemaVersion | Should -Be 1 + $payload.validationRoot | Should -BeExactly ([IO.Path]::GetFullPath($root)) + $payload.reason | Should -BeExactly "ownership_unproven" + Should -Invoke Write-Host -ModuleName ShellProcessOwnership -Times 1 -Exactly -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + } + It "preserves an existing receipt and still flags a receipt-write failure" { + $receipt = Join-Path $root "ownership-unproven.json" + [IO.File]::WriteAllText($receipt, "first retained evidence") + $before = (Get-FileHash -LiteralPath $receipt).Hash + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) + (Get-FileHash -LiteralPath $receipt).Hash | Should -BeExactly $before + $error.Data["GraphCodeValidationRetentionReceiptFailure"] | Should -Not -BeNullOrEmpty + $error.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + Should -Invoke Write-Host -ModuleName ShellProcessOwnership -Times 1 -Exactly -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + } + It "flags standalone same-process failures even when no validation root is configured" { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT -ErrorAction SilentlyContinue + [Environment]::GetEnvironmentVariables().Contains("GRAPHCODE_VALIDATION_ROOT") | Should -BeFalse + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) @($root) + Test-ShellOwnershipUnproven $error | Should -BeTrue + Test-Path (Join-Path $root "ownership-unproven.json") | Should -BeFalse + } + It "refuses a configured file root without suppressing the retention signal" { + $file = Join-Path $root "not-a-directory" + [IO.File]::WriteAllText($file, "preserve") + $env:GRAPHCODE_VALIDATION_ROOT = $file + $error = Set-ShellOwnershipUnproven ([InvalidOperationException]::new("unattested survivor")) + $error.Data["GraphCodeValidationRetentionReceiptFailure"] | Should -Not -BeNullOrEmpty + $error.Data["GraphCodeValidationRootRetentionRequired"] | Should -BeTrue + [IO.File]::ReadAllText($file) | Should -BeExactly "preserve" + } + It "preserves the tagged primary when diagnostic transport fails: " -TestCases @( + @{ case = "sentinel"; failSentinel = $true; failReceiptDiagnostic = $false; blockedRoot = $false; absentRoot = $false; failures = 1 } + @{ case = "receipt diagnostic"; failSentinel = $false; failReceiptDiagnostic = $true; blockedRoot = $true; absentRoot = $false; failures = 1 } + @{ case = "both channels"; failSentinel = $true; failReceiptDiagnostic = $true; blockedRoot = $true; absentRoot = $false; failures = 2 } + @{ case = "standalone sentinel"; failSentinel = $true; failReceiptDiagnostic = $false; blockedRoot = $false; absentRoot = $true; failures = 1 } + ) { + param($failSentinel, $failReceiptDiagnostic, $blockedRoot, $absentRoot, $failures) + if ($failSentinel) { + Mock Write-Host { throw [IO.IOException]::new("sentinel transport failed") } -ModuleName ShellProcessOwnership -ParameterFilter { + $Object -eq "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" + } + } + if ($failReceiptDiagnostic) { + Mock Write-Host { throw [IO.IOException]::new("receipt diagnostic transport failed") } -ModuleName ShellProcessOwnership -ParameterFilter { + $Object -like "SHELL_RETENTION_RECEIPT_UNPROVEN=*" + } + } + if ($blockedRoot) { + $file = Join-Path $root "blocked-root" + [IO.File]::WriteAllText($file, "preserve") + $env:GRAPHCODE_VALIDATION_ROOT = $file + } elseif ($absentRoot) { + Remove-Item Env:\GRAPHCODE_VALIDATION_ROOT + } + $primary = [InvalidOperationException]::new("unattested survivor") + $returned = Set-ShellOwnershipUnproven $primary @($root) + [object]::ReferenceEquals($returned, $primary) | Should -BeTrue + Test-ShellOwnershipUnproven $returned | Should -BeTrue + $returned.Message | Should -BeExactly "unattested survivor" + $returned.Data["GraphCodeValidationRetainedRoots"] | Should -Contain $root + @($returned.Data["GraphCodeValidationRetentionDiagnosticFailures"]).Count | Should -Be $failures + foreach ($failure in $returned.Data["GraphCodeValidationRetentionDiagnosticFailures"]) { + $failure | Should -BeOfType ([IO.IOException]) + $failure.Message | Should -BeLike "*transport failed" + } + if ($blockedRoot) { + $returned.Data["GraphCodeValidationRetentionReceiptFailure"] | Should -Not -BeNullOrEmpty + [IO.File]::ReadAllText($file) | Should -BeExactly "preserve" + } + $receipt = Join-Path $root "ownership-unproven.json" + (Test-Path -LiteralPath $receipt) | Should -Be (-not $blockedRoot -and -not $absentRoot) + if (-not $blockedRoot -and -not $absentRoot) { + (Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json).reason | Should -BeExactly "ownership_unproven" + } + } + It "recognizes the Boolean tag through a native-method exception wrapper" { + $inner = [InvalidOperationException]::new("unattested survivor") + $inner.Data["GraphCodeValidationRootRetentionRequired"] = $true + $outer = [InvalidOperationException]::new("wrapper", $inner) + Test-ShellOwnershipUnproven $outer | Should -BeTrue + $inner.Data["GraphCodeValidationRootRetentionRequired"] = "true" + Test-ShellOwnershipUnproven $outer | Should -BeFalse + } +} diff --git a/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs b/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs index d9b9c067..9906aea8 100644 --- a/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs +++ b/Tools/windows/ValidationRunner/Adapters/EvidenceParser.cs @@ -75,6 +75,17 @@ public static IReadOnlyList CaptureGroups(string reportPath, IReadOnlyLi return required.ToArray(); } + public static bool CaptureDiagnostics(string text, string suiteRoot) + { + var lines = Lines(text); + if (lines.Count == 0) return true; + const string selector = "CAP_TRACE_FAILURE command=CapSelectorRefusal: Cannot find an overload for \"ToInt64\" and the argument count: \"0\"."; + var denied = Path.Combine(suiteRoot, "manifest-creation-budget", "snapshots"); + var journal = "JournalObservationFailure while reporting JournalUnavailable: JournalObservationFailure: " + + $"Exception calling \"Open\" with \"4\" argument(s): \"Access to the path '{denied}' is denied.\""; + return lines.Count == 3 && lines[0] == selector && lines[1] == selector && lines[2] == journal; + } + private static int Integer(JsonElement element, string property) { if (!element.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Number || diff --git a/Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 b/Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 new file mode 100644 index 00000000..ecb0b4e9 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/HostSourceContracts.ps1 @@ -0,0 +1,129 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][ValidateSet("host-prerequisite", "provider-boundary", "delayed-responses")][string] $Contract +) + +$ErrorActionPreference = "Stop" +$source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\validate.ps1")) +$cases = [Collections.Generic.List[string]]::new() +function Assert-Source([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw "Compiled validation source contract: $Message" } +} +function Get-TaskBody([string] $Text, [string] $Task) { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($Text, [ref]$null, [ref]$errors) + Assert-Source ($errors.Count -eq 0) "validation source does not parse" + $function = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Invoke-Task" + }, $true)) + Assert-Source ($function.Count -eq 1) "task dispatcher is missing or duplicated" + $switches = @($function[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.SwitchStatementAst] + }, $true)) + $clauses = @($switches.Clauses | Where-Object { $_.Item1.Value -ceq $Task }) + Assert-Source ($clauses.Count -eq 1) "task branch missing or duplicated: $Task" + return $clauses[0].Item2 +} +function Confirm-HostOrder([string] $Text) { + $body = Get-TaskBody $Text "windows-shell" + $commands = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true)) + $stages = @{} + foreach ($entry in @( + @{ key = "build"; title = '"Pinned Winghostty host build for App contracts"' }, + @{ key = "smoke"; title = '"Pinned GraphCode Windows shell build and smoke"' } + )) { + $values = @($commands | Where-Object { + $_.GetCommandName() -ceq "Invoke-Native" -and $_.CommandElements[1].Extent.Text -ceq $entry.title + }) + Assert-Source ($values.Count -eq 1) "missing or duplicated $($entry.key)" + $stages[$entry.key] = $values[0].Extent + } + $calls = @($commands | Where-Object { + $_.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $_.CommandElements[0].Extent.Text -match 'Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1' + }) + Assert-Source ($calls.Count -eq 1) "App contracts call missing or duplicated" + $stages.contracts = $calls[0].Extent + $guards = @($body.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] + }, $true)) + foreach ($entry in @( + @{ key = "pin"; pattern = '\$actualWinghosttyPin -ne \$pins\.winghostty\.sha' }, + @{ key = "clean"; pattern = '\$providerStatus\.Count -ne 0' }, + @{ key = "library"; pattern = 'Test-Path -LiteralPath \$winghosttyLib -PathType Leaf' } + )) { + $values = @($guards | Where-Object { $_.Extent.Text -match $entry.pattern }) + Assert-Source ($values.Count -eq 1) "missing or duplicated $($entry.key) guard" + $stages[$entry.key] = $values[0].Extent + } + Assert-Source ($stages.pin.EndOffset -lt $stages.build.StartOffset -and + $stages.clean.EndOffset -lt $stages.build.StartOffset -and + $stages.build.EndOffset -lt $stages.library.StartOffset -and + $stages.library.EndOffset -lt $stages.contracts.StartOffset -and + $stages.contracts.EndOffset -lt $stages.smoke.StartOffset) "App host prerequisite ordering changed" + Assert-Source ($stages.build.Text -match + '(?s)Push-Location \$winghosttyRoot.*& \$zig0152 build -Demit-win32-host=true.*finally \{ Pop-Location \}') ` + "host compile is not the pinned build with restored working directory" +} +switch ($Contract) { + "host-prerequisite" { + Confirm-HostOrder $source + $cases.Add("actual-pinned-order") + $call = [regex]::Match($source, + '(?s)& \(Join-Path \$repoRoot "Tools\\windows\\Tests\\WindowsShell\.Tests\.ps1"\)\s*`\s*-ZigExecutable \$zig0152').Value + Assert-Source (-not [string]::IsNullOrEmpty($call)) "cannot construct early-call mutation" + $variants = [ordered]@{ + "missing-build" = $source.Replace('& $zig0152 build -Demit-win32-host=true', 'Write-Output "build removed"') + "missing-stage" = $source.Replace('"Pinned Winghostty host build for App contracts"', '"build removed"') + "early-contracts" = $source.Replace($call, "").Replace( + 'Invoke-Native "Pinned Winghostty host build for App contracts"', + $call + "`n" + 'Invoke-Native "Pinned Winghostty host build for App contracts"') + } + foreach ($name in $variants.Keys) { + Assert-Source ($variants[$name] -cne $source) "ineffective mutation: $name" + $rejected = $false + try { Confirm-HostOrder $variants[$name] } catch { $rejected = $true } + Assert-Source $rejected "unsafe prerequisite mutation admitted: $name" + $cases.Add($name) + } + } + "provider-boundary" { + $body = (Get-TaskBody $source "provider-build").Extent.Text + Assert-Source ($body -match 'Invoke-ProviderBuild' -and + $body -notmatch '(?i)terminal-gate\.ps1|TerminalGate\.Tests|windows-shell\.ps1|uia|Stress') ` + "build-only task runs more than the shared provider compile" + $cases.Add("build-only-dispatch") + $terminal = (Get-TaskBody $source "terminal-gate").Extent.Text + Assert-Source ($terminal -match '(?s)Invoke-ProviderBuild.*?terminal-gate\.ps1.*?-SkipProviderBuild.*?-Stress') ` + "terminal gate no longer reuses the shared compile" + $cases.Add("terminal-shared-build") + foreach ($consumer in @("terminal-gate.ps1", "windows-shell.ps1")) { + $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\$consumer")) + Assert-Source ($text -match 'provider-build\.ps1' -and + $text -notmatch '-Demit-win32-host=true|-Dtarget=x86_64-windows-gnu') "consumer duplicates build flags: $consumer" + $cases.Add($consumer) + } + $provider = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\provider-build.ps1")) + Assert-Source ($provider.Contains('-Demit-win32-host=true') -and $provider.Contains('-Dtarget=x86_64-windows-gnu')) ` + "canonical provider build flags are absent" + $cases.Add("canonical-flags") + } + "delayed-responses" { + foreach ($path in @("Tools\windows\validate.ps1", "Tools\windows\Tests\Hardening.Tests.ps1")) { + $text = [IO.File]::ReadAllText((Join-Path $RepositoryRoot $path)) + Assert-Source ($text.Contains('-StubResponseDelayMilliseconds 150')) "delayed correlated response not exercised: $path" + $cases.Add($path) + } + } +} +Assert-Source ($cases.Count -gt 0) "no cases executed" +[ordered]@{ + suite = "HostSource.$Contract"; executed = $cases.Count; passed = $cases.Count; failed = 0; skipped = 0 +} | ConvertTo-Json -Compress | Write-Output diff --git a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 new file mode 100644 index 00000000..5da9e026 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 @@ -0,0 +1,62 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath, + [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup")][string] $Contract +) +$ErrorActionPreference = "Stop" +$tests = Join-Path $RepositoryRoot "Tools\windows\Tests" +$caseRoot = Join-Path $ArtifactRoot ("legacy-mock-" + [guid]::NewGuid().ToString("N")) +[void][IO.Directory]::CreateDirectory($caseRoot) +$previous = [Console]::Out +$capturedConsole = [IO.StringWriter]::new() +$output = @() +$run = $null +try { + # These named suites use mocked authorities only; their deliberate retention + # diagnostics must not be forwarded as a real native owner's lifetime signal. + [Console]::SetOut($capturedConsole) + if ($Contract -ceq "live-cleanup") { + $output = @(& (Join-Path $tests "OwnedLiveProcessCleanup.Tests.ps1") 6>&1 3>&1 4>&1 5>&1) + } else { + . (Join-Path $RepositoryRoot "Tools\windows\Import-PinnedPester.ps1") + $file = if ($Contract -ceq "shell-scope") { "ShellProcessOwnership.Tests.ps1" } else { "OwnedLiveAdapter.Tests.ps1" } + $output = @(Invoke-Pester -Path (Join-Path $tests $file) -PassThru -Output None 6>&1 3>&1 4>&1 5>&1) + $runs = @($output | Where-Object { + $null -ne $_.PSObject.Properties["TotalCount"] -and + $null -ne $_.PSObject.Properties["PassedCount"] -and + $null -ne $_.PSObject.Properties["FailedCount"] + }) + if ($runs.Count -ne 1) { throw "Original ownership Pester suite did not return exactly one run result" } + $run = $runs[0] + } +} finally { + [Console]::SetOut($previous) + [IO.File]::WriteAllText((Join-Path $caseRoot "mock-console.txt"), $capturedConsole.ToString(), [Text.UTF8Encoding]::new($false)) + $capturedConsole.Dispose() + [IO.File]::WriteAllText((Join-Path $caseRoot "mock-powershell-streams.txt"), + (($output | ForEach-Object { "$_" }) -join "`n"), [Text.UTF8Encoding]::new($false)) +} +if ($Contract -ceq "live-cleanup") { + $summary = @($output | Where-Object { "$_" -ceq "OWNED_LIVE_PROCESS_CLEANUP_CASES=84 FAILURES=0" }) + if ($summary.Count -ne 1) { throw "Original owned live cleanup suite did not pass all 84 actual controls" } + $count = 84 +} else { + foreach ($property in @("TotalCount", "PassedCount", "FailedCount", "SkippedCount", "NotRunCount")) { + if ($null -eq $run.PSObject.Properties[$property]) { throw "Pester result omits $property" } + } + [IO.File]::WriteAllText((Join-Path $caseRoot "pester-summary.json"), ([ordered]@{ + executed = $run.TotalCount; passed = $run.PassedCount; failed = $run.FailedCount + skipped = $run.SkippedCount; notRun = $run.NotRunCount + } | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + if ($run.TotalCount -le 0 -or $run.PassedCount -ne $run.TotalCount -or + $run.FailedCount -ne 0 -or $run.SkippedCount -ne 0 -or $run.NotRunCount -ne 0) { + throw "Original ownership Pester suite failed or omitted controls; retained evidence: $caseRoot" + } + $count = $run.TotalCount +} +$row = [ordered]@{ + id = "host.legacy-ownership.$Contract"; status = "Passed"; executedCount = $count + evidence = "Actual original $Contract mock-only suite; positive count and zero failed/skipped/not-run; retained detailed evidence: $caseRoot" +} +Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) diff --git a/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs index a8692ba2..1721e286 100644 --- a/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs +++ b/Tools/windows/ValidationRunner/Adapters/PowerShellPayload.cs @@ -2,6 +2,7 @@ using System.IO; using System.Reflection; using System.Text; +using System.Security.Cryptography; using GraphCode.Validation.Ownership; using GraphCode.Validation.Host; @@ -9,9 +10,21 @@ namespace GraphCode.Validation.Adapters; public static class PowerShellPayload { - public static async Task RunAsync( + public sealed record Receipt(string Output, string ArtifactRoot, string StdoutTextSha256); + + public static string CreateScratchRoot(string repositoryRoot) + { + var parent = Path.Combine(Path.GetPathRoot(repositoryRoot) ?? + throw new InvalidDataException("Repository has no volume root"), "gc-v"); + Directory.CreateDirectory(parent); + var path = Path.Combine(parent, Guid.NewGuid().ToString("N")[..16]); + GraphCode.ValidationFixtureDirectoryNative.Create(path); + return path; + } + + public static async Task RunAsync( ValidationContext context, string id, string relativeScript, IReadOnlyList arguments, - CancellationToken cancellationToken) + CancellationToken cancellationToken, Func? expectedDiagnostics = null) { var executable = Environment.GetEnvironmentVariable("GRAPHCODE_VALIDATION_PWSH") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"); @@ -25,11 +38,7 @@ public static async Task RunAsync( throw new InvalidOperationException("The legacy monolithic runner is not an allowed payload"); var artifacts = Path.Combine(context.ArtifactRoot, id + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(artifacts); - var tempParent = Path.Combine(Path.GetPathRoot(context.RepositoryRoot) ?? - throw new InvalidDataException("Repository has no volume root"), "gc-validation"); - Directory.CreateDirectory(tempParent); - var temp = Path.Combine(tempParent, Guid.NewGuid().ToString("N")); - GraphCode.ValidationFixtureDirectoryNative.Create(temp); + var temp = CreateScratchRoot(context.RepositoryRoot); await File.WriteAllTextAsync(Path.Combine(artifacts, "scratch-root.txt"), temp, cancellationToken); var start = new ProcessStartInfo(executable) { @@ -47,16 +56,22 @@ public static async Task RunAsync( var result = await OwnedChildSupervisor.RunAsync(start, TimeSpan.FromMinutes(12), artifacts, cancellationToken); if (!result.Cleanup.CanRelease || !result.Cleanup.ResourcesClosed) throw new InvalidOperationException($"{id}: owned child cleanup is UNKNOWN; artifacts retained: {artifacts}"); - if (result.StandardOutput.Contains("GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven", StringComparison.Ordinal)) + var unknown = false; + var observer = new RetentionSignalObserver(() => unknown = true); + observer.OnText(result.StandardOutput); + observer.OnEof(); + if (unknown) { TerminalSafety.MarkUnknown(); var failure = new InvalidOperationException($"{id}: descendant reported UNKNOWN ownership; artifacts retained: {artifacts}"); failure.Data["GraphCodeValidationRootRetentionRequired"] = true; throw failure; } - if (result.ExitCode != 0 || !string.IsNullOrWhiteSpace(result.StandardError)) + if (result.ExitCode != 0 || (!string.IsNullOrWhiteSpace(result.StandardError) && + (expectedDiagnostics is null || !expectedDiagnostics(result.StandardError)))) throw new InvalidOperationException($"{id}: protected payload failed (exit={result.ExitCode}); " + $"stdout/stderr and scratch receipt retained: {artifacts}"); - return result.StandardOutput; + return new Receipt(result.StandardOutput, artifacts, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(result.StandardOutput)))); } } diff --git a/Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 new file mode 100644 index 00000000..32d4b1d6 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/Provenance.Contracts.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = "Stop" +$loader = Join-Path $RepositoryRoot "Tools\windows\Import-CompiledValidationAssembly.ps1" +$source = Join-Path $RepositoryRoot "Tools\windows\ValidationFixtureDirectoryNative.cs" +$before = [IO.File]::ReadAllBytes($source) +$backup = Join-Path $ArtifactRoot ("provenance-source-" + [guid]::NewGuid().ToString("N") + ".before") +[IO.File]::WriteAllBytes($backup, $before) +function Write-ProvenanceCase([string] $Id, [string] $Evidence) { + $row = [ordered]@{ id = $Id; status = "Passed"; executedCount = 1; evidence = $Evidence } + Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) +} +& $loader -AssemblyPath $AssemblyPath -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null +Write-ProvenanceCase "host.provenance.current-source" "Actual compiled source/reference fingerprints and file MVID admitted; native create not invoked" +try { + $changed = [Text.Encoding]::UTF8.GetBytes([Text.Encoding]::UTF8.GetString($before) + "`n// Controlled stale-source contract.`n") + [IO.File]::WriteAllBytes($source, $changed) + $failure = $null + try { & $loader -AssemblyPath $AssemblyPath -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null } + catch { $failure = $_ } + if ($null -eq $failure -or + $failure.Exception.Message -cne "Compiled validation inputs changed after build; rebuild with Build-CompiledValidationRunner.ps1") { + throw "Same-worktree source change was not refused before native binding use" + } + Write-ProvenanceCase "host.provenance.source-changed" "Actual same-worktree C# input changed without rebuilding; already-loaded same-path assembly refused" +} finally { + [IO.File]::WriteAllBytes($source, $before) +} +if ((Get-FileHash -LiteralPath $source).Hash -cne (Get-FileHash -LiteralPath $backup).Hash) { + throw "Provenance negative control did not restore its exact owned source bytes" +} +& $loader -AssemblyPath $AssemblyPath -RequiredType "GraphCode.ValidationFixtureDirectoryNative" | Out-Null +Write-ProvenanceCase "host.provenance.source-restored" "Exact original source bytes restored; same binding admitted again; native create not invoked" diff --git a/Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 new file mode 100644 index 00000000..e7e23713 --- /dev/null +++ b/Tools/windows/ValidationRunner/Adapters/ProviderBuild.Contracts.ps1 @@ -0,0 +1,85 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +$ErrorActionPreference = "Stop" +$root = Join-Path ([IO.Path]::GetTempPath()) ("providers-" + [guid]::NewGuid().ToString("N").Substring(0,8)) +[void][IO.Directory]::CreateDirectory($root) +$compiler = Join-Path $root "compiler.cmd" +$executable = [IO.Path]::ChangeExtension($AssemblyPath, ".exe") +[IO.File]::WriteAllText($compiler, '@"' + $executable + '" --host-fixture provider-compiler %*' + "`r`n", + [Text.ASCIIEncoding]::new()) +$pins = [ordered]@{ schemaVersion = 1 } +foreach ($provider in @("winghostty", "zmx")) { + $path = Join-Path $root $provider + [void][IO.Directory]::CreateDirectory($path) + & git -C $path init --quiet + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git init failed" } + & git -C $path config core.autocrlf false + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git line-ending isolation failed" } + [IO.File]::WriteAllText((Join-Path $path ".gitignore"), "zig-out/`n", [Text.UTF8Encoding]::new($false)) + & git -C $path add .gitignore + & git -C $path -c user.name=ValidationFixture -c user.email=validation@example.invalid commit --quiet -s ` + -m "Pin controlled provider fixture" -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git pin failed" } + $sha = & git -C $path rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw "Controlled provider Git revision failed" } + $pins[$provider] = [ordered]@{ sha = $sha } +} +$pinsPath = Join-Path $root "pins.json" +$pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath +$providerScript = Join-Path $RepositoryRoot "Tools\windows\provider-build.ps1" +$pwsh = [Environment]::ProcessPath +$executed = 0 +function Invoke-CompilerCase([string] $Behavior = "") { + foreach ($provider in @("winghostty", "zmx")) { + $generated = Join-Path $root "$provider\zig-out" + if (Test-Path -LiteralPath $generated) { Remove-Item -LiteralPath $generated -Recurse -Force } + } + $log = Join-Path $root ("compiler-" + [guid]::NewGuid().ToString("N") + ".jsonl") + $env:GRAPHCODE_PROVIDER_COMPILER_LOG = $log + $env:GRAPHCODE_PROVIDER_COMPILER_BEHAVIOR = $Behavior + $output = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $providerScript ` + -WinghosttyRoot (Join-Path $root "winghostty") -ZmxRoot (Join-Path $root "zmx") ` + -Zig0152 $compiler -Zig0160 $compiler -PinsPath $pinsPath -ZmxAttempts 1 2>&1 | + ForEach-Object { "$_" }) + $exit = $LASTEXITCODE + $calls = if (Test-Path -LiteralPath $log) { @([IO.File]::ReadAllLines($log)) } else { @() } + return [pscustomobject]@{ exit = $exit; output = $output; calls = $calls } +} +function Assert-Provider([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw "Controlled provider-build contract: $Message" } +} +$pass = Invoke-CompilerCase +Assert-Provider ($pass.exit -eq 0 -and $pass.calls.Count -eq 2 -and + @($pass.output | Where-Object { $_ -ceq "PROVIDER_BUILD_EXECUTED=2" }).Count -eq 1 -and + @($pass.output | Where-Object { $_ -like "PROVIDER_BUILD_STEP=*" }).Count -eq 2) "complete two-provider build not counted exactly" +Assert-Provider ($pass.calls[0] -ceq '["build","-Demit-win32-host=true"]' -and + $pass.calls[1] -ceq '["build","-Dtarget=x86_64-windows-gnu"]') "canonical flags changed" +Assert-Provider (($pass.output -join "`n") -notmatch '(?i)terminal gate|smoke|TerminalGate\.Tests|zmx send|uia') ` + "build-only provider script dispatched a terminal test" +$executed++ +foreach ($behavior in @("missing", "fail")) { + $actual = Invoke-CompilerCase $behavior + Assert-Provider ($actual.exit -ne 0 -and + @($actual.output | Where-Object { $_ -like "PROVIDER_BUILD_EXECUTED=*" }).Count -eq 0) "partial compiler result admitted: $behavior" + $executed++ +} +$dirty = Join-Path $root "zmx\dirty.txt" +[IO.File]::WriteAllText($dirty, "controlled untracked source") +$actual = Invoke-CompilerCase +Assert-Provider ($actual.exit -ne 0 -and $actual.calls.Count -eq 0) "dirty provider compiled" +Remove-Item -LiteralPath $dirty +$executed++ +$pins.zmx.sha = "0" * 40 +$pins | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $pinsPath +$actual = Invoke-CompilerCase +Assert-Provider ($actual.exit -ne 0 -and $actual.calls.Count -eq 0) "unpinned provider compiled" +$executed++ +Assert-Provider ($executed -eq 5) "not all actual production dispatch controls ran" +$row = [ordered]@{ + id = "host.provider-build"; status = "Passed"; executedCount = $executed + evidence = "Actual provider-build.ps1 with ordinary compiled controlled compiler; two canonical flags, partial/error/dirty/wrong-pin controls" +} +Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index ede33435..ef8a01dc 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -31,6 +31,19 @@ public static IReadOnlyList Create(IReadOnlyList new HostTaskSuite("host.unknown-task", "unknown", 1), new HostTaskSuite("host.foreign-fixture", "foreign-fixture", 1), new HostTaskSuite("host.untracked-format", "untracked-format", 1), + new HostSourceSuite("host.shell-prerequisite", "host-prerequisite", 4), + new HostSourceSuite("host.provider-task-boundary", "provider-boundary", 5), + new HostSourceSuite("ui.root.source.line-5346", "delayed-responses", 2), + new CaseScriptSuite("host.provenance", "Tools\\windows\\ValidationRunner\\Adapters\\Provenance.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.provenance.current-source", "host.provenance.source-changed", "host.provenance.source-restored"]), + new CaseScriptSuite("host.provider-build", "Tools\\windows\\ValidationRunner\\Adapters\\ProviderBuild.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.provider-build"]), + new CaseScriptSuite("host.legacy-ownership.shell-scope", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.legacy-ownership.shell-scope"], ["-Contract", "shell-scope"]), + new CaseScriptSuite("host.legacy-ownership.live-adapter", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.legacy-ownership.live-adapter"], ["-Contract", "live-adapter"]), + new CaseScriptSuite("host.legacy-ownership.live-cleanup", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.legacy-ownership.live-cleanup"], ["-Contract", "live-cleanup"]), new CaseScriptSuite("ui.source-contracts", "Tools\\windows\\ValidationRunner\\Ui\\UiSourceContracts.ps1", "UI_CASE_RESULT=", required.Where(unit => unit.Id.StartsWith("ui.activation.source.", StringComparison.Ordinal) || unit.Id.StartsWith("ui.popup.source.", StringComparison.Ordinal)).Select(unit => unit.Id).ToArray()) @@ -43,7 +56,8 @@ private sealed class ScriptSuite(string id, string script, Func> RunAsync(ValidationContext context, CancellationToken cancellationToken) { var output = await PowerShellPayload.RunAsync(context, id, "Tools\\windows\\Tests\\" + script, [], cancellationToken); - return [parse(output)]; + var result = parse(output.Output); + return [WithReceipt(result, output)]; } } @@ -56,7 +70,21 @@ public async Task> RunAsync(ValidationContext context, var output = await PowerShellPayload.RunAsync(context, id, "Tools\\windows\\ValidationRunner\\Adapters\\HostTasks.ps1", ["-RepositoryRoot", context.RepositoryRoot, "-Contract", contract], cancellationToken); - return [EvidenceParser.Structured(id, "HostTasks." + contract, output, count, count)]; + return [WithReceipt(EvidenceParser.Structured(id, "HostTasks." + contract, output.Output, count, count), output)]; + } + + } + + private sealed class HostSourceSuite(string id, string contract, int count) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var output = await PowerShellPayload.RunAsync(context, id, + "Tools\\windows\\ValidationRunner\\Adapters\\HostSourceContracts.ps1", + ["-RepositoryRoot", context.RepositoryRoot, "-Contract", contract], cancellationToken); + return [WithReceipt(EvidenceParser.Structured(id, "HostSource." + contract, output.Output, count, count), output)]; } } @@ -67,8 +95,8 @@ private sealed class CaptureSuite(RequiredUnit required) : IValidationSuite public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) { const string script = "Tools\\windows\\Tests\\UiCaptureDriver.Tests.ps1"; - var captureRoot = Path.Combine(context.ArtifactRoot, "capture-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(captureRoot); + var captureRoot = PowerShellPayload.CreateScratchRoot(context.RepositoryRoot); + await File.WriteAllTextAsync(Path.Combine(context.ArtifactRoot, "capture-root.txt"), captureRoot, cancellationToken); var prebuilt = Path.Combine(captureRoot, "prebuilt"); await PowerShellPayload.RunAsync(context, Id + ".prepare", script, ["-PrepareFixturesOnly", "-ScratchRoot", prebuilt], cancellationToken); @@ -77,14 +105,15 @@ await PowerShellPayload.RunAsync(context, Id + ".prepare", script, var suiteRoot = Path.Combine(captureRoot, "suite"); var output = await PowerShellPayload.RunAsync(context, Id, script, ["-ScratchRoot", suiteRoot, "-PrebuiltFixtureManifest", manifest, "-PrebuiltFixtureManifestSha256", hash], - cancellationToken); - var result = EvidenceParser.Structured(Id, "UiCaptureDriver", output, 182, 182); + cancellationToken, value => EvidenceParser.CaptureDiagnostics(value, suiteRoot)); + var result = WithReceipt(EvidenceParser.Structured(Id, "UiCaptureDriver", output.Output, 182, 182), output); return [result with { Groups = EvidenceParser.CaptureGroups(Path.Combine(suiteRoot, "report.json"), required.RequiredGroups ?? []) }]; } } - public sealed class CaseScriptSuite(string id, string script, string prefix, IReadOnlyList unitIds) : IValidationSuite + public sealed class CaseScriptSuite(string id, string script, string prefix, IReadOnlyList unitIds, + IReadOnlyList? additionalArguments = null) : IValidationSuite { public string Id => id; public IReadOnlyList UnitIds => unitIds; @@ -92,13 +121,23 @@ public async Task> RunAsync(ValidationContext context, { var output = await PowerShellPayload.RunAsync(context, id, script, ["-RepositoryRoot", context.RepositoryRoot, "-ArtifactRoot", context.ArtifactRoot, - "-AssemblyPath", Assembly.GetExecutingAssembly().Location], cancellationToken); - var rows = output.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries) + "-AssemblyPath", Assembly.GetExecutingAssembly().Location, .. additionalArguments ?? []], cancellationToken); + var rows = output.Output.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries) .Where(line => line.StartsWith(prefix, StringComparison.Ordinal)).Select(line => JsonSerializer.Deserialize(line[prefix.Length..], Coverage.JsonOptions) ?? throw new InvalidDataException($"{id}: null case evidence")).ToArray(); if (rows.Length == 0) throw new InvalidDataException($"{id}: no case evidence"); - return rows; + return rows.Select(result => result with + { + Evidence = $"Case evidence retained in payload logs; evidence-text-sha256=" + + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(result.Evidence))) + + $"; stdout-text-sha256={output.StdoutTextSha256}; artifacts={output.ArtifactRoot}" + }).ToArray(); } } + + private static UnitResult WithReceipt(UnitResult result, PowerShellPayload.Receipt receipt) => result with + { + Evidence = $"{result.Evidence}; stdout-text-sha256={receipt.StdoutTextSha256}; artifacts={receipt.ArtifactRoot}" + }; } diff --git a/Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj b/Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj new file mode 100644 index 00000000..819cef26 --- /dev/null +++ b/Tools/windows/ValidationRunner/Bindings/Fixtures/ValidationTestFixtures.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + latest + enable + true + false + + + + + + + + diff --git a/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj b/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj index 042fee71..5328c53a 100644 --- a/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj +++ b/Tools/windows/ValidationRunner/Bindings/ValidationFixtureBindings.csproj @@ -10,4 +10,5 @@ + diff --git a/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj b/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj index f608f2c7..13359609 100644 --- a/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj +++ b/Tools/windows/ValidationRunner/Bindings/Windows/WindowsValidationBindings.csproj @@ -10,11 +10,13 @@ + + diff --git a/Tools/windows/ValidationRunner/BuildProvenance.targets b/Tools/windows/ValidationRunner/BuildProvenance.targets new file mode 100644 index 00000000..d3ee099c --- /dev/null +++ b/Tools/windows/ValidationRunner/BuildProvenance.targets @@ -0,0 +1,19 @@ + + + + <_ValidationInput Include="@(Compile)" Exclude="$(BaseIntermediateOutputPath)**" /> + <_ValidationInput Include="@(ReferencePath)" /> + <_ValidationInput Include="$(MSBuildProjectFullPath);$(MSBuildThisFileFullPath);$(MSBuildThisFileDirectory)global.json" /> + + + + + + + + + + + + diff --git a/Tools/windows/ValidationRunner/Host/ConsoleReport.cs b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs index 9f5e4538..f591bfb8 100644 --- a/Tools/windows/ValidationRunner/Host/ConsoleReport.cs +++ b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs @@ -4,8 +4,8 @@ namespace GraphCode.Validation.Host; public static class ConsoleReport { - public static bool ValidId(string id) => - Regex.IsMatch(id, "^[a-z0-9][a-z0-9.-]{0,160}$", RegexOptions.CultureInvariant); + public static bool ValidId(string? id) => + id is not null && Regex.IsMatch(id, "^[a-z0-9][a-z0-9.-]{0,160}$", RegexOptions.CultureInvariant); public static string Unit(UnitResult result) => $"{result.Status}: {(ValidId(result.Id) ? result.Id : "[invalid-id]")} executed={result.ExecutedCount}"; diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs index fc74ee35..ced491f2 100644 --- a/Tools/windows/ValidationRunner/Host/Coverage.cs +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -6,7 +6,11 @@ namespace GraphCode.Validation.Host; public sealed record RequiredUnit( string Id, string Owner, int MinimumCount, IReadOnlyList? RequiredGroups = null, - int? ExactCount = null); + int? ExactCount = null, IReadOnlyList? CompositeOf = null, + int? SourceTemplateCount = null, string? SourceMap = null); + +public sealed record CompositeTemplate(string SourceId, IReadOnlyList UnitIds, int ExpectedCount); +public sealed record CompositeSourceMap(IReadOnlyList Templates); public sealed record UnitManifest(IReadOnlyList Units); @@ -21,6 +25,7 @@ public static class Coverage PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = false, WriteIndented = true, + RespectRequiredConstructorParameters = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, Converters = { new JsonStringEnumConverter() } }; @@ -48,40 +53,19 @@ public static CoverageReport Evaluate( IReadOnlyList required, IReadOnlyList suites, IReadOnlyList results, - IReadOnlyList? executionErrors = null) + IReadOnlyList? executionErrors = null, + string? sourceDirectory = null) { var errors = new List(executionErrors ?? []); var ledger = new List(); - var requirements = new Dictionary(StringComparer.Ordinal); - if (required.Count == 0) errors.Add("Required-unit manifest is empty"); - foreach (var unit in required) - { - if (!ConsoleReport.ValidId(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || - unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) - errors.Add($"Invalid required unit: {unit.Id}"); - if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); - if (unit.RequiredGroups is not null && - (unit.RequiredGroups.Any(string.IsNullOrWhiteSpace) || - unit.RequiredGroups.Distinct(StringComparer.Ordinal).Count() != unit.RequiredGroups.Count)) - errors.Add($"Invalid required groups: {unit.Id}"); - } - var executors = new Dictionary(StringComparer.Ordinal); - var suiteNames = new HashSet(StringComparer.Ordinal); - foreach (var suite in suites) - { - if (string.IsNullOrWhiteSpace(suite.Id) || !suiteNames.Add(suite.Id)) - errors.Add($"Invalid or duplicate suite ID: {suite.Id}"); - if (suite.UnitIds.Count == 0) errors.Add($"Suite has no units: {suite.Id}"); - foreach (var id in suite.UnitIds) - { - if (!requirements.ContainsKey(id)) errors.Add($"Undeclared executor unit: {id}"); - executors[id] = executors.GetValueOrDefault(id) + 1; - } - } + var (requirements, executors, definitionErrors) = Definitions(required, suites); + errors.AddRange(definitionErrors); var observed = new Dictionary>(StringComparer.Ordinal); foreach (var result in results) { if (!requirements.ContainsKey(result.Id)) errors.Add($"Undeclared result unit: {result.Id}"); + if (requirements.TryGetValue(result.Id, out var declaration) && declaration.CompositeOf is not null) + errors.Add($"Composite evidence must be derived by the host, not submitted: {result.Id}"); if (!observed.TryGetValue(result.Id, out var rows)) { rows = []; @@ -89,7 +73,7 @@ public static CoverageReport Evaluate( } rows.Add(result); } - foreach (var (id, unit) in requirements) + foreach (var (id, unit) in requirements.Where(pair => pair.Value.CompositeOf is null)) { var count = executors.GetValueOrDefault(id); if (count != 1) @@ -106,7 +90,88 @@ public static CoverageReport Evaluate( ledger.Add(new UnitResult(id, UnitStatus.Incomplete, 0, detail)); continue; } - var result = rows[0]; + ledger.Add(ValidateResult(unit, rows[0], errors)); + } + foreach (var unit in requirements.Values.Where(unit => unit.CompositeOf is not null)) + { + try + { + if (executors.GetValueOrDefault(unit.Id) != 1 || observed.ContainsKey(unit.Id)) + throw new InvalidDataException("A composite has a duplicate or directly submitted executor/result"); + var composite = DeriveComposite(unit, ledger, sourceDirectory); + ledger.Add(ValidateResult(unit, composite, errors)); + } + catch (Exception error) when (error is InvalidDataException or IOException or JsonException or ArgumentException or OverflowException) + { + errors.Add($"{unit.Id}: {error.Message}"); + ledger.Add(new UnitResult(unit.Id, UnitStatus.Incomplete, 0, "Composite proof is incomplete; see coverage errors")); + } + } + var passed = ledger.Count(result => result.Status == UnitStatus.Passed); + return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && + passed == required.Count, required.Count, passed, ledger, errors); + } + + public static IReadOnlyList RegistryErrors(IReadOnlyList required, IReadOnlyList suites) => + Definitions(required, suites).Errors; + + private static (Dictionary Requirements, Dictionary Executors, List Errors) + Definitions(IReadOnlyList required, IReadOnlyList suites) + { + var errors = new List(); + var requirements = new Dictionary(StringComparer.Ordinal); + if (required.Count == 0) errors.Add("Required-unit manifest is empty"); + foreach (var unit in required) + { + if (unit is null || unit.Id is null) + { + errors.Add("Required-unit manifest contains a null unit or ID"); + continue; + } + if (!ConsoleReport.ValidId(unit.Id) || string.IsNullOrWhiteSpace(unit.Owner) || + unit.MinimumCount <= 0 || (unit.ExactCount is not null && unit.ExactCount < unit.MinimumCount)) + errors.Add($"Invalid required unit: {unit.Id}"); + if (!requirements.TryAdd(unit.Id, unit)) errors.Add($"Duplicate required unit: {unit.Id}"); + if (unit.RequiredGroups is not null && + (unit.RequiredGroups.Any(string.IsNullOrWhiteSpace) || + unit.RequiredGroups.Distinct(StringComparer.Ordinal).Count() != unit.RequiredGroups.Count)) + errors.Add($"Invalid required groups: {unit.Id}"); + if (unit.CompositeOf is not null && + (unit.CompositeOf.Count == 0 || unit.CompositeOf.Distinct(StringComparer.Ordinal).Count() != unit.CompositeOf.Count || + unit.CompositeOf.Contains(unit.Id, StringComparer.Ordinal) || unit.SourceTemplateCount is null or <= 0 || + string.IsNullOrWhiteSpace(unit.SourceMap))) + errors.Add($"Invalid composite declaration: {unit.Id}"); + } + var executors = new Dictionary(StringComparer.Ordinal); + foreach (var unit in requirements.Values.Where(unit => unit.CompositeOf is not null)) + { + executors[unit.Id] = 1; + foreach (var id in unit.CompositeOf!) + if (!requirements.TryGetValue(id, out var dependency) || dependency.CompositeOf is not null) + errors.Add($"Composite dependency is unknown or nested: {id}"); + } + var suiteNames = new HashSet(StringComparer.Ordinal); + foreach (var suite in suites) + { + if (string.IsNullOrWhiteSpace(suite.Id) || !suiteNames.Add(suite.Id)) + errors.Add($"Invalid or duplicate suite ID: {suite.Id}"); + if (suite.UnitIds.Count == 0) errors.Add($"Suite has no units: {suite.Id}"); + foreach (var id in suite.UnitIds) + { + if (!ConsoleReport.ValidId(id)) + { + errors.Add($"Invalid executor unit ID in suite: {suite.Id}"); + continue; + } + if (!requirements.ContainsKey(id)) errors.Add($"Undeclared executor unit: {id}"); + executors[id] = executors.GetValueOrDefault(id) + 1; + } + } + return (requirements, executors, errors); + } + + private static UnitResult ValidateResult(RequiredUnit unit, UnitResult result, List errors) + { var problems = new List(); if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); @@ -118,18 +183,50 @@ public static CoverageReport Evaluate( problems.Add($"Required group did not pass exactly once: {group}"); if (problems.Count != 0) { - errors.Add($"{id}: {string.Join("; ", problems)}"); - ledger.Add(result with + errors.Add($"{unit.Id}: {string.Join("; ", problems)}"); + return result with { Status = result.Status is UnitStatus.Incomplete or UnitStatus.Skipped ? result.Status : UnitStatus.Failed, Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" - }); + }; } - else ledger.Add(result); + return result; + } + + private static UnitResult DeriveComposite(RequiredUnit unit, IReadOnlyList ledger, string? sourceDirectory) + { + if (unit.CompositeOf is null || sourceDirectory is null || unit.SourceMap is null || Path.IsPathRooted(unit.SourceMap)) + throw new InvalidDataException("Composite declaration/source directory is missing or invalid"); + var directory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceDirectory)); + var path = Path.GetFullPath(Path.Combine(directory, unit.SourceMap)); + if (!path.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Composite source map escapes the current source directory"); + var map = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException("Composite source map is null"); + if (map.Templates is null || map.Templates.Count != unit.SourceTemplateCount || + map.Templates.Any(template => template is null || string.IsNullOrWhiteSpace(template.SourceId) || template.UnitIds is null || + template.UnitIds.Count == 0 || template.ExpectedCount <= 0) || + map.Templates.Select(template => template.SourceId).Distinct(StringComparer.Ordinal).Count() != map.Templates.Count) + throw new InvalidDataException("Composite source templates are missing, duplicate, or malformed"); + var mapped = map.Templates.SelectMany(template => template.UnitIds).ToArray(); + if (mapped.Distinct(StringComparer.Ordinal).Count() != mapped.Length || + mapped.Length != unit.CompositeOf.Count || + !mapped.ToHashSet(StringComparer.Ordinal).SetEquals(unit.CompositeOf)) + throw new InvalidDataException("Composite dependencies do not correspond exactly once to every source template"); + var dependencies = new Dictionary(StringComparer.Ordinal); + foreach (var id in unit.CompositeOf) + { + var matches = ledger.Where(result => result.Id == id).ToArray(); + if (matches.Length != 1 || matches[0].Status != UnitStatus.Passed || matches[0].ExecutedCount <= 0) + throw new InvalidDataException("A mandatory composite dependency did not pass exactly once"); + dependencies.Add(id, matches[0]); } - var passed = ledger.Count(result => result.Status == UnitStatus.Passed); - return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && - passed == required.Count, required.Count, passed, ledger, errors); + foreach (var template in map.Templates) + if (template.UnitIds.Sum(id => dependencies[id].ExecutedCount) != template.ExpectedCount) + throw new InvalidDataException("A source template did not execute its exact original expanded case count"); + var groups = dependencies.Values.SelectMany(result => result.Groups ?? []).Distinct(StringComparer.Ordinal).ToArray(); + return new UnitResult(unit.Id, UnitStatus.Passed, dependencies.Values.Sum(result => result.ExecutedCount), + $"Derived from {map.Templates.Count} independently mapped original source templates and actual dependency receipts", groups); } public static void ValidateProvenance(IValidationSuite suite, IReadOnlyList results) diff --git a/Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs b/Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs new file mode 100644 index 00000000..f515d014 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Fixtures/ShellStreamFixtures.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace GraphCode.Validation.TestFixtures; + +public sealed class ControlledOutputReader(string prefix) : TextReader +{ + private int position; + public string Tail = ""; + public TaskCompletionSource End = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override Task ReadAsync(char[] buffer, int offset, int count) + { + if (position < prefix.Length) + { + var length = Math.Min(count, prefix.Length - position); + prefix.CopyTo(position, buffer, offset, length); + position += length; + return Task.FromResult(length); + } + return ReadNext(buffer, offset); + } + + private async Task ReadNext(char[] buffer, int offset) + { + var length = await End.Task.ConfigureAwait(false); + if (length > 0) + { + Tail.CopyTo(0, buffer, offset, length); + End = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + return length; + } +} + +public sealed class FailingForwardWriter : TextWriter +{ + public bool FailFlush; + public override Encoding Encoding => Encoding.UTF8; + public override void Write(char[] buffer, int offset, int count) + { + if (!FailFlush) throw new IOException("forward write failed"); + } + + public override void Flush() + { + if (FailFlush) throw new IOException("forward flush failed"); + } +} + +public sealed class FailingRetentionSentinelWriter : TextWriter +{ + public override Encoding Encoding => Encoding.UTF8; + public override void WriteLine(string? value) => throw new IOException("controlled sentinel write failure"); +} + +public sealed class FailingRetentionSentinelFlush : TextWriter +{ + public override Encoding Encoding => Encoding.UTF8; + public override void WriteLine(string? value) { } + public override void Flush() => throw new IOException("controlled sentinel flush failure"); +} diff --git a/Tools/windows/ValidationRunner/Host/HostFixture.cs b/Tools/windows/ValidationRunner/Host/HostFixture.cs new file mode 100644 index 00000000..c5c41313 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/HostFixture.cs @@ -0,0 +1,28 @@ +using System.IO; +using System.Text.Json; + +namespace GraphCode.Validation.Host; + +public static class HostFixture +{ + public static int? TryRun(string[] args) + { + if (args.Length == 0 || args[0] != "--host-fixture") return null; + if (args.Length != 4 || args[1] != "provider-compiler" || args[2] != "build" || + args[3] is not ("-Demit-win32-host=true" or "-Dtarget=x86_64-windows-gnu")) + throw new ArgumentException("Unknown controlled host fixture invocation"); + var log = Environment.GetEnvironmentVariable("GRAPHCODE_PROVIDER_COMPILER_LOG") + ?? throw new InvalidOperationException("Controlled provider compiler log is required"); + File.AppendAllText(log, JsonSerializer.Serialize(args[2..]) + "\n"); + var behavior = Environment.GetEnvironmentVariable("GRAPHCODE_PROVIDER_COMPILER_BEHAVIOR"); + if (behavior == "fail") return 7; + if (behavior == "missing") return 0; + if (!string.IsNullOrEmpty(behavior)) throw new ArgumentException("Unknown controlled compiler behavior"); + var artifact = args[3] == "-Demit-win32-host=true" + ? Path.Combine("zig-out", "lib", "winghostty-win32-host.lib") + : Path.Combine("zig-out", "bin", "zmx.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(artifact)!); + File.WriteAllBytes(artifact, []); + return 0; + } +} diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index eb783a06..b6b99dfc 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -1,5 +1,6 @@ using GraphCode.Validation.Adapters; using System.IO; +using System.Text.Json; namespace GraphCode.Validation.Host; @@ -125,6 +126,56 @@ void RejectSummary(string value) catch (IOException) { } Assert(barrier is not null && !barrier.IsCompleted, "reporting exception bypassed terminal hold"); }); + Check("capture-expected-negative-diagnostics", () => + { + const string root = "D:\\owned\\suite"; + const string selector = "CAP_TRACE_FAILURE command=CapSelectorRefusal: Cannot find an overload for \"ToInt64\" and the argument count: \"0\"."; + var journal = "JournalObservationFailure while reporting JournalUnavailable: JournalObservationFailure: " + + "Exception calling \"Open\" with \"4\" argument(s): \"Access to the path 'D:\\owned\\suite\\manifest-creation-budget\\snapshots' is denied.\""; + var text = selector + "\n" + selector + "\n" + journal + "\n"; + Assert(EvidenceParser.CaptureDiagnostics(text, root), "known controlled refusals were rejected"); + Assert(!EvidenceParser.CaptureDiagnostics(text + "unexpected error\n", root), "unexpected diagnostic was admitted"); + Assert(!EvidenceParser.CaptureDiagnostics(text.Replace("D:\\owned", "D:\\foreign"), root), "foreign-path diagnostic was admitted"); + }); + Check("registry-positive", () => Assert(Coverage.RegistryErrors([unit], [suite]).Count == 0, + "valid registry rejected before execution")); + Check("registry-duplicate-preflight", () => Assert(Coverage.RegistryErrors([unit], [suite, suite]).Count > 0, + "duplicate executors not rejected before execution")); + var compositeDirectory = Path.Combine(Path.GetTempPath(), "gc-composite-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(compositeDirectory); + var mapPath = Path.Combine(compositeDirectory, "map.json"); + try + { + var aggregate = new RequiredUnit("test.aggregate", "Host", 1, ["group"], 1, + ["test.case"], 1, "map.json"); + var template = new CompositeTemplate("source-1", ["test.case"], 1); + CoverageReport Composite(IReadOnlyList? observations = null, RequiredUnit? declaration = null, + IReadOnlyList? templates = null, IReadOnlyList? registry = null) + { + File.WriteAllText(mapPath, JsonSerializer.Serialize(new CompositeSourceMap(templates ?? [template]), Coverage.JsonOptions)); + return Coverage.Evaluate([unit, declaration ?? aggregate], registry ?? [suite], + observations ?? [passed with { Groups = ["group"] }], sourceDirectory: compositeDirectory); + } + Check("composite-positive", () => Assert(Composite().Complete, "complete independently mapped proof rejected")); + Check("composite-missing", () => Assert(!Composite(observations: []).Complete, "missing dependency admitted")); + Check("composite-duplicate", () => Assert(!Composite(observations: [passed, passed]).Complete, "duplicate dependency admitted")); + Check("composite-skipped", () => Assert(!Composite(observations: [passed with { Status = UnitStatus.Skipped }]).Complete, "skipped dependency admitted")); + Check("composite-zero", () => Assert(!Composite(observations: [passed with { ExecutedCount = 0 }]).Complete, "zero dependency admitted")); + Check("composite-unimplemented", () => Assert(!Composite(registry: []).Complete, "unimplemented dependency admitted")); + Check("composite-source-count", () => Assert(!Composite(declaration: aggregate with { SourceTemplateCount = 2 }).Complete, "missing original source template admitted")); + Check("composite-map-duplicate", () => Assert(!Composite(declaration: aggregate with { SourceTemplateCount = 2 }, + templates: [template, template]).Complete, "duplicate source mapping admitted")); + Check("composite-expanded-count", () => Assert(!Composite(templates: [template with { ExpectedCount = 2 }]).Complete, "wrong expanded original count admitted")); + Check("composite-unknown-dependency", () => Assert(!Composite(declaration: aggregate with { CompositeOf = ["missing"] }).Complete, "unknown dependency admitted")); + Check("composite-submitted-proof", () => Assert(!Composite(observations: [passed, passed with { Id = "test.aggregate" }]).Complete, "caller-submitted composite admitted")); + Check("composite-groups", () => Assert(!Composite(observations: [passed]).Complete, "missing measured group admitted")); + Check("composite-map-escape", () => Assert(!Composite(declaration: aggregate with { SourceMap = "..\\outside.json" }).Complete, "escaped source map admitted")); + } + finally + { + File.Delete(mapPath); + Directory.Delete(compositeDirectory, false); + } foreach (var error in errors) Console.Error.WriteLine(error); Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); return errors.Count == 0 ? 0 : 1; diff --git a/Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs b/Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs new file mode 100644 index 00000000..4061f80e --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/ProviderCacheSuite.cs @@ -0,0 +1,168 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Host; + +public sealed class ProviderCacheSuite : IValidationSuite +{ + private const string SaveCondition = "${{ !cancelled() && steps.bootstrap.conclusion == 'success' && steps.verify-pins.conclusion == 'success' && steps.provider-build.conclusion == 'success' && steps.provider-cache.outputs.cache-hit != 'true' }}"; + public string Id => "host.provider-cache"; + public IReadOnlyList UnitIds => ["host.provider-cache-seeding", "host.provider-cache-mutations"]; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var shell = File.ReadAllText(Path.Combine(context.RepositoryRoot, ".github", "workflows", "windows-shell.yml")).Replace("\r\n", "\n"); + var warmer = File.ReadAllText(Path.Combine(context.RepositoryRoot, ".github", "workflows", "windows-cache-warmer.yml")).Replace("\r\n", "\n"); + var rows = new List(); + try { Verify(shell, warmer); } + catch (InvalidDataException error) + { + rows.Add(new UnitResult(UnitIds[0], UnitStatus.Failed, 1, error.Message)); + return Task.FromResult>(rows); + } + rows.Add(new UnitResult(UnitIds[0], UnitStatus.Passed, 1, "Actual integration/warmer ordering, immutable key, read-only consumers and measured timeout guards")); + var integration = Jobs(shell)["shell-integration"]; + var steps = Steps(integration); + var save = steps.Single(step => step.Contains("actions/cache/save@", StringComparison.Ordinal)); + var gate = steps.Single(step => step.Contains("validate.ps1 -Task windows-shell", StringComparison.Ordinal)); + var build = steps.Single(step => Match(step, "(?m)^ id: provider-build\\s*$")); + var changedIntegration = integration.Replace(save, "", StringComparison.Ordinal).Replace(gate, gate + save, StringComparison.Ordinal); + var shellMutations = new Dictionary(StringComparer.Ordinal) + { + ["whole-job-success"] = shell.Replace(SaveCondition, "${{ success() && steps.provider-cache.outputs.cache-hit != 'true' }}", StringComparison.Ordinal), + ["implicit-success"] = shell.Replace(SaveCondition, SaveCondition.Replace("!cancelled() && ", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["always"] = shell.Replace(SaveCondition, SaveCondition.Replace("!cancelled()", "always()", StringComparison.Ordinal), StringComparison.Ordinal), + ["no-build-outcome"] = shell.Replace(SaveCondition, SaveCondition.Replace(" && steps.provider-build.conclusion == 'success'", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["resave-hit"] = shell.Replace(SaveCondition, SaveCondition.Replace(" && steps.provider-cache.outputs.cache-hit != 'true'", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["save-after-gate"] = shell.Replace(integration, changedIntegration, StringComparison.Ordinal), + ["no-compile"] = shell.Replace(build, "", StringComparison.Ordinal), + ["compile-replaced"] = shell.Replace("run: ./Tools/windows/validate.ps1 -Task provider-build", + "run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers", StringComparison.Ordinal), + ["partial-tree"] = shell.Replace(save, save.Replace(" .ci-tools/zig-global\n", "", StringComparison.Ordinal), StringComparison.Ordinal), + ["continue-failure"] = shell.Replace(build, build.Replace(" shell: pwsh", + " continue-on-error: true\n shell: pwsh", StringComparison.Ordinal), StringComparison.Ordinal), + ["second-writer"] = shell.Replace("uses: actions/cache/restore@", "uses: actions/cache@", StringComparison.Ordinal) + }; + var warmerMutations = new Dictionary(StringComparer.Ordinal) + { + ["warmer-terminal-tests"] = warmer.Replace("run: ./Tools/windows/validate.ps1 -Task provider-build", + "run: ./Tools/windows/validate.ps1 -Task terminal-gate", StringComparison.Ordinal), + ["warmer-success"] = warmer.Replace(SaveCondition, "${{ success() && steps.provider-cache.outputs.cache-hit != 'true' }}", StringComparison.Ordinal), + ["warmer-bootstrap-cap"] = Regex.Replace(warmer, "(?s)(id: bootstrap.*?timeout-minutes: )\\d+", "${1}30", RegexOptions.CultureInvariant) + }; + var executed = 0; + try + { + foreach (var (name, mutation) in shellMutations) + { + Require(mutation != shell, $"Ineffective cache mutation: {name}"); + Refuse(mutation, warmer, name); + executed++; + } + foreach (var (name, mutation) in warmerMutations) + { + Require(mutation != warmer, $"Ineffective warmer mutation: {name}"); + Refuse(shell, mutation, name); + executed++; + } + rows.Add(new UnitResult(UnitIds[1], UnitStatus.Passed, executed, "Fourteen actual malformed workflow controls refused by the production-input oracle")); + } + catch (InvalidDataException error) { rows.Add(new UnitResult(UnitIds[1], UnitStatus.Failed, executed + 1, error.Message)); } + return Task.FromResult>(rows); + } + + private static void Refuse(string shell, string warmer, string name) + { + try { Verify(shell, warmer); } + catch (InvalidDataException) { return; } + throw new InvalidDataException($"Unsafe cache mutation admitted: {name}"); + } + + private static void Verify(string shell, string warmer) + { + var shellJobs = Jobs(shell); + var warmerJobs = Jobs(warmer); + Require(shellJobs.ContainsKey("shell-integration") && warmerJobs.ContainsKey("warm"), "Missing integration or warmer job"); + VerifyJob(shellJobs["shell-integration"], true); + VerifyJob(warmerJobs["warm"], false); + Require(Regex.Matches(shell, "actions/cache/save@", RegexOptions.CultureInvariant).Count == 1, "Integration must be the sole provider cache writer"); + foreach (var (name, job) in shellJobs) + { + if (name == "shell-integration") continue; + foreach (var step in Steps(job)) + Require(!Match(step, "(?m)^\\s+\\.ci-providers\\s*$") || step.Contains("actions/cache/restore@", StringComparison.Ordinal), + $"Provider cache consumer is not read-only: {name}"); + } + var warm = warmerJobs["warm"]; + var bootstrap = Number(warm, "(?s)id: bootstrap.*?timeout-minutes: (\\d+)"); + var build = Number(warm, "(?s)id: provider-build.*?timeout-minutes: (\\d+)"); + var outer = Number(warm, "(?m)^ timeout-minutes: (\\d+)\\s*$"); + Require(bootstrap >= 35 && outer > bootstrap + build, "Warmer caps truncate a healthy cold build"); + Require(Number(shellJobs["shell-integration"], "(?m)^ timeout-minutes: (\\d+)\\s*$") >= 41, + "Integration cap truncates measured cold bootstrap, build, save and gate"); + } + + private static void VerifyJob(string job, bool requireGate) + { + var steps = Steps(job); + int Find(params string[] patterns) => Array.FindIndex(steps, step => patterns.All(pattern => Match(step, pattern))); + var restore = Find("(?m)^ id: provider-cache\\s*$", "actions/cache/restore@"); + var bootstrap = Find("(?m)^ id: bootstrap\\s*$", "(?m)^ run: \\./Tools/windows/bootstrap\\.ps1 "); + var pins = Find("(?m)^ id: verify-pins\\s*$", "(?m)^ run: \\./Tools/windows/Assert-ProviderCheckout\\.ps1 -ProviderRoot \\.ci-providers\\s*$"); + var build = Find("(?m)^ id: provider-build\\s*$", "(?m)^ run: \\./Tools/windows/validate\\.ps1 -Task provider-build\\s*$"); + var saves = Enumerable.Range(0, steps.Length).Where(index => steps[index].Contains("actions/cache/save@", StringComparison.Ordinal)).ToArray(); + var gate = Find("(?m)^ run: \\./Tools/windows/validate\\.ps1 -Task windows-shell\\b"); + Require(restore >= 0 && bootstrap >= 0 && pins >= 0 && build >= 0 && saves.Length == 1, "Missing or duplicate cache stages"); + var save = saves[0]; + Require(restore < bootstrap && bootstrap < pins && pins < build && build < save && + (!requireGate || (gate >= 0 && save < gate)), "Provider cache save ordering is unsafe"); + var condition = Regex.Match(steps[save], "(?m)^ if:\\s*(.+?)\\s*$", RegexOptions.CultureInvariant); + Require(condition.Success && condition.Groups[1].Value == SaveCondition, "Provider cache save must require explicit uncancelled bootstrap/pin/build success and a miss"); + Require(Match(steps[save], "(?m)^ \\.ci-providers\\s*$") && + Match(steps[save], "(?m)^ \\.ci-tools/zig-global\\s*$") && + Match(steps[save], "(?m)^ key: \\$\\{\\{ steps\\.provider-cache\\.outputs\\.cache-primary-key \\}\\}\\s*$"), + "Provider cache save omits a required immutable tree or restored key"); + Require(!job.Contains("continue-on-error", StringComparison.Ordinal), "Provider build may not continue after a failure"); + } + + private static Dictionary Jobs(string workflow) + { + var jobsAt = Regex.Match(workflow, "(?m)^jobs:\\s*$", RegexOptions.CultureInvariant); + Require(jobsAt.Success, "Workflow has no jobs mapping"); + var body = workflow[(jobsAt.Index + jobsAt.Length)..]; + var heads = Regex.Matches(body, "(?m)^ ([A-Za-z0-9_-]+):[ \\t]*$", RegexOptions.CultureInvariant); + Require(heads.Count > 0, "Workflow has no jobs"); + var jobs = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < heads.Count; index++) + { + var end = index + 1 < heads.Count ? heads[index + 1].Index : body.Length; + Require(jobs.TryAdd(heads[index].Groups[1].Value, body[heads[index].Index..end]), "Workflow job is duplicated"); + } + return jobs; + } + + private static string[] Steps(string job) + { + var heads = Regex.Matches(job, "(?m)^ - ", RegexOptions.CultureInvariant); + Require(heads.Count > 0, "Job has no steps"); + return Enumerable.Range(0, heads.Count).Select(index => + { + var end = index + 1 < heads.Count ? heads[index + 1].Index : job.Length; + return job[heads[index].Index..end]; + }).ToArray(); + } + + private static int Number(string text, string pattern) + { + var match = Regex.Match(text, pattern, RegexOptions.CultureInvariant); + Require(match.Success, "Workflow timeout is missing"); + return int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static bool Match(string text, string pattern) => Regex.IsMatch(text, pattern, RegexOptions.CultureInvariant); + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidDataException(message); + } +} diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index f9072069..0a8730fa 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -1,19 +1,26 @@ { "units": [ + { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 66, "exactCount": 66 }, + { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 12, "exactCount": 12 }, + { "id": "host.legacy-ownership.live-cleanup", "owner": "Host", "minimumCount": 84, "exactCount": 84 }, + { "id": "host.provenance.current-source", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provenance.source-changed", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provenance.source-restored", "owner": "Host", "minimumCount": 1 }, { "id": "host.task-inventory", "owner": "Host", "minimumCount": 1 }, { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 1 }, { "id": "host.unknown-task", "owner": "Host", "minimumCount": 1 }, { "id": "host.untracked-format", "owner": "Host", "minimumCount": 1 }, { "id": "host.foreign-fixture", "owner": "Host", "minimumCount": 1 }, { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 1 }, - { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 1 }, + { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 4, "exactCount": 4 }, { "id": "host.ci-partition", "owner": "Host", "minimumCount": 1 }, { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 1 }, { "id": "host.ci-topology", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, + { "id": "ui.root.source.line-5346", "owner": "Host", "minimumCount": 2, "exactCount": 2 }, { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 1 }, - { "id": "host.provider-build", "owner": "Host", "minimumCount": 1 }, + { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, + { "id": "host.provider-build", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 1 }, { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 1 }, { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 1 }, diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index c3a71ecc..fe23a8df 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -11,6 +11,7 @@ static async Task RunAsync(string[] args) { +if (HostFixture.TryRun(args) is int hostFixtureExit) return hostFixtureExit; if (await OwnedFixture.TryRunAsync(args) is int fixtureExit) return fixtureExit; if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); @@ -40,8 +41,10 @@ static async Task RunAsync(string[] args) IReadOnlyList required = []; try { - required = Coverage.Load(Path.Combine(AppContext.BaseDirectory, "Manifests")); + var sourceDirectory = Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner"); + required = Coverage.Load(Path.Combine(sourceDirectory, "Manifests")); suites.AddRange(StandaloneSuites.Create(required)); + suites.Add(new ProviderCacheSuite()); foreach (var name in new[] { "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration" }) { var type = Assembly.GetExecutingAssembly().GetType(name); @@ -54,6 +57,9 @@ static async Task RunAsync(string[] args) } if (selectedSuite is not null && suites.All(suite => suite.Id != selectedSuite)) throw new ArgumentException($"No registered suite: {selectedSuite}"); + var registryErrors = Coverage.RegistryErrors(required, suites); + if (registryErrors.Count != 0) + throw new InvalidDataException("Registry admission failed: " + string.Join("; ", registryErrors)); var context = new ValidationContext(repositoryRoot, artifactRoot); foreach (var suite in suites.Where(suite => selectedSuite is null || suite.Id == selectedSuite)) { @@ -76,7 +82,8 @@ static async Task RunAsync(string[] args) } } catch (Exception error) { errors.Add(error.ToString()); } -var report = Coverage.Evaluate(required, suites, results, errors); +var report = Coverage.Evaluate(required, suites, results, errors, + Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner")); var reportPath = Path.Combine(artifactRoot, "coverage.json"); await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); foreach (var unit in report.Units) diff --git a/Tools/windows/ValidationRunner/ValidationRunner.csproj b/Tools/windows/ValidationRunner/ValidationRunner.csproj index c0353e70..5a65c0f1 100644 --- a/Tools/windows/ValidationRunner/ValidationRunner.csproj +++ b/Tools/windows/ValidationRunner/ValidationRunner.csproj @@ -11,10 +11,11 @@ - + + From a86714ef947720df34a1fc188fbfe725dc12aee6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:45:51 -0700 Subject: [PATCH 21/46] Support same-invocation suite composition without double-counting derived proof Allow repeated exact suite selectors for bounded diagnostics while keeping all unselected mandatory units incomplete. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/ValidationRunner/Host/Coverage.cs | 8 ++++++-- .../ValidationRunner/Host/HostSelfTests.cs | 6 ++++++ Tools/windows/ValidationRunner/Program.cs | 15 +++++++++------ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs index ced491f2..ce279633 100644 --- a/Tools/windows/ValidationRunner/Host/Coverage.cs +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -16,7 +16,8 @@ public sealed record UnitManifest(IReadOnlyList Units); public sealed record CoverageReport( string Runner, bool Complete, int RequiredCount, int PassedCount, - IReadOnlyList Units, IReadOnlyList Errors); + IReadOnlyList Units, IReadOnlyList Errors, + long ActualExecutedCount = 0, int DerivedProofCount = 0); public static class Coverage { @@ -109,7 +110,10 @@ public static CoverageReport Evaluate( } var passed = ledger.Count(result => result.Status == UnitStatus.Passed); return new CoverageReport("CompiledValidationRunner", errors.Count == 0 && passed > 0 && - passed == required.Count, required.Count, passed, ledger, errors); + passed == required.Count, required.Count, passed, ledger, errors, + ledger.Where(result => requirements[result.Id].CompositeOf is null && result.ExecutedCount > 0) + .Sum(result => (long)result.ExecutedCount), + ledger.Count(result => requirements[result.Id].CompositeOf is not null && result.Status == UnitStatus.Passed)); } public static IReadOnlyList RegistryErrors(IReadOnlyList required, IReadOnlyList suites) => diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index b6b99dfc..dc5a944d 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -157,6 +157,12 @@ CoverageReport Composite(IReadOnlyList? observations = null, Require observations ?? [passed with { Groups = ["group"] }], sourceDirectory: compositeDirectory); } Check("composite-positive", () => Assert(Composite().Complete, "complete independently mapped proof rejected")); + Check("composite-not-double-counted", () => + { + var report = Composite(); + Assert(report.ActualExecutedCount == 1 && report.DerivedProofCount == 1, + "derived composite proof was counted again as actual test execution"); + }); Check("composite-missing", () => Assert(!Composite(observations: []).Complete, "missing dependency admitted")); Check("composite-duplicate", () => Assert(!Composite(observations: [passed, passed]).Complete, "duplicate dependency admitted")); Check("composite-skipped", () => Assert(!Composite(observations: [passed with { Status = UnitStatus.Skipped }]).Complete, "skipped dependency admitted")); diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index fe23a8df..5b01452f 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -17,7 +17,7 @@ static async Task RunAsync(string[] args) string? repositoryRoot = null; string? artifactRoot = null; -string? selectedSuite = null; +var selectedSuites = new HashSet(StringComparer.Ordinal); for (var index = 0; index < args.Length; index++) { if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); @@ -26,7 +26,9 @@ static async Task RunAsync(string[] args) { case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; - case "--suite": selectedSuite = value; break; + case "--suite": + if (!selectedSuites.Add(value)) throw new ArgumentException($"Duplicate suite selector: {value}"); + break; default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); } } @@ -55,13 +57,14 @@ static async Task RunAsync(string[] args) throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); suites.AddRange(registered); } - if (selectedSuite is not null && suites.All(suite => suite.Id != selectedSuite)) - throw new ArgumentException($"No registered suite: {selectedSuite}"); + foreach (var selection in selectedSuites) + if (suites.All(suite => suite.Id != selection)) + throw new ArgumentException($"No registered suite: {selection}"); var registryErrors = Coverage.RegistryErrors(required, suites); if (registryErrors.Count != 0) throw new InvalidDataException("Registry admission failed: " + string.Join("; ", registryErrors)); var context = new ValidationContext(repositoryRoot, artifactRoot); - foreach (var suite in suites.Where(suite => selectedSuite is null || suite.Id == selectedSuite)) + foreach (var suite in suites.Where(suite => selectedSuites.Count == 0 || selectedSuites.Contains(suite.Id))) { try { @@ -89,7 +92,7 @@ static async Task RunAsync(string[] args) foreach (var unit in report.Units) Console.WriteLine(ConsoleReport.Unit(unit)); for (var index = 0; index < report.Errors.Count; index++) Console.Error.WriteLine(ConsoleReport.Error(index + 1)); -Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} complete={report.Complete} report={JsonSerializer.Serialize(reportPath)}"); +Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} actualExecuted={report.ActualExecutedCount} derivedProofs={report.DerivedProofCount} complete={report.Complete} report={JsonSerializer.Serialize(reportPath)}"); if (!report.Complete) return 1; Console.WriteLine("CompiledValidationRunner: PASS"); Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); From ca86b23af01ccc3b7137f6157c6515a06e9370be Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:36:45 -0700 Subject: [PATCH 22/46] Implement six compiled host root contract families Port production-backed diagnostic, CI partition, aggregate, section, and Zig resolver cases with typed compiled CI topology guards and exact execution inventories. Preserve genuine pre-cutover CI source failures rather than accepting the legacy runner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Host/CiContracts/AdapterSupport.ps1 | 42 +++ .../Host/CiContracts/Aggregate.ps1 | 32 ++ .../Host/CiContracts/Focused.ps1 | 33 ++ .../Host/CiContracts/Partition.ps1 | 97 +++++ .../Host/CiContracts/README.md | 62 ++++ .../Host/CiContracts/ShellSections.ps1 | 123 ++++++ .../Host/CiContracts/SourceContracts.cs | 351 ++++++++++++++++++ .../Host/CiContracts/SuiteRegistration.cs | 104 ++++++ .../Host/CiContracts/ZigResolver.ps1 | 219 +++++++++++ .../Host/CiContracts/manifest-fragment.json | 10 + .../CiContracts/source-correspondence.json | 88 +++++ .../Host/Hardening/Diagnostics.ps1 | 54 +++ 12 files changed, 1215 insertions(+) create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/README.md create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json create mode 100644 Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 new file mode 100644 index 00000000..93d833b0 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/AdapterSupport.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = 'Stop' +$script:RootCases = [Collections.Generic.List[object]]::new() + +function Test-RootCase([string] $Id, [scriptblock] $Body) { + try { + & $Body | Out-Null + $script:RootCases.Add([ordered]@{ id = $Id; passed = $true; error = $null }) + } catch { + $script:RootCases.Add([ordered]@{ id = $Id; passed = $false; error = $_.Exception.Message }) + } +} + +function Assert-Root([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} + +function Get-ProductionFunction([string] $Path, [string] $Name) { + $tokens = $null + $errors = $null + $tree = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw "Production source does not parse: $Path" } + $found = @($tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $Name + }, $true)) + if ($found.Count -ne 1) { throw "Expected one production function $Name; found $($found.Count)" } + [scriptblock]::Create($found[0].Extent.Text) +} + +function Write-RootSummary([string] $Suite) { + $failures = @($script:RootCases | Where-Object { -not $_.passed }) + $report = Join-Path $ArtifactRoot ($Suite + '-cases-' + [guid]::NewGuid().ToString('N') + '.json') + $script:RootCases.ToArray() | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $report + $row = [ordered]@{ + id = $Suite + status = $(if ($failures.Count -eq 0) { 'Passed' } else { 'Failed' }) + executedCount = $script:RootCases.Count + evidence = "Production-backed cases: executed=$($script:RootCases.Count); failed=$($failures.Count); report=$report" + groups = @($script:RootCases | Where-Object passed | ForEach-Object { $_.id }) + } + Write-Output ('HOST_CASE_RESULT=' + ($row | ConvertTo-Json -Depth 8 -Compress)) +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 new file mode 100644 index 00000000..645508cd --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Aggregate.ps1 @@ -0,0 +1,32 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$gate = Join-Path $RepositoryRoot 'Tools\windows\Assert-CiPartResults.ps1' +$cases = @( + @{ Id = 'required.success'; Required = 'true'; Changes = 'success'; A = 'success'; B = 'success'; Pass = $true } + @{ Id = 'required.skipped'; Required = 'true'; Changes = 'success'; A = 'success'; B = 'skipped'; Pass = $false } + @{ Id = 'required.failure'; Required = 'true'; Changes = 'failure'; A = 'success'; B = 'failure'; Pass = $false } + @{ Id = 'required.cancelled'; Required = 'true'; Changes = 'success'; A = 'cancelled'; B = 'success'; Pass = $false } + @{ Id = 'not-required.skipped'; Required = 'false'; Changes = 'success'; A = 'skipped'; B = 'skipped'; Pass = $true } + @{ Id = 'not-required.failure'; Required = 'false'; Changes = 'success'; A = 'failure'; B = 'skipped'; Pass = $false } + @{ Id = 'not-required.classifier-failure'; Required = 'false'; Changes = 'failure'; A = 'skipped'; B = 'skipped'; Pass = $false } + @{ Id = 'invalid-requirement'; Required = 'unknown'; Changes = 'success'; A = 'success'; B = 'success'; Pass = $false } +) +foreach ($case in $cases) { + Test-RootCase $case.Id { + $needs = [ordered]@{ + changes = @{ result = $case.Changes; outputs = @{} } + a = @{ result = $case.A; outputs = @{} } + b = @{ result = $case.B; outputs = @{} } + } | ConvertTo-Json -Depth 4 -Compress + $accepted = $true + try { & $gate -NeedsJson $needs -Required $case.Required | Out-Null } catch { $accepted = $false } + Assert-Root ($accepted -eq $case.Pass) "Aggregate decision changed for $($case.Id)" + } +} +Test-RootCase 'empty-parts' { + $accepted = $true + try { & $gate -NeedsJson '{"changes":{"result":"success"}}' -Required true | Out-Null } catch { $accepted = $false } + Assert-Root (-not $accepted) 'Empty aggregate passed' +} +Write-RootSummary 'host.ci-aggregate' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 new file mode 100644 index 00000000..b4427fef --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, + [Parameter(Mandatory)] [string] $AssemblyPath, + [string] $Suite +) +$ErrorActionPreference = 'Stop' +$null = [Reflection.Assembly]::LoadFrom($AssemblyPath) +$exitCode = 1 +try { + $context = [GraphCode.Validation.ValidationContext]::new($RepositoryRoot, $ArtifactRoot) + $suites = @([GraphCode.Validation.Host.RootContracts.SuiteRegistration]::CreateSuites()) + if ($Suite) { + $suites = @($suites | Where-Object Id -CEQ $Suite) + if ($suites.Count -ne 1) { throw "Unknown root suite: $Suite" } + } + $results = [Collections.Generic.List[object]]::new() + foreach ($executor in $suites) { + $rows = $executor.RunAsync($context, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + foreach ($row in $rows) { + $results.Add($row) + $row | ConvertTo-Json -Depth 8 -Compress | Write-Output + } + } + if ($results.Count -eq $suites.Count -and @($results | Where-Object { $_.Status -ne 'Passed' -or $_.ExecutedCount -le 0 }).Count -eq 0) { + $exitCode = 0 + Write-Output "ROOT_FOCUSED_PASSED=$($results.Count)" + } +} finally { + $null = [GraphCode.Validation.Host.TerminalSafety]::PreserveRetainedAuthorityAsync( + [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount).GetAwaiter().GetResult() +} +exit $exitCode diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 new file mode 100644 index 00000000..c8a7c028 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Partition.ps1 @@ -0,0 +1,97 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$runner = Join-Path $RepositoryRoot 'Tools\windows\validate.ps1' +$expected = @(& $runner -List) +$observed = [Collections.Generic.List[string]]::new() + +function Assert-PartitionParts([string[]] $Lines) { + $shell = @($Lines | Where-Object { $_ -like 'task=windows-shell *' }) + Assert-Root (@($shell | Where-Object { $_ -match 'part=(all|integration) ' }).Count -gt 0) 'No shell integration coverage' + $complete = $false + foreach ($count in @($shell | ForEach-Object { + if ($_ -notmatch 'part=(all|unit|integration) shard=(\d+)/(\d+)') { throw 'Malformed shell dry-run evidence' } + if ($Matches[1] -ne 'integration') { [int]$Matches[3] } + } | Sort-Object -Unique)) { + $seen = @($shell | ForEach-Object { + if ($_ -match "part=(all|unit) shard=(\d+)/$count(?: |$)") { [int]$Matches[2] } + } | Sort-Object -Unique) + if ($count -gt 0 -and $seen.Count -eq $count -and ($seen -join ',') -ceq ((0..($count - 1)) -join ',')) { + $complete = $true + } + } + Assert-Root $complete 'No complete shell unit shard partition' + $packaging = @($Lines | Where-Object { $_ -like 'task=packaging *' } | ForEach-Object { ($_ -split 'part=')[1] }) + Assert-Root ($packaging -contains 'all' -or ($packaging -contains 'contracts' -and $packaging -contains 'real')) 'Missing packaging part' +} + +foreach ($file in @('windows-shell.yml', 'windows-port-validation.yml', 'windows-hardening.yml')) { + $source = (Get-Content -LiteralPath (Join-Path $RepositoryRoot ".github\workflows\$file") -Raw).Replace("`r`n", "`n") + $jobsAt = [regex]::Match($source, '(?m)^jobs:\s*$') + if (-not $jobsAt.Success) { throw "Missing jobs mapping: $file" } + $body = $source.Substring($jobsAt.Index + $jobsAt.Length) + $heads = [regex]::Matches($body, '(?m)^ ([A-Za-z0-9_-]+):[ \t]*$') + foreach ($index in 0..($heads.Count - 1)) { + $end = if ($index + 1 -lt $heads.Count) { $heads[$index + 1].Index } else { $body.Length } + $job = $body.Substring($heads[$index].Index, $end - $heads[$index].Index) + if ($job -match '(?m)^ if:.*github\.event_name ==') { continue } + $shards = @('') + $matrix = [regex]::Match($job, '(?m)^\s+shard:\s*\[([0-9, ]+)\]') + if ($matrix.Success) { $shards = @($matrix.Groups[1].Value -split ',\s*') } + $runs = [regex]::Matches($job, '(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 (.+)$') + $ordinal = 0 + foreach ($run in $runs) { + foreach ($shard in $shards) { + $id = "workflow.$file.$($heads[$index].Groups[1].Value).$ordinal.$shard".TrimEnd('.') + Test-RootCase $id { + $text = $run.Groups[1].Value.Trim().Replace('${{ matrix.shard }}', $shard) + Assert-Root (-not $text.Contains('${{')) 'Unsupported expression in workflow validation arguments' + $tokens = @($text -split '\s+') + $arguments = @{ DryRun = $true } + for ($position = 0; $position -lt $tokens.Count; $position++) { + $name = $tokens[$position].TrimStart('-') + switch ($name) { + { $_ -in @('SkipTrayLive', 'SkipWslRemoteE2E') } { $arguments[$name] = $true } + { $_ -in @('Task', 'SkipTask') } { $arguments[$name] = @($tokens[++$position] -split ',') } + { $_ -in @('ShellPart', 'ShellTestShard', 'ShellTestShardCount', 'ShellTestManifest', 'PackagingPart') } { + $arguments[$name] = $tokens[++$position] + } + default { throw "Unsupported dry-run parameter: $name" } + } + } + $output = @(& $runner @arguments) + $lines = @($output | Where-Object { "$_" -match '^task=' }) + Assert-Root ($lines.Count -gt 0) 'Workflow dry-run executed no tasks' + foreach ($line in $lines) { $observed.Add("$line") } + } + } + $ordinal++ + } + } +} +Test-RootCase 'coverage.all-listed-tasks' { + Assert-Root ($expected.Count -gt 0 -and $observed.Count -gt 0) 'No task inventory or dry-run output' + $covered = @($observed | ForEach-Object { ($_ -split ' ')[0].Substring(5) } | Sort-Object -Unique) + $missing = @($expected | Where-Object { $covered -cnotcontains $_ }) + Assert-Root ($missing.Count -eq 0) "Missing PR tasks: $($missing -join ', ')" +} +Test-RootCase 'coverage.all-parts' { Assert-PartitionParts $observed.ToArray() } +$fixture = @( + 'task=windows-shell part=unit shard=0/3', 'task=windows-shell part=unit shard=1/3', + 'task=windows-shell part=unit shard=2/3', 'task=windows-shell part=integration shard=0/1', + 'task=packaging part=contracts', 'task=packaging part=real' +) +foreach ($kind in @('complete', 'missing-integration', 'missing-unit-shard', 'missing-packaging-real')) { + Test-RootCase "control.$kind" { + $lines = switch ($kind) { + 'complete' { $fixture } + 'missing-integration' { $fixture | Where-Object { $_ -notmatch 'part=integration' } } + 'missing-unit-shard' { $fixture | Where-Object { $_ -notmatch 'shard=2/3' } } + 'missing-packaging-real' { $fixture | Where-Object { $_ -cne 'task=packaging part=real' } } + } + $accepted = $true + try { Assert-PartitionParts @($lines) } catch { $accepted = $false } + Assert-Root ($accepted -eq ($kind -eq 'complete')) "Partition guard accepted $kind" + } +} +Write-RootSummary 'host.ci-partition' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/README.md b/Tools/windows/ValidationRunner/Host/CiContracts/README.md new file mode 100644 index 00000000..7ede7b3d --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/README.md @@ -0,0 +1,62 @@ +# Compiled host root contracts + +`GraphCode.Validation.Host.RootContracts.SuiteRegistration.CreateSuites()` supplies +exactly six mandatory `IValidationSuite` implementations. The host owner registers +this method and merges `manifest-fragment.json` into the existing host manifest; +do not load the fragment as a fourth manifest or register the units twice. + +The five PowerShell payloads run through the host's public `CaseScriptSuite`, +which supplies owned-child supervision, isolated TEMP/TMP, raw stream capture and +cleanup/retention authority. They independently exercise actual production +functions or scripts. AST extraction is restricted to production functions; +no part of the old monolithic test program is loaded or executed. Aggregate and +section-manifest scripts are invoked within that supervised payload, not through +unowned nested native processes. No provider, product, UI or Zig executable is +launched by these six families. + +The C# source contracts use typed predicates, with explicit positive and negative +controls. They preserve the original workflow scheduling, required job names, +partitioning, source observations and measured timeout headroom. New execution +guards require the normal full compiled entry, exact SDK, nested build working +directory, complete coverage with positive equal required/passed counts, exit +zero and exactly one of each terminal success marker. They intentionally reject +the old PowerShell entry; a pre-cutover workflow is a real source-contract failure. +These source observations are not evidence that GitHub Actions executed. + +`source-correspondence.json` identifies every expanded case and its frozen source +condition. The manifest inventory is not counted as execution. Adapter case JSON +under the requested artifact root records individual pass/fail results. Successful +unit groups must match the runtime case inventory exactly, including positive +counts; missing, extra or duplicate successful adapter groups fail closed. + +Build from `Tools\windows\ValidationRunner` so `global.json` selects SDK 10.0.403: + +```powershell +Push-Location Tools\windows\ValidationRunner +dotnet build -c Release +Pop-Location +``` + +For focused development before the host registration lands: + +```powershell +$artifacts = Join-Path $PWD '.build\root-contracts' +New-Item -ItemType Directory -Force $artifacts | Out-Null +pwsh -NoLogo -NoProfile -File Tools\windows\ValidationRunner\Host\CiContracts\Focused.ps1 ` + -RepositoryRoot $PWD.Path -ArtifactRoot $artifacts ` + -AssemblyPath (Join-Path $PWD 'Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.dll') +``` + +`-Suite` selects one of the six IDs for local diagnostics only. This focused +entry reports only those suites and never emits the full-host PASS marker. +Canonical CI must use the normal compiled EXE, without a suite selector. + +Local implementation evidence used PowerShell 7.6.9 on .NET 10, not PowerShell +7.4. The first no-restore build failed because project assets were absent; +restore then exposed missing `System.IO` imports. Those compile failures are +not behavioral RED evidence. The first focused run exposed two CRLF-sensitive +negative-control fixtures; normalizing those fixtures fixed them. Subsequent +focused execution passed the five behavioral families and all topology controls, +while four topology observations correctly remained RED against the legacy +workflow/wrapper before the host owner's cutover. No full coverage or CI success +is claimed by that partial result. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 new file mode 100644 index 00000000..e30d9fb2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ShellSections.ps1 @@ -0,0 +1,123 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$shell = Join-Path $RepositoryRoot 'Tools\windows\Tests\WindowsShell.Tests.ps1' +. (Get-ProductionFunction $shell 'Invoke-Native') +$sectionCatalog = @('Probe section') +$sectionPlan = [pscustomobject]@{ Assignment = @{ 'Probe section' = 0 } } +$Shard = 0 +$ShardCount = 1 +$executedSections = [Collections.Generic.List[object]]::new() +$summaries = [ordered]@{ + zero_all = 'All 0 tests passed.' + failed = '0 passed; 0 skipped; 1 failed.' + skipped_only = '0 passed; 2 skipped; 0 failed.' +} +foreach ($entry in $summaries.GetEnumerator()) { + Test-RootCase "section.reject.$($entry.Key)" { + $rejected = $false + try { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig --test-filter unmatched + $entry.Value + $global:LASTEXITCODE = 0 + } 6> $null + } catch { $rejected = $true } + Assert-Root ($rejected -and $executedSections.Count -eq 0) 'Zero executed tests were recorded as passing' + } +} +Test-RootCase 'section.positive-two-invocations' { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig + # $zig test src\Second.zig + 'All 3 tests passed.' + '2 passed; 1 skipped; 0 failed.' + $global:LASTEXITCODE = 0 + } 6> $null + Assert-Root ($executedSections.Count -eq 1 -and $executedSections[0].positiveSummaries -eq 2 -and + $executedSections[0].zigTestInvocations -eq 2) 'Positive per-invocation evidence was not recorded' +} +Test-RootCase 'section.reject.missing-second-summary' { + $rejected = $false + try { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig + # $zig test src\Second.zig + 'All 3 tests passed.' + $global:LASTEXITCODE = 0 + } 6> $null + } catch { $rejected = $true } + Assert-Root ($rejected -and $executedSections.Count -eq 1) 'One summary satisfied two Zig invocations' +} +Test-RootCase 'section.reject.native-nonzero' { + $rejected = $false + try { + Invoke-Native 'Probe section' { + # $zig test src\Probe.zig + 'All 3 tests passed.' + $global:LASTEXITCODE = 7 + } 6> $null + } catch { $rejected = $true } + Assert-Root ($rejected -and $executedSections.Count -eq 1) 'Nonzero exit was recorded as passing' +} + +$plans = @() +foreach ($index in 0..2) { + $plans += & $shell -PlanOnly -Shard $index -ShardCount 3 | Out-String | ConvertFrom-Json +} +$whole = & $shell -PlanOnly | Out-String | ConvertFrom-Json +$catalog = @($whole.catalog) +Test-RootCase 'plan.unsharded-catalog' { + Assert-Root ($catalog.Count -ge 40 -and (@($whole.assigned) -join "`n") -ceq ($catalog -join "`n")) ` + 'Unsharded catalog is empty, truncated, or reordered' +} +Test-RootCase 'plan.complete-disjoint-three-shards' { + $assigned = @($plans | ForEach-Object { $_.assigned }) + Assert-Root ($assigned.Count -eq $catalog.Count -and @($assigned | Sort-Object -Unique).Count -eq $catalog.Count -and + @($catalog | Where-Object { $assigned -cnotcontains $_ }).Count -eq 0 -and + @($plans | Where-Object { @($_.assigned).Count -eq 0 }).Count -eq 0) 'Shard assignment lost completeness or disjointness' +} +Test-RootCase 'plan.workflow-agreement' { + $text = Get-Content -LiteralPath (Join-Path $RepositoryRoot '.github\workflows\windows-shell.yml') -Raw + Assert-Root ($text -match '(?m)^\s+shard: \[0, 1, 2\]\s*$' -and + $text -match '-ShellTestShardCount 3 ' -and + $text -match 'Test-ShellSectionManifests\.ps1 -Directory \.ci-sections -ShardCount 3') 'Workflow and section shard counts disagree' +} +$scratch = Join-Path ([IO.Path]::GetTempPath()) ('root-sections-' + [guid]::NewGuid().ToString('N')) +$verifier = Join-Path $RepositoryRoot 'Tools\windows\Test-ShellSectionManifests.ps1' +foreach ($kind in @('complete', 'missing-section', 'duplicate-section', 'zero-tests', 'skipped-assignment', 'missing-shard')) { + Test-RootCase "manifest.$kind" { + $manifests = @($plans | ForEach-Object { + [pscustomobject]@{ + schemaVersion = 1; shard = $_.shard; shardCount = 3; catalog = @($_.catalog); assigned = @($_.assigned) + executed = @($_.assigned | ForEach-Object { + [pscustomobject]@{ name = $_; seconds = 1; zigTestInvocations = 1; positiveSummaries = 1 } + }) + } + }) + switch ($kind) { + 'missing-section' { + $manifests[1].executed = @($manifests[1].executed | Select-Object -Skip 1) + $manifests[1].assigned = @($manifests[1].assigned | Select-Object -Skip 1) + } + 'duplicate-section' { + $manifests[0].executed += @($manifests[1].executed[0]) + $manifests[0].assigned += @($manifests[1].assigned[0]) + } + 'zero-tests' { $manifests[2].executed[0].positiveSummaries = 0 } + 'skipped-assignment' { $manifests[2].executed = @($manifests[2].executed | Select-Object -SkipLast 1) } + 'missing-shard' { $manifests[2] = $null } + } + $directory = Join-Path $scratch $kind + $null = New-Item -ItemType Directory -Path $directory -Force + foreach ($manifest in $manifests) { + if ($null -ne $manifest) { + $manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $directory "shard-$($manifest.shard).json") + } + } + $accepted = $true + try { & $verifier -Directory $directory -ShardCount 3 | Out-Null } catch { $accepted = $false } + Assert-Root ($accepted -eq ($kind -eq 'complete')) "Manifest verifier changed its decision for $kind" + } +} +Write-RootSummary 'host.shell-section-gate' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs new file mode 100644 index 00000000..b6808615 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs @@ -0,0 +1,351 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace GraphCode.Validation.Host.RootContracts; + +internal sealed class SourceCases +{ + private readonly List<(string Id, string? Error)> results = []; + + internal void Check(string id, Action body) + { + try { body(); results.Add((id, null)); } + catch (Exception error) when (error is InvalidDataException or ArgumentException or KeyNotFoundException) + { results.Add((id, error.Message)); } + } + + internal UnitResult Result(string id) + { + var failed = results.Where(row => row.Error is not null).ToArray(); + return new UnitResult(id, failed.Length == 0 ? UnitStatus.Passed : UnitStatus.Failed, results.Count, + $"Typed source contracts: executed={results.Count}; failed={failed.Length}" + + string.Concat(failed.Select(row => $"; {row.Id}: {row.Error}")), + results.Where(row => row.Error is null).Select(row => row.Id).ToArray()); + } + + internal static void Require(bool condition, string message) + { + if (!condition) throw new InvalidDataException(message); + } + + internal static bool Match(string text, string pattern) => + Regex.IsMatch(text, pattern, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); +} + +internal sealed class WorkflowText +{ + internal string Text { get; } + internal IReadOnlyDictionary Jobs { get; } + + internal WorkflowText(string text) + { + Text = text.Replace("\r\n", "\n", StringComparison.Ordinal); + var start = Regex.Match(Text, @"(?m)^jobs:[ \t]*$", RegexOptions.CultureInvariant); + SourceCases.Require(start.Success, "Workflow has no jobs mapping"); + var body = Text[(start.Index + start.Length)..]; + var headings = Regex.Matches(body, @"(?m)^ ([A-Za-z0-9_-]+):[ \t]*$", RegexOptions.CultureInvariant); + var jobs = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < headings.Count; index++) + { + var end = index + 1 < headings.Count ? headings[index + 1].Index : body.Length; + SourceCases.Require(jobs.TryAdd(headings[index].Groups[1].Value, + body[headings[index].Index..end]), "Workflow repeats a job identifier"); + } + SourceCases.Require(jobs.Count > 0, "Workflow jobs mapping is empty"); + Jobs = jobs; + } + + internal static IReadOnlyList Steps(string job) => + Regex.Matches(job, @"(?ms)^ - .*?(?=^ - |\z)", RegexOptions.CultureInvariant) + .Select(match => match.Value).ToArray(); +} + +internal static class SourceContracts +{ + internal static UnitResult Hardening(string root) + { + var cases = new SourceCases(); + var hardening = File.ReadAllText(Path.Combine(root, "Tools", "windows", "Tests", "Hardening.Tests.ps1")); + var workflow = File.ReadAllText(Path.Combine(root, ".github", "workflows", "windows-hardening.yml")); + cases.Check("source.full-pinned-real-hardening", () => SourceCases.Require(SourceCases.Match(workflow, + @"(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment"), + "Full pinned CI must run real hardening after provider setup")); + cases.Check("source.owned-environment", () => SourceCases.Require(workflow.Contains("GRAPHCODE_HARDENING_TARGET", + StringComparison.Ordinal), "Full pinned CI must supply an owned environment harness")); + foreach (var stage in new[] { "real zmx/ConPTY terminal matrix", "real GraphCode shell matrix" }) + cases.Check("source.native-exit." + (stage.Contains("zmx", StringComparison.Ordinal) ? "terminal" : "shell"), + () => SourceCases.Require(SourceCases.Match(hardening, + Regex.Escape(stage) + @" failed \(exit=\$LASTEXITCODE; hex="), "Native stage failure lost exit/hex diagnostics")); + cases.Check("source.high-output-failure-diagnostics", () => SourceCases.Require(SourceCases.Match(hardening, + @"(?s)if \(-not \$completed\).*?Get-HighOutputDiagnostics.*?HARDENING_OUTPUT_DIAGNOSTICS_JSON=.*?Assert-True \$completed"), + "High-output failure lost bounded transcript diagnostics")); + cases.Check("source.repeat-preserves-child-diagnostics", () => SourceCases.Require(SourceCases.Match(hardening, + "(?s)if \\(\\$LASTEXITCODE -ne 0\\) \\{\\s*\\$output \\| Write-Output\\s*throw \"hardening repeated run"), + "Failed repeated hardening discards child diagnostics")); + cases.Check("source.release-shell-version", () => SourceCases.Require(SourceCases.Match(hardening, + @"(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion"), + "Post-release hardening lost the built shell version")); + return cases.Result("host.hardening-source-and-diagnostics"); + } + + internal static UnitResult Aggregate(string root) + { + var cases = new SourceCases(); + foreach (var (file, id, exempt) in new[] + { + ("windows-shell.yml", "windows-shell", Array.Empty()), + ("windows-port-validation.yml", "windows-spikes", new[] { "investigation-privacy" }) + }) + { + var workflow = new WorkflowText(File.ReadAllText(Path.Combine(root, ".github", "workflows", file))); + cases.Check("source.aggregate." + id, () => AssertAggregate(workflow, id, exempt)); + } + var fixture = """ + jobs: + changes: + runs-on: ubuntu-latest + part: + runs-on: windows-2022 + gate: + needs: [changes, part] + if: ${{ !cancelled() }} + env: + NEEDS_JSON: ${{ toJSON(needs) }} + steps: + - run: ./Tools/windows/Assert-CiPartResults.ps1 -NeedsJson $env:NEEDS_JSON -Required $env:WINDOWS_REQUIRED + """.Replace("\r\n", "\n", StringComparison.Ordinal); + cases.Check("control.aggregate.valid", () => AssertAggregate(new WorkflowText(fixture), "gate", [])); + foreach (var (id, before, after) in new[] + { + ("missing-part", "[changes, part]", "[changes]"), + ("renamed-required-check", " gate:\n", " gate:\n name: renamed\n"), + ("success-only", "${{ !cancelled() }}", "${{ success() }}"), + ("missing-needs-json", "NEEDS_JSON:", "UNUSED_JSON:"), + ("missing-required-argument", " -Required $env:WINDOWS_REQUIRED", "") + }) + { + cases.Check("control.aggregate." + id, () => + { + var mutant = Mutate(fixture, before, after); + Reject(() => AssertAggregate(new WorkflowText(mutant), "gate", [])); + }); + } + return cases.Result("host.ci-aggregate"); + } + + private static void AssertAggregate(WorkflowText workflow, string id, IReadOnlyList exempt) + { + var aggregate = workflow.Jobs[id]; + SourceCases.Require(!SourceCases.Match(aggregate, @"(?m)^ name:"), + "Required aggregate must retain its job ID as its check name"); + var match = Regex.Match(aggregate, @"(?m)^ needs:\s*\[([^\]]+)\]", RegexOptions.CultureInvariant); + SourceCases.Require(match.Success, "Aggregate does not declare needed parts"); + var needed = match.Groups[1].Value.Split(',').Select(value => value.Trim()).ToArray(); + SourceCases.Require(workflow.Jobs.Keys.All(name => name == id || exempt.Contains(name) || needed.Contains(name)), + "Aggregate does not wait for every non-exempt part"); + SourceCases.Require(SourceCases.Match(aggregate, @"(?m)^ if: \$\{\{ !cancelled\(\) \}\}\s*$") && + aggregate.Contains("NEEDS_JSON: ${{ toJSON(needs) }}", StringComparison.Ordinal) && + aggregate.Contains("Assert-CiPartResults.ps1 -NeedsJson $env:NEEDS_JSON -Required $env:WINDOWS_REQUIRED", StringComparison.Ordinal), + "Aggregate must fail closed over every part"); + } + + internal static UnitResult Topology(string root) + { + var cases = new SourceCases(); + var directory = Path.Combine(root, ".github", "workflows"); + var port = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-port-validation.yml"))); + var shell = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-shell.yml"))); + var hardening = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-hardening.yml"))); + var warmer = new WorkflowText(File.ReadAllText(Path.Combine(directory, "windows-cache-warmer.yml"))); + foreach (var (workflow, id, cap, steps) in new[] + { + (port, "spikes-swift", 45, 5), (port, "spikes-other", 40, 6), (shell, "packaging-real", 50, 6) + }) + { + cases.Check("source.cold-cache." + id, () => + { + var text = workflow.Jobs[id]; + SourceCases.Require(SourceCases.Match(text, $@"(?m)^ timeout-minutes: {cap}\s*$") && + Regex.Matches(text, @"(?m)^ timeout-minutes: \d+\s*$").Count >= steps && + SourceCases.Match(text, @"(?s)name: Bootstrap exact Windows dependencies.*?timeout-minutes: 30"), + "Measured cold-cache job/step/bootstrap headroom was removed"); + }); + } + cases.Check("source.runner-topology-timeout", () => + { + var step = WorkflowText.Steps(port.Jobs["spikes-swift"]) + .SingleOrDefault(text => text.Contains("name: Verify validation runner contract", StringComparison.Ordinal)) ?? ""; + var cap = Regex.Match(step, @"(?m)^ timeout-minutes: (\d+)\s*$"); + SourceCases.Require(cap.Success && int.Parse(cap.Groups[1].Value, + System.Globalization.CultureInfo.InvariantCulture) >= 12, "Runner topology step lost measured timeout headroom"); + }); + foreach (var key in new[] + { + "windows-zig-v1-${{ hashFiles('Tools/windows/bootstrap.ps1') }}", + "windows-providers-v1-${{ hashFiles('graphcode-windows/provider-pins.json', 'Tools/windows/bootstrap.ps1') }}-emit-win32-host-x86_64-windows-gnu" + }) + cases.Check("source.warmer-key." + (key.Contains("providers", StringComparison.Ordinal) ? "providers" : "zig"), + () => SourceCases.Require(warmer.Text.Contains(key, StringComparison.Ordinal), "Warmer does not seed the production immutable key")); + cases.Check("source.warmer-events-and-build-only", () => SourceCases.Require( + SourceCases.Match(warmer.Text, @"(?m)^ push:\s*$") && + SourceCases.Match(warmer.Text, @"(?m)^ branches: \[main\]\s*$") && + SourceCases.Match(warmer.Text, @"(?m)^ schedule:\s*$") && + SourceCases.Match(warmer.Text, "(?m)^ - cron: \"23 4 \\* \\* 1\"\\s*$") && + SourceCases.Match(warmer.Text, @"(?m)^ workflow_dispatch:\s*$") && + SourceCases.Match(warmer.Text, @"(?s)validate\.ps1 -Task provider-build.*?actions/cache/save@") && + !warmer.Text.Contains("validate.ps1 -Task terminal-gate", StringComparison.Ordinal), + "Warmer lost main/weekly/manual events or build-only execution")); + foreach (var (name, workflow) in new[] { ("shell", shell), ("port", port) }) + cases.Check("source.uia-diagnostics." + name, () => SourceCases.Require(SourceCases.Match(workflow.Text, + @"(?s)if: failure\(\).*?actions/upload-artifact@.*?gu-\*.*?logs\\\*\.json"), "Failed UIA diagnostics are not retained")); + foreach (var (name, workflow) in new[] { ("shell", shell), ("port", port), ("hardening", hardening) }) + cases.Check("source.pinned-swift." + name, () => SourceCases.Require( + workflow.Text.Contains("compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6", StringComparison.Ordinal) && + workflow.Text.Contains("swift-version: swift-6.3.3-release", StringComparison.Ordinal) && + workflow.Text.Contains("swift-build: 6.3.3-RELEASE", StringComparison.Ordinal), "Pinned Swift setup changed")); + cases.Check("source.shell-bootstrap", () => SourceCases.Require(shell.Text.Contains("bootstrap.ps1", StringComparison.Ordinal), + "Shell CI no longer bootstraps pinned dependencies")); + cases.Check("source.hosted-explicit-skip-limits", () => SourceCases.Require( + shell.Text.Contains("validate.ps1 -Task windows-shell -SkipTrayLive", StringComparison.Ordinal) && + port.Text.Contains("validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E", StringComparison.Ordinal) && + hardening.Text.Contains("validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E", StringComparison.Ordinal) && + hardening.Text.Contains("Hardening.Tests.ps1 -Environment -SkipTrayLive", StringComparison.Ordinal), + "Hosted CI must explicitly declare unsupported tray/WSL fixtures")); + cases.Check("source.shell-live-task", () => SourceCases.Require(SourceCases.Match(shell.Text, + @"(?m)^\s*run:\s*\./Tools/windows/validate\.ps1 -Task windows-shell\b"), "Shell CI no longer invokes its live task")); + cases.Check("source.runner-single-win2022-fail-closed", () => + { + var job = port.Jobs["validation-runner"]; + SourceCases.Require(SourceCases.Match(job, @"(?m)^ needs: changes\s*$") && + job.Contains("needs.changes.outputs.windows == 'true'", StringComparison.Ordinal) && + job.Contains("needs.changes.result != 'success'", StringComparison.Ordinal) && + SourceCases.Match(job, @"(?m)^ runs-on: windows-2022\s*$") && + !SourceCases.Match(job, @"(?m)^\s+(strategy|matrix):"), "Dedicated runner job must execute once with the fail-closed Windows path gate"); + }); + cases.Check("source.runner-no-providers-or-hidden-failure", () => SourceCases.Require( + !SourceCases.Match(port.Jobs["validation-runner"], @"bootstrap\.ps1|\.ci-providers|continue-on-error"), + "Source-only runner job must not bootstrap providers or hide failure")); + cases.Check("source.runner-new-sdk-build-execution", () => AssertCanonicalJob(port.Jobs["validation-runner"])); + cases.Check("source.runner-port-exactly-once", () => SourceCases.Require(ExeInvocations(port.Text) == 1 && + !SourceCases.Match(port.Jobs["spikes-swift"], @"ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1|ValidationRunner\.exe"), + "Port workflow must execute the full compiled runner exactly once, never the legacy program")); + cases.Check("source.runner-aggregate-required", () => SourceCases.Require(SourceCases.Match(port.Jobs["windows-spikes"], + @"(?m)^ needs: \[changes, validation-runner, spikes-swift, spikes-other\]\s*$"), + "Required aggregate must include the dedicated compiled runner")); + cases.Check("source.runner-hardening-single-fallback", () => + { + var candidates = WorkflowText.Steps(hardening.Jobs["deterministic"]) + .Where(step => ExeInvocations(step) > 0).ToArray(); + SourceCases.Require(candidates.Length == 1 && ExeInvocations(hardening.Text) == 1 && + candidates[0].Contains("env.HARDENING_REQUIRED == 'true'", StringComparison.Ordinal) && + candidates[0].Contains("github.event_name != 'pull_request'", StringComparison.Ordinal) && + candidates[0].Contains("matrix.os == 'windows-2022'", StringComparison.Ordinal), + "Hardening must keep one non-PR Windows 2022 fallback without duplicate PR execution"); + AssertCanonicalJob(hardening.Jobs["deterministic"]); + }); + cases.Check("source.runner-compatibility-no-monolith", () => + { + var wrapper = File.ReadAllText(Path.Combine(root, "Tools", "windows", "Invoke-ValidationRunnerContract.ps1")); + SourceCases.Require(wrapper.Contains("ValidationRunner.exe", StringComparison.Ordinal) && + !wrapper.Contains("ValidationRunner.Tests.ps1", StringComparison.Ordinal), + "Compatibility entry must execute only the compiled host, never the old monolith"); + AssertProof(wrapper); + }); + AddCanonicalControls(cases); + return cases.Result("host.ci-topology"); + } + + internal static int ExeInvocations(string text) => Regex.Matches(text, + @"(?m)^\s*(?:run:\s*)?\.\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10\.0-windows\\ValidationRunner\.exe --repository-root \$PWD --artifact-root \.ci-validation-runner[ \t]*$").Count; + + private static void AssertCanonicalJob(string job) + { + SourceCases.Require(SourceCases.Match(job, @"uses: actions/setup-dotnet@") && + SourceCases.Match(job, @"(?m)^\s+dotnet-version: ['""]?10\.0\.403['""]?[ \t]*$"), "Compiled host requires the exact .NET SDK"); + var build = WorkflowText.Steps(job).Where(step => SourceCases.Match(step, @"(?m)^\s+dotnet build -c Release[ \t]*$")).ToArray(); + SourceCases.Require(build.Length == 1 && SourceCases.Match(build[0], + @"(?m)^ working-directory: Tools/windows/ValidationRunner[ \t]*$") && + build[0].Contains("dotnet --version", StringComparison.Ordinal) && + build[0].Contains("10.0.403", StringComparison.Ordinal), "Host build must select global.json from the nested project directory and assert its SDK"); + SourceCases.Require(ExeInvocations(job) == 1 && + !SourceCases.Match(job, @"--suite\b|--self-test\b|--fixture\b|ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1"), + "CI must execute exactly one normal full compiled host, not a filtered/self-test/legacy entry"); + AssertProof(job); + } + + private static void AssertProof(string text) + { + SourceCases.Require(text.Contains("coverage.json", StringComparison.Ordinal) && + SourceCases.Match(text, @"(?i)-not\s+\$[A-Za-z][A-Za-z0-9_]*\.complete\b") && + SourceCases.Match(text, @"(?i)\$[A-Za-z][A-Za-z0-9_]*\.requiredCount\s+(?:-le\s+0|-lt\s+1)") && + SourceCases.Match(text, @"(?i)\$([A-Za-z][A-Za-z0-9_]*)\.passedCount\s+-ne\s+\$\1\.requiredCount\b") && + text.Contains("CompiledValidationRunner: PASS", StringComparison.Ordinal) && + text.Contains("VALIDATION_RUNNER_EXECUTED=1", StringComparison.Ordinal) && + SourceCases.Match(text, @"(?i)\$exitCode\s+-ne\s+0|\$LASTEXITCODE\s+-ne\s+0") && + SourceCases.Match(text, @"(?i)\$passMarkers\s+-ne\s+1") && + SourceCases.Match(text, @"(?i)\$executedMarkers\s+-ne\s+1"), + "Normal execution must prove exit zero, one full PASS/count marker, complete coverage and positive required/passed counts"); + } + + private static void AddCanonicalControls(SourceCases cases) + { + var fixture = """ + runner: + steps: + - uses: actions/setup-dotnet@pinned + with: + dotnet-version: 10.0.403 + - name: Build compiled host + working-directory: Tools/windows/ValidationRunner + run: | + if ((dotnet --version) -ne '10.0.403') { throw 'SDK' } + dotnet build -c Release + - name: Run compiled host + run: | + .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { throw 'exit' } + $report = Get-Content .ci-validation-runner\coverage.json | ConvertFrom-Json + if (-not $report.complete -or $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount) { throw 'coverage' } + $passMarkers = @($output | Where-Object { $_ -ceq 'CompiledValidationRunner: PASS' }).Count + $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count + if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { throw 'markers' } + """.Replace("\r\n", "\n", StringComparison.Ordinal); + cases.Check("control.canonical.valid", () => AssertCanonicalJob(fixture)); + foreach (var (id, before, after) in new[] + { + ("wrong-sdk", "dotnet-version: 10.0.403", "dotnet-version: 10.0.x"), + ("wrong-build-cwd", "working-directory: Tools/windows/ValidationRunner", "working-directory: ."), + ("filtered-run", "--artifact-root .ci-validation-runner", "--artifact-root .ci-validation-runner --suite ui.activation"), + ("missing-run", @".\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe", "Write-Output"), + ("missing-complete", "$report.complete", "$report.unused"), + ("missing-positive-count", "$report.requiredCount -le 0", "$report.requiredCount -le -1"), + ("missing-full-count-equality", "$report.passedCount -ne $report.requiredCount", "$report.passedCount -lt 0"), + ("inverted-complete", "-not $report.complete", "$report.complete"), + ("missing-pass-count", "$passMarkers -ne 1", "$passMarkers -ne 0"), + ("missing-executed-count", "$executedMarkers -ne 1", "$executedMarkers -ne 0"), + ("ignored-exit", "$exitCode -ne 0", "$exitCode -ne 1") + }) + cases.Check("control.canonical." + id, () => + { + var mutant = Mutate(fixture, before, after); + Reject(() => AssertCanonicalJob(mutant)); + }); + cases.Check("control.canonical.duplicate-run", () => Reject(() => AssertCanonicalJob(fixture + + "\n .\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10.0-windows\\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner\n"))); + } + + private static void Reject(Action body) + { + var rejected = false; + try { body(); } catch (InvalidDataException) { rejected = true; } + SourceCases.Require(rejected, "Deliberate negative control was accepted"); + } + + private static string Mutate(string text, string before, string after) + { + var mutant = text.Replace(before, after, StringComparison.Ordinal); + SourceCases.Require(mutant != text, "Negative control did not change its fixture"); + return mutant; + } +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs new file mode 100644 index 00000000..53aa90ef --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs @@ -0,0 +1,104 @@ +using System.IO; +using GraphCode.Validation.Adapters; + +namespace GraphCode.Validation.Host.RootContracts; + +public static class SuiteRegistration +{ + public static IReadOnlyList CreateSuites() => + [ + new RootSuite("host.hardening-source-and-diagnostics", "Host\\Hardening\\Diagnostics.ps1", HardeningCases, + SourceContracts.Hardening, 7), + new RootSuite("host.ci-partition", "Host\\CiContracts\\Partition.ps1", PartitionCases), + new RootSuite("host.ci-aggregate", "Host\\CiContracts\\Aggregate.ps1", AggregateCases, SourceContracts.Aggregate, 8), + new RootSuite("host.ci-topology", null, [], SourceContracts.Topology, 35), + new RootSuite("host.shell-section-gate", "Host\\CiContracts\\ShellSections.ps1", SectionCases), + new RootSuite("host.zig-resolver", "Host\\CiContracts\\ZigResolver.ps1", ResolverCases) + ]; + + private static readonly string[] HardeningCases = + [ + "diagnostics.positions-and-bounds", "diagnostics.end-without-start", "diagnostics.empty-capture", + "payload.complete.plain", "payload.complete.csi", "payload.complete.wrapped", "payload.complete.osc_bell", + "payload.complete.osc_st_inside", "payload.complete.osc_st_outside", + "payload.pending.empty", "payload.pending.missing_end", "payload.pending.missing_start", + "payload.pending.reversed", "payload.pending.unterminated_osc", "payload.completed-empty" + ]; + + private static readonly string[] AggregateCases = + [ + "required.success", "required.skipped", "required.failure", "required.cancelled", "not-required.skipped", + "not-required.failure", "not-required.classifier-failure", "invalid-requirement", "empty-parts" + ]; + + private static readonly string[] PartitionCases = + [ + "workflow.windows-shell.yml.shell-unit.0.0", "workflow.windows-shell.yml.shell-unit.0.1", + "workflow.windows-shell.yml.shell-unit.0.2", "workflow.windows-shell.yml.shell-integration.0", + "workflow.windows-shell.yml.shell-integration.1", "workflow.windows-shell.yml.packaging-contracts.0", + "workflow.windows-shell.yml.packaging-real.0", "workflow.windows-port-validation.yml.investigation-privacy.0", + "workflow.windows-port-validation.yml.spikes-swift.0", "workflow.windows-port-validation.yml.spikes-other.0", + "workflow.windows-hardening.yml.deterministic.0", "coverage.all-listed-tasks", "coverage.all-parts", + "control.complete", "control.missing-integration", "control.missing-unit-shard", "control.missing-packaging-real" + ]; + + private static readonly string[] SectionCases = + [ + "section.reject.zero_all", "section.reject.failed", "section.reject.skipped_only", + "section.positive-two-invocations", "section.reject.missing-second-summary", "section.reject.native-nonzero", + "plan.unsharded-catalog", "plan.complete-disjoint-three-shards", "plan.workflow-agreement", + "manifest.complete", "manifest.missing-section", "manifest.duplicate-section", "manifest.zero-tests", + "manifest.skipped-assignment", "manifest.missing-shard" + ]; + + private static readonly string[] ResolverCases = + [ + "probe.stdout-stderr-native-exit", "probe.launch-failure-nullable-exit", "resolver.configured-success", + "resolver.fallback-order-deduplication", "resolver.native-exit-redacted-fallback", + "resolver.env-exit.invalid", "resolver.env-zero-not-new-gate.invalid", + "resolver.env-exit.json", "resolver.env-zero-not-new-gate.json", + "resolver.launch-exception.version", "resolver.launch-exception.env", + "resolver.writer-failure", "resolver.both-writers-fail", "resolver.both-writers-original-missing-guard", + "diagnostic.unsafe-version.unicode.version", "diagnostic.unsafe-version.unicode.env", + "diagnostic.unsafe-version.over_limit.version", "diagnostic.unsafe-version.over_limit.env", + "diagnostic.candidate.userinfo", "diagnostic.candidate.secret", "diagnostic.candidate.over_limit" + ]; + + private sealed class RootSuite( + string id, string? script, IReadOnlyList cases, + Func? source = null, int sourceCount = 0) : IValidationSuite + { + public string Id => id; + public IReadOnlyList UnitIds => [id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var rows = new List(); + if (script is not null) + { + var adapter = new StandaloneSuites.CaseScriptSuite(id, + "Tools\\windows\\ValidationRunner\\" + script, "HOST_CASE_RESULT=", [id]); + var observed = await adapter.RunAsync(context, cancellationToken); + if (observed.Count != 1 || observed[0].Id != id) + throw new InvalidDataException($"{id}: expected exactly one matching adapter result"); + var row = observed[0]; + if (row.ExecutedCount != cases.Count || (row.Status == UnitStatus.Passed && + (row.Groups is null || row.Groups.Count != cases.Count || + !row.Groups.Order(StringComparer.Ordinal).SequenceEqual(cases.Order(StringComparer.Ordinal), StringComparer.Ordinal)))) + row = row with { Status = UnitStatus.Failed, Evidence = row.Evidence + "; exact case inventory/count mismatch" }; + rows.Add(row); + } + if (source is not null) + { + var row = source(context.RepositoryRoot); + if (row.Id != id || row.ExecutedCount != sourceCount) + row = row with { Status = UnitStatus.Failed, Evidence = row.Evidence + "; exact source case inventory/count mismatch" }; + rows.Add(row); + } + return [new UnitResult(id, rows.All(row => row.Status == UnitStatus.Passed) ? UnitStatus.Passed : UnitStatus.Failed, + rows.Sum(row => row.ExecutedCount), string.Join("; ", rows.Select(row => row.Evidence)), + rows.SelectMany(row => row.Groups ?? []).ToArray())]; + } + } +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 new file mode 100644 index 00000000..c5491345 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ZigResolver.ps1 @@ -0,0 +1,219 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$production = Join-Path $RepositoryRoot 'Tools\windows\validate.ps1' +foreach ($name in @('Invoke-ZigResolverProbe', 'Write-ZigResolverDiagnostic', 'Resolve-ZigVersion')) { + . (Get-ProductionFunction $production $name) +} +$canary = 'ghp_PRIVATE_CANARY' +function RootProbeCommand([string] $operation) { + if ($operation -eq 'throw') { throw [ComponentModel.Win32Exception]::new(5, $canary) } + $global:LASTEXITCODE = -1073741502 + '0.15.2' + Write-Error $canary -ErrorAction Continue +} +Test-RootCase 'probe.stdout-stderr-native-exit' { + $result = Invoke-ZigResolverProbe 'RootProbeCommand' 'version' + Assert-Root ($result.Output -ceq '0.15.2' -and $result.ExitCode -eq -1073741502 -and + $result.ErrorText.Contains($canary) -and $null -eq $result.Failure) 'Real probe lost stream separation or native exit' +} +Test-RootCase 'probe.launch-failure-nullable-exit' { + $global:LASTEXITCODE = 123 + $result = Invoke-ZigResolverProbe 'RootProbeCommand' 'throw' + Assert-Root ($null -eq $result.ExitCode -and $null -ne $result.Failure) 'Launch failure reused a stale exit code' +} + +$configured = 'C:\fixture\configured\zig.exe' +$pathCandidate = 'C:\fixture\path\zig.exe' +$discovered = 'C:\fixture\discovered\zig.exe' +$repoRoot = 'C:\fixture\repo' +$environmentName = 'GRAPHCODE_ROOT_ZIG_TEST' +$prior = [Environment]::GetEnvironmentVariable($environmentName) +$state = @{ + Exists = @{}; Probes = @{} + Calls = [Collections.Generic.List[string]]::new() + Lines = [Collections.Generic.List[string]]::new() + Warnings = [Collections.Generic.List[string]]::new() + WriterFails = $false; WarningFails = $false +} +function Get-Command($Name, $ErrorAction) { [pscustomobject]@{ Source = $pathCandidate } } +function Get-ChildItem($Path, [switch] $Recurse, $Filter, [switch] $File, $ErrorAction) { + foreach ($value in @($configured, $discovered)) { [pscustomobject]@{ FullName = $value } } +} +function Test-Path($LiteralPath, $PathType, $ErrorAction) { $state.Exists[$LiteralPath] -eq $true } +function Resolve-Path($LiteralPath) { [pscustomobject]@{ Path = $LiteralPath } } +function Invoke-ZigResolverProbe([string] $candidate, [string] $operation) { + $key = "$candidate|$operation" + $state.Calls.Add($key) + if (-not $state.Probes.ContainsKey($key)) { throw "Unplanned probe $key" } + $state.Probes[$key] +} +function Write-Host($Object) { + if ($state.WriterFails) { throw $canary } + $state.Lines.Add([string]$Object) +} +function Write-Warning($Message, $WarningAction) { + $state.Warnings.Add([string]$Message) + if ($state.WarningFails) { throw $canary } +} +function New-RootProbe($Output, $Exit = 0, $ErrorText = '', $Failure = $null) { + [pscustomobject]@{ Output = $Output; ExitCode = $Exit; ErrorText = $ErrorText; Failure = $Failure } +} +function Reset-RootResolver { + $state.Calls.Clear() + $state.Lines.Clear() + $state.Warnings.Clear() + $state.WriterFails = $false + $state.WarningFails = $false + $state.Exists = @{ $configured = $true; $pathCandidate = $true; $discovered = $true } + $state.Probes = @{} + foreach ($candidate in @($configured, $pathCandidate, $discovered)) { + $state.Probes["$candidate|version"] = New-RootProbe '0.15.2' + $state.Probes["$candidate|env"] = New-RootProbe '{"version":"0.15.2","lib_dir":"C:\\fixture\\lib"}' + } + [Environment]::SetEnvironmentVariable($environmentName, $configured) +} +function Read-RootDiagnostic([int] $Index = 0) { + Assert-Root ($state.Lines.Count -gt $Index) 'Diagnostic was not emitted' + $line = $state.Lines[$Index] + Assert-Root ($line.StartsWith('ZIG_RESOLVER_DIAGNOSTIC ') -and $line.Length -lt 2500 -and + $line -notmatch 'ghp_PRIVATE_CANARY|userinfo|GITHUB_TOKEN') 'Diagnostic tag, bounds, or secret redaction changed' + $line.Substring('ZIG_RESOLVER_DIAGNOSTIC '.Length) | ConvertFrom-Json +} +try { + Test-RootCase 'resolver.configured-success' { + Reset-RootResolver + $result = @(Resolve-ZigVersion '0.15.2' $environmentName) + Assert-Root ($result.Count -eq 1 -and $result[0] -ceq $configured -and $state.Lines.Count -eq 0 -and + ($state.Calls -join ',') -ceq "$configured|version,$configured|env") 'Configured success changed probes, return stream, or diagnostics' + } + Test-RootCase 'resolver.fallback-order-deduplication' { + Reset-RootResolver + $state.Exists[$configured] = $false + $state.Probes["$pathCandidate|version"] = New-RootProbe '0.16.0' + $result = @(Resolve-ZigVersion '0.15.2' $environmentName) + Assert-Root ($result.Count -eq 1 -and $result[0] -ceq $discovered -and + ($state.Calls -join ',') -ceq "$pathCandidate|version,$discovered|version,$discovered|env") 'Fallback order or candidate deduplication changed' + $missing = Read-RootDiagnostic + $mismatch = Read-RootDiagnostic 1 + Assert-Root (-not $missing.exists -and $null -eq $missing.version -and $missing.source -eq 'configured' -and + $mismatch.source -eq 'PATH' -and $mismatch.version.observedVersion -eq '0.16.0' -and + $null -eq $mismatch.env) 'Missing candidate or version mismatch evidence changed' + } + Test-RootCase 'resolver.native-exit-redacted-fallback' { + Reset-RootResolver + $state.Probes["$configured|version"] = New-RootProbe $canary -1073741502 $canary + $result = Resolve-ZigVersion '0.15.2' $environmentName + $diagnostic = Read-RootDiagnostic + Assert-Root ($result -ceq $pathCandidate -and $diagnostic.version.exitCodeHex -eq '0xC0000142' -and + $null -eq $diagnostic.version.observedVersion -and $diagnostic.version.stderr.reason -eq 'unclassified') ` + 'Nonzero exit, fallback, or redaction changed' + } + foreach ($variant in @('invalid', 'json')) { + $text = if ($variant -eq 'invalid') { "invalid $canary" } else { + '{"version":"0.15.2","lib_dir":"C:\\fixture\\absent","env":{"GITHUB_TOKEN":"ghp_PRIVATE_CANARY"}}' + } + Test-RootCase "resolver.env-exit.$variant" { + Reset-RootResolver + $state.Probes["$configured|env"] = New-RootProbe $text 9 "error: unable to find zig installation directory $canary" + $result = Resolve-ZigVersion '0.15.2' $environmentName + $diagnostic = Read-RootDiagnostic + Assert-Root ($result -ceq $pathCandidate -and $diagnostic.reason -eq 'env-exit' -and + $diagnostic.env.exitCode -eq 9 -and $diagnostic.env.stderr.reason -eq 'unable to find zig installation directory') ` + 'Env failure or safe known reason changed' + if ($variant -eq 'invalid') { + Assert-Root ($diagnostic.env.parse -eq 'metadata-unavailable') 'Invalid JSON metadata changed' + } else { + Assert-Root ($diagnostic.env.parse -eq 'parsed' -and $diagnostic.env.libDirectoryPresent -eq $false) 'Missing lib metadata changed' + } + } + Test-RootCase "resolver.env-zero-not-new-gate.$variant" { + Reset-RootResolver + $state.Probes["$configured|env"] = New-RootProbe $text + $result = Resolve-ZigVersion '0.15.2' $environmentName + Assert-Root ($result -ceq $configured -and $state.Lines.Count -eq 0) 'Diagnostic metadata introduced a new acceptance gate' + } + } + foreach ($operation in @('version', 'env')) { + Test-RootCase "resolver.launch-exception.$operation" { + Reset-RootResolver + $errorRecord = [Management.Automation.ErrorRecord]::new( + [Management.Automation.RuntimeException]::new($canary, [ComponentModel.Win32Exception]::new(5, $canary)), + 'Launch', [Management.Automation.ErrorCategory]::OpenError, $null) + $state.Probes["$configured|$operation"] = New-RootProbe $null $null '' $errorRecord + $failure = $null + try { Resolve-ZigVersion '0.15.2' $environmentName | Out-Null } catch { $failure = $_ } + $diagnostic = Read-RootDiagnostic + $expectedCalls = if ($operation -eq 'version') { 1 } else { 2 } + Assert-Root ($null -ne $failure -and $failure.ToString() -notmatch $canary -and + $state.Calls.Count -eq $expectedCalls -and $diagnostic.reason -eq "$operation-exception" -and + $null -eq $diagnostic.$operation.exitCode -and $diagnostic.$operation.nativeErrorCode -eq 5) ` + 'Launch exception did not stop safely with nullable exit and native error' + } + } + Test-RootCase 'resolver.writer-failure' { + Reset-RootResolver + $state.Probes["$configured|version"] = New-RootProbe '0.16.0' + $state.WriterFails = $true + $result = Resolve-ZigVersion '0.15.2' $environmentName + Assert-Root ($result -ceq $pathCandidate -and $state.Warnings.Count -eq 1) 'Writer failure changed fallback' + } + Test-RootCase 'resolver.both-writers-fail' { + Reset-RootResolver + $state.Probes["$configured|version"] = New-RootProbe '0.16.0' + $state.WriterFails = $true + $state.WarningFails = $true + $result = @(Resolve-ZigVersion '0.15.2' $environmentName) + Assert-Root ($result.Count -eq 1 -and $result[0] -ceq $pathCandidate -and + ($state.Calls -join ',') -ceq "$configured|version,$pathCandidate|version,$pathCandidate|env") 'Both writers changed selection' + } + Test-RootCase 'resolver.both-writers-original-missing-guard' { + Reset-RootResolver + $state.WriterFails = $true + $state.WarningFails = $true + $state.Exists.Clear() + $failure = $null + try { Resolve-ZigVersion '0.15.2' $environmentName | Out-Null } catch { $failure = $_ } + Assert-Root ($null -ne $failure -and + $failure.ToString() -ceq "Zig 0.15.2 is required for the pinned Windows provider; set $environmentName.") 'Diagnostics masked the missing Zig guard' + } + $unsafeVersions = [ordered]@{ + unicode = ([string][char]0x0661) + '.2.3' + over_limit = '1.2.3+' + ('a' * 65) + } + foreach ($entry in $unsafeVersions.GetEnumerator()) { + foreach ($operation in @('version', 'env')) { + Test-RootCase "diagnostic.unsafe-version.$($entry.Key).$operation" { + Reset-RootResolver + $text = if ($operation -eq 'version') { $entry.Value } else { @{ version = $entry.Value } | ConvertTo-Json -Compress } + $probe = New-RootProbe $text 1 + if ($operation -eq 'version') { + Write-ZigResolverDiagnostic $configured 'configured' '0.15.2' $true 'version-exit' $probe $null + } else { + Write-ZigResolverDiagnostic $configured 'configured' '0.15.2' $true 'env-exit' (New-RootProbe '0.15.2') $probe + } + $diagnostic = Read-RootDiagnostic + $summary = $diagnostic.$operation + Assert-Root ($null -eq $summary.observedVersion -and + $summary.stdout.bytes -eq [Text.Encoding]::UTF8.GetByteCount($text) -and + $summary.stdout.sha256.Length -eq 64 -and -not $state.Lines[0].Contains($entry.Value)) 'Unsafe version was not reduced to exact byte count/hash' + } + } + } + $unsafeCandidates = [ordered]@{ + userinfo = 'https://userinfo@github.com/zig' + secret = 'C:\ghp_PRIVATE_CANARY\zig.exe' + over_limit = 'C:\' + ('x' * 520) + } + foreach ($entry in $unsafeCandidates.GetEnumerator()) { + Test-RootCase "diagnostic.candidate.$($entry.Key)" { + Reset-RootResolver + Write-ZigResolverDiagnostic $entry.Value 'configured' '0.15.2' $true 'version-exit' (New-RootProbe ($canary * 2000) 1) $null + $diagnostic = Read-RootDiagnostic + Assert-Root ($diagnostic.candidate -ceq '[omitted]' -and $diagnostic.version.stdout.bytes -gt 16384) 'Candidate redaction or output cap changed' + } + } +} finally { + [Environment]::SetEnvironmentVariable($environmentName, $prior) +} +Write-RootSummary 'host.zig-resolver' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json new file mode 100644 index 00000000..230cd419 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json @@ -0,0 +1,10 @@ +{ + "units": [ + { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 22, "exactCount": 22 }, + { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 35, "exactCount": 35 }, + { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 15, "exactCount": 15 }, + { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 21, "exactCount": 21 } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json new file mode 100644 index 00000000..e71f0164 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "baselineManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "baselineSource": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "inventoryIsExecutionEvidence": false, + "executionEvidence": "UnitResult.executedCount and groups, plus per-adapter case JSON written beneath ArtifactRoot", + "families": [ + { + "unit": "host.hardening-source-and-diagnostics", + "expectedExpandedCount": 22, + "production": "Tools/windows/Tests/Hardening.Tests.ps1", + "cases": [ + { "originalLines": "5144-5155", "ids": ["source.full-pinned-real-hardening", "source.owned-environment", "source.native-exit.terminal", "source.native-exit.shell"] }, + { "originalLines": "5170-5176", "ids": ["diagnostics.positions-and-bounds"] }, + { "originalLines": "5177-5180", "ids": ["diagnostics.end-without-start"] }, + { "originalLines": "5181-5186", "ids": ["diagnostics.empty-capture"] }, + { "originalLines": "5188-5200", "ids": ["payload.complete.plain", "payload.complete.csi", "payload.complete.wrapped", "payload.complete.osc_bell", "payload.complete.osc_st_inside", "payload.complete.osc_st_outside"] }, + { "originalLines": "5201-5205", "ids": ["payload.pending.empty", "payload.pending.missing_end", "payload.pending.missing_start", "payload.pending.reversed", "payload.pending.unterminated_osc"] }, + { "originalLines": "5206-5210", "ids": ["payload.completed-empty"] }, + { "originalLines": "5211-5222", "ids": ["source.high-output-failure-diagnostics", "source.repeat-preserves-child-diagnostics", "source.release-shell-version"] } + ] + }, + { + "unit": "host.ci-partition", + "expectedExpandedCount": 17, + "production": "Tools/windows/validate.ps1 -List and -DryRun, with actual PR workflow invocations expanded over the declared matrix", + "cases": [ + { "originalLines": "4384-4403", "ids": ["workflow.windows-shell.yml.shell-unit.0.0", "workflow.windows-shell.yml.shell-unit.0.1", "workflow.windows-shell.yml.shell-unit.0.2", "workflow.windows-shell.yml.shell-integration.0", "workflow.windows-shell.yml.shell-integration.1", "workflow.windows-shell.yml.packaging-contracts.0", "workflow.windows-shell.yml.packaging-real.0", "workflow.windows-port-validation.yml.investigation-privacy.0", "workflow.windows-port-validation.yml.spikes-swift.0", "workflow.windows-port-validation.yml.spikes-other.0", "workflow.windows-hardening.yml.deterministic.0"] }, + { "originalLines": "4404-4410", "ids": ["coverage.all-listed-tasks"] }, + { "originalLines": "4413-4432", "ids": ["coverage.all-parts"] }, + { "originalLines": null, "purpose": "New positive and deletion controls for the migrated partition predicate", "ids": ["control.complete", "control.missing-integration", "control.missing-unit-shard", "control.missing-packaging-real"] } + ] + }, + { + "unit": "host.ci-aggregate", + "expectedExpandedCount": 17, + "production": "Tools/windows/Assert-CiPartResults.ps1", + "cases": [ + { "originalLines": "4434-4457", "ids": ["source.aggregate.windows-shell", "source.aggregate.windows-spikes"] }, + { "originalLines": "4459-4475", "ids": ["required.success", "required.skipped", "required.failure", "required.cancelled", "not-required.skipped", "not-required.failure", "not-required.classifier-failure"] }, + { "originalLines": null, "purpose": "Additional actual producer refusal cases", "ids": ["invalid-requirement", "empty-parts"] }, + { "originalLines": null, "purpose": "New positive/mutation controls for compiled aggregate source contracts", "ids": ["control.aggregate.valid", "control.aggregate.missing-part", "control.aggregate.renamed-required-check", "control.aggregate.success-only", "control.aggregate.missing-needs-json", "control.aggregate.missing-required-argument"] } + ] + }, + { + "unit": "host.ci-topology", + "expectedExpandedCount": 35, + "production": ".github/workflows/windows-*.yml and Tools/windows/Invoke-ValidationRunnerContract.ps1", + "cases": [ + { "originalLines": "5233-5257", "ids": ["source.cold-cache.spikes-swift", "source.cold-cache.spikes-other", "source.cold-cache.packaging-real", "source.runner-topology-timeout"] }, + { "originalLines": "5258-5273", "ids": ["source.warmer-key.zig", "source.warmer-key.providers", "source.warmer-events-and-build-only"] }, + { "originalLines": "5277-5282", "ids": ["source.uia-diagnostics.shell", "source.uia-diagnostics.port"] }, + { "originalLines": "5283-5291", "ids": ["source.pinned-swift.shell", "source.pinned-swift.port", "source.pinned-swift.hardening"] }, + { "originalLines": "5292-5307", "ids": ["source.shell-bootstrap", "source.hosted-explicit-skip-limits", "source.shell-live-task"] }, + { "originalSource": "Tools/windows/Tests/WindowsCiTopology.Tests.ps1:39-115", "purpose": "Preserve scheduling and required names; replace old monolithic execution assertions with the compiled host contract", "ids": ["source.runner-single-win2022-fail-closed", "source.runner-no-providers-or-hidden-failure", "source.runner-new-sdk-build-execution", "source.runner-port-exactly-once", "source.runner-aggregate-required", "source.runner-hardening-single-fallback", "source.runner-compatibility-no-monolith"] }, + { "originalLines": null, "purpose": "New SDK/CWD/full-execution/coverage/exit/marker positive and negative controls; not executions of GitHub Actions", "ids": ["control.canonical.valid", "control.canonical.wrong-sdk", "control.canonical.wrong-build-cwd", "control.canonical.filtered-run", "control.canonical.missing-run", "control.canonical.missing-complete", "control.canonical.missing-positive-count", "control.canonical.missing-full-count-equality", "control.canonical.inverted-complete", "control.canonical.missing-pass-count", "control.canonical.missing-executed-count", "control.canonical.ignored-exit", "control.canonical.duplicate-run"] } + ] + }, + { + "unit": "host.shell-section-gate", + "expectedExpandedCount": 15, + "production": "Tools/windows/Tests/WindowsShell.Tests.ps1 and Tools/windows/Test-ShellSectionManifests.ps1", + "cases": [ + { "originalLines": "4739-4750", "ids": ["section.reject.zero_all", "section.reject.failed", "section.reject.skipped_only"] }, + { "originalLines": "4751-4761", "ids": ["section.positive-two-invocations"] }, + { "originalLines": null, "purpose": "Additional refusal cases against the actual section runner", "ids": ["section.reject.missing-second-summary", "section.reject.native-nonzero"] }, + { "originalLines": "4764-4783", "ids": ["plan.unsharded-catalog", "plan.complete-disjoint-three-shards", "plan.workflow-agreement"] }, + { "originalLines": "4788-4825", "ids": ["manifest.complete", "manifest.missing-section", "manifest.duplicate-section", "manifest.zero-tests", "manifest.skipped-assignment", "manifest.missing-shard"] } + ] + }, + { + "unit": "host.zig-resolver", + "expectedExpandedCount": 21, + "production": "Tools/windows/validate.ps1: Invoke-ZigResolverProbe, Write-ZigResolverDiagnostic, Resolve-ZigVersion", + "cases": [ + { "originalLines": "4851-4862", "ids": ["probe.stdout-stderr-native-exit", "probe.launch-failure-nullable-exit"] }, + { "originalLines": "4926-4929", "ids": ["resolver.configured-success"] }, + { "originalLines": "4930-4939", "ids": ["resolver.fallback-order-deduplication"] }, + { "originalLines": "4941-4946", "ids": ["resolver.native-exit-redacted-fallback"] }, + { "originalLines": "4947-4963", "ids": ["resolver.env-exit.invalid", "resolver.env-zero-not-new-gate.invalid", "resolver.env-exit.json", "resolver.env-zero-not-new-gate.json"] }, + { "originalLines": "4965-4980", "ids": ["resolver.launch-exception.version", "resolver.launch-exception.env"] }, + { "originalLines": "4981-4993", "ids": ["resolver.writer-failure", "resolver.both-writers-fail", "resolver.both-writers-original-missing-guard"] }, + { "originalLines": "4995-5016", "ids": ["diagnostic.unsafe-version.unicode.version", "diagnostic.unsafe-version.unicode.env", "diagnostic.unsafe-version.over_limit.version", "diagnostic.unsafe-version.over_limit.env"] }, + { "originalLines": "5017-5023", "ids": ["diagnostic.candidate.userinfo", "diagnostic.candidate.secret", "diagnostic.candidate.over_limit"] } + ] + } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 b/Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 new file mode 100644 index 00000000..754adc94 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/Hardening/Diagnostics.ps1 @@ -0,0 +1,54 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot '..\CiContracts\AdapterSupport.ps1') +$production = Join-Path $RepositoryRoot 'Tools\windows\Tests\Hardening.Tests.ps1' +foreach ($name in @('Find-Bytes', 'Get-HighOutputDiagnostics', 'Get-HighOutputPayloadText')) { + . (Get-ProductionFunction $production $name) +} + +Test-RootCase 'diagnostics.positions-and-bounds' { + $bytes = [Text.Encoding]::ASCII.GetBytes('START' + ('A' * 1024) + 'END') + $actual = Get-HighOutputDiagnostics $bytes 'START' 'END' + Assert-Root ($actual.capturedBytes -eq 1032 -and $actual.startOffset -eq 0 -and + $actual.endOffset -eq 1029 -and $actual.prefix.Length -eq 512 -and $actual.suffix.Length -eq 512) ` + 'Captured byte count, marker offsets, or bounded excerpts changed' +} +Test-RootCase 'diagnostics.end-without-start' { + $actual = Get-HighOutputDiagnostics ([Text.Encoding]::ASCII.GetBytes('END')) 'START' 'END' + Assert-Root ($actual.startOffset -eq -1 -and $actual.endOffset -eq 0) 'Missing start hid the end marker' +} +Test-RootCase 'diagnostics.empty-capture' { + $actual = Get-HighOutputDiagnostics ([byte[]]::new(0)) 'START' 'END' + Assert-Root ($actual.capturedBytes -eq 0 -and $actual.startOffset -eq -1 -and + $actual.endOffset -eq -1 -and $actual.prefix -ceq '' -and $actual.suffix -ceq '') 'Empty capture changed' +} +$esc = [string][char]27 +$complete = [ordered]@{ + plain = 'STARTAAAAEND' + csi = "ST${esc}[0mARTAA${esc}[31mAAEN${esc}[0mD" + wrapped = "STA`r`nRTAAAAE`r`nND" + osc_bell = "START${esc}]0;END$([char]7)AAAAEND" + osc_st_inside = "ST${esc}]0;title${esc}\ARTAAAAEND" + osc_st_outside = "${esc}]0;before${esc}\STARTAAAAEND${esc}]0;after${esc}\" +} +foreach ($entry in $complete.GetEnumerator()) { + Test-RootCase "payload.complete.$($entry.Key)" { + $actual = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($entry.Value)) 'START' 'END' + Assert-Root ($actual -ceq 'AAAA') 'Terminal framing corrupted completion payload' + } +} +$incomplete = [ordered]@{ + empty = ''; missing_end = 'STARTAAAA'; missing_start = 'AAAAEND' + reversed = 'ENDSTARTAAAA'; unterminated_osc = "${esc}]0;STARTAAAAEND" +} +foreach ($entry in $incomplete.GetEnumerator()) { + Test-RootCase "payload.pending.$($entry.Key)" { + $actual = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes($entry.Value)) 'START' 'END' + Assert-Root ($null -eq $actual) 'Incomplete markers were accepted' + } +} +Test-RootCase 'payload.completed-empty' { + $actual = Get-HighOutputPayloadText ([Text.Encoding]::ASCII.GetBytes('STARTEND')) 'START' 'END' + Assert-Root ($null -ne $actual -and $actual -ceq '') 'Empty completion was confused with pending output' +} +Write-RootSummary 'host.hardening-source-and-diagnostics' From e71e886ddc9d6d8b519bafcca138cb3e049ffcea Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:43:32 -0700 Subject: [PATCH 23/46] Preserve residual packaging task wiring in compiled validation Exercise seven original producer AST predicates and nine negative controls through the protected host transport. Record existing UI and host ownership without counting those cases twice. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Host/CiContracts/ResidualScope-README.md | 41 +++++++ .../ResidualScope-manifest-fragment.json | 10 ++ .../ResidualScope-source-correspondence.json | 32 +++++ .../Host/CiContracts/ResidualScope.ps1 | 116 ++++++++++++++++++ .../Host/CiContracts/ResidualScopeFocused.ps1 | 23 ++++ .../CiContracts/ResidualScopeRegistration.cs | 42 +++++++ 6 files changed, 264 insertions(+) create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md new file mode 100644 index 00000000..a9f1c042 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-README.md @@ -0,0 +1,41 @@ +# Residual root source wiring + +Register +`GraphCode.Validation.Host.RootContracts.ResidualScopeRegistration.CreateSuites()` +once, in addition to the frozen six-family registration. Merge the single +`ResidualScope-manifest-fragment.json` entry into the existing host unit rather +than adding a duplicate unit. + +The residual audit leaves seven original packaging predicates: six contract +invocations inside the fixture-only part guard before real packaging, and real +input initialization before the real packaging invocation. The suite expands +these to **16 cases: seven production-source observations and nine negative +controls**. Successful adapter groups must match those 16 identities exactly. + +These are actual PowerShell AST observations against `validate.ps1`, not matches +against comments, inventories or a reimplemented packaging executor. The checks +require the correct task clause, correct part guards, unique direct command +invocations and source ordering. Independently authored source mutations remove +each contract call, remove initialization, invert the guard and comment out a +call. Mutants are parsed and inspected only; production packaging is never run +by this unit. + +The long existing mandatory unit ID +`host.root-source-scope-mock-environment-error-streams` is retained for manifest +compatibility. It does not imply that these static wiring cases prove runtime +scope restoration, error streams, LASTEXITCODE behavior, packaging lifecycle or +native UIA behavior. Those remain the responsibility of their actual behavioral +suites. + +`ResidualScope-source-correspondence.json` records exact frozen lines and foreign +ownership. In particular, the existing Ui unit `ui.root.source.line-5342` +already covers ProviderPins-before-TerminalGate. The smoke-before-UIA predicate +is `ui.root.source.line-5350`, not line 5342. Neither is counted here. The two +delayed-stub observations at line 5346 remain with the Host implementation. + +Build the nested host project normally. For focused development, use +`ResidualScopeFocused.ps1` with `-RepositoryRoot`, `-ArtifactRoot` and +`-AssemblyPath`, as with `Focused.ps1`. It invokes the public protected +`CaseScriptSuite` transport, preserves unknown ownership through the outer +terminal hold, requires a positive exact count and never emits a full-host +success marker. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json new file mode 100644 index 00000000..bc847c02 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-manifest-fragment.json @@ -0,0 +1,10 @@ +{ + "units": [ + { + "id": "host.root-source-scope-mock-environment-error-streams", + "owner": "Host", + "minimumCount": 16, + "exactCount": 16 + } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json new file mode 100644 index 00000000..765a4b66 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope-source-correspondence.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "baselineManifestSha256": "CC8ED5141AE38CB425F4E41AB7F2E4EF1F07F5AE4CAD7FA969A68645B07A0A2F", + "baselineSource": "Tools/windows/Tests/ValidationRunner.Tests.ps1", + "unit": "host.root-source-scope-mock-environment-error-streams", + "expectedExpandedCount": 16, + "originalPredicates": 7, + "newNegativeControls": 9, + "inventoryIsExecutionEvidence": false, + "production": "Tools/windows/validate.ps1: actual packaging switch clause, part guards and invoked commands", + "cases": [ + { "originalLines": "5322-5324", "ids": ["source.packaging.signing", "control.packaging.signing.missing-call"] }, + { "originalLines": "5325-5327", "ids": ["source.packaging.rollback", "control.packaging.rollback.missing-call"] }, + { "originalLines": "5328-5330", "ids": ["source.packaging.standalone", "control.packaging.standalone.missing-call"] }, + { "originalLines": "5331-5335", "expandedValue": "Packaging.ScriptSigning.Tests.ps1", "ids": ["source.packaging.script_signing", "control.packaging.script_signing.missing-call"] }, + { "originalLines": "5331-5335", "expandedValue": "Packaging.Scheduler.Tests.ps1", "ids": ["source.packaging.scheduler", "control.packaging.scheduler.missing-call"] }, + { "originalLines": "5331-5335", "expandedValue": "Packaging.Uninstall.Tests.ps1", "ids": ["source.packaging.uninstall", "control.packaging.uninstall.missing-call"] }, + { "originalLines": "5336-5338", "ids": ["source.packaging.real-input-order", "control.packaging.real-missing-initialization"] }, + { "originalLines": null, "purpose": "New controls prove an inverted part guard and a commented-out command are not accepted as actual packaging wiring", "ids": ["control.packaging.wrong-part-guard", "control.packaging.comment-is-not-invocation"] } + ], + "foreignCoverageNotExecutedOrCountedByThisUnit": [ + { "originalLines": "5031-5033", "owner": "Host", "coverage": "ps.pester-version, ps.isolated-process-environment, ps.fixture-directory" }, + { "originalLines": "5035-5137", "owner": "Host", "coverage": "task inventory/dry-run, provider task boundary, unknown task, real formatter, foreign fixture" }, + { "originalLines": "5143-5222", "owner": "RootContracts", "coverage": "host.hardening-source-and-diagnostics" }, + { "originalLines": "5223-5307", "owner": "RootContracts and Host", "coverage": "host.ci-topology plus Host provider cache/build suites" }, + { "originalLines": "5308-5321", "owner": "RootContracts and Host/Ownership", "coverage": "host.zig-resolver, host.shell-prerequisite and pre-UIA isolation adapters" }, + { "originalLines": "5339-5341", "owner": "RootContracts", "coverage": "host.ci-partition, host.shell-section-gate, host.ci-aggregate" }, + { "originalLines": "5342-5344", "owner": "Ui", "coverage": "ui.root.source.line-5342", "predicate": "ProviderPins.Tests.ps1 precedes TerminalGate.Tests.ps1" }, + { "originalLines": "5345-5349", "owner": "Host", "coverage": "ui.root.source.line-5346", "expandedChecks": 2, "predicate": "150ms delayed stub wiring in validate.ps1 and Hardening.Tests.ps1" }, + { "originalLines": "5350-5352", "owner": "Ui", "coverage": "ui.root.source.line-5350", "predicate": "Shell build/smoke precedes native UIA live gate" } + ] +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 new file mode 100644 index 00000000..19b5fcec --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScope.ps1 @@ -0,0 +1,116 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, [string] $AssemblyPath) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') + +function Read-ResidualTree([string] $Source) { + $tokens = $null + $errors = $null + $tree = [Management.Automation.Language.Parser]::ParseInput($Source, [ref]$tokens, [ref]$errors) + Assert-Root ($errors.Count -eq 0) 'Production wiring source does not parse' + return $tree +} + +function Get-ResidualTaskClause($Tree, [string] $Name) { + $switches = @($Tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.SwitchStatementAst] + }, $true)) + $matches = @($switches.Clauses | Where-Object { $_.Item1.Value -ceq $Name }) + Assert-Root ($matches.Count -eq 1) "Expected exactly one $Name task clause" + return $matches[0].Item2 +} + +function Get-ResidualGuard($Clause, [string] $ExcludedPart) { + $expected = '$PackagingPart -ne "' + $ExcludedPart + '"' + $guards = @($Clause.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses.Count -eq 1 -and $node.Clauses[0].Item1.Extent.Text -ceq $expected + }, $true)) + Assert-Root ($guards.Count -eq 1) "Missing unique packaging part guard excluding $ExcludedPart" + return $guards[0].Clauses[0].Item2 +} + +function Get-ResidualScriptCall($Tree, [string] $RelativePath) { + $expected = '(Join-Path $repoRoot "' + $RelativePath + '")' + $calls = @($Tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $node.CommandElements[0].Extent.Text -ceq $expected + }, $true)) + Assert-Root ($calls.Count -eq 1) "Missing or duplicated actual script invocation: $RelativePath" + return $calls[0] +} + +function Assert-ResidualPackagingContract($Tree, [string] $File) { + $clause = Get-ResidualTaskClause $Tree 'packaging' + $contracts = Get-ResidualGuard $clause 'real' + $real = Get-ResidualGuard $clause 'contracts' + $contract = Get-ResidualScriptCall $contracts "Tools\windows\Tests\$File" + $package = Get-ResidualScriptCall $real 'Tools\windows\Tests\Packaging.Tests.ps1' + Assert-Root ($contract.Parent.Parent -eq $contracts -and + $contract.Extent.EndOffset -lt $package.Extent.StartOffset) 'Packaging contract must be a direct part-guarded invocation before real packaging' +} + +function Assert-ResidualRealPackaging($Tree) { + $clause = Get-ResidualTaskClause $Tree 'packaging' + $real = Get-ResidualGuard $clause 'contracts' + $initializers = @($real.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Initialize-PackagingInputs' + }, $true)) + $package = Get-ResidualScriptCall $real 'Tools\windows\Tests\Packaging.Tests.ps1' + Assert-Root ($initializers.Count -eq 1 -and $initializers[0].Parent.Parent -eq $real -and + $package.Parent.Parent -eq $real -and $initializers[0].Extent.EndOffset -lt $package.Extent.StartOffset) ` + 'Real packaging must initialize its own inputs before its actual test invocation' +} + +function Assert-ResidualMutation([string] $Source, [string] $Before, [string] $After, [scriptblock] $Predicate) { + $mutant = $Source.Replace($Before, $After) + Assert-Root ($mutant -cne $Source) 'Residual source mutation did not apply' + $rejected = $false + try { & $Predicate (Read-ResidualTree $mutant) } catch { $rejected = $true } + Assert-Root $rejected 'Residual source wiring guard accepted a deliberate negative control' +} + +$runnerSource = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'Tools\windows\validate.ps1') -Raw +$runnerTree = Read-ResidualTree $runnerSource +$contracts = [ordered]@{ + signing = 'Packaging.Signing.Tests.ps1' + rollback = 'Packaging.Rollback.Tests.ps1' + standalone = 'Packaging.Standalone.Tests.ps1' + script_signing = 'Packaging.ScriptSigning.Tests.ps1' + scheduler = 'Packaging.Scheduler.Tests.ps1' + uninstall = 'Packaging.Uninstall.Tests.ps1' +} +foreach ($entry in $contracts.GetEnumerator()) { + Test-RootCase "source.packaging.$($entry.Key)" { Assert-ResidualPackagingContract $runnerTree $entry.Value } + Test-RootCase "control.packaging.$($entry.Key).missing-call" { + Assert-ResidualMutation $runnerSource $entry.Value ('Disabled.' + $entry.Value) { + param($tree) + Assert-ResidualPackagingContract $tree $entry.Value + } + } +} +Test-RootCase 'source.packaging.real-input-order' { Assert-ResidualRealPackaging $runnerTree } +Test-RootCase 'control.packaging.real-missing-initialization' { + Assert-ResidualMutation $runnerSource 'Initialize-PackagingInputs' 'Disabled-PackagingInputs' { + param($tree) + Assert-ResidualRealPackaging $tree + } +} +Test-RootCase 'control.packaging.wrong-part-guard' { + Assert-ResidualMutation $runnerSource '$PackagingPart -ne "real"' '$PackagingPart -eq "real"' { + param($tree) + Assert-ResidualPackagingContract $tree 'Packaging.Signing.Tests.ps1' + } +} +Test-RootCase 'control.packaging.comment-is-not-invocation' { + $call = '& (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Signing.Tests.ps1")' + Assert-ResidualMutation $runnerSource $call ("# " + $call) { + param($tree) + Assert-ResidualPackagingContract $tree 'Packaging.Signing.Tests.ps1' + } +} +Write-RootSummary 'host.root-source-scope-mock-environment-error-streams' diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 new file mode 100644 index 00000000..87b77855 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 @@ -0,0 +1,23 @@ +param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot, + [Parameter(Mandatory)] [string] $AssemblyPath +) +$ErrorActionPreference = 'Stop' +$null = [Reflection.Assembly]::LoadFrom($AssemblyPath) +$exitCode = 1 +try { + $context = [GraphCode.Validation.ValidationContext]::new($RepositoryRoot, $ArtifactRoot) + $suites = [GraphCode.Validation.Host.RootContracts.ResidualScopeRegistration]::CreateSuites() + if ($suites.Count -ne 1) { throw 'Residual registration must expose exactly one suite' } + $rows = $suites[0].RunAsync($context, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + $rows | ConvertTo-Json -Depth 8 -Compress | Write-Output + if ($rows.Count -eq 1 -and $rows[0].Status -eq 'Passed' -and $rows[0].ExecutedCount -eq 16) { + $exitCode = 0 + Write-Output 'RESIDUAL_FOCUSED_PASSED=16' + } +} finally { + $null = [GraphCode.Validation.Host.TerminalSafety]::PreserveRetainedAuthorityAsync( + [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount).GetAwaiter().GetResult() +} +exit $exitCode diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs new file mode 100644 index 00000000..aa7584eb --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeRegistration.cs @@ -0,0 +1,42 @@ +using System.IO; +using GraphCode.Validation.Adapters; + +namespace GraphCode.Validation.Host.RootContracts; + +public static class ResidualScopeRegistration +{ + public static IReadOnlyList CreateSuites() => [new ResidualSuite()]; + + private static readonly string[] Cases = + [ + "source.packaging.signing", "control.packaging.signing.missing-call", + "source.packaging.rollback", "control.packaging.rollback.missing-call", + "source.packaging.standalone", "control.packaging.standalone.missing-call", + "source.packaging.script_signing", "control.packaging.script_signing.missing-call", + "source.packaging.scheduler", "control.packaging.scheduler.missing-call", + "source.packaging.uninstall", "control.packaging.uninstall.missing-call", + "source.packaging.real-input-order", "control.packaging.real-missing-initialization", + "control.packaging.wrong-part-guard", "control.packaging.comment-is-not-invocation" + ]; + + private sealed class ResidualSuite : IValidationSuite + { + public string Id => "host.root-source-scope-mock-environment-error-streams"; + public IReadOnlyList UnitIds => [Id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var suite = new StandaloneSuites.CaseScriptSuite(Id, + "Tools\\windows\\ValidationRunner\\Host\\CiContracts\\ResidualScope.ps1", "HOST_CASE_RESULT=", [Id]); + var results = await suite.RunAsync(context, cancellationToken); + if (results.Count != 1 || results[0].Id != Id) + throw new InvalidDataException("Residual root scope requires exactly one matching unit result"); + var row = results[0]; + if (row.ExecutedCount != Cases.Length || (row.Status == UnitStatus.Passed && + (row.Groups is null || row.Groups.Count != Cases.Length || + !row.Groups.Order(StringComparer.Ordinal).SequenceEqual(Cases.Order(StringComparer.Ordinal), StringComparer.Ordinal)))) + row = row with { Status = UnitStatus.Failed, Evidence = row.Evidence + "; exact residual case inventory/count mismatch" }; + return [row]; + } + } +} From 7eb1d635d4bec89a74d98bd5122342d265d89b33 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:21:29 -0700 Subject: [PATCH 24/46] Add production-backed mixed UI policy and geometry contracts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Ui/MixedPolicyContracts.ps1 | 461 ++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 diff --git a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 new file mode 100644 index 00000000..e5d5d0af --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 @@ -0,0 +1,461 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath, + [switch] $ImportOnly +) +$ErrorActionPreference = 'Stop' +$script:MixedWindowsRoot = Join-Path $RepositoryRoot 'Tools\windows' +$validationAssemblyPath = $AssemblyPath +$mixedAssembly = & (Join-Path $script:MixedWindowsRoot 'Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' +foreach ($frameworkFile in @('WindowsBase.dll', 'UIAutomationTypes.dll', 'UIAutomationClient.dll')) { + $null = [Reflection.Assembly]::LoadFrom((Join-Path $PSHOME $frameworkFile)) +} +$script:MixedCases = [Collections.Generic.List[object]]::new() +$gateSource = [IO.File]::ReadAllText((Join-Path $script:MixedWindowsRoot 'uia-live-gate.ps1')) +$gateAst = $null +foreach ($productionPath in @('Stub-Daemon.ps1', 'uia-live-gate.ps1')) { + $parseErrors = $null + $productionAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $script:MixedWindowsRoot $productionPath), [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count) { throw "Production source does not parse: $productionPath" } + if ($productionPath -ceq 'uia-live-gate.ps1') { $gateAst = $productionAst } + foreach ($definition in $productionAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + ($node.Name -like '*-MultiProject*' -or $node.Name -cin @( + 'ConvertTo-SketchCanonicalJson', 'Test-RetryableUiaError', 'Test-UpdateBannerClickTarget', + 'Get-DynamicProjectCardSnapshotDecision', 'Get-ConnectorDragSnapshotDecision', + 'Get-WorkspaceTabMountDecision', 'Get-UiaSelectionEventDecision')) + }, $true)) { + # Keep the actual helper's file-backed loader rooted at its production directory. + . ([scriptblock]::Create($definition.Extent.Text.Replace('$PSScriptRoot', '$script:MixedWindowsRoot'))) + } +} +function Assert-Mixed([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw [InvalidOperationException]::new($Message) } +} +function Reject-Mixed([scriptblock] $Action, [string] $Prefix) { + try { $null = & $Action } catch { + Assert-Mixed ($_.Exception.Message.StartsWith($Prefix, [StringComparison]::Ordinal)) "Wrong rejection: $($_.Exception.Message)" + return $_ + } + throw [InvalidOperationException]::new("Expected actual $Prefix rejection") +} +function Invoke-Mixed([int] $Line, [string] $Name, [scriptblock] $Action) { + $failure = $null + try { $null = & $Action 6>&1 } catch { $failure = $_ } + $script:MixedCases.Add([ordered]@{ + line = $Line; name = $Name; passed = ($null -eq $failure) + failure = $(if ($failure) { $failure.ToString() } else { $null }) + }) +} +function Complete-Mixed([Collections.IDictionary] $Expected, [string[]] $Groups) { + $failed = $false + foreach ($entry in $Expected.GetEnumerator()) { + $line = $entry.Key + $expectedCount = $entry.Value + $observed = @($script:MixedCases | Where-Object line -EQ ([int]$line)) + $failures = @($observed | Where-Object { -not $_.passed }) + $status = if ($observed.Count -eq 0) { 'Incomplete' } + elseif ($observed.Count -ne $expectedCount -or $failures.Count) { 'Failed' } else { 'Passed' } + if ($status -cne 'Passed') { $failed = $true } + $row = [ordered]@{ + id = "ownership.mixed-ui.source-line-$line"; status = $status; executedCount = $observed.Count + evidence = (ConvertTo-Json -InputObject ([ordered]@{ + source = 'Tools/windows/Tests/ValidationRunner.Tests.ps1'; invocationLine = [int]$line + expectedExpandedCount = $expectedCount; cases = $observed + productionInputs = 'Current production gate and compiled bindings'; nativeProductCalls = 0 + }) -Depth 14 -Compress) + groups = $Groups + } + Write-Output ('OWNERSHIP_CASE_RESULT=' + ($row | ConvertTo-Json -Depth 16 -Compress)) + } + foreach ($case in $script:MixedCases) { + if (-not $Expected.Contains([int]$case.line)) { throw "Unregistered mixed invocation line: $($case.line)" } + } + if ($failed) { exit 1 } +} +function New-MixedMetadata($Before, $Identity, $After=$null, [switch]$Unavailable) { + $state=@{before=$Before;after=$(if($null -eq $After){$Before}else{$After});id=$Identity;unavailable=[bool]$Unavailable;reads=0;order=[Collections.Generic.List[string]]::new()} + $current=[pscustomobject]@{State=$state} + $current|Add-Member ScriptProperty ProcessId {$this.State.order.Add('PID');$this.State.reads++;if($this.State.reads -eq 1){$this.State.before}else{$this.State.after}} + $current|Add-Member ScriptProperty AutomationId {$this.State.order.Add('ID');if($this.State.unavailable){throw [Runtime.InteropServices.COMException]::new('controlled stale UIA metadata',[int]0x80040201)};$this.State.id} + $current|Add-Member ScriptProperty Name {$this.State.order.Add('FORBIDDEN_NAME');throw 'Foreign or unresolved Name must not be read'} + return [pscustomobject]@{Current=$current;State=$state} +} +function New-MixedCaptureState($PidValue=4242,$Identity='canvas-card-1983941480823304696') { + @{pid=$PidValue;id=$Identity;runtime=@(3,42,17);name='Alpha loop';rect=[System.Windows.Rect]::new(248,118,220,86);error=$null} +} +function New-MixedCapture($States) { + $element=[pscustomobject]@{states=$States;calls=0;order=[Collections.Generic.List[string]]::new()} + $element|Add-Member ScriptMethod Capture { + param([bool]$IncludeContent) + $state=$this.states[$this.calls];$this.calls++;$this.order.Add($(if($IncludeContent){'CONTENT_CACHE'}else{'OWNERSHIP_CACHE'})) + if($state.error){throw $state.error} + $cache=[pscustomobject]@{state=$state;order=$this.order;content=$IncludeContent} + $cache|Add-Member ScriptMethod GetCachedPropertyValue { + param($property,[bool]$IgnoreDefault) + if(-not $IgnoreDefault){throw 'Cache must request ignoreDefaultValue=true'} + if($property -eq [System.Windows.Automation.AutomationElement]::ProcessIdProperty){$this.order.Add('PID');return $this.state.pid} + if($property -eq [System.Windows.Automation.AutomationElement]::AutomationIdProperty){$this.order.Add('ID');return $this.state.id} + if(-not $this.content){throw 'Ownership cache cannot expose content'} + if($property -eq [System.Windows.Automation.AutomationElement]::BoundingRectangleProperty){$this.order.Add('BOUNDS');return $this.state.rect} + if($property -eq [System.Windows.Automation.AutomationElement]::NameProperty){$this.order.Add('NAME');return $this.state.name} + throw 'Unexpected cached property' + } + $cache|Add-Member ScriptMethod GetRuntimeId {$this.order.Add('RUNTIME');return ,$this.state.runtime} + return $cache + } + return $element +} + +if ($ImportOnly) { return } +$null = [IO.Directory]::CreateDirectory($ArtifactRoot) +$a = '11111111-1111-4111-8111-111111111111' +$b = '22222222-2222-4222-8222-222222222222' +$alpha = Join-Path $ArtifactRoot 'Alpha' +$beta = Join-Path $ArtifactRoot 'Beta' +function New-MixedTab([string] $Id, [string] $Name, [bool] $Selected) { + [ordered]@{ automationId = $Id; name = $Name; selected = $Selected } +} +$initial = New-MixedTab 'workspace-tab-initial' 'Goal' $true +$new = New-MixedTab 'workspace-tab-new' 'Shell' $true +$initialOff = New-MixedTab 'workspace-tab-initial' 'Goal' $false +$newOff = New-MixedTab 'workspace-tab-new' 'Shell' $false +Invoke-Mixed 1724 'narrow-mounted-tabs' { + $d = Get-WorkspaceTabMountDecision @($initial) @($new) @($initialOff,$new) @($initial,$newOff) @($initialOff,$new) + Assert-Mixed ($d.ready -and $d.overflowObserved -and $d.initialId -ceq 'workspace-tab-initial' -and $d.newId -ceq 'workspace-tab-new') 'Narrow mounted-tab roundtrip differs' +} +Invoke-Mixed 1733 'wide-mounted-tabs' { + $d = Get-WorkspaceTabMountDecision @($initial) @($initialOff,$new) @($initialOff,$new) @($initial,$newOff) @($initialOff,$new) + Assert-Mixed ($d.ready -and -not $d.overflowObserved) 'Wide mounted-tab roundtrip differs' +} +foreach ($test in @( + @{ name='same-id'; after=@(New-MixedTab 'workspace-tab-initial' 'Shell' $true); mounted=@($initialOff,$new); first=@($initial,$newOff); second=@($initialOff,$new); failure='new-tab' }, + @{ name='missing-mounted-initial'; after=@($new); mounted=@($new); first=@($initial,$newOff); second=@($initialOff,$new); failure='mounted-pair' }, + @{ name='renamed-mounted-initial'; after=@($new); mounted=@((New-MixedTab 'workspace-tab-initial' 'Agent' $false),$new); first=@($initial,$newOff); second=@($initialOff,$new); failure='mounted-pair' }, + @{ name='initial-switch-did-not-select'; after=@($new); mounted=@($initialOff,$new); first=@($initialOff,$new); second=@($initialOff,$new); failure='initial-switch' }, + @{ name='new-switch-changed-id'; after=@($new); mounted=@($initialOff,$new); first=@($initial,$newOff); second=@($initialOff,(New-MixedTab 'workspace-tab-replaced' 'Shell' $true)); failure='new-switch' }, + @{ name='duplicate-mounted-selection'; after=@($new); mounted=@($initial,$new); first=@($initial,$newOff); second=@($initialOff,$new); failure='mounted-pair' } +)) { + Invoke-Mixed 1749 $test.name { + $d = Get-WorkspaceTabMountDecision @($initial) @($test.after) @($test.mounted) @($test.first) @($test.second) + Assert-Mixed (-not $d.ready -and $d.failure -ceq $test.failure) 'Permanent mounted-tab failure changed' + } +} +Invoke-Mixed 1757 'stable-selection-source' { + Assert-Mixed (Get-UiaSelectionEventDecision 1 'worktree-row-safe' '42,7,9' 'worktree-row-safe' '42,7,9').ready 'Stable event rejected' +} +foreach ($test in @( + @{name='zero';count=0;id='worktree-row-safe';runtime='42,7,9';failure='zero-events'}, + @{name='duplicate';count=2;id='worktree-row-safe';runtime='42,7,9';failure='duplicate-events'}, + @{name='wrong-element';count=1;id='worktree-row-foreign';runtime='42,7,9';failure='wrong-automation-id'}, + @{name='wrong-generation';count=1;id='worktree-row-safe';runtime='42,7,10';failure='wrong-runtime-id'} +)) { + Invoke-Mixed 1769 $test.name { + $d = Get-UiaSelectionEventDecision $test.count $test.id $test.runtime 'worktree-row-safe' '42,7,9' + Assert-Mixed (-not $d.ready -and $d.failure -ceq $test.failure) 'Selection refusal changed' + } +} +Invoke-Mixed 1777 'narrow-lane-and-real-pan' { + $g = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(382,134,632,230) + Assert-Mixed (($g.band -join ',') -ceq '300,96,1822,250' -and ($g.open -join ',') -ceq '1690,106,1746,126') 'Compact Mac lane differs' + $null = Reject-Mixed { Get-MultiProjectClippedRectangle $g.open @(220,34,1028,900) } 'MULTIPROJECT_BOUNDS' + $g = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(-390,134,-140,230) + $worktrees = @(($g.band[2]-72),106,($g.band[2]-18),126) + $clipped = Get-MultiProjectClippedRectangle $worktrees @(220,34,1028,900) + Assert-Mixed (($worktrees -join ',') -ceq '978,106,1032,126' -and ($clipped -join ',') -ceq '978,106,1028,126') 'Narrow Worktrees clipping hidden' +} +Invoke-Mixed 1789 'wide-lane-and-dpi' { + $g = Get-MultiProjectLaneGeometry @(220,34,1200,900) @(382,134,632,230) + $scaled = Get-MultiProjectLaneGeometry @(330,51,1542,1350) @(573,201,948,345) + Assert-Mixed (($g.band -join ',') -ceq '300,96,1822,250' -and ($scaled.band -join ',') -ceq '450,144,2733,375' -and + ($scaled.open -join ',') -ceq '2535,159,2619,189') 'Lane width or once-only scaling differs' + $negative = Get-MultiProjectLaneGeometry @(358,215,1402,942) @(19,315,268,411) @(1032,287,1318,307) + Assert-Mixed ($negative.scale -eq 1 -and ($negative.open -join ',') -ceq '1326,287,1382,307') 'Horizontal pan corrupts anchor' + for ($i=0;$i -lt 4;$i++) { Assert-Mixed ([Math]::Abs($negative.summary[$i]-@(1032,287,1318,307)[$i]) -le 1) 'Summary exceeds one-pixel budget' } +} +Invoke-Mixed 1804 'rounded-fitted-card' { + $g = Get-MultiProjectLaneGeometry @(228,85,1036,780) @(390,185,500,227) + Assert-Mixed ($g.scale -eq 0.44 -and $g.scaleSource -ceq 'card-width-rounded' -and + [Math]::Abs($g.normalizedHeight-95.45454545454545) -lt $g.heightTolerance) 'Fitted rounding budget lost' +} +Invoke-Mixed 1811 'obsolete-or-clipped-card-refused' { + foreach ($card in @(@(262,118,482,204),@(19,210,268,306),@(19,315,268,400),@(19,315,267,411))) { + $canvas = if ($card[0] -eq 262) { @(220,34,1200,900) } else { @(358,215,1402,942) } + $null = Reject-Mixed { Get-MultiProjectLaneGeometry $canvas $card } 'MULTIPROJECT_BOUNDS' + } +} +Invoke-Mixed 1818 'obsolete-lane-floor-refused' { + $g=Get-MultiProjectLaneGeometry @(220,34,1028,900) @(382,134,632,230) + Assert-Mixed (($g.band -join ',') -cne '244,72,1004,248' -and ($g.open -join ',') -cne '872,82,928,102') 'Old floor accepted' +} +$leftQuote=[char]0x201c;$rightQuote=[char]0x201d +$exactRename="Rename $leftQuote" + "Alpha renamed$rightQuote" +Invoke-Mixed 1824 'exact-unicode-rename-title' { + $title=Get-MultiProjectRenameTitle 'Alpha renamed' + Assert-Mixed ($title -ceq $exactRename -and (Test-MultiProjectRenameTitle $title $title)) 'Exact dynamic Rename title drifted' +} +foreach($title in @('Rename Loop','Rename "Alpha renamed"',"$exactRename extra","Rename $leftQuote$rightQuote",("Rename $leftQuote"+"Alpha`nrenamed$rightQuote"))) { + Invoke-Mixed 1837 $title { Assert-Mixed (-not (Test-MultiProjectRenameTitle $title $exactRename)) 'Malformed exact Rename title accepted' } +} +Invoke-Mixed 1843 'bounded-rename-fallback' { Assert-Mixed (Test-MultiProjectRenameTitle ("Rename $leftQuote"+"Owned loop$rightQuote")) 'Valid bounded fallback rejected' } +foreach($title in @('Rename Loop',"Rename $leftQuote$rightQuote",("Rename $leftQuote"+"nested $leftQuote"+"quote$rightQuote$rightQuote"),("Rename $leftQuote"+('x'*513)+$rightQuote))) { + Invoke-Mixed 1854 "malformed-length-$($title.Length)" { Assert-Mixed (-not (Test-MultiProjectRenameTitle $title)) 'Bounded Rename fallback widened' } +} +$sheet=[ordered]@{processId=4242;handle=[long]42;owner=[long]7;className='GraphCodeSheetHost';title=("Delete "+$leftQuote+'Alpha renamed'+$rightQuote+'?');contentClass='GraphCodeWindowsDialogContent';visible=$true;captionless=$true} +Invoke-Mixed 1870 'owned-captionless-delete-sheet' { + Assert-Mixed (Test-MultiProjectOwnedSheetIdentity $sheet 4242 ([IntPtr]7) $sheet.title 'GraphCodeWindowsDialogContent') 'Exact owned sheet rejected' +} +foreach($mutation in @('pid','owner','class','title','content','hidden','captioned','zero-handle')) { + Invoke-Mixed 1879 $mutation { + $candidate=[ordered]@{};foreach($key in $sheet.Keys){$candidate[$key]=$sheet[$key]} + switch($mutation){pid{$candidate.processId=7777} owner{$candidate.owner=[long]8} class{$candidate.className='#32770'} title{$candidate.title='Delete Loop'} + content{$candidate.contentClass='GraphCodeNativeForm'} hidden{$candidate.visible=$false} captioned{$candidate.captionless=$false} zero-handle{$candidate.handle=[long]0}} + Assert-Mixed (-not (Test-MultiProjectOwnedSheetIdentity $candidate 4242 ([IntPtr]7) $sheet.title 'GraphCodeWindowsDialogContent')) 'Invalid sheet identity accepted' + } +} +$banner=[ordered]@{processId=4242;automationId='sidebar-update-banner-83fd6a5f429785e1';name='Update available';controlType='ControlType.Button';enabled=$true;left=208.0;top=704.0;right=412.0;bottom=754.0} +Invoke-Mixed 1917 'owned-banner-after-Later' { Assert-Mixed (Test-UpdateBannerClickTarget $banner 4242 ([IntPtr]42) ([IntPtr]42) 4242 'Update offer deferred') 'Owned update banner rejected' } +foreach($mutation in @('process','window','focus','status')) { + Invoke-Mixed 1924 $mutation { + $candidate=[ordered]@{};foreach($key in $banner.Keys){$candidate[$key]=$banner[$key]} + $foreground=[IntPtr]42;$focused=4242;$status='Update offer deferred' + switch($mutation){process{$candidate.processId=7777} window{$foreground=[IntPtr]43} focus{$focused=7777} status{$status='Update available'}} + Assert-Mixed (-not (Test-UpdateBannerClickTarget $candidate 4242 ([IntPtr]42) $foreground $focused $status)) 'Foreign or stale banner accepted' + } +} +Invoke-Mixed 1951 'actual-caption-assignment' { + $assignments=@($gateAst.FindAll({param($n)$n -is [Management.Automation.Language.AssignmentStatementAst] -and $n.Left -is [Management.Automation.Language.VariableExpressionAst] -and $n.Left.VariablePath.UserPath -ceq 'laneOpenScreenX'},$true)) + $obsolete=@($gateAst.FindAll({param($n)$n -is [Management.Automation.Language.AssignmentStatementAst] -and $n.Left -is [Management.Automation.Language.VariableExpressionAst] -and $n.Left.VariablePath.UserPath -ceq 'laneWorktreesScreenX'},$true)) + Assert-Mixed ($assignments.Count -eq 1 -and $obsolete.Count -eq 0) 'Caption assignment missing/ambiguous or obsolete click retained' + $assignment=[scriptblock]::Create('param($laneSnapshot) '+$assignments[0].Extent.Text+';return $laneOpenScreenX') + foreach($x in @(614,866,1013)){Assert-Mixed ((& $assignment ([pscustomobject]@{OpenX=$x})) -eq $x) 'Actual caption assignment ignored exported snapshot'} +} + +# Remaining policy families use literal source-derived identities and geometry, +# never a local copy of the production predicates. +$workspaceOwner=@{path='A';node=$a;name='Alpha';title='Alpha loop'} +$rosterOwners=@($workspaceOwner,@{path='B';node=$b;name='Beta';title='Beta loop'}) +function New-MixedWorkspace { + [ordered]@{loopBars=@([ordered]@{automationId='workspace-loop-bar-1320898360543276851';name='Selected loop workspace';processId=4242;bounds=@(220,34,1200,80)}) + toolbars=@([ordered]@{automationId='workspace-toolbar-2086745613872223135';name='Alpha';processId=4242;bounds=@(8,1,280,33)});foreignWorkspaceFragmentCount=0} +} +function New-MixedRoster { + [ordered]@{ + projectRows=@([ordered]@{automationId='open-project-1737218920896369762';name='Alpha';processId=4242;bounds=@(12,100,232,126)}, + [ordered]@{automationId='open-project-1737217821384741551';name='Beta';processId=4242;bounds=@(12,148,232,174)}) + cards=@([ordered]@{automationId='canvas-card-1331915895917204893';name='Alpha, 1 loop';processId=4242;bounds=@(318,106,1804,126)}, + [ordered]@{automationId='canvas-card-1929002346198335884';name='Alpha loop';processId=4242;bounds=@(382,134,632,230)}, + [ordered]@{automationId='canvas-card-1331912597382320260';name='Beta, 1 loop';processId=4242;bounds=@(318,284,1804,304)}, + [ordered]@{automationId='canvas-card-1616358459734844849';name='Beta loop';processId=4242;bounds=@(382,312,632,408)}) + foreignCardFragmentCount=0;foreignProjectFragmentCount=0;canvasBounds=@(220,34,1200,900) + } +} +Invoke-Mixed 3392 'owner-surface-hash' { + $ids=@((Get-MultiProjectAutomationId loop $alpha $a),(Get-MultiProjectAutomationId project-card $alpha $a), + (Get-MultiProjectAutomationId overview-card $alpha $a),(Get-MultiProjectAutomationId overview-card $beta $b)) + Assert-Mixed (@($ids|Sort-Object -Unique).Count -eq 4 -and (Get-MultiProjectAutomationId project-card 'graphcode://stub/project' $a) -ceq 'canvas-card-1510499067760483540') 'Owner/surface hashes or FNV golden differ' +} +$retainedAlpha='D:\a\_temp\gu-239af6fb60ce40b5b2846baf38b9a9d7\mp\Alpha' +$retainedBeta='D:\a\_temp\gu-239af6fb60ce40b5b2846baf38b9a9d7\mp\Beta' +$retainedOwners=@(@{path=$retainedAlpha;name='Alpha';node=$a;title='Alpha loop'},@{path=$retainedBeta;name='Beta';node=$b;title='Beta loop'}) +Invoke-Mixed 3423 'retained-identities-current-compact-geometry' { + $p=[ordered]@{projectRows=@( + [ordered]@{automationId='open-project-1729193928419568695';name='Alpha';processId=3140;bounds=@(20,222,240,248)}, + [ordered]@{automationId='open-project-2225278778924952707';name='Beta';processId=3140;bounds=@(20,308,240,334)}) + cards=@([ordered]@{automationId=Get-MultiProjectAutomationId overview-project $retainedAlpha;name='Alpha, 1 loop';processId=3140;bounds=@(246,133,1754,153)}, + [ordered]@{automationId='canvas-card-1662987038540838399';name='Alpha loop';processId=3140;bounds=@(270,169,490,255)}, + [ordered]@{automationId=Get-MultiProjectAutomationId overview-project $retainedBeta;name='Beta, 1 loop';processId=3140;bounds=@(246,329,1754,349)}, + [ordered]@{automationId='canvas-card-1884655564485847249';name='Beta loop';processId=3140;bounds=@(270,365,490,451)}) + foreignCardFragmentCount=0;foreignProjectFragmentCount=0;canvasBounds=@(228,85,1036,780)} + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $retainedOwners $null 3140)) 'Historical dimensions accepted' + $compact=Copy-MultiProjectValue $p + $compact.cards[0].bounds=@(326,157,1812,177);$compact.cards[1].bounds=@(390,185,640,281);$compact.cards[2].bounds=@(326,335,1812,355);$compact.cards[3].bounds=@(390,363,640,459) + Assert-Mixed (Test-MultiProjectObservedRoster overview $compact $retainedOwners $null 3140) 'Current compact roster rejected' + $p.cards[0].bounds=@(37,308,1226,324);$p.cards[1].bounds=@(88,330,288,406);$p.cards[2].bounds=@(37,450,1226,466);$p.cards[3].bounds=@(88,472,288,548) + Assert-Mixed (Test-MultiProjectObservedRoster overview $p $retainedOwners $null 3140) '80-percent rounded roster rejected' +} +Invoke-Mixed 3443 'caption-and-notice-hash-goldens' { + foreach($g in @(@('overview-project','A','1331915895917204893'),@('overview-project','B','1331912597382320260'), + @('overview-worktree-notice','A','1294898434078301384'),@('overview-worktree-notice','B','1294901732613186017'), + @('overview-worktree-notice',$retainedAlpha,'1669006899659070917'),@('overview-worktree-notice',$retainedBeta,'1518601899186967385'))) { + Assert-Mixed ((Get-MultiProjectAutomationId $g[0] $g[1]) -ceq ('canvas-card-'+$g[2])) 'Caption/notice golden differs' + } +} +Invoke-Mixed 3458 'workspace-prefix-hash-goldens' { + foreach($g in @(@('workspace-loop-bar',$a,'1320898360543276851'),@('workspace-loop-bar',$b,'2044737300313126929'), + @('workspace-toolbar','A','2086745613872223135'),@('workspace-toolbar','B','2086746713383851346'))) { + Assert-Mixed ((Get-MultiProjectAutomationId $g[0] $g[1]) -ceq ($g[0]+'-'+$g[2])) 'Workspace prefix/hash golden differs' + } +} +Invoke-Mixed 3469 'unknown-kind-refused' {$null=Reject-Mixed {Get-MultiProjectAutomationId unsupported-marker A $a} 'MULTIPROJECT_KIND'} +Invoke-Mixed 3482 'owned-workspace-markers' {Assert-Mixed (Test-MultiProjectObservedSurface workspace $true (New-MixedWorkspace) $workspaceOwner 4242) 'Exact workspace rejected'} +Invoke-Mixed 3487 'restart-is-not-owner-marker' { + $metadata=Get-MultiProjectFragmentMetadata (New-MixedMetadata 4242 'workspace-restart-session-2140902926206787169') 4242 + Assert-Mixed ($metadata.observationReady -and -not (Test-MultiProjectObservedSurface workspace $true ([ordered]@{loopBars=@();toolbars=@();foreignWorkspaceFragmentCount=0}) $workspaceOwner 4242)) 'Restart chrome replaced owner markers' +} +foreach($mutation in @('project-only','missing-loop','missing-toolbar','wrong-prefix','wrong-node','wrong-project','wrong-pid','pid-type','loop-name','toolbar-name','loop-duplicate','toolbar-duplicate','bounds-empty','bounds-type','bounds-zero','selection-unmatched','foreign-marker-count')) { + Invoke-Mixed 3497 $mutation { + $p=New-MixedWorkspace;$selected=$true + switch($mutation){ + project-only{$p.loopBars=@();$p.toolbars=@()} missing-loop{$p.loopBars=@()} missing-toolbar{$p.toolbars=@()} + wrong-prefix{$p.loopBars[0].automationId='canvas-card-1320898360543276851'} wrong-node{$p.loopBars[0].automationId='workspace-loop-bar-2044737300313126929'} + wrong-project{$p.toolbars[0].automationId='workspace-toolbar-2086746713383851346'} wrong-pid{$p.toolbars[0].processId=7777} pid-type{$p.loopBars[0].processId='4242'} + loop-name{$p.loopBars[0].name='Alpha loop'} toolbar-name{$p.toolbars[0].name='Alpha loop'} loop-duplicate{$p.loopBars=@($p.loopBars[0],$p.loopBars[0])} + toolbar-duplicate{$p.toolbars=@($p.toolbars[0],$p.toolbars[0])} bounds-empty{$p.loopBars[0].bounds=@()} bounds-type{$p.toolbars[0].bounds[0]='8'} + bounds-zero{$p.loopBars[0].bounds=@(220,34,220,80)} selection-unmatched{$selected=$false} foreign-marker-count{$p.foreignWorkspaceFragmentCount=1}} + Assert-Mixed (-not (Test-MultiProjectObservedSurface workspace $selected $p $workspaceOwner 4242)) 'Wrong workspace marker projection accepted' + } +} +Invoke-Mixed 3524 'project-refuses-workspace-chrome' {Assert-Mixed (-not (Test-MultiProjectObservedSurface project $true (New-MixedWorkspace) $workspaceOwner 4242)) 'Project accepted workspace chrome'} +foreach($test in @( + @{line=3530;surface='project';owner=0;project=$true;loop=$true;expected=$true}, + @{line=3535;surface='project';owner=1;project=$false;loop=$false;expected=$true}, + @{line=3540;surface='workspace';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3545;surface='overview';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3550;surface='quick_chats';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3555;surface='workspace';owner=0;project=$true;loop=$true;expected=$false}, + @{line=3560;surface='project';owner=0;project=$false;loop=$true;expected=$false}, + @{line=3565;surface='overview';owner=0;project=$true;loop=$true;expected=$false}, + @{line=3570;surface='quick_chats';owner=0;project=$true;loop=$true;expected=$false}, + @{line=3575;surface='workspace';owner=0;project=$false;loop=$false;expected=$false} +)) { + Invoke-Mixed $test.line $test.surface {Assert-Mixed ((Test-MultiProjectOwnerSelection $test.surface $rosterOwners[$test.owner] $workspaceOwner $test.project $test.loop) -eq $test.expected) 'Owner/loop selection boundary changed'} +} +Invoke-Mixed 3596 'complete-two-owner-four-fragment-roster' {Assert-Mixed (Test-MultiProjectObservedRoster overview (New-MixedRoster) $rosterOwners $null 4242) 'Complete roster rejected'} +Invoke-Mixed 3601 'obsolete-node-dimensions-refused' { + $p=New-MixedRoster;$p.cards[1].bounds=@(262,118,482,204) + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Historical dimensions accepted' +} +Invoke-Mixed 3608 'obsolete-caption-positions-refused' { + $p=New-MixedRoster;$p.canvasBounds=@(220,34,1028,900) + foreach($positions in @(@(@(578,82,864,102),@(578,278,864,298)),@(@(624,82,910,102),@(624,278,910,298)))) { + $p.cards[0].bounds=$positions[0];$p.cards[2].bounds=$positions[1] + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Obsolete caption grid accepted' + } + $p.cards[0].bounds=@(318,106,1804,126);$p.cards[2].bounds=@(318,284,1804,304) + Assert-Mixed (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242) 'Producer-derived captions rejected' +} + +# Metadata and cached-content fixtures are deliberate data transports. Their +# methods contain no ownership/geometry policy or native UI invocation. +$captureReader={param($element,[bool]$content)$element.Capture($content)} +$capturePrior=@{ownership='owned';finalPID=@{value=4242};automationID=@{value='canvas-card-1983941480823304696'}} +Invoke-Mixed 3625 'fresh-owned-content-does-not-hide-missing-peer' { + $mock=New-MixedCapture @((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $e=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/missing-peer' $captureReader + $p=New-MixedRoster;$p.cards=@($p.cards[0],$p.cards[1],$p.cards[2]) + Assert-Mixed ($e.processId -eq 4242 -and -not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Fresh ownership hid missing peer' +} +Invoke-Mixed 3635 'selected-project-roster-and-physical-connector-snapshots' { + $p=New-MixedRoster;$p.cards=@([ordered]@{automationId='canvas-card-1983941480823304696';name='Alpha loop';processId=4242;bounds=@(248,118,468,204)}) + Assert-Mixed (Test-MultiProjectObservedRoster project $p $rosterOwners $workspaceOwner 4242) 'Project roster rejected' + Assert-Mixed (Test-MultiProjectObservedSurface project $true ([ordered]@{loopBars=@();toolbars=@();foreignWorkspaceFragmentCount=0}) $workspaceOwner 4242) 'Project-only marker shape rejected' + $expected=@([ordered]@{automationId='canvas-card-1983941480823304696';name='UIA loop A'},[ordered]@{automationId='canvas-card-1983940381311676485';name='UIA loop B'}) + $dynamic=@([ordered]@{automationId=$expected[0].automationId;name=$expected[0].name;processId=4242;bounds=@(496,236,936,408)}, + [ordered]@{automationId=$expected[1].automationId;name=$expected[1].name;processId=4242;bounds=@(976,236,1416,408)}) + $graph=@(440,68,2400,1300) + Assert-Mixed (Get-DynamicProjectCardSnapshotDecision project $dynamic $expected 4242 $graph).ready 'DPI192 project snapshot rejected' + $empty=Copy-MultiProjectValue $dynamic;$empty[1].bounds=@(0,0,0,0) + $d=Get-DynamicProjectCardSnapshotDecision project $empty $expected 4242 $graph + Assert-Mixed (-not $d.ready -and $d.failure -ceq 'bounds-empty' -and $d.cards[1].automationId -ceq $expected[1].automationId) 'Empty bounds did not fail exactly' + foreach($mutation in @('missing','foreign','duplicate','order','wrong-surface','outside-viewport','overlap')) { + $candidate=Copy-MultiProjectValue $dynamic;$surface='project' + switch($mutation){missing{$candidate=@($candidate[0])} foreign{$candidate[1].processId=7777} duplicate{$candidate[1].automationId=$candidate[0].automationId} + order{$candidate=@($candidate[1],$candidate[0])} wrong-surface{$surface='overview'} outside-viewport{$candidate[1].bounds=@(2600,236,3040,408)} overlap{$candidate[1].bounds=@(700,236,1140,408)}} + Assert-Mixed (-not (Get-DynamicProjectCardSnapshotDecision $surface $candidate $expected 4242 $graph).ready) "Dynamic snapshot accepted $mutation" + } + $connectorGraph=@(600,312,1414,858);$client=@(147,196,1427,967) + $fitted=@([ordered]@{automationId=$expected[0].automationId;name=$expected[0].name;processId=4242;bounds=@(600,552,790,630)}, + [ordered]@{automationId=$expected[1].automationId;name=$expected[1].name;processId=4242;bounds=@(908,552,1114,630)}) + $d=Get-ConnectorDragSnapshotDecision $fitted $expected 4242 $connectorGraph $client $false + Assert-Mixed ($d.ready -and ($d.sourcePoint -join ',') -ceq '790,591' -and ($d.targetPoint -join ',') -ceq '1011,591' -and + $d.targetCenterVisible -and $d.sourceCardId -ceq $expected[0].automationId -and $d.targetCardId -ceq $expected[1].automationId) 'Fitted physical connector plan differs' + $cutoff=Copy-MultiProjectValue $fitted;$cutoff[0].bounds=@(488,365,988,557);$cutoff[1].bounds=@(1264,365,1764,557) + $d=Get-ConnectorDragSnapshotDecision $cutoff $expected 4242 $connectorGraph $client $false + Assert-Mixed ($d.ready -and ($d.targetCenter -join ',') -ceq '1514,461' -and -not $d.targetCenterVisible -and + ($d.targetPoint -join ',') -ceq '1339,461') 'Actual Size cutoff did not use visible card intersection' + foreach($mutation in @('wrong-connector','wrong-target','wrong-process','logical-card-space','logical-client-space','preexisting-dialog')) { + $candidate=Copy-MultiProjectValue $fitted;$candidateClient=@($client);$dialog=$false + switch($mutation){wrong-connector{$candidate[0].automationId='canvas-card-wrong-source'} wrong-target{$candidate[1].name='Wrong target'} + wrong-process{$candidate[1].processId=7777} logical-card-space{$candidate[0].bounds=@(248,118,468,204);$candidate[1].bounds=@(488,118,708,204)} + logical-client-space{$candidateClient=@(0,0,640,410)} preexisting-dialog{$dialog=$true}} + Assert-Mixed (-not (Get-ConnectorDragSnapshotDecision $candidate $expected 4242 $connectorGraph $candidateClient $dialog).ready) "Connector accepted $mutation" + } +} +foreach($mutation in @('extra-card','extra-project','duplicate-card','duplicate-project','foreign-card-pid','foreign-project-pid','foreign-card-count','foreign-project-count')) { + Invoke-Mixed 3732 $mutation { + $p=New-MixedRoster + switch($mutation){ + extra-card{$p.cards += [ordered]@{automationId='canvas-card-1999999999999999999';name='Unexpected cached loop';processId=4242;bounds=@(508,118,728,204)}} + extra-project{$p.projectRows += [ordered]@{automationId='open-project-1999999999999999999';name='Unexpected project';processId=4242;bounds=@(12,196,232,222)}} + duplicate-card{$p.cards += $p.cards[0]} duplicate-project{$p.projectRows += $p.projectRows[0]} + foreign-card-pid{$p.cards[0].processId=7777} foreign-project-pid{$p.projectRows[0].processId=7777} + foreign-card-count{$p.foreignCardFragmentCount=1} foreign-project-count{$p.foreignProjectFragmentCount=1}} + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Extra/duplicate/foreign roster fragment hidden' + } +} +Invoke-Mixed 3755 'four-fragments-two-nodes-two-captions' { + $expected=Get-MultiProjectExpectedCanvasRoster overview $rosterOwners $null + Assert-Mixed ($expected.Count -eq 4 -and @($expected|Where-Object classification -CEQ node).Count -eq 2 -and + @($expected|Where-Object classification -CEQ project-caption).Count -eq 2 -and $expected[0].projectPath -ceq 'A' -and + $null -eq $expected[0].nodeID -and $expected[0].identityKind -ceq 'overview-project') 'Complete caption/node taxonomy collapsed' +} +Invoke-Mixed 3763 'caption-geometry-separate-from-node' { + $g=Get-MultiProjectCaptionGeometry @(228,85,1036,780) @(390,185,640,281) @(326,157,1812,177) + Assert-Mixed (($g.caption -join ',') -ceq '326,157,1812,177') 'Source project caption differs' +} +foreach($mutation in @('two-nodes-unknown','unknown-caption','duplicate-caption','wrong-caption-owner','wrong-caption-pid','wrong-caption-name','wrong-caption-bounds','caption-as-node-bounds','hidden-chrome')) { + Invoke-Mixed 3771 $mutation { + $p=New-MixedRoster + switch($mutation){ + two-nodes-unknown{$p.cards=@($p.cards[1],$p.cards[3],[ordered]@{automationId='canvas-card-1999999999999999999';name='Unknown loop';processId=4242;bounds=@(508,118,728,204)})} + unknown-caption{$p.cards[0].automationId='canvas-card-1999999999999999999'} duplicate-caption{$p.cards += $p.cards[0]} + wrong-caption-owner{$p.cards[0].automationId='canvas-card-1331912597382320260'} wrong-caption-pid{$p.cards[0].processId=7777} + wrong-caption-name{$p.cards[0].name='Last inspected'} wrong-caption-bounds{$p.cards[0].bounds=@(750,278,1036,298)} + caption-as-node-bounds{$p.cards[1].bounds=$p.cards[0].bounds} hidden-chrome{$p.cards=@($p.cards[1],$p.cards[3])}} + Assert-Mixed (-not (Test-MultiProjectObservedRoster overview $p $rosterOwners $null 4242)) 'Classified roster hid unknown/wrong-owner/caption fragments' + } +} +Invoke-Mixed 3792 'literal-lane-and-once-scaled-dpi' { + $g=Get-MultiProjectLaneGeometry @(220,34,1200,900) @(382,134,632,230) + $scaled=Get-MultiProjectLaneGeometry @(330,51,1800,1350) @(573,201,948,345) + Assert-Mixed (($g.band -join ',') -ceq '300,96,1822,250' -and ($g.open -join ',') -ceq '1690,106,1746,126' -and + ($scaled.open -join ',') -ceq '2535,159,2619,189') 'Literal lane bounds or DPI scaling differs' +} +$overviewIds=@('overview-card:fixture:a','overview-card:fixture:b') +$projectIds=@('project-card:fixture:a','project-card:fixture:b') +$laneNames=@('UIA loop A','UIA loop B') +function New-MixedLaneCards([string[]]$Ids,[double[]]$First,[double[]]$Second) { + @([pscustomobject]@{automationId=$Ids[0];name=$laneNames[0];bounds=$First},[pscustomobject]@{automationId=$Ids[1];name=$laneNames[1];bounds=$Second}) +} +Invoke-Mixed 3816 'DPI192-fitted-destination-normalization' { + $overview=Get-MultiProjectNormalizedLaneGeometry (New-MixedLaneCards $overviewIds @(100,100,290,178) @(408,100,614,178)) $overviewIds $laneNames @(100,0,1200,900) 250 96 + $project=Get-MultiProjectNormalizedLaneGeometry (New-MixedLaneCards $projectIds @(120,100,324,184) @(450,100,670,184)) $projectIds $laneNames @(120,0,1200,900) 250 96 + $comparison=Compare-MultiProjectNormalizedLaneGeometry $project $overview + Assert-Mixed (($overview.ObservedPixelWidths -join ',') -ceq '190,206' -and ($overview.ObservedPixelHeights -join ',') -ceq '78,78' -and + $overview.HorizontalClipping[0] -ceq 'left' -and $overview.HorizontalClipping[1] -ceq 'none' -and $comparison.equal) 'Fitted lane source geometry lost' +} +foreach($mutation in @('wrong-source-width','wrong-source-height','wrong-observed-width','wrong-observed-height','wrong-order','overlap','foreign','missing','outside-viewport','both-clipped','no-full-peer')) { + Invoke-Mixed 3846 $mutation { + $ids=@($overviewIds);$width=250;$height=96;$first=@(100,100,290,178);$second=@(350,100,540,178);$viewport=@(0,0,1200,900) + switch($mutation){ + wrong-source-width{$width=249} wrong-source-height{$height=95} wrong-observed-width{$second=@(350,100,520,178)} + wrong-observed-height{$second=@(350,100,540,160)} wrong-order{$ids=@($overviewIds[1],$overviewIds[0])} + overlap{$second=@(250,100,440,178)} foreign{$ids=@($overviewIds[0],'overview-card:foreign:b')} + outside-viewport{$first=@(-1,100,189,178)} + both-clipped{$first=@(0,100,190,178);$second=@(306,100,500,178);$viewport=@(0,0,500,900)} + no-full-peer{$first=@(0,100,206,178);$second=@(294,100,500,178);$viewport=@(0,0,500,900)}} + $cards=if($mutation -ceq 'missing'){@([pscustomobject]@{automationId=$overviewIds[0];name=$laneNames[0];bounds=$first})}else{New-MixedLaneCards $ids $first $second} + $null=Reject-Mixed {Get-MultiProjectNormalizedLaneGeometry $cards $overviewIds $laneNames $viewport $width $height} 'MULTIPROJECT_BOUNDS' + } +} +$expected=[ordered]@{ + 1724=1;1733=1;1749=6;1757=1;1769=4;1777=1;1789=1;1804=1;1811=1;1818=1;1824=1;1837=5;1843=1;1854=4;1870=1;1879=8;1917=1;1924=4;1951=1 + 3392=1;3423=1;3443=1;3458=1;3469=1;3482=1;3487=1;3497=17;3524=1;3530=1;3535=1;3540=1;3545=1;3550=1;3555=1;3560=1;3565=1;3570=1;3575=1 + 3596=1;3601=1;3608=1;3625=1;3635=1;3732=8;3755=1;3763=1;3771=9;3792=1;3816=1;3846=11 +} +Complete-Mixed $expected @('mixed-ui','geometry-selection-roster','source-correspondence') From 04f5009621c42620aa1ec0bdf406c8851b49c148 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:26:11 -0700 Subject: [PATCH 25/46] Emit byte-stable UTF-8 mixed UI case protocol Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 index e5d5d0af..20d16be2 100644 --- a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 @@ -5,6 +5,8 @@ param( [switch] $ImportOnly ) $ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$OutputEncoding = [Text.UTF8Encoding]::new($false) $script:MixedWindowsRoot = Join-Path $RepositoryRoot 'Tools\windows' $validationAssemblyPath = $AssemblyPath $mixedAssembly = & (Join-Path $script:MixedWindowsRoot 'Import-CompiledValidationAssembly.ps1') ` @@ -67,10 +69,13 @@ function Complete-Mixed([Collections.IDictionary] $Expected, [string[]] $Groups) source = 'Tools/windows/Tests/ValidationRunner.Tests.ps1'; invocationLine = [int]$line expectedExpandedCount = $expectedCount; cases = $observed productionInputs = 'Current production gate and compiled bindings'; nativeProductCalls = 0 - }) -Depth 14 -Compress) + }) -Depth 14 -Compress -EscapeHandling EscapeNonAscii) groups = $Groups } - Write-Output ('OWNERSHIP_CASE_RESULT=' + ($row | ConvertTo-Json -Depth 16 -Compress)) + $json = $row | ConvertTo-Json -Depth 16 -Compress -EscapeHandling EscapeNonAscii + $document = [System.Text.Json.JsonDocument]::Parse([string]$json) + $document.Dispose() + Write-Output ('OWNERSHIP_CASE_RESULT=' + $json) } foreach ($case in $script:MixedCases) { if (-not $Expected.Contains([int]$case.line)) { throw "Unregistered mixed invocation line: $($case.line)" } From 1e07958a77e35a59f44dc4f620da071170fedf3d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:28:46 -0700 Subject: [PATCH 26/46] Exercise actual mixed native edit Rename and metadata policies with inert transports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Ui/MixedNativeContracts.ps1 | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 diff --git a/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 new file mode 100644 index 00000000..31836d05 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 @@ -0,0 +1,514 @@ +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) +. (Join-Path $PSScriptRoot 'MixedPolicyContracts.ps1') -RepositoryRoot $RepositoryRoot ` + -ArtifactRoot $ArtifactRoot -AssemblyPath $AssemblyPath -ImportOnly +$renameClick = (New-MultiProjectRenameNativeApi).PSObject.Methods['Click'].Script +$focusMouse = (New-MultiProjectEditNativeApi).PSObject.Methods['FocusMouse'].Script +Assert-Mixed ($focusMouse.ToString().Contains('$this.FocusControl($modal,$edit)') -and + $focusMouse.ToString() -notmatch '\[GraphCode[^\]]*\]::' -and + $renameClick.ToString() -notmatch '\[GraphCode[^\]]*\]::') ` + 'Production policy contains a non-injectable native operation; no mixed fake payload may execute' +function New-MixedRename { + $controls=@{} + foreach($id in @(9800,9808,9904)){ + $controls["$id"]=@{handle=[IntPtr]$id;processId=4242;id=$id;root=[IntPtr]42;visible=$true;enabled=$true;bounds=@(300,300,380,328)} + } + $controls['9904'].bounds=@(30,100,500,124) + $api=[pscustomobject]@{ + state=@{modalPID=4242;title=("Rename "+[char]0x201c+'Alpha renamed'+[char]0x201d);visible=$true;enabled=$true + client=@(10,40,600,400);work=@(0,0,1920,1080);foreground=$true;buffer=' Alpha renamed ';secondBuffer=' Alpha renamed ' + controls=$controls;hitRoot=[IntPtr]42;hitPID=4242;hitChild=[IntPtr]9800;late=$null;inputSent=2;inputExpected=2} + inputCalls=0;bufferReads=0;hitReads=0;ExpectedTitle='';order=[Collections.Generic.List[string]]::new();requested=[Collections.Generic.List[int]]::new() + } + $api|Add-Member ScriptMethod ProcessId {param($window)$this.order.Add("PID:$window");if($window -eq [IntPtr]42){return $this.state.modalPID};return $this.state.controls["$window"].processId} + $api|Add-Member ScriptMethod Title {param($window)$this.order.Add("TITLE:$window");return $this.state.title} + $api|Add-Member ScriptMethod Visible {param($window)if($window -eq [IntPtr]42){return $this.state.visible};return $this.state.controls["$window"].visible} + $api|Add-Member ScriptMethod Enabled {param($window)if($window -eq [IntPtr]42){return $this.state.enabled};return $this.state.controls["$window"].enabled} + $api|Add-Member ScriptMethod Root {param($window)return $this.state.controls["$window"].root} + $api|Add-Member ScriptMethod Control { + param($window,$id)$this.requested.Add($id);$this.order.Add("CONTROL:$id") + if(-not $this.state.controls.ContainsKey("$id")){return [IntPtr]::Zero};return $this.state.controls["$id"].handle + } + $api|Add-Member ScriptMethod ControlId {param($window)return $this.state.controls["$window"].id} + $api|Add-Member ScriptMethod Bounds {param($window)return $this.state.controls["$window"].bounds} + $api|Add-Member ScriptMethod ClientBounds { + param($window)if($this.state.ContainsKey('staleClient') -and $this.state.staleClient){throw [ComponentModel.Win32Exception]::new(6,'controlled stale native client')};return $this.state.client + } + $api|Add-Member ScriptMethod WorkBounds {param($window)return $this.state.work} + $api|Add-Member ScriptMethod Foreground {param($window)return $this.state.foreground} + $api|Add-Member ScriptMethod Buffer {param($window,$id)$this.order.Add("BUFFER:$id");$this.bufferReads++;if($this.bufferReads -eq 1){return $this.state.buffer};return $this.state.secondBuffer} + $api|Add-Member ScriptMethod Pause {if($this.state.late){& $this.state.late $this.state}} + $api|Add-Member ScriptMethod Hit { + param($window,$x,$y)$this.hitReads++ + if($this.state.ContainsKey('finalDrift') -and $this.hitReads -eq 2){& $this.state.finalDrift $this.state} + $child=if($this.state.ContainsKey('coveredFirst') -and $this.state.coveredFirst -and $this.hitReads -eq 1){[IntPtr]9904}else{$this.state.hitChild} + return @{root=$this.state.hitRoot;processId=$this.state.hitPID;child=$child} + } + $api|Add-Member ScriptMethod SendMouse { + param($window,$point)$this.order.Add('INPUT:mouse');$this.inputCalls++ + return ,@($point[0],$point[1],($point[0]+1),($point[1]+1),$point[0],$point[1],$this.state.inputSent,$this.state.inputExpected) + } + $api|Add-Member ScriptMethod Click $renameClick + return $api +} +function New-MixedEdit { + $api=New-MixedRename + $api|Add-Member NoteProperty Now ([long]0) + $api|Add-Member NoteProperty LastFocusControlFallback $false + $api|Add-Member NoteProperty LastFocusPostedFallback $false + $api|Add-Member NoteProperty PhaseState @{ + buffer='Alpha renamed';selection=@(0,0);focus=[IntPtr]::Zero;clearCalls=0;unicodeCalls=0;deleteProcessed=$false + clearPending=$false;observes=0;deleteDelay=3;focusClicks=0;focusPending=$false;focusObserves=0;focusDelay=1;focusSent=2 + neverFocus=$false;neverClear=$false;finalWrong=$false;clearSent=2;unicodeShort=$false;prefillReads=0;emptyReads=0;rebound=$false} + $api.state.hitChild=[IntPtr]9904 + $api|Add-Member NoteProperty PhaseOrder ([Collections.Generic.List[string]]::new()) + $api|Add-Member ScriptMethod Clock {return $this.Now} + $api|Add-Member ScriptMethod FocusInsertion { + param($modal,$point)$this.PhaseOrder.Add('focus-insert');$this.PhaseState.focusClicks++;$this.PhaseState.focusPending=$true + return ,@($point[0],$point[1],($point[0]+1),($point[1]+1),$point[0],$point[1],$this.PhaseState.focusSent,2) + } + $api|Add-Member ScriptMethod FocusMouse $focusMouse + $api|Add-Member ScriptMethod FocusControl { + param($modal,$edit) + if(-not $this.PhaseState.ContainsKey('focusControlCalls')){$this.PhaseState.focusControlCalls=0} + $this.PhaseState.focusControlCalls++ + if($this.PhaseState.ContainsKey('focusControlError')){throw $this.PhaseState.focusControlError} + if($this.PhaseState.ContainsKey('focusControlResult')){return $this.PhaseState.focusControlResult} + return $true + } + $api|Add-Member ScriptMethod FocusHandle {param($modal)return $this.PhaseState.focus} + $api|Add-Member ScriptMethod EditBuffer { + param($edit,$remaining)$this.PhaseState.prefillReads++ + if($this.PhaseState.buffer -ceq ''){$this.PhaseState.emptyReads++;if($this.PhaseState.rebound -and $this.PhaseState.emptyReads -eq 4){$this.PhaseState.buffer='Rebound'}} + return $this.PhaseState.buffer + } + $api|Add-Member ScriptMethod Selection {param($edit,$remaining)return ,$this.PhaseState.selection} + $api|Add-Member ScriptMethod SelectAll { + param($edit,$remaining)$this.PhaseOrder.Add('select') + $this.PhaseState.selection=if($this.PhaseState.ContainsKey('badSelection')){$this.PhaseState.badSelection}else{@(0,$this.PhaseState.buffer.Length)} + } + $api|Add-Member ScriptMethod Delete {$this.PhaseOrder.Add('clear-enqueue');$this.PhaseState.clearCalls++;$this.PhaseState.clearPending=$true;return ,@($this.PhaseState.clearSent,2)} + $api|Add-Member ScriptMethod Unicode { + param($text) + if(-not $this.PhaseState.deleteProcessed -or $this.PhaseState.clearPending -or $this.PhaseState.buffer -cne '' -or $this.PhaseState.clearCalls -ne 1){throw 'Unicode before actual inert Delete transition'} + $this.PhaseOrder.Add('unicode-enqueue');$this.PhaseState.unicodeCalls++ + $this.PhaseState.buffer=if($this.PhaseState.finalWrong){'lpha renamed'}else{$text} + if($this.PhaseState.ContainsKey('unicodeDrift')){& $this.PhaseState.unicodeDrift $this} + $count=2*$text.Length;$sent=if($this.PhaseState.unicodeShort){$count-1}else{$count};return ,@($sent,$count) + } + $api|Add-Member ScriptMethod Observe { + param($remaining)$this.Now += [Math]::Min(25,$remaining);$this.PhaseState.observes++ + if($this.PhaseState.focusPending){ + $this.PhaseState.focusObserves++ + if(-not $this.PhaseState.neverFocus -and $this.PhaseState.focusObserves -ge $this.PhaseState.focusDelay){ + $this.PhaseState.focus=[IntPtr]9904;$this.PhaseState.focusPending=$false;$this.PhaseOrder.Add('focus-observed') + } + } + if($this.PhaseState.clearPending -and -not $this.PhaseState.neverClear -and $this.PhaseState.observes -ge $this.PhaseState.deleteDelay){ + $this.PhaseState.buffer='';$this.PhaseState.selection=@(0,0);$this.PhaseState.clearPending=$false + $this.PhaseState.deleteProcessed=$true;$this.PhaseOrder.Add('clear-processed') + if($this.PhaseState.ContainsKey('clearDrift')){& $this.PhaseState.clearDrift $this} + } + } + return $api +} +foreach($target in @(' ','Alpha renamed',' Alpha renamed ')){ + Invoke-Mixed 2706 "exact-target-$($target.Length)" { + $api=New-MixedEdit;$evidence=[Collections.Generic.List[string]]::new();$calls=@{count=0} + $null=Invoke-MultiProjectTypeText 9904 $target $evidence -typeSource { + param($id,$text)$calls.count++ + Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' $text -nativeApi $api + } + Assert-Mixed ($calls.count -eq 1 -and $api.PhaseState.clearCalls -eq 1 -and $api.PhaseState.unicodeCalls -eq 1 -and + ($api.PhaseOrder -join '|') -ceq 'focus-insert|focus-observed|select|clear-enqueue|clear-processed|unicode-enqueue' -and + $api.PhaseState.focusClicks -eq 1 -and $api.PhaseState.buffer -ceq $target -and $api.Now -lt 5000 -and + $evidence.Count -eq 1 -and $evidence[0].Contains('clearSent=2/2') -and + $evidence[0].Contains("textSent=$([int](2*$target.Length))/$([int](2*$target.Length))")) 'Sequenced edit phase/count/target proof changed' + } +} +Invoke-Mixed 2724 'actual-FocusMouse-owned-edit' { + $api=New-MixedEdit;$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0) + Assert-Mixed ($api.PhaseState.focusClicks -eq 1 -and $api.hitReads -eq 1 -and $api.state.hitRoot -eq [IntPtr]42 -and $api.state.hitPID -eq 4242) 'Actual FocusMouse ownership guard changed' +} +foreach($mutation in @('background','foreign-root')){ + Invoke-Mixed 2732 $mutation { + $api=New-MixedEdit;if($mutation -ceq 'background'){$api.state.hitChild=[IntPtr]9890}else{$api.state.hitRoot=[IntPtr]99} + $failure=$null;try{$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0)}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('MULTIPROJECT_EDIT_FOCUS:') -and $api.PhaseState.focusClicks -eq 0) 'Viewport miss became input target' + } +} +Invoke-Mixed 2742 'already-focused-no-focus-input' { + $api=New-MixedEdit;$api.PhaseState.focus=[IntPtr]9904 + $null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' 'Alpha renamed' -nativeApi $api + Assert-Mixed ($api.PhaseState.focusClicks -eq 0 -and $api.PhaseState.clearCalls -eq 1 -and $api.PhaseState.unicodeCalls -eq 1 -and + ($api.PhaseOrder -join '|') -ceq 'select|clear-enqueue|clear-processed|unicode-enqueue') 'Already-owned focus inserted mouse' +} +foreach($mutation in @('focus-short','focus-occluded','focus-foreign-hit','focus-outside','focus-timeout','focus-post-owner','focus-switched','before-mouse-deadline','last-selection-deadline','last-selection-owner')){ + Invoke-Mixed 2751 $mutation { + $api=New-MixedEdit + switch($mutation){ + focus-short{$api.PhaseState.focusSent=1} focus-occluded{$api.state.hitChild=[IntPtr]9800} focus-foreign-hit{$api.state.hitPID=7777} + focus-outside{$api.state.controls['9904'].bounds=@(700,700,900,724)} focus-timeout{$api.PhaseState.neverFocus=$true} + focus-post-owner{$api|Add-Member ScriptMethod FocusInsertion {param($modal,$point)$this.PhaseState.focusClicks++;$this.state.controls['9904'].processId=7777;return ,@($point[0],$point[1],($point[0]+1),($point[1]+1),$point[0],$point[1],2,2)} -Force} + focus-switched{$api.PhaseState.clearDrift={param($a)$a.PhaseState.focus=[IntPtr]9800}} + before-mouse-deadline{$api|Add-Member ScriptMethod Hit {param($modal,$x,$y)$this.Now=5000;return @{root=[IntPtr]42;child=[IntPtr]9904;processId=4242}} -Force} + last-selection-deadline{$api|Add-Member ScriptMethod Selection {param($edit,$remaining)if($this.PhaseState.emptyReads -ge 4){$this.Now=5000};return ,$this.PhaseState.selection} -Force} + last-selection-owner{$api|Add-Member ScriptMethod Selection {param($edit,$remaining)if($this.PhaseState.emptyReads -ge 4){$this.state.controls['9904'].processId=7777};return ,$this.PhaseState.selection} -Force}} + $failure=$null;try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' 'Alpha renamed' -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $api.PhaseState.focusClicks -le 1 -and $api.PhaseState.clearCalls -le 1 -and $api.PhaseState.unicodeCalls -eq 0) 'Focus/deadline failure added or replayed input' + if($mutation -ceq 'before-mouse-deadline'){Assert-Mixed ($api.PhaseState.focusClicks -eq 0) 'Expired mouse deadline allowed focus insertion'} + } +} +foreach($mutation in @('both-foreign','both-zero','both-type','id-wrong','title-wrong','foreground-false','hit-wrong','title-deadline')){ + Invoke-Mixed 2785 $mutation { + $api=New-MixedEdit + switch($mutation){ + both-foreign{$api.state.modalPID=7777;$api.state.controls['9904'].processId=7777} + both-zero{$api.state.modalPID=0;$api.state.controls['9904'].processId=0} + both-type{$api.state.modalPID='4242';$api.state.controls['9904'].processId='4242'} + id-wrong{$api.state.controls['9904'].id=9800} title-wrong{$api.state.title='Different dialog'} foreground-false{$api.state.foreground=$false} + hit-wrong{$api.state.hitChild=[IntPtr]9800} title-deadline{$api|Add-Member ScriptMethod Title {param($modal,$remaining)$this.order.Add("TITLE:$modal");$this.Now=5000;return ("Rename "+[char]0x201c+'Alpha renamed'+[char]0x201d)} -Force}} + $failure=$null;try{$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0)}catch{$failure=$_} + Assert-Mixed ($failure -and $api.PhaseState.focusClicks -eq 0) 'Actual focus primitive adopted foreign PID or late budget' + if($mutation -cin @('both-foreign','both-zero','both-type')){Assert-Mixed (-not ($api.order -contains 'TITLE:42')) 'Foreign modal title read before PID guard'} + } +} +foreach($mutation in @('empty-initial','mismatch-prefill','selection-short','selection-offset','selection-type','never-clear','empty-rebound','clear-short','unicode-short','final-wrong','focus-deadline','initial-foreign','initial-control','initial-hidden','initial-disabled','initial-foreground','clear-foreign','clear-focus','preunicode-control','final-owner','final-focus','selection-nonzero-empty')){ + Invoke-Mixed 2808 $mutation { + $api=New-MixedEdit + switch($mutation){ + empty-initial{$api.PhaseState.buffer='';$api.PhaseState.clearPending=$true} mismatch-prefill{$api.PhaseState.buffer='Other title'} + selection-short{$api.PhaseState.badSelection=@(0,12)} selection-offset{$api.PhaseState.badSelection=@(1,13)} selection-type{$api.PhaseState.badSelection=@('0',13)} + never-clear{$api.PhaseState.neverClear=$true} empty-rebound{$api.PhaseState.rebound=$true} clear-short{$api.PhaseState.clearSent=1} + unicode-short{$api.PhaseState.unicodeShort=$true} final-wrong{$api.PhaseState.finalWrong=$true} focus-deadline{$api.PhaseState.neverFocus=$true} + initial-foreign{$api.state.controls['9904'].processId=7777} initial-control{$api.state.controls['9904'].id=9105} + initial-hidden{$api.state.controls['9904'].visible=$false} initial-disabled{$api.state.controls['9904'].enabled=$false} initial-foreground{$api.state.foreground=$false} + clear-foreign{$api.PhaseState.clearDrift={param($a)$a.state.controls['9904'].processId=7777}} clear-focus{$api.PhaseState.clearDrift={param($a)$a.PhaseState.focus=[IntPtr]9800}} + preunicode-control{$api.PhaseState.clearDrift={param($a)$a.state.controls['9904'].id=9105}} + final-owner{$api.PhaseState.unicodeDrift={param($a)$a.state.controls['9904'].root=[IntPtr]99}} final-focus{$api.PhaseState.unicodeDrift={param($a)$a.PhaseState.focus=[IntPtr]9800}} + selection-nonzero-empty{$api.PhaseState.clearDrift={param($a)$a.PhaseState.selection=@(1,1)}}} + $failure=$null;try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' 'Alpha renamed' -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.Message.StartsWith('MULTIPROJECT_EDIT_',[StringComparison]::Ordinal) -and + $api.PhaseState.clearCalls -le 1 -and $api.PhaseState.unicodeCalls -le 1) 'Sequenced guard lost exact refusal or replayed batch' + $unicode=if($mutation -cin @('unicode-short','final-wrong','final-owner','final-focus')){1}else{0} + Assert-Mixed ($api.PhaseState.unicodeCalls -eq $unicode) 'Rejected prefill/ownership/clear allowed Unicode' + if($mutation -ceq 'empty-initial'){Assert-Mixed ($api.PhaseState.clearCalls -eq 0 -and $api.PhaseState.observes -eq 0) 'Initial empty buffer invented transition acknowledgement'} + } +} +Invoke-Mixed 2845 'compiled-edit-native-declaration-only' { + $api=New-MultiProjectEditNativeApi + Assert-Mixed ($mixedAssembly.GetType('GraphCodeMultiProjectEditNative',$false) -ne $null -and $null -ne $api.PSObject.Methods['Selection']) 'Current file-backed edit declaration unavailable' +} +Invoke-Mixed 2850 'empty-prefill-refused-before-input' { + $api=New-MixedEdit;$failure=$null + try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 '' ' ' -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $api.PhaseState.focusClicks -eq 0 -and $api.PhaseState.clearCalls -eq 0 -and $api.PhaseState.unicodeCalls -eq 0) 'Unknown empty prefill admitted transition' +} +Invoke-Mixed 2857 'diagnostic-failure-retains-primary' { + $api=New-MixedEdit;$api.PhaseState.buffer='';$failure=$null + try{$null=Invoke-MultiProjectSequencedEdit ([IntPtr]42) 4242 'Alpha renamed' ' ' -nativeApi $api -phaseSink {param($message)throw [IO.IOException]::new('phase refusal writer failed')}}catch{$failure=$_} + $secondary=$failure.Exception.Data['MultiProjectEditDiagnostic'] + Assert-Mixed ($failure.Exception.Message -ceq 'MULTIPROJECT_EDIT_PREFILL: fresh buffer differs from known nonempty prefill' -and + $secondary.errorType -ceq 'System.IO.IOException' -and $secondary.operation -ceq 'sequenced-edit-refusal' -and + $api.PhaseState.focusClicks -eq 0 -and $api.PhaseState.unicodeCalls -eq 0) 'Diagnostic writer replaced exact primary or sent input' +} +$renameText=' Alpha renamed ' +$renameProof=@('UIA_EDGE_TEXT_STABLE id=9904 inputAttempted=True inputCountsFull=True clearSent=2/2 textSent=34/34') +foreach($cancelRoute in @($false,$true)){ + Invoke-Mixed 2871 "actual-caller-cancel-$cancelRoute" { + $api=New-MixedRename;if($cancelRoute){$api.state.hitChild=[IntPtr]9808} + function New-MultiProjectRenameNativeApi {return $api} + $caller=@($gateAst.FindAll({param($n)$n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -ceq 'Invoke-MultiProjectNativeRename'},$true)) + Assert-Mixed ($caller.Count -eq 1) 'Actual Rename caller missing or ambiguous' + $statements=@($caller[0].FindAll({param($n)$n -is [Management.Automation.Language.AssignmentStatementAst] -and $n.Left.Extent.Text -ceq '$action'},$true)) + Assert-Mixed ($statements.Count -eq 1) 'Actual caller action assignment missing or ambiguous' + $modal=[IntPtr]42;$multiProcess=[pscustomobject]@{Id=4242};$typedTitle=$renameText;$Cancel=$cancelRoute + $renameTitle=Get-MultiProjectRenameTitle 'Alpha renamed' + $inputEvidence=[Collections.Generic.List[string]]::new();$inputEvidence.Add($renameProof[0]) + . ([scriptblock]::Create($statements[0].Extent.Text)) + $button=if($cancelRoute){9808}else{9800} + Assert-Mixed ($action.buttonId -eq $button -and $action.sent -eq 2 -and $action.expected -eq 2 -and + $api.inputCalls -eq 1 -and -not ($api.requested -contains 1) -and $api.requested -contains 9904 -and + $action.nativeTarget.observed.button.controlId -eq $button -and $action.nativeTarget.observed.button.topOwner -eq 42 -and + ($api.order -join '|').StartsWith('PID:42|TITLE:42|CONTROL:',[StringComparison]::Ordinal)) 'Actual caller uses legacy button or input before owned target capture' + } +} +Invoke-Mixed 2897 'actual-button-client-work-clipping' { + $api=New-MixedRename;$api.state.controls['9800'].bounds=@(580,380,680,430) + $action=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api + Assert-Mixed (($action.clippedBounds -join ',') -ceq '580,380,600,400' -and + $action.point[0] -ge 580 -and $action.point[0] -lt 600 -and $action.point[1] -ge 380 -and $action.point[1] -lt 400) 'Rename point ignores actual client/work clipping' +} +Invoke-Mixed 2906 'uncovered-owned-point-without-covering-captions' { + $api=New-MixedRename;$api.state.coveredFirst=$true + $action=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api + Assert-Mixed ($action.nativeTarget.hitSamples.Count -eq 2 -and -not $action.nativeTarget.hitSamples[0].hitTarget -and + $action.nativeTarget.hitSamples[1].hitTarget -and $api.inputCalls -eq 1) 'Covered point clicked or uncovered target not measured' +} +foreach($mutation in @( + 'missing-button','zero-handle','invalid-handle','hidden-button','disabled-button','wrong-id','id-type','foreign-button','pid-zero','pid-type', + 'wrong-top-owner','owner-type','missing-edit','foreign-edit','wrong-edit-id','edit-disabled','modal-foreign','modal-zero','modal-pid-type','wrong-title', + 'title-type','modal-hidden','modal-disabled','visibility-type','bounds-missing','bounds-zero','bounds-negative-width','bounds-type','bounds-nan','outside-client', + 'outside-work','client-zero','work-invalid','foreground-false','foreground-type','buffer-wrong','buffer-changing','buffer-type','proof-empty','proof-wrong-id', + 'proof-short','proof-clear-short','proof-not-attempted','fully-occluded','foreign-hit','hit-pid-type','hit-root-wrong','hit-child-type','late-hidden', + 'late-disabled','late-foreign','late-id-drift','late-owner-drift','late-bounds-drift','late-modal-foreign','late-title-drift','late-client-drift','late-work-drift','late-foreground' +)){ + Invoke-Mixed 2924 $mutation { + $api=New-MixedRename;$proof=@($renameProof);$button=$api.state.controls['9800'] + switch($mutation){ + missing-button{$api.state.controls.Remove('9800')} zero-handle{$button.handle=[IntPtr]::Zero} invalid-handle{$button.handle='9800'} + hidden-button{$button.visible=$false} disabled-button{$button.enabled=$false} wrong-id{$button.id=1} id-type{$button.id='9800'} + foreign-button{$button.processId=7777} pid-zero{$button.processId=0} pid-type{$button.processId='4242'} + wrong-top-owner{$button.root=[IntPtr]99} owner-type{$button.root='42'} missing-edit{$api.state.controls.Remove('9904')} + foreign-edit{$api.state.controls['9904'].processId=7777} wrong-edit-id{$api.state.controls['9904'].id=9105} edit-disabled{$api.state.controls['9904'].enabled=$false} + modal-foreign{$api.state.modalPID=7777} modal-zero{$api.state.modalPID=0} modal-pid-type{$api.state.modalPID='4242'} wrong-title{$api.state.title='New Loop'} + title-type{$api.state.title=1} modal-hidden{$api.state.visible=$false} modal-disabled{$api.state.enabled=$false} visibility-type{$button.visible='True'} + bounds-missing{$button.bounds=@()} bounds-zero{$button.bounds=@(300,300,300,328)} bounds-negative-width{$button.bounds=@(300,300,299,328)} + bounds-type{$button.bounds[0]='300'} bounds-nan{$button.bounds[0]=[double]::NaN} outside-client{$button.bounds=@(700,700,780,728)} + outside-work{$api.state.work=@(0,0,200,200)} client-zero{$api.state.client=@(10,40,10,400)} work-invalid{$api.state.work[0]='0'} + foreground-false{$api.state.foreground=$false} foreground-type{$api.state.foreground='True'} + buffer-wrong{$api.state.buffer='Wrong';$api.state.secondBuffer='Wrong'} buffer-changing{$api.state.secondBuffer='Changed'} buffer-type{$api.state.buffer=1} + proof-empty{$proof=@()} proof-wrong-id{$proof[0]=$proof[0].Replace('id=9904','id=9105')} proof-short{$proof[0]=$proof[0].Replace('34/34','33/34')} + proof-clear-short{$proof[0]=$proof[0].Replace('2/2','1/2')} proof-not-attempted{$proof[0]=$proof[0].Replace('inputAttempted=True','inputAttempted=False')} + fully-occluded{$api.state.hitChild=[IntPtr]9904} foreign-hit{$api.state.hitPID=7777} hit-pid-type{$api.state.hitPID='4242'} + hit-root-wrong{$api.state.hitRoot=[IntPtr]99} hit-child-type{$api.state.hitChild='9800'} + late-hidden{$api.state.late={param($s)$s.controls['9800'].visible=$false}} late-disabled{$api.state.late={param($s)$s.controls['9800'].enabled=$false}} + late-foreign{$api.state.late={param($s)$s.controls['9800'].processId=7777}} late-id-drift{$api.state.late={param($s)$s.controls['9800'].id=1}} + late-owner-drift{$api.state.late={param($s)$s.controls['9800'].root=[IntPtr]99}} late-bounds-drift{$api.state.late={param($s)$s.controls['9800'].bounds=@(310,300,390,328)}} + late-modal-foreign{$api.state.late={param($s)$s.modalPID=7777}} late-title-drift{$api.state.late={param($s)$s.title='New Loop'}} + late-client-drift{$api.state.late={param($s)$s.client=@(10,40,590,400)}} late-work-drift{$api.state.late={param($s)$s.work=@(0,0,1900,1080)}} + late-foreground{$api.state.late={param($s)$s.foreground=$false}}} + $prefix=if($mutation -cin @('outside-client','outside-work')){'MULTIPROJECT_BOUNDS:'}else{'MULTIPROJECT_RENAME_GUARD:'} + $records=@(& {$null=Reject-Mixed {Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $proof -nativeApi $api} $prefix} 6>&1) + $diagnostics=@($records|Where-Object {$_ -is [Management.Automation.InformationRecord] -and ([string]$_.MessageData).StartsWith('UIA_MULTIPROJECT_RENAME_NATIVE_TARGET=',[StringComparison]::Ordinal)}| + ForEach-Object {([string]$_.MessageData).Substring('UIA_MULTIPROJECT_RENAME_NATIVE_TARGET='.Length)|ConvertFrom-Json}) + Assert-Mixed ($api.inputCalls -eq 0 -and -not ($api.order|Where-Object {$_.StartsWith('INPUT:')}) -and + $diagnostics.Count -gt 0 -and $diagnostics[0].expected.buttonId -eq 9800 -and -not $diagnostics[0].inputAttempted) 'Refused Rename guard sent input or lost actual typed diagnostic' + if($mutation -cin @('modal-foreign','modal-zero','modal-pid-type')){Assert-Mixed (-not ($api.order -contains 'TITLE:42') -and $api.requested.Count -eq 0) 'Foreign modal content read'} + if($mutation -cin @('missing-button','zero-handle')){Assert-Mixed ($diagnostics[0].observed.button.handle -eq 0 -and $null -eq $diagnostics[0].observed.button.controlId) 'Missing actual target fields invented'} + } +} +foreach($mutation in @('short-count','typed-count')){ + Invoke-Mixed 3013 $mutation { + $api=New-MixedRename;$api.state.inputSent=if($mutation -ceq 'short-count'){1}else{'2'} + $null=Reject-Mixed {Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api} 'MULTIPROJECT_RENAME_INPUT:' + Assert-Mixed ($api.inputCalls -eq 1) 'Incomplete input replayed' + } +} +foreach($mutation in @('foreign-button','wrong-title','covered-point')){ + Invoke-Mixed 3024 $mutation { + $api=New-MixedRename + $api.state.finalDrift=switch($mutation){foreign-button{{param($s)$s.controls['9800'].processId=7777}} wrong-title{{param($s)$s.title='Different modal'}} covered-point{{param($s)$s.hitChild=[IntPtr]9904}}} + $failure=$null;try{$null=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('native target changed immediately before input') -and + $api.inputCalls -eq 0 -and $api.hitReads -eq 2) 'Production Click entered primitive input after final target drift' + } +} +Invoke-Mixed 3040 'stale-native-read-primary-and-unavailable-fields' { + $api=New-MixedRename;$api.state.staleClient=$true;$seen=@{failure=$null} + $records=@(& { + try{$null=Invoke-MultiProjectRenameAction ([IntPtr]42) 4242 $renameText $renameProof -nativeApi $api}catch{$seen.failure=$_} + } 6>&1) + Assert-Mixed ($seen.failure -and $seen.failure.Exception.ToString().Contains('controlled stale native client')) 'Primary stale error replaced' + $failures=@($records|Where-Object {$_ -is [Management.Automation.InformationRecord] -and ([string]$_.MessageData).StartsWith('UIA_MULTIPROJECT_RENAME_NATIVE_FAILURE=',[StringComparison]::Ordinal)}| + ForEach-Object {([string]$_.MessageData).Substring('UIA_MULTIPROJECT_RENAME_NATIVE_FAILURE='.Length)|ConvertFrom-Json}) + Assert-Mixed ($api.inputCalls -eq 0 -and $failures.Count -eq 1 -and $failures[0].failure.errorType -ceq 'System.ComponentModel.Win32Exception' -and + $failures[0].failure.nativeErrorCode -eq 6 -and -not $failures[0].inputAttempted -and $null -eq $failures[0].observed.button) 'Stale error lost typed availability metadata or sent input' +} +Invoke-Mixed 3057 'compiled-rename-binding-without-invocation' { + $api=New-MultiProjectRenameNativeApi + Assert-Mixed ($mixedAssembly.GetType('GraphCodeMultiProjectRenameNative',$false) -ne $null -and + $null -ne $api.PSObject.Methods['ClientBounds'] -and $null -ne $api.PSObject.Methods['Hit']) 'Current file-backed Rename adapter unavailable' +} +$captureReader={param($element,[bool]$content)$element.Capture($content)} +$capturePrior=@{ownership='owned';finalPID=@{value=4242};automationID=@{value='canvas-card-1983941480823304696'}} +Invoke-Mixed 3129 'fresh-owned-before-content-and-after' { + $mock=New-MixedCapture @((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $e=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/owned' $captureReader + Assert-Mixed ($e.processId -eq 4242 -and $e.automationId -ceq $capturePrior.automationID.value -and $e.name -ceq 'Alpha loop' -and + ($e.bounds -join ',') -ceq '248,118,468,204' -and + ($mock.order -join '|') -ceq 'OWNERSHIP_CACHE|PID|ID|RUNTIME|CONTENT_CACHE|PID|ID|RUNTIME|BOUNDS|NAME|OWNERSHIP_CACHE|PID|ID|RUNTIME') 'Fresh cached ownership/content ordering or output shape changed' +} +Invoke-Mixed 3138 'unsupported-is-not-zero-default' { + $unsupported=Read-MultiProjectOwnershipSnapshot (New-MixedCapture @((New-MixedCaptureState ([System.Windows.Automation.AutomationElement]::NotSupported)))) $captureReader + $zero=Read-MultiProjectOwnershipSnapshot (New-MixedCapture @((New-MixedCaptureState 0))) $captureReader + Assert-Mixed ($unsupported.pidState.state -ceq 'unsupported' -and $null -eq $unsupported.processId -and + $zero.pidState.state -ceq 'zero' -and $zero.processId -eq 0 -and $unsupported.state -ceq 'unavailable' -and $zero.state -ceq 'unavailable') 'Unsupported/default ownership states conflated' +} +foreach($mutation in @( + 'fresh-zero','fresh-null-pid','fresh-unsupported-pid','fresh-pid-type','fresh-foreign','fresh-null-id','fresh-empty-id','fresh-id-type','fresh-id-drift', + 'fresh-null-runtime','fresh-runtime-type','fresh-retired','prior-owner-changed','prior-pid-changed', + 'content-foreign','content-zero','content-unsupported','content-id-drift','content-runtime-drift', + 'after-foreign','after-zero','after-id-drift','after-runtime-drift','after-retired','name-type','name-unsupported','bounds-null','bounds-zero','bounds-empty' +)){ + Invoke-Mixed 3154 $mutation { + $states=@((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $prior=@{ownership='owned';finalPID=@{value=4242};automationID=@{value=$capturePrior.automationID.value}} + switch($mutation){ + fresh-zero{$states[0].pid=0} fresh-null-pid{$states[0].pid=$null} fresh-unsupported-pid{$states[0].pid=[System.Windows.Automation.AutomationElement]::NotSupported} + fresh-pid-type{$states[0].pid='4242'} fresh-foreign{$states[0].pid=7777} fresh-null-id{$states[0].id=$null} fresh-empty-id{$states[0].id=''} + fresh-id-type{$states[0].id=7} fresh-id-drift{$states[0].id='canvas-card-unknown'} fresh-null-runtime{$states[0].runtime=@()} fresh-runtime-type{$states[0].runtime=@('3',42,17)} + fresh-retired{$states[0].error=[Runtime.InteropServices.COMException]::new('controlled retirement',[int]0x80040201)} + prior-owner-changed{$prior.ownership='changed'} prior-pid-changed{$prior.finalPID.value=7777} + content-foreign{$states[1].pid=7777} content-zero{$states[1].pid=0} content-unsupported{$states[1].pid=[System.Windows.Automation.AutomationElement]::NotSupported} + content-id-drift{$states[1].id='canvas-card-unknown'} content-runtime-drift{$states[1].runtime=@(3,42,99)} + after-foreign{$states[2].pid=7777} after-zero{$states[2].pid=0} after-id-drift{$states[2].id='canvas-card-unknown'} + after-runtime-drift{$states[2].runtime=@(3,42,99)} after-retired{$states[2].error=[Runtime.InteropServices.COMException]::new('controlled retirement',[int]0x80040201)} + name-type{$states[1].name=7} name-unsupported{$states[1].name=[System.Windows.Automation.AutomationElement]::NotSupported} + bounds-null{$states[1].rect=$null} bounds-zero{$states[1].rect=[System.Windows.Rect]::new(248,118,0,86)} bounds-empty{$states[1].rect=[System.Windows.Rect]::Empty}} + $mock=New-MixedCapture $states + $prefix=if($mutation -cin @('fresh-foreign','content-foreign','after-foreign')){'MULTIPROJECT_PRIVACY:'}else{'MULTIPROJECT_METADATA:'} + $null=Reject-Mixed {Get-MultiProjectElementEvidence $mock 4242 $prior "mixed/$mutation" $captureReader} $prefix + $contentAllowed=$mutation.StartsWith('after-',[StringComparison]::Ordinal) -or $mutation.StartsWith('name-',[StringComparison]::Ordinal) -or $mutation.StartsWith('bounds-',[StringComparison]::Ordinal) + Assert-Mixed (($mock.order -contains 'NAME') -eq $contentAllowed -and ($mock.order -contains 'BOUNDS') -eq $contentAllowed) 'Content sampled before fresh ownership/runtime guard' + if($mutation.StartsWith('fresh-',[StringComparison]::Ordinal) -or $mutation.StartsWith('prior-',[StringComparison]::Ordinal)){ + Assert-Mixed (-not ($mock.order -contains 'CONTENT_CACHE')) 'Content cache requested before positive owned preflight' + } + } +} +Invoke-Mixed 3202 'unknown-cache-error-keeps-primary' { + $state=New-MixedCaptureState;$state.error=[InvalidOperationException]::new('controlled unexpected cache error') + $mock=New-MixedCapture @($state);$failure=$null + try{$null=Read-MultiProjectOwnershipSnapshot $mock $captureReader}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('controlled unexpected cache error') -and -not ($mock.order -contains 'NAME')) 'Unknown cache error swallowed or content sampled' +} +Invoke-Mixed 3214 'retirement-exact-typed-availability' { + $state=New-MixedCaptureState;$state.error=[Runtime.InteropServices.COMException]::new('controlled retirement',[int]0x80040201) + $mock=New-MixedCapture @($state);$snapshot=Read-MultiProjectOwnershipSnapshot $mock $captureReader + Assert-Mixed ($snapshot.state -ceq 'unavailable' -and $snapshot.pidState.state -ceq 'unavailable' -and + $snapshot.errorType -ceq 'System.Runtime.InteropServices.COMException' -and $snapshot.hresult -eq [int]0x80040201 -and + ($mock.order -join '|') -ceq 'OWNERSHIP_CACHE') 'Retirement lost exact typed diagnostic' +} +Invoke-Mixed 3224 'unrelated-COM-failure-is-not-retirement' { + $state=New-MixedCaptureState;$state.error=[Runtime.InteropServices.COMException]::new('controlled unexpected COM',[int]0x80004005) + $mock=New-MixedCapture @($state);$failure=$null + try{$null=Read-MultiProjectOwnershipSnapshot $mock $captureReader}catch{$failure=$_} + Assert-Mixed ($failure -and $failure.Exception.ToString().Contains('controlled unexpected COM') -and $mock.calls -eq 1 -and -not ($mock.order -contains 'NAME')) 'Unknown COM converted to unavailable fallback' +} +foreach($scenario in @('transient','persistent','positive-foreign')){ + Invoke-Mixed 3237 $scenario { + $multiProcess=[pscustomobject]@{Id=4242} + $observationState=@{attempts=0;sleeps=0;scenario=$scenario} + function Start-Sleep {param([int]$Milliseconds)$observationState.sleeps++} + function Get-MultiProjectObservation { + param([string]$surface,$selectedOwner)$observationState.attempts++ + $state=New-MixedCaptureState + if($observationState.scenario -ceq 'positive-foreign'){$state.pid=7777} + elseif($observationState.scenario -ceq 'persistent' -or $observationState.attempts -eq 1){$state.pid=0} + $mock=New-MixedCapture @($state,(New-MixedCaptureState),(New-MixedCaptureState)) + $e=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/whole-observation' $captureReader + return @{matched=$true;requestedSurface=$surface;evidence=$e} + } + if($scenario -ceq 'transient'){ + $observation=Wait-MultiProjectObservation overview + Assert-Mixed ($observation.matched -and $observationState.attempts -eq 2 -and $observationState.sleeps -eq 1) 'Transient whole capture not reacquired exactly once' + }else{ + $prefix=if($scenario -ceq 'positive-foreign'){'MULTIPROJECT_PRIVACY:'}else{'MULTIPROJECT_METADATA:'} + $null=Reject-Mixed {Wait-MultiProjectObservation overview} $prefix + $expected=if($scenario -ceq 'positive-foreign'){1}else{100} + Assert-Mixed ($observationState.attempts -eq $expected -and $observationState.sleeps -eq ($expected-1)) 'Foreign capture retried or persistent ambiguity escaped exact attempt bound' + } + } +} +Invoke-Mixed 3268 'whole-capture-rejects-runtime-replacement' { + $snapshots=[Collections.Generic.List[object]]::new() + $mock=New-MixedCapture @((New-MixedCaptureState),(New-MixedCaptureState),(New-MixedCaptureState)) + $null=Get-MultiProjectElementEvidence $mock 4242 $capturePrior 'mixed/collection' $captureReader $snapshots + Assert-Mixed ($snapshots.Count -eq 1) 'Verified snapshot was not registered' + $changed=New-MixedCaptureState;$changed.runtime=@(3,42,99) + $after=Read-MultiProjectOwnershipSnapshot (New-MixedCapture @($changed)) $captureReader + $null=Reject-Mixed {Confirm-MultiProjectOwnedSnapshot $snapshots[0].snapshot $after 4242 'mixed/whole-observation'} 'MULTIPROJECT_METADATA:' +} +Invoke-Mixed 3278 'prefix-does-not-coerce-unavailable-ID' { + Assert-Mixed (-not (Test-MultiProjectAutomationPrefix $null workspace-loop-bar-) -and -not (Test-MultiProjectAutomationPrefix 7 canvas-card-)) 'Unavailable/wrong typed IDs became prefix matches' +} +Invoke-Mixed 3283 'metadata-PID-first-and-again' { + $mock=New-MixedMetadata 4242 'canvas-card-1983941480823304696' + $m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed ($m.observationReady -and $m.ownership -ceq 'owned' -and $m.automationID.state -ceq 'available' -and $m.family -ceq 'canvas-fragment' -and + ($mock.State.order -join '|') -ceq 'PID|ID|PID') 'Owned metadata shape or PID ordering differs' +} +Invoke-Mixed 3291 'static-source-chrome-classification' { + $m=Get-MultiProjectFragmentMetadata (New-MixedMetadata 4242 actual-size) 4242 + Assert-Mixed ($m.observationReady -and $m.family -ceq 'source-supported-chrome') 'Known static chrome rejected' +} +Invoke-Mixed 3296 'owned-restart-in-complete-batch' { + $mock=New-MixedMetadata 4242 'workspace-restart-session-2140902926206787169' + $m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed ($m.observationReady -and $m.ownership -ceq 'owned' -and $m.family -ceq 'source-supported-chrome' -and + (Test-MultiProjectMetadataBatch @($m)) -and ($mock.State.order -join '|') -ceq 'PID|ID|PID') 'Owned numeric restart chrome lost exact identity/metadata proof' +} +foreach($test in @( + @{name='foreign restart';pid=7777;after=7777;id='workspace-restart-session-2140902926206787169'}, + @{name='changed restart owner';pid=4242;after=7777;id='workspace-restart-session-2140902926206787169'}, + @{name='missing restart PID';pid=0;after=0;id='workspace-restart-session-2140902926206787169'}, + @{name='missing restart ID';pid=4242;after=4242;id=$null},@{name='invalid typed restart ID';pid=4242;after=4242;id=7}, + @{name='empty restart suffix';pid=4242;after=4242;id='workspace-restart-session-'}, + @{name='nonnumeric restart suffix';pid=4242;after=4242;id='workspace-restart-session-foreign'}, + @{name='restart extra suffix';pid=4242;after=4242;id='workspace-restart-session-2140902926206787169-extra'} +)){ + Invoke-Mixed 3314 $test.name { + $mock=New-MixedMetadata $test.pid $test.id $test.after;$m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed (-not $m.observationReady -and -not (Test-MultiProjectMetadataBatch @($m)) -and -not ($mock.State.order -contains 'FORBIDDEN_NAME')) 'Restart taxonomy accepted unresolved/foreign identity' + } +} +foreach($test in @( + @{name='null native candidate';pid=0;id=$null;after=0;state='unavailable';owner='unavailable'}, + @{name='foreign null ID';pid=7777;id=$null;after=7777;state='unavailable';owner='foreign'}, + @{name='empty owned ID';pid=4242;id='';after=4242;state='empty';owner='owned'}, + @{name='invalid typed ID';pid=4242;id=7;after=4242;state='invalid';owner='owned'}, + @{name='unavailable stale ID';pid=4242;id='unused';after=4242;state='unavailable';owner='owned';unavailable=$true}, + @{name='changed ownership';pid=4242;id='canvas-card-1983941480823304696';after=7777;state='available';owner='changed'}, + @{name='unknown native-boundary ID';pid=7777;id='native-boundary';after=7777;state='available';owner='foreign'} +)){ + Invoke-Mixed 3331 $test.name { + $mock=New-MixedMetadata $test.pid $test.id $test.after -Unavailable:($test.ContainsKey('unavailable') -and $test.unavailable) + $m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed (-not $m.observationReady -and $m.automationID.state -ceq $test.state -and $m.ownership -ceq $test.owner -and + -not (Test-MultiProjectMetadataBatch @($m)) -and -not ($mock.State.order -contains 'FORBIDDEN_NAME') -and $m.proofLimit.Length -gt 0) 'Unresolved/stale metadata silently dropped or foreign Name read' + } +} +Invoke-Mixed 3341 'foreign-semantic-fragment-remains-counted' { + $mock=New-MixedMetadata 7777 'canvas-card-1983941480823304696';$m=Get-MultiProjectFragmentMetadata $mock 4242 + Assert-Mixed ($m.observationReady -and $m.ownership -ceq 'foreign' -and + (Test-MultiProjectAutomationPrefix $m.automationID.value canvas-card-) -and -not ($mock.State.order -contains 'FORBIDDEN_NAME')) 'Foreign semantic metadata lost before PID-only classification' + $actual=@([ordered]@{automationId=$m.automationID.value;name='Owned fixture placeholder';processId=7777;bounds=@(248,118,468,204)}) + Assert-Mixed (-not (Test-MultiProjectFragmentRoster $actual @([ordered]@{automationId='canvas-card-1983941480823304696';name='Owned fixture placeholder'}) 4242)) 'Wrong PID semantic fragment accepted' +} +$expected=[ordered]@{ + 2706=3;2724=1;2732=2;2742=1;2751=10;2785=8;2808=22;2845=1;2850=1;2857=1;2871=2;2897=1;2906=1;2924=59;3013=2;3024=3;3040=1;3057=1 + 3129=1;3138=1;3154=29;3202=1;3214=1;3224=1;3237=3;3268=1;3278=1;3283=1;3291=1;3296=1;3314=8;3331=7;3341=1 +} +Complete-Mixed $expected @('mixed-ui','native-phase-refusal','fresh-metadata','powershell-errorrecord') +$controlFailed=$false +foreach($control in @('false','throw')){ + $failure=$null + try{ + $api=New-MixedEdit + if($control -ceq 'false'){$api.PhaseState.focusControlResult=$false} + else{$api.PhaseState.focusControlError=[IO.IOException]::new('controlled focus primitive failure')} + $caught=$null + try{$null=$api.FocusMouse([IntPtr]42,[IntPtr]9904,@(100,110),4242,[long]0)}catch{$caught=$_} + Assert-Mixed ($caught -and $api.PhaseState.focusControlCalls -eq 1 -and $api.PhaseState.focusClicks -eq 1 -and + $api.PhaseState.clearCalls -eq 0 -and $api.PhaseState.unicodeCalls -eq 0) 'Focus primitive failure was swallowed or followed by key input' + if($control -ceq 'false'){ + Assert-Mixed ($caught.Exception.ToString().Contains('MULTIPROJECT_EDIT_FOCUS: direct control focus fallback failed') -and + -not $api.LastFocusControlFallback) 'False focus primitive lost actual fallback refusal' + }else{ + Assert-Mixed ($caught.Exception.GetBaseException() -is [IO.IOException] -and + $caught.Exception.GetBaseException().Message -ceq 'controlled focus primitive failure') 'Focus primitive throw replaced exact primary error' + } + }catch{$failure=$_;$controlFailed=$true} + $row=[ordered]@{ + id="ownership.mixed-ui.focus-control-$control";status=$(if($failure){'Failed'}else{'Passed'});executedCount=1 + evidence=$(if($failure){$failure.ToString()}else{'Actual production FocusMouse primitive false/throw refusal; no clear/Unicode; test-only operation transport'}) + groups=@('mixed-ui','focus-control-boundary','powershell-errorrecord') + } + $json=$row|ConvertTo-Json -Compress -Depth 8 -EscapeHandling EscapeNonAscii + $document=[System.Text.Json.JsonDocument]::Parse([string]$json);$document.Dispose() + Write-Output ('OWNERSHIP_CASE_RESULT='+$json) +} +if($controlFailed){exit 1} From 1881f681f1e0302314a70bbbdef0f4f2a8f1b27f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:30:26 -0700 Subject: [PATCH 27/46] Bind measured mixed UI results to canonical root coverage group Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 | 4 ++-- Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 index 31836d05..9f82fb17 100644 --- a/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/MixedNativeContracts.ps1 @@ -482,7 +482,7 @@ $expected=[ordered]@{ 2706=3;2724=1;2732=2;2742=1;2751=10;2785=8;2808=22;2845=1;2850=1;2857=1;2871=2;2897=1;2906=1;2924=59;3013=2;3024=3;3040=1;3057=1 3129=1;3138=1;3154=29;3202=1;3214=1;3224=1;3237=3;3268=1;3278=1;3283=1;3291=1;3296=1;3314=8;3331=7;3341=1 } -Complete-Mixed $expected @('mixed-ui','native-phase-refusal','fresh-metadata','powershell-errorrecord') +Complete-Mixed $expected @('mixed-ui','mixed-ui-policy','native-phase-refusal','fresh-metadata','powershell-errorrecord') $controlFailed=$false foreach($control in @('false','throw')){ $failure=$null @@ -505,7 +505,7 @@ foreach($control in @('false','throw')){ $row=[ordered]@{ id="ownership.mixed-ui.focus-control-$control";status=$(if($failure){'Failed'}else{'Passed'});executedCount=1 evidence=$(if($failure){$failure.ToString()}else{'Actual production FocusMouse primitive false/throw refusal; no clear/Unicode; test-only operation transport'}) - groups=@('mixed-ui','focus-control-boundary','powershell-errorrecord') + groups=@('mixed-ui','mixed-ui-policy','focus-control-boundary','powershell-errorrecord') } $json=$row|ConvertTo-Json -Compress -Depth 8 -EscapeHandling EscapeNonAscii $document=[System.Text.Json.JsonDocument]::Parse([string]$json);$document.Dispose() diff --git a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 index 20d16be2..d30d7bc3 100644 --- a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 @@ -463,4 +463,4 @@ $expected=[ordered]@{ 3392=1;3423=1;3443=1;3458=1;3469=1;3482=1;3487=1;3497=17;3524=1;3530=1;3535=1;3540=1;3545=1;3550=1;3555=1;3560=1;3565=1;3570=1;3575=1 3596=1;3601=1;3608=1;3625=1;3635=1;3732=8;3755=1;3763=1;3771=9;3792=1;3816=1;3846=11 } -Complete-Mixed $expected @('mixed-ui','geometry-selection-roster','source-correspondence') +Complete-Mixed $expected @('mixed-ui','mixed-ui-policy','geometry-selection-roster','source-correspondence') From d982f84e9d39b3deace2731d66abcf1698ad9b7b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:38:46 -0700 Subject: [PATCH 28/46] Map every original mixed validation contract to measured focused cases Add actual PowerShell startup-event, stream identity, complete receipt, protocol, and real production-stub pipe families. Bind the independent UI policy and native fixture deliverables with exact expanded counts. Map all126 original templates onto133 unique dependencies and465 original expanded cases; keep two new focus failures separate. The frozen host composite schema must admit actual current results before root success. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ownership.json | 1708 +++++++++++++++-- .../Ownership/Adapters/AdapterSupport.ps1 | 24 + .../Adapters/ControlledCapture.Contracts.ps1 | 4 +- .../Adapters/MixedPipe.Contracts.ps1 | 119 ++ .../Adapters/MixedProtocol.Contracts.ps1 | 208 ++ .../Adapters/MixedReceipt.Contracts.ps1 | 169 ++ .../Adapters/MixedStartup.Contracts.ps1 | 226 +++ .../Adapters/MixedStream.Contracts.ps1 | 221 +++ .../ValidationRunner/Ownership/README.md | 19 +- .../Ownership/SuiteRegistration.cs | 39 +- .../Ownership/mixed-source-map.json | 893 +++++++++ 11 files changed, 3502 insertions(+), 128 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 create mode 100644 Tools/windows/ValidationRunner/Ownership/mixed-source-map.json diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json index 9fddbf88..7def145f 100644 --- a/Tools/windows/ValidationRunner/Manifests/ownership.json +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -1,120 +1,1596 @@ { "units": [ - { "id": "ownership.managed.quote-empty", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.quote-trailing-slash", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.quote-embedded", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.quote-unicode", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.quote-nul", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.stream-raw", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.stream-surrogate-boundary", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.stream-no-overwrite", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.stream-independent-faults", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.stream-shared-deadline", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-valid", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-existing", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-missing", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-file", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-absent", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-duplicate-json", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-foreign-root", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.receipt-over-limit", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.proof-success", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.proof-root-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.proof-job-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.proof-stdout-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.proof-stderr-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-success", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-root-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-job-survivor", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-stdout-fault", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-both-stream-faults", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-primary-error", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-only-error", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-original-deadline", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-later-actions", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.cleanup-descendant-unknown", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.signal-fragmented", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.signal-crlf", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.signal-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.signal-exact-only", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.signal-overlong", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.raw-codec-malformed-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.raw-codec-retention-after-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.json-malformed", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.json-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.json-nested-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.json-array", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.json-case-sensitive", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-fragmented-unicode", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-clean-eof", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-short-header", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-short-body", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-zero", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-over-limit", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.frame-invalid-utf8", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-positive-rename", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-resume-unchanged", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-foreign-owner", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-foreign-node", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-missing-title", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-extra-field", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-title-type", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-version-string", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-version-bool", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-request-id", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-blank-title", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-request-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-unanswered-publication", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-response-unknown", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-response-extra", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-response-type", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-response-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-positive", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-owner", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-selection", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-extra", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-type", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-duplicate", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.managed.peer-control-second", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.normal", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.abrupt", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.inherited", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.inherited-timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.arguments-environment", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.real-pipe", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.bytes-utf8-bom", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.bytes-utf16-bom", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.bytes-invalid-utf8", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.native.bytes-utf8-supplementary", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.normal", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.stop", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.inherited", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.abrupt", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.inherited-timeout", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.primary-secondary", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.mixed-capture.cleanup-only", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.legacy.ownership-receipts", "owner": "Ownership", "minimumCount": 7, "exactCount": 7 }, - { "id": "ownership.legacy.retention-boundary", "owner": "Ownership", "minimumCount": 6, "exactCount": 6 }, - { "id": "ownership.legacy.capture-ownership", "owner": "Ownership", "minimumCount": 14, "exactCount": 14 }, - { "id": "ownership.legacy.tree-ownership", "owner": "Ownership", "minimumCount": 4, "exactCount": 4 }, - { "id": "ownership.legacy.provider-ownership", "owner": "Ownership", "minimumCount": 4, "exactCount": 4 }, - { "id": "ownership.legacy.pre-uia-isolation", "owner": "Ownership", "minimumCount": 10, "exactCount": 10 }, - { "id": "ownership.legacy.mixed-multiproject", "owner": "Ownership", "minimumCount": 1, - "requiredGroups": ["mixed-ui-policy", "request-response-publication", "receipt-negative-controls", "owned-real-pipes", "bounded-capture-eight-modes", "primary-and-cleanup-errors"] }, - { "id": "ownership.legacy.peer-emission", "owner": "Ownership", "minimumCount": 1 }, - { "id": "ownership.legacy.rename-submit", "owner": "Ownership", "minimumCount": 1 }, - { "id": "ownership.legacy.fresh-capture", "owner": "Ownership", "minimumCount": 1 }, - { "id": "ownership.legacy.packaging-shared-linkage", "owner": "Ownership", "minimumCount": 1 }, - { "id": "ownership.legacy.uia-shared-linkage", "owner": "Ownership", "minimumCount": 1 }, - { "id": "ownership.ui-startup.helpers", "owner": "Ownership", "minimumCount": 13, "exactCount": 13 }, - { "id": "ownership.ui-startup.redacted-log", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.ui-startup.truncated-log", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.ui-startup.missing-log", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.ui-startup.retained-report", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.ui-process-tree.controlled-child", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.ui-process-tree.cleanup-complete", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 }, - { "id": "ownership.ui-process-tree.pid-reuse", "owner": "Ownership", "minimumCount": 1, "exactCount": 1 } + { + "id": "ownership.managed.quote-empty", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-trailing-slash", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-embedded", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-unicode", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.quote-nul", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-raw", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-surrogate-boundary", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-no-overwrite", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-independent-faults", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.stream-shared-deadline", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-valid", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-existing", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-missing", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-file", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-absent", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-duplicate-json", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-foreign-root", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.receipt-over-limit", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-success", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-root-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-job-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-stdout-fault", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.proof-stderr-fault", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-success", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-root-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-job-survivor", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-stdout-fault", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-both-stream-faults", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-primary-error", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-only-error", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-original-deadline", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-later-actions", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.cleanup-descendant-unknown", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-fragmented", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-crlf", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-eof", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-exact-only", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.signal-overlong", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.raw-codec-malformed-eof", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.raw-codec-retention-after-malformed", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-malformed", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-nested-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-array", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.json-case-sensitive", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-fragmented-unicode", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-clean-eof", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-short-header", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-short-body", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-zero", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-over-limit", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.frame-invalid-utf8", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-positive-rename", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-resume-unchanged", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-foreign-owner", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-foreign-node", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-missing-title", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-extra-field", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-title-type", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-version-string", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-version-bool", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-request-id", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-blank-title", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-request-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-unanswered-publication", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-unknown", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-extra", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-type", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-response-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-positive", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-owner", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-selection", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-extra", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-type", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-duplicate", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.managed.peer-control-second", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.normal", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.abrupt", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.inherited", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.inherited-timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.arguments-environment", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.real-pipe", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-utf8-bom", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-utf16-bom", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-invalid-utf8", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.native.bytes-utf8-supplementary", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.normal", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.stop", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.inherited", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.abrupt", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.inherited-timeout", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.primary-secondary", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-capture.cleanup-only", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.legacy.ownership-receipts", + "owner": "Ownership", + "minimumCount": 7, + "exactCount": 7 + }, + { + "id": "ownership.mixed-receipt.source-line-2411", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-receipt.source-line-2439", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-receipt.source-line-2445", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-receipt.source-line-2460", + "owner": "Ownership", + "minimumCount": 51, + "exactCount": 51 + }, + { + "id": "ownership.mixed-receipt.source-line-2520", + "owner": "Ownership", + "minimumCount": 11, + "exactCount": 11 + }, + { + "id": "ownership.mixed-protocol.source-line-3353", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3368", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3373", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3383", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3897", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3908", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3930", + "owner": "Ownership", + "minimumCount": 9, + "exactCount": 9 + }, + { + "id": "ownership.mixed-protocol.source-line-3941", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-protocol.source-line-3943", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3948", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3954", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-protocol.source-line-3973", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-protocol.source-line-3988", + "owner": "Ownership", + "minimumCount": 10, + "exactCount": 10 + }, + { + "id": "ownership.mixed-protocol.source-line-3889", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-protocol.source-line-3894", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-stream.source-line-2193", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-stream.source-line-2221", + "owner": "Ownership", + "minimumCount": 7, + "exactCount": 7 + }, + { + "id": "ownership.mixed-stream.source-line-2305", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-stream.source-line-2342", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-stream.source-line-2359", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-startup.source-line-2060", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2068", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2074", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2083", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2101", + "owner": "Ownership", + "minimumCount": 14, + "exactCount": 14 + }, + { + "id": "ownership.mixed-startup.source-line-2109", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-startup.source-line-2126", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-startup.source-line-2137", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2143", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2149", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2165", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-startup.source-line-2173", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4053", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4056", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4063", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4072", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-pipe.source-line-4093", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2706", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-ui.source-line-2724", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2732", + "owner": "Ownership", + "minimumCount": 2, + "exactCount": 2 + }, + { + "id": "ownership.mixed-ui.source-line-2742", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2751", + "owner": "Ownership", + "minimumCount": 10, + "exactCount": 10 + }, + { + "id": "ownership.mixed-ui.source-line-2785", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-2808", + "owner": "Ownership", + "minimumCount": 22, + "exactCount": 22 + }, + { + "id": "ownership.mixed-ui.source-line-2845", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2850", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2857", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2871", + "owner": "Ownership", + "minimumCount": 2, + "exactCount": 2 + }, + { + "id": "ownership.mixed-ui.source-line-2897", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2906", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-2924", + "owner": "Ownership", + "minimumCount": 59, + "exactCount": 59 + }, + { + "id": "ownership.mixed-ui.source-line-3013", + "owner": "Ownership", + "minimumCount": 2, + "exactCount": 2 + }, + { + "id": "ownership.mixed-ui.source-line-3024", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-ui.source-line-3040", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3057", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3129", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3138", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3154", + "owner": "Ownership", + "minimumCount": 29, + "exactCount": 29 + }, + { + "id": "ownership.mixed-ui.source-line-3202", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3214", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3224", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3237", + "owner": "Ownership", + "minimumCount": 3, + "exactCount": 3 + }, + { + "id": "ownership.mixed-ui.source-line-3268", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3278", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3283", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3291", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3296", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3314", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-3331", + "owner": "Ownership", + "minimumCount": 7, + "exactCount": 7 + }, + { + "id": "ownership.mixed-ui.source-line-3341", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.focus-control-false", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.focus-control-throw", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1724", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1733", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1749", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.mixed-ui.source-line-1757", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1769", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-ui.source-line-1777", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1789", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1804", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1811", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1818", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1824", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1837", + "owner": "Ownership", + "minimumCount": 5, + "exactCount": 5 + }, + { + "id": "ownership.mixed-ui.source-line-1843", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1854", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-ui.source-line-1870", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1879", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-1917", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-1924", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.mixed-ui.source-line-1951", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3392", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3423", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3443", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3458", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3469", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3482", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3487", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3497", + "owner": "Ownership", + "minimumCount": 17, + "exactCount": 17 + }, + { + "id": "ownership.mixed-ui.source-line-3524", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3530", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3535", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3540", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3545", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3550", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3555", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3560", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3565", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3570", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3575", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3596", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3601", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3608", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3625", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3635", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3732", + "owner": "Ownership", + "minimumCount": 8, + "exactCount": 8 + }, + { + "id": "ownership.mixed-ui.source-line-3755", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3763", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3771", + "owner": "Ownership", + "minimumCount": 9, + "exactCount": 9 + }, + { + "id": "ownership.mixed-ui.source-line-3792", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3816", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.mixed-ui.source-line-3846", + "owner": "Ownership", + "minimumCount": 11, + "exactCount": 11 + }, + { + "id": "ownership.legacy.retention-boundary", + "owner": "Ownership", + "minimumCount": 6, + "exactCount": 6 + }, + { + "id": "ownership.legacy.capture-ownership", + "owner": "Ownership", + "minimumCount": 14, + "exactCount": 14 + }, + { + "id": "ownership.legacy.tree-ownership", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.legacy.provider-ownership", + "owner": "Ownership", + "minimumCount": 4, + "exactCount": 4 + }, + { + "id": "ownership.legacy.pre-uia-isolation", + "owner": "Ownership", + "minimumCount": 10, + "exactCount": 10 + }, + { + "id": "ownership.legacy.mixed-multiproject", + "owner": "Ownership", + "minimumCount": 465, + "requiredGroups": [ + "mixed-ui-policy", + "request-response-publication", + "receipt-negative-controls", + "owned-real-pipes", + "bounded-capture-eight-modes", + "primary-and-cleanup-errors" + ], + "exactCount": 465, + "compositeOf": [ + "ownership.mixed-ui.source-line-1724", + "ownership.mixed-ui.source-line-1733", + "ownership.mixed-ui.source-line-1749", + "ownership.mixed-ui.source-line-1757", + "ownership.mixed-ui.source-line-1769", + "ownership.mixed-ui.source-line-1777", + "ownership.mixed-ui.source-line-1789", + "ownership.mixed-ui.source-line-1804", + "ownership.mixed-ui.source-line-1811", + "ownership.mixed-ui.source-line-1818", + "ownership.mixed-ui.source-line-1824", + "ownership.mixed-ui.source-line-1837", + "ownership.mixed-ui.source-line-1843", + "ownership.mixed-ui.source-line-1854", + "ownership.mixed-ui.source-line-1870", + "ownership.mixed-ui.source-line-1879", + "ownership.mixed-ui.source-line-1917", + "ownership.mixed-ui.source-line-1924", + "ownership.mixed-ui.source-line-1951", + "ownership.mixed-startup.source-line-2060", + "ownership.mixed-startup.source-line-2068", + "ownership.mixed-startup.source-line-2074", + "ownership.mixed-startup.source-line-2083", + "ownership.mixed-startup.source-line-2101", + "ownership.mixed-startup.source-line-2109", + "ownership.mixed-startup.source-line-2126", + "ownership.mixed-startup.source-line-2137", + "ownership.mixed-startup.source-line-2143", + "ownership.mixed-startup.source-line-2149", + "ownership.mixed-startup.source-line-2165", + "ownership.mixed-startup.source-line-2173", + "ownership.mixed-stream.source-line-2193", + "ownership.mixed-stream.source-line-2221", + "ownership.mixed-stream.source-line-2305", + "ownership.mixed-stream.source-line-2342", + "ownership.mixed-stream.source-line-2359", + "ownership.mixed-receipt.source-line-2411", + "ownership.mixed-receipt.source-line-2439", + "ownership.mixed-receipt.source-line-2445", + "ownership.mixed-receipt.source-line-2460", + "ownership.mixed-receipt.source-line-2520", + "ownership.mixed-ui.source-line-2706", + "ownership.mixed-ui.source-line-2724", + "ownership.mixed-ui.source-line-2732", + "ownership.mixed-ui.source-line-2742", + "ownership.mixed-ui.source-line-2751", + "ownership.mixed-ui.source-line-2785", + "ownership.mixed-ui.source-line-2808", + "ownership.mixed-ui.source-line-2845", + "ownership.mixed-ui.source-line-2850", + "ownership.mixed-ui.source-line-2857", + "ownership.mixed-ui.source-line-2871", + "ownership.mixed-ui.source-line-2897", + "ownership.mixed-ui.source-line-2906", + "ownership.mixed-ui.source-line-2924", + "ownership.mixed-ui.source-line-3013", + "ownership.mixed-ui.source-line-3024", + "ownership.mixed-ui.source-line-3040", + "ownership.mixed-ui.source-line-3057", + "ownership.mixed-ui.source-line-3129", + "ownership.mixed-ui.source-line-3138", + "ownership.mixed-ui.source-line-3154", + "ownership.mixed-ui.source-line-3202", + "ownership.mixed-ui.source-line-3214", + "ownership.mixed-ui.source-line-3224", + "ownership.mixed-ui.source-line-3237", + "ownership.mixed-ui.source-line-3268", + "ownership.mixed-ui.source-line-3278", + "ownership.mixed-ui.source-line-3283", + "ownership.mixed-ui.source-line-3291", + "ownership.mixed-ui.source-line-3296", + "ownership.mixed-ui.source-line-3314", + "ownership.mixed-ui.source-line-3331", + "ownership.mixed-ui.source-line-3341", + "ownership.mixed-protocol.source-line-3353", + "ownership.mixed-protocol.source-line-3368", + "ownership.mixed-protocol.source-line-3373", + "ownership.mixed-protocol.source-line-3383", + "ownership.mixed-ui.source-line-3392", + "ownership.mixed-ui.source-line-3423", + "ownership.mixed-ui.source-line-3443", + "ownership.mixed-ui.source-line-3458", + "ownership.mixed-ui.source-line-3469", + "ownership.mixed-ui.source-line-3482", + "ownership.mixed-ui.source-line-3487", + "ownership.mixed-ui.source-line-3497", + "ownership.mixed-ui.source-line-3524", + "ownership.mixed-ui.source-line-3530", + "ownership.mixed-ui.source-line-3535", + "ownership.mixed-ui.source-line-3540", + "ownership.mixed-ui.source-line-3545", + "ownership.mixed-ui.source-line-3550", + "ownership.mixed-ui.source-line-3555", + "ownership.mixed-ui.source-line-3560", + "ownership.mixed-ui.source-line-3565", + "ownership.mixed-ui.source-line-3570", + "ownership.mixed-ui.source-line-3575", + "ownership.mixed-ui.source-line-3596", + "ownership.mixed-ui.source-line-3601", + "ownership.mixed-ui.source-line-3608", + "ownership.mixed-ui.source-line-3625", + "ownership.mixed-ui.source-line-3635", + "ownership.mixed-ui.source-line-3732", + "ownership.mixed-ui.source-line-3755", + "ownership.mixed-ui.source-line-3763", + "ownership.mixed-ui.source-line-3771", + "ownership.mixed-ui.source-line-3792", + "ownership.mixed-ui.source-line-3816", + "ownership.mixed-ui.source-line-3846", + "ownership.mixed-protocol.source-line-3889", + "ownership.mixed-protocol.source-line-3894", + "ownership.mixed-protocol.source-line-3897", + "ownership.mixed-protocol.source-line-3908", + "ownership.mixed-protocol.source-line-3930", + "ownership.mixed-protocol.source-line-3941", + "ownership.mixed-protocol.source-line-3943", + "ownership.mixed-protocol.source-line-3948", + "ownership.mixed-protocol.source-line-3954", + "ownership.mixed-protocol.source-line-3973", + "ownership.mixed-protocol.source-line-3988", + "ownership.mixed-pipe.source-line-4053", + "ownership.mixed-pipe.source-line-4056", + "ownership.mixed-pipe.source-line-4063", + "ownership.mixed-pipe.source-line-4072", + "ownership.mixed-pipe.source-line-4093", + "ownership.mixed-capture.normal", + "ownership.mixed-capture.stop", + "ownership.mixed-capture.inherited", + "ownership.mixed-capture.abrupt", + "ownership.mixed-capture.timeout", + "ownership.mixed-capture.inherited-timeout", + "ownership.mixed-capture.primary-secondary", + "ownership.mixed-capture.cleanup-only" + ], + "sourceTemplateCount": 126, + "sourceMap": "Ownership\\mixed-source-map.json" + }, + { + "id": "ownership.legacy.peer-emission", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.rename-submit", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.fresh-capture", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.packaging-shared-linkage", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.legacy.uia-shared-linkage", + "owner": "Ownership", + "minimumCount": 1 + }, + { + "id": "ownership.ui-startup.helpers", + "owner": "Ownership", + "minimumCount": 13, + "exactCount": 13 + }, + { + "id": "ownership.ui-startup.redacted-log", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-startup.truncated-log", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-startup.missing-log", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-startup.retained-report", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-process-tree.controlled-child", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-process-tree.cleanup-complete", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + }, + { + "id": "ownership.ui-process-tree.pid-reuse", + "owner": "Ownership", + "minimumCount": 1, + "exactCount": 1 + } ] } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 index 2a9314b7..beaa276b 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/AdapterSupport.ps1 @@ -1,12 +1,36 @@ $ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) $script:OwnershipCases = [Collections.Generic.List[object]]::new() +trap { + if (-not $script:OwnershipMockSignals -and $_.Exception.Data['GraphCodeValidationRootRetentionRequired'] -eq $true) { + try { + [Console]::Out.WriteLine('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven') + [Console]::Out.Flush() + } finally { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + } + throw +} + function Import-OwnershipBindings([string] $RepositoryRoot, [string] $AssemblyPath, [string] $RequiredType = 'GraphCode.Validation.Ownership.StandaloneProcessJob') { & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` -AssemblyPath $AssemblyPath -RequiredType $RequiredType | Out-Null } +function Import-OwnershipCaptureFunctions([string] $RepositoryRoot, [string] $AssemblyPath) { + Import-OwnershipBindings $RepositoryRoot $AssemblyPath + Import-Module (Join-Path $RepositoryRoot 'Tools\windows\ShellProcessOwnership.psm1') -Force -ErrorAction Stop + $gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') + $context = @{ + '$PSScriptRoot' = ("'" + (Join-Path $RepositoryRoot 'Tools\windows').Replace("'", "''") + "'") + '$validationAssemblyPath' = ("'" + $AssemblyPath.Replace("'", "''") + "'") + } + . Import-OwnershipProductionFunctions $gate @('Initialize-MultiProjectProcessJob', 'Initialize-UiaOwnedStreamCapture') $context + . Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Start-MultiProjectOwnedProcess', + 'Wait-MultiProjectCapture', 'Complete-MultiProjectCapture') +} + function Read-OwnershipProductionAst([string] $Path) { $errors = $null $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$errors) diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 index 9a23c3d1..de8c36a3 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/ControlledCapture.Contracts.ps1 @@ -99,7 +99,9 @@ foreach ($mode in @('normal', 'stop', 'inherited', 'abrupt', 'timeout', 'inherit if ($capture -and -not $capture.cleaned) { $null = Complete-MultiProjectCapture $capture $root 3000 $primary } } } - Complete-OwnershipFamily ("ownership.mixed-capture." + $mode) 1 @('controlled-native', 'bounded-capture-eight-modes') + $groups = @('controlled-native', 'bounded-capture-eight-modes') + if ($mode -in @('primary-secondary', 'cleanup-only')) { $groups += 'primary-and-cleanup-errors' } + Complete-OwnershipFamily ("ownership.mixed-capture." + $mode) 1 $groups } $treeRoot = Join-Path $env:TEMP ('owned-tree-' + [guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $treeRoot diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 new file mode 100644 index 00000000..ffbbd674 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedPipe.Contracts.ps1 @@ -0,0 +1,119 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +. Import-OwnershipCaptureFunctions $RepositoryRoot $AssemblyPath +$stub = Join-Path $RepositoryRoot 'Tools\windows\Stub-Daemon.ps1' +$stubAst = Read-OwnershipProductionAst $stub +. Import-OwnershipProductionFunctions $stubAst @('Assert-MultiProjectJsonProperties', 'ConvertFrom-MultiProjectJson') +$root = Join-Path $env:TEMP ('actual-mixed-pipe-' + [guid]::NewGuid().ToString('N')) +$alpha = Join-Path $root 'Alpha' +$beta = Join-Path $root 'Beta' +$null = New-Item -ItemType Directory -Path $alpha, $beta +$alphaId = '11111111-1111-4111-8111-111111111111' +$betaId = '22222222-2222-4222-8222-222222222222' +$listId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +$renameId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +$token = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +$capture = $null +$client = $null +$primary = $null +function Send-PipeFrame($Frame) { + $json = ConvertTo-Json -InputObject $Frame -Depth 16 -Compress + $deadline = [GraphCode.Validation.Ownership.Deadline]::new([TimeSpan]::FromSeconds(5)) + [GraphCode.Validation.Ownership.PipeFrames]::WriteAsync($client, $json, $deadline, 7, + [Threading.CancellationToken]::None).GetAwaiter().GetResult() +} +function Read-PipeFrame { + $deadline = [GraphCode.Validation.Ownership.Deadline]::new([TimeSpan]::FromSeconds(5)) + $document = [GraphCode.Validation.Ownership.PipeFrames]::ReadAsync($client, $deadline, + [Threading.CancellationToken]::None).GetAwaiter().GetResult() + if ($null -eq $document) { throw 'Actual owned pipe ended before an expected frame.' } + try { return ConvertFrom-MultiProjectJson $document.RootElement.GetRawText() } + finally { $document.Dispose() } +} +function Start-ProtocolPeer([string] $Pipe, [string] $Result, [switch] $Multi) { + $arguments = @('-NoProfile', '-NonInteractive', '-File', $stub, '-PipeName', $Pipe, '-ResultPath', $Result) + if ($Multi) { + $arguments += @('-SeedMultiProjects', '-ProjectAPath', $alpha, '-ProjectBPath', $beta, '-PublicationControlPath', $control) + } + return Start-MultiProjectOwnedProcess -file (Join-Path $PSHOME 'pwsh.exe') -arguments $arguments ` + -environment @{ TEMP = $root; TMP = $root } -stem 'actual-peer' ` + -jobSourcePath (Join-Path $RepositoryRoot 'Tools\windows\Tests\Packaging.Standalone.Tests.ps1') +} +try { + $pipe = 'graphcode-compiled-mixed-' + [guid]::NewGuid().ToString('N') + $control = Join-Path $root 'publish.json' + $capture = Start-ProtocolPeer $pipe (Join-Path $root 'peer.json') -Multi + $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipe, [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) + $client.Connect(5000) + Send-PipeFrame ([ordered]@{ version = 2; kind = 'hello'; supportedVersions = @(1, 2) }) + $hello = Read-PipeFrame + Invoke-OwnershipCase 'real-hello' { + Assert-Ownership ($hello.selectedVersion -eq 2) 'Actual owned stub did not negotiate v2.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4053' 1 @('owned-real-pipes') + Send-PipeFrame ([ordered]@{ version = 2; kind = 'request'; requestID = $listId; command = [ordered]@{ listRecentProjects = [ordered]@{} } }) + $response = Read-PipeFrame + $first = Read-PipeFrame + $second = Read-PipeFrame + Invoke-OwnershipCase 'fragmented-two-owner-frames' { + Assert-Ownership ($response.requestID -ceq $listId -and $first.sequence -eq 1 -and $second.sequence -eq 2 -and + $first.event.graphChanged.project.path -ceq $alpha -and $second.event.graphChanged.project.path -ceq $beta) 'Actual fragmented wire owner/sequence frames differ.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4056' 1 @('owned-real-pipes') + Send-PipeFrame ([ordered]@{ version = 2; kind = 'request'; requestID = $renameId; command = [ordered]@{ + graphCommand = [ordered]@{ projectPath = $alpha; command = [ordered]@{ renameNode = [ordered]@{ _0 = $alphaId; title = 'Alpha renamed' } } } + } }) + $response = Read-PipeFrame + $publication = Read-PipeFrame + Invoke-OwnershipCase 'real-rename-application' { + Assert-Ownership ($response.requestID -ceq $renameId -and $publication.sequence -eq 3 -and + $publication.event.graphChanged.nodes[0].title -ceq 'Alpha renamed') 'Actual rename application/response/publication differs.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4063' 1 @('owned-real-pipes') + $publicationControl = [ordered]@{ token = $token; projectPath = $alpha; nodeID = $alphaId; title = 'Alpha interleaved'; selection = [ordered]@{ + projectPath = $beta; nodeID = $betaId; source = 'synthetic-client' + } } + [IO.File]::WriteAllText(($control + '.pending'), (ConvertTo-Json -InputObject $publicationControl -Depth 8 -Compress), [Text.UTF8Encoding]::new($false)) + [IO.File]::Move(($control + '.pending'), $control) + $publication = Read-PipeFrame + Invoke-OwnershipCase 'idle-file-controlled-publication' { + Assert-Ownership ($publication.sequence -eq 4 -and $publication.event.graphChanged.project.path -ceq $alpha -and + $publication.event.graphChanged.nodes[0].title -ceq 'Alpha interleaved') 'Actual idle owned pipe did not publish the interleaved Alpha control.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4072' 1 @('owned-real-pipes') + $client.Dispose() + $client = $null + $null = Complete-MultiProjectCapture $capture $root 5000 + $capture = $null + $pipe = 'graphcode-compiled-default-' + [guid]::NewGuid().ToString('N') + $capture = Start-ProtocolPeer $pipe (Join-Path $root 'default-peer.json') + $client = [IO.Pipes.NamedPipeClientStream]::new('.', $pipe, [IO.Pipes.PipeDirection]::InOut, [IO.Pipes.PipeOptions]::Asynchronous) + $client.Connect(5000) + Send-PipeFrame ([ordered]@{ version = 2; kind = 'hello'; supportedVersions = @(1, 2) }) + $hello = Read-PipeFrame + Send-PipeFrame ([ordered]@{ version = 2; kind = 'request'; requestID = $listId; command = [ordered]@{ listRecentProjects = [ordered]@{} } }) + $response = Read-PipeFrame + $publication = Read-PipeFrame + Invoke-OwnershipCase 'real-default-fixture-unchanged' { + Assert-Ownership ($hello.selectedVersion -eq 2 -and $response.requestID -ceq $listId -and + $response.event.recentProjectsListed.Count -eq 1 -and $publication.sequence -eq 1 -and + $publication.event.graphChanged.project.path -ceq 'graphcode://stub/project' -and + $publication.event.graphChanged.nodes.Count -eq 2 -and + $publication.event.graphChanged.nodes[0].id -ceq $alphaId -and $publication.event.graphChanged.nodes[0].title -ceq 'Stub node A' -and + $publication.event.graphChanged.nodes[1].id -ceq $betaId -and $publication.event.graphChanged.nodes[1].title -ceq 'Stub node B') 'The actual unchanged one-project/two-node default fixture changed.' + } + Complete-OwnershipFamily 'ownership.mixed-pipe.source-line-4093' 1 @('owned-real-pipes') +} catch { + $primary = $_ + throw +} finally { + if ($client) { $client.Dispose() } + if ($capture) { $null = Complete-MultiProjectCapture $capture $root 5000 $primary } +} +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 new file mode 100644 index 00000000..fad00207 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedProtocol.Contracts.ps1 @@ -0,0 +1,208 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +foreach ($file in @('Stub-Daemon.ps1', 'uia-live-gate.ps1')) { + $ast = Read-OwnershipProductionAst (Join-Path $RepositoryRoot "Tools\windows\$file") + $names = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + ($node.Name -like '*-MultiProject*' -or $node.Name -ceq 'ConvertTo-SketchCanonicalJson') + }, $true) | ForEach-Object Name) + . Import-OwnershipProductionFunctions $ast $names +} +$root = Join-Path $env:TEMP ('mixed-protocol-' + [guid]::NewGuid().ToString('N')) +$alpha = Join-Path $root 'Alpha' +$beta = Join-Path $root 'Beta' +$null = New-Item -ItemType Directory -Path $alpha, $beta +$alphaId = '11111111-1111-4111-8111-111111111111' +$betaId = '22222222-2222-4222-8222-222222222222' +$listId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +$renameId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +$token = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +function New-ProtocolBaseline { + $peer = New-MultiProjectPeer $alpha $beta + $list = [ordered]@{ version = 2; kind = 'request'; requestID = $listId; command = [ordered]@{ listRecentProjects = [ordered]@{} } } + $reply = Invoke-MultiProjectRequest $peer $list + Complete-MultiProjectResponse $peer $reply.response + foreach ($path in @($alpha, $beta)) { + Complete-MultiProjectPublication $peer (New-MultiProjectPublication $peer $path) 'initial' $listId + } + return $peer +} +function New-ProtocolRequest([string] $Title = 'Alpha renamed') { + return [ordered]@{ version = 2; kind = 'request'; requestID = $renameId; command = [ordered]@{ + graphCommand = [ordered]@{ projectPath = $alpha; command = [ordered]@{ + renameNode = [ordered]@{ _0 = $alphaId; title = $Title } + } } + } } +} +function New-ProtocolControl { + return [ordered]@{ token = $token; projectPath = $alpha; nodeID = $alphaId; title = 'Alpha interleaved'; selection = [ordered]@{ + projectPath = $beta; nodeID = $betaId; source = 'synthetic-client' + } } +} +function Assert-ProtocolRejected([scriptblock] $Action, [string] $Prefix) { + $failure = $null + try { & $Action | Out-Null } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Message.StartsWith($Prefix, [StringComparison]::Ordinal)) "Expected specific production protocol rejection $Prefix" +} +function Get-RenamedProtocolSnapshots([string] $Title = 'Alpha renamed') { + $peer = New-ProtocolBaseline + $before = Get-MultiProjectPeerSnapshot $peer + $reply = Invoke-MultiProjectRequest $peer (New-ProtocolRequest $Title) + Complete-MultiProjectResponse $peer $reply.response + Complete-MultiProjectPublication $peer (New-MultiProjectPublication $peer $alpha) 'rename' $renameId + return @{ before = $before; after = (Get-MultiProjectPeerSnapshot $peer) } +} +Invoke-OwnershipCase 'two-positive-owner-publications' { + $snapshot = Get-MultiProjectPeerSnapshot (New-ProtocolBaseline) + Assert-Ownership ($snapshot.requestCount -eq 1 -and $snapshot.responseCount -eq 1 -and $snapshot.graphSequence -eq 2 -and + $snapshot.graphs.Count -eq 2 -and $snapshot.graphs[0].project.path -ceq $alpha -and $snapshot.graphs[1].project.path -ceq $beta -and + $snapshot.graphs[0].nodes[0].id -ceq $alphaId -and $snapshot.graphs[1].nodes[0].id -ceq $betaId) 'Positive owner/UUID/count/sequence fixture changed.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3353' 1 @('request-response-publication') +Invoke-OwnershipCase 'whole-correlated-rename' { + $snapshots = Get-RenamedProtocolSnapshots + Assert-Ownership ((Test-MultiProjectRenameReceipt $snapshots.before $snapshots.after $alpha $alphaId 'Alpha renamed') -and + $snapshots.after.graphs[1].nodes[0].title -ceq 'Beta loop') 'Exact rename receipt was rejected or mutated Beta.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3368' 1 @('request-response-publication') +Invoke-OwnershipCase 'one-shot-interleaved-event' { + $peer = New-ProtocolBaseline + $frame = Invoke-MultiProjectPublicationControl $peer (New-ProtocolControl) + Complete-MultiProjectPublication $peer $frame 'control' $token + $snapshot = Get-MultiProjectPeerSnapshot $peer + Assert-Ownership ($snapshot.requestCount -eq 1 -and $snapshot.responseCount -eq 1 -and $snapshot.appliedCount -eq 0 -and + $snapshot.graphSequence -eq 3 -and $snapshot.graphs[1].nodes[0].title -ceq 'Beta loop') 'Synthetic selection control invented requests or mutated another owner.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3373' 1 @('request-response-publication') +Invoke-OwnershipCase 'unchanged-title-dispatch' { + $snapshots = Get-RenamedProtocolSnapshots 'Alpha loop' + Assert-Ownership (Test-MultiProjectRenameReceipt $snapshots.before $snapshots.after $alpha $alphaId 'Alpha loop') 'Unchanged-title dispatch was suppressed.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3383' 1 @('request-response-publication') +foreach ($foreign in @($false, $true)) { + Invoke-OwnershipCase "resume-foreign-$foreign" { + $peer = New-ProtocolBaseline + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer) + $graphs = ConvertTo-MultiProjectCanonicalJson $peer.graphs + $request = New-ProtocolRequest + $request.command.graphCommand.command = [ordered]@{ resumeSession = [ordered]@{ _0 = $(if ($foreign) { $betaId } else { $alphaId }) } } + if ($foreign) { + Assert-ProtocolRejected { Invoke-MultiProjectRequest $peer $request } 'MULTIPROJECT_OWNER' + Assert-Ownership ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer)) -ceq $before) 'Rejected foreign session mutated peer state.' + } else { + $reply = Invoke-MultiProjectRequest $peer $request + Assert-Ownership ($reply.response.success -eq $true -and $null -eq $reply.publishPath -and $peer.applied.Count -eq 0 -and + (ConvertTo-MultiProjectCanonicalJson $peer.graphs) -ceq $graphs) 'Resume acknowledgement changed the graph or launched a publication.' + } + } + Complete-OwnershipFamily $(if ($foreign) { 'ownership.mixed-protocol.source-line-3908' } else { 'ownership.mixed-protocol.source-line-3897' }) 1 @('request-response-publication') +} +foreach ($mutation in @('owner', 'node', 'missing', 'extra', 'type', 'version', 'boolean', 'requestID', 'blank')) { + Invoke-OwnershipCase $mutation { + $peer = New-ProtocolBaseline + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer) + $request = New-ProtocolRequest + $prefix = 'MULTIPROJECT_SCHEMA' + switch ($mutation) { + 'owner' { $request.command.graphCommand.projectPath = $beta; $prefix = 'MULTIPROJECT_OWNER' } + 'node' { $request.command.graphCommand.command.renameNode._0 = $betaId; $prefix = 'MULTIPROJECT_OWNER' } + 'missing' { $request.command.graphCommand.command.renameNode.Remove('title') } + 'extra' { $request.command.graphCommand.command.renameNode.extra = 1 } + 'type' { $request.command.graphCommand.command.renameNode.title = 7; $prefix = 'MULTIPROJECT_TYPE' } + 'version' { $request.version = '2'; $prefix = 'MULTIPROJECT_TYPE' } + 'boolean' { $request.version = $true; $prefix = 'MULTIPROJECT_TYPE' } + 'requestID' { $request.requestID = 'bad'; $prefix = 'MULTIPROJECT_UUID' } + 'blank' { $request.command.graphCommand.command.renameNode.title = ' '; $prefix = 'MULTIPROJECT_TITLE' } + } + Assert-ProtocolRejected { Invoke-MultiProjectRequest $peer $request } $prefix + Assert-Ownership ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer)) -ceq $before) 'Rejected request mutated peer state.' + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3930' 9 @('request-response-publication') +foreach ($json in @('{', '{"x":1,"x":2}', '[]')) { + Invoke-OwnershipCase $json { Assert-ProtocolRejected { ConvertFrom-MultiProjectJson $json } 'MULTIPROJECT_JSON' } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3941' 3 @('request-response-publication') +Invoke-OwnershipCase 'duplicate-request' { + $peer = New-ProtocolBaseline + $null = Invoke-MultiProjectRequest $peer (New-ProtocolRequest) + Assert-ProtocolRejected { Invoke-MultiProjectRequest $peer (New-ProtocolRequest) } 'MULTIPROJECT_DUPLICATE' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3943' 1 @('request-response-publication') +Invoke-OwnershipCase 'unanswered-publication' { + $peer = New-ProtocolBaseline + $null = Invoke-MultiProjectRequest $peer (New-ProtocolRequest) + Assert-ProtocolRejected { Complete-MultiProjectPublication $peer (New-MultiProjectPublication $peer $alpha) 'rename' $renameId } 'MULTIPROJECT_UNANSWERED' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3948' 1 @('request-response-publication') +foreach ($mutation in @('owner', 'selection', 'extra', 'type', 'duplicate', 'second')) { + Invoke-OwnershipCase $mutation { + $peer = New-ProtocolBaseline + $control = New-ProtocolControl + $prefix = 'MULTIPROJECT_OWNER' + switch ($mutation) { + 'owner' { $control.projectPath = $beta; $control.nodeID = $betaId } + 'selection' { $control.selection.nodeID = $alphaId; $prefix = 'MULTIPROJECT_SELECTION' } + 'extra' { $control.extra = 1; $prefix = 'MULTIPROJECT_SCHEMA' } + 'type' { $control.title = 7; $prefix = 'MULTIPROJECT_TYPE' } + 'duplicate' { $null = Invoke-MultiProjectPublicationControl $peer $control; $prefix = 'MULTIPROJECT_DUPLICATE' } + 'second' { $null = Invoke-MultiProjectPublicationControl $peer $control; $control.token = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; $prefix = 'MULTIPROJECT_ONE_SHOT' } + } + $before = ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer) + Assert-ProtocolRejected { Invoke-MultiProjectPublicationControl $peer $control } $prefix + Assert-Ownership ((ConvertTo-MultiProjectCanonicalJson (Get-MultiProjectPeerSnapshot $peer)) -ceq $before) 'Rejected control mutated peer state.' + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3954' 6 @('request-response-publication') +foreach ($mutation in @('unknown', 'extra', 'type', 'duplicate')) { + Invoke-OwnershipCase $mutation { + $peer = New-ProtocolBaseline + $response = (Invoke-MultiProjectRequest $peer (New-ProtocolRequest)).response + $prefix = 'MULTIPROJECT_CORRELATION' + switch ($mutation) { + 'unknown' { $response.requestID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' } + 'extra' { $response.extra = 1; $prefix = 'MULTIPROJECT_SCHEMA' } + 'type' { $response.success = 'true'; $prefix = 'MULTIPROJECT_TYPE' } + 'duplicate' { Complete-MultiProjectResponse $peer $response; $prefix = 'MULTIPROJECT_DUPLICATE' } + } + Assert-ProtocolRejected { Complete-MultiProjectResponse $peer $response } $prefix + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3973' 4 @('request-response-publication') +foreach ($mutation in @('value', 'missing', 'type', 'extra', 'requestID', 'unanswered', 'sequence', 'publicationExtra', 'answerType', 'zero')) { + Invoke-OwnershipCase $mutation { + $snapshots = Get-RenamedProtocolSnapshots + $after = Copy-MultiProjectValue $snapshots.after + switch ($mutation) { + 'value' { $after.received[-1].frame.command.graphCommand.command.renameNode.title = 'wrong' } + 'missing' { $after.received[-1].frame.command.graphCommand.command.renameNode.Remove('title') } + 'type' { $after.appliedCount = '1' } + 'extra' { $after.received[-1].frame.command.graphCommand.extra = 1 } + 'requestID' { $after.answered[-1].requestID = $listId } + 'unanswered' { $after.unansweredRequests = @($renameId) } + 'sequence' { $after.publications[-1].frame.sequence = '3' } + 'publicationExtra' { $after.publications[-1].frame.extra = 1 } + 'answerType' { $after.answered[-1].response.success = 1 } + 'zero' { $after.publicationCount = 0 } + } + Assert-Ownership (-not (Test-MultiProjectRenameReceipt $snapshots.before $after $alpha $alphaId 'Alpha renamed')) 'Canonical receipt negative was accepted.' + } +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3988' 10 @('receipt-negative-controls') +Invoke-OwnershipCase 'visible-intersection' { + $clipped = Get-MultiProjectClippedRectangle @(100, 100, 400, 400) @(220, 34, 1200, 250) + Assert-Ownership (($clipped -join ',') -ceq '220,100,400,250') 'Actual clipping did not retain the literal visible intersection.' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3889' 1 @('mixed-ui-policy') +Invoke-OwnershipCase 'offscreen-target-refused' { + Assert-ProtocolRejected { Get-MultiProjectClippedRectangle @(0, 0, 50, 50) @(220, 34, 1200, 900) } 'MULTIPROJECT_BOUNDS' +} +Complete-OwnershipFamily 'ownership.mixed-protocol.source-line-3894' 1 @('mixed-ui-policy') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 new file mode 100644 index 00000000..9baf2a06 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedReceipt.Contracts.ps1 @@ -0,0 +1,169 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +$names = @($gate.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + ($node.Name -like '*-MultiProject*' -or $node.Name -ceq 'ConvertTo-SketchCanonicalJson') +}, $true) | ForEach-Object Name) +. Import-OwnershipProductionFunctions $gate $names +$bytes = [IO.File]::ReadAllBytes((Join-Path $PSScriptRoot '..\Fixtures\accepted-peer-91dc.json')) +$expectedHash = '361883611D172E3A884A3F6CBCD5918EBACB94F531DDDF921627B5E36A706241' +Assert-Ownership ([Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -ceq $expectedHash) 'Accepted data-only peer fixture changed.' +$fixture = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) +function New-ReceiptReport { return ConvertFrom-MultiProjectReceiptJson $fixture } +function Assert-ReceiptRejected([scriptblock] $Action) { + $failure = $null + try { & $Action | Out-Null } catch { $failure = $_ } + Assert-Ownership ($failure -and $failure.Exception.Message.StartsWith('MULTIPROJECT_PEER_RECEIPT:', [StringComparison]::Ordinal)) 'Actual complete receipt validator did not specifically reject invalid evidence.' +} +Invoke-OwnershipCase 'accepted-whole-file' { + $report = New-ReceiptReport + $records = @(Write-MultiProjectPeerReceipt $fixture $report.multiProjectPeer 6>&1) + $information = @($records | Where-Object { $_ -is [Management.Automation.InformationRecord] }) + Assert-Ownership ($information.Count -eq 1) 'Whole accepted peer file did not produce exactly one receipt.' + $receipt = Read-MultiProjectPeerReceipt ([string]$information[0].MessageData) + Assert-Ownership ($receipt.utf8Sha256 -ceq $expectedHash -and $receipt.rawJson -ceq $fixture -and + $receipt.report.protocolConnected -and $receipt.report.correlatedRequests -and + $receipt.report.multiProjectPeer.received.Count -eq 11 -and $receipt.report.multiProjectPeer.answered.Count -eq 11 -and + $receipt.report.multiProjectPeer.applied.Count -eq 2 -and $receipt.report.multiProjectPeer.publications.Count -eq 7 -and + $receipt.report.multiProjectPeer.controls.Count -eq 1) 'Actual receipt lost original bytes, custody, complete correlations or peer state.' +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2411' 1 @('accepted-91dc-fixture-not-product-runtime') +function New-ReceiptWithResume { + $report = New-ReceiptReport + $peer = $report.multiProjectPeer + $owner = $peer.graphs[1] + $id = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + $response = [ordered]@{ version = 2; kind = 'response'; requestID = $id; success = $true } + $frame = [ordered]@{ version = 2; kind = 'request'; requestID = $id; command = [ordered]@{ + graphCommand = [ordered]@{ projectPath = $owner.project.path; command = [ordered]@{ + resumeSession = [ordered]@{ _0 = $owner.nodes[0].id } + } } + } } + $peer.received += [ordered]@{ requestID = $id; frame = $frame; expectedResponse = $response } + $peer.answered += [ordered]@{ requestID = $id; response = $response } + $peer.receivedCount++; $peer.requestCount++; $peer.responseCount++ + $report.requestCount++; $report.responseCount++ + $report.commands += 'graphCommand' + return $report +} +Invoke-OwnershipCase 'correlated-nonmutating-session' { + $report = New-ReceiptWithResume + Assert-MultiProjectCompleteReport $report + Assert-Ownership ($report.multiProjectPeer.applied.Count -eq 2) 'Resume acknowledgement changed rename accounting.' +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2439' 1 @('request-response-publication') +Invoke-OwnershipCase 'foreign-session-owner' { + $report = New-ReceiptWithResume + $report.multiProjectPeer.received[-1].frame.command.graphCommand.command.resumeSession._0 = $report.multiProjectPeer.graphs[0].nodes[0].id + Assert-ReceiptRejected { Assert-MultiProjectCompleteReport $report } +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2445' 1 @('request-response-publication') +$mutations = @( + 'missing-top', 'unknown-top', 'disconnected', 'correlation-false', 'global-error', 'global-unanswered', + 'root-count', 'connection-type', 'graph-not-sent', 'missing-peer', 'unknown-peer', 'count-type', 'count-missing', + 'unanswered', 'empty-requests', 'request-duplicate', 'request-id', 'request-kind-type', 'request-extra', + 'unknown-verb', 'wrong-owner', 'wrong-node', 'rename-title-type', 'missing-expectation', + 'answer-duplicate', 'answer-missing', 'answer-id', 'answer-kind', 'answer-success', 'answer-type', 'quick-chat-extra', + 'application-owner', 'application-node', 'application-title', 'application-before', 'application-duplicate', + 'publication-sequence', 'publication-kind-type', 'publication-owner', 'publication-title', 'publication-correlation', + 'publication-duplicate', 'unknown-cause', 'control-owner', 'control-selection', 'control-token', + 'graph-missing', 'graph-duplicate', 'graph-title', 'graph-id', 'graph-depth-loss' +) +foreach ($mutation in $mutations) { + Invoke-OwnershipCase $mutation { + $report = New-ReceiptReport + $peer = $report.multiProjectPeer + switch ($mutation) { + 'missing-top' { $report.Remove('protocolConnected') } + 'unknown-top' { $report.unexpected = $true } + 'disconnected' { $report.protocolConnected = $false } + 'correlation-false' { $report.correlatedRequests = $false } + 'global-error' { $report.error = 'actual error' } + 'global-unanswered' { $report.unansweredRequests = @('unanswered') } + 'root-count' { $report.requestCount++ } + 'connection-type' { $report.connectionCount = '2' } + 'graph-not-sent' { $report.graphSent = $false } + 'missing-peer' { $report.multiProjectPeer = $null } + 'unknown-peer' { $peer.unexpected = $true } + 'count-type' { $peer.requestCount = '11' } + 'count-missing' { $peer.Remove('receivedCount') } + 'unanswered' { $peer.unansweredRequests = @($peer.received[0].requestID) } + 'empty-requests' { $peer.received = @() } + 'request-duplicate' { $peer.received[1] = $peer.received[0] } + 'request-id' { $peer.received[0].frame.requestID = 'wrong' } + 'request-kind-type' { $peer.received[0].frame.kind = $true } + 'request-extra' { $peer.received[0].frame.extra = 1 } + 'unknown-verb' { $peer.received[0].frame.command = @{ unknown = @{} } } + 'wrong-owner' { $peer.received[-1].frame.command.graphCommand.projectPath = $peer.graphs[1].project.path } + 'wrong-node' { $peer.received[-1].frame.command.graphCommand.command.renameNode._0 = $peer.graphs[1].nodes[0].id } + 'rename-title-type' { $peer.received[-1].frame.command.graphCommand.command.renameNode.title = 7 } + 'missing-expectation' { $peer.received[-1].expectedResponse = $null } + 'answer-duplicate' { $peer.answered[1] = $peer.answered[0] } + 'answer-missing' { $peer.answered = @($peer.answered[0]) } + 'answer-id' { $peer.answered[-1].response.requestID = $peer.answered[0].requestID } + 'answer-kind' { $peer.answered[-1].response.kind = 'event' } + 'answer-success' { $peer.answered[-1].response.success = $false } + 'answer-type' { $peer.answered[-1].response.success = 'true' } + 'quick-chat-extra' { $peer.answered[1].response.event.quickChatsListed[0].unknown = 1 } + 'application-owner' { $peer.applied[0].projectPath = $peer.graphs[1].project.path } + 'application-node' { $peer.applied[0].nodeID = $peer.graphs[1].nodes[0].id } + 'application-title' { $peer.applied[0].title = 'Not applied' } + 'application-before' { $peer.applied[0].beforeTitle = 'Not prior' } + 'application-duplicate' { $peer.applied[1] = $peer.applied[0] } + 'publication-sequence' { $peer.publications[-1].frame.sequence = 99 } + 'publication-kind-type' { $peer.publications[-1].frame.kind = $true } + 'publication-owner' { $peer.publications[-1].frame.event.graphChanged.project.path = $peer.graphs[1].project.path } + 'publication-title' { $peer.publications[-1].frame.event.graphChanged.nodes[0].title = 'Not published' } + 'publication-correlation' { $peer.publications[-1].correlationID = $peer.received[0].requestID } + 'publication-duplicate' { $peer.publications[-1] = $peer.publications[-2] } + 'unknown-cause' { $peer.publications[-1].cause = 'unknown' } + 'control-owner' { $peer.controls[0].projectPath = $peer.graphs[1].project.path } + 'control-selection' { $peer.controls[0].selection.nodeID = $peer.graphs[0].nodes[0].id } + 'control-token' { $peer.controls[0].token = $peer.received[0].requestID } + 'graph-missing' { $peer.graphs = @($peer.graphs[0]) } + 'graph-duplicate' { $peer.graphs[1] = $peer.graphs[0] } + 'graph-title' { $peer.graphs[0].nodes[0].title = 'Not final' } + 'graph-id' { $peer.graphs[0].id = 'bad' } + 'graph-depth-loss' { $peer.graphs[0].nodes[0].presence = '@{presence=idle}' } + } + Assert-ReceiptRejected { New-MultiProjectPeerReceipt (ConvertTo-Json -InputObject $report -Depth 16 -Compress) } + } +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2460' 51 @('receipt-negative-controls') +foreach ($mutation in @('no-record', 'duplicate-record', 'bad-json', 'duplicate-property', 'bad-hash', + 'byte-count', 'report-mismatch', 'schema', 'provenance', 'json-depth', 'settled-mismatch')) { + Invoke-OwnershipCase $mutation { + $receipt = New-MultiProjectPeerReceipt $fixture + $line = 'UIA_MULTIPROJECT_PEER_RECEIPT=' + (ConvertTo-Json -InputObject $receipt -Depth 16 -Compress) + Assert-ReceiptRejected { + switch ($mutation) { + 'no-record' { Read-MultiProjectPeerReceipt 'native job succeeded without retained peer' } + 'duplicate-record' { Read-MultiProjectPeerReceipt ($line + "`n" + $line) } + 'bad-json' { Read-MultiProjectPeerReceipt 'UIA_MULTIPROJECT_PEER_RECEIPT={bad' } + 'duplicate-property' { Read-MultiProjectPeerReceipt 'UIA_MULTIPROJECT_PEER_RECEIPT={"x":1,"x":2}' } + 'bad-hash' { $receipt.utf8Sha256 = 'bad'; Assert-MultiProjectPeerReceipt $receipt } + 'byte-count' { $receipt.utf8ByteCount++; Assert-MultiProjectPeerReceipt $receipt } + 'report-mismatch' { $receipt.report.protocolConnected = $false; Assert-MultiProjectPeerReceipt $receipt } + 'schema' { $receipt.schemaVersion = '1'; Assert-MultiProjectPeerReceipt $receipt } + 'provenance' { $receipt.provenance = 'expected fixture'; Assert-MultiProjectPeerReceipt $receipt } + 'json-depth' { Read-MultiProjectPeerReceipt ('UIA_MULTIPROJECT_PEER_RECEIPT=' + + (ConvertTo-Json -InputObject $receipt -Depth 2 -Compress -WarningAction SilentlyContinue)) } + 'settled-mismatch' { + $peer = (New-ReceiptReport).multiProjectPeer + $peer.graphSequence++ + Write-MultiProjectPeerReceipt $fixture $peer + } + } + } + } +} +Complete-OwnershipFamily 'ownership.mixed-receipt.source-line-2520' 11 @('receipt-negative-controls') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 new file mode 100644 index 00000000..565d103a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStartup.Contracts.ps1 @@ -0,0 +1,226 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +. Import-OwnershipProductionFunctions $gate @('ConvertTo-MultiProjectStartupXPathLiteral', 'New-MultiProjectStartupEventQuery', + 'ConvertFrom-MultiProjectEventNumber', 'ConvertFrom-MultiProjectOwnedStartupEvent', + 'Invoke-MultiProjectOwnedStartupFailure', 'Get-MultiProjectRetentionError') +$eventStart = [DateTimeOffset]::Parse('2026-10-01T18:29:43.1462496Z').UtcDateTime +$eventEnd = $eventStart.AddSeconds(1) +$held = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } +$executable = 'D:\owned\graphcode-windows.exe' +function New-OwnedEvent([string] $Mutation = '') { + $pidValue = '0x{0:x}' -f $held.Id + $generation = '0x{0:x}' -f $eventStart.ToFileTimeUtc() + $path = $executable; $provider = 'Application Error'; $timestamp = $eventEnd.ToString('o') + $module = 'kernelbase.dll'; $code = '0xc0000142'; $extra = '' + switch ($Mutation) { + 'foreign-pid' { $pidValue = '0x7777'; $module = 'SECRET_FOREIGN_MODULE' } + 'reused-pid' { $generation = '0x{0:x}' -f $eventStart.AddSeconds(-1).ToFileTimeUtc() } + 'foreign-path' { $path = 'D:\foreign\other.exe'; $module = 'SECRET_FOREIGN_MODULE' } + 'provider' { $provider = 'Windows Error Reporting' } + 'outside-window' { $timestamp = $eventStart.AddSeconds(-1).ToString('o') } + 'duplicate-id' { $extra = '0x7777' } + 'module-path' { $module = 'C:\private\SECRET_FOREIGN_MODULE.dll' } + 'code-type' { $code = 'SECRET_COMMANDLINE' } + 'unnamed' { $extra = 'SECRET_MESSAGE' } + } + $xml = @" +100042$pidValue$generation$path$module$code0x1234$extra +"@ + if ($Mutation -ceq 'execution-only') { $xml = $xml.Replace('' + $pidValue + '', '') } + if ($Mutation -ceq 'missing-generation') { $xml = $xml.Replace('' + $generation + '', '') } + if ($Mutation -ceq 'oversized') { $xml += 'x' * 65536 } + if ($Mutation -ceq 'bad-xml') { $xml = 'broken' } + if ($Mutation -ceq 'dtd') { $xml = ']>' + $xml } + $record = [pscustomobject]@{ Xml = $xml } + $record | Add-Member ScriptMethod ToXml { $this.Xml } + $record | Add-Member ScriptProperty Message { throw 'Forbidden event Message access.' } + return $record +} +function Invoke-StartupProbe($Records, $Process = $held, [string] $Path = $executable, [string] $Faults = '') { + $state = @{ queries = 0; files = 0; summaries = 0; warnings = 0; caught = $null } + $original = [InvalidOperationException]::new('shell exited with code -1073741502') + try { + Invoke-MultiProjectOwnedStartupFailure $Process -1073741502 $Path $ArtifactRoot { throw $original } -observedUtc $eventEnd ` + -querySource { + param($filter, $maximum) + $state.queries++ + Assert-Ownership ($maximum -eq 8) 'Canonical startup event result bound changed.' + $xml = [xml]$filter + $selects = @($xml.QueryList.Query.Select) + $selector = $selects[0].InnerText + Assert-Ownership ($selects.Count -eq 1 -and $selects[0].Path -ceq 'Application' -and + $selector.Contains("Provider[@Name='Application Error']") -and $selector.Contains('EventID=1000') -and + $selector.Contains("Data[@Name='ProcessId']='5832'") -and + $selector.Contains("Data[@Name='ProcessCreationTime']='" + $eventStart.ToFileTimeUtc() + "'") -and + $selector.Contains("Data[@Name='AppPath']") -and $selector.Contains($executable) -and + $selector.Contains($eventStart.ToString('o')) -and $selector.Contains($eventEnd.ToString('o')) -and + -not $selector.Contains('Execution')) 'Canonical selector lost exact held subject generation/path/time.' + if ($Faults.Contains('query')) { throw [IO.IOException]::new('SECRET_QUERY_COMMANDLINE') } + return $Records + } -fileSink { + param($file, $json) + $state.files++ + if ($Faults.Contains('file')) { throw [IO.IOException]::new('SECRET_FILE_COMMANDLINE') } + } -summarySink { + param($line) + $state.summaries++ + if ($Faults.Contains('summary')) { throw [IO.IOException]::new('SECRET_SUMMARY_COMMANDLINE') } + Assert-Ownership (-not $line.Contains('SECRET') -and -not $line.Contains('kernelbase.dll', '').Replace('0xc0000142', '') + $result = Invoke-StartupProbe @($record) + Assert-Ownership ($result.report.events.Count -eq 1 -and $result.report.events[0].module.state -ceq 'unavailable' -and + $result.report.events[0].exceptionCode.state -ceq 'unavailable' -and $null -eq $result.report.events[0].module.value -and + -not $result.report.rootCauseEstablished) 'Unavailable module/code was synthesized from the failing exit.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2074' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'actual-no-match-error-record' { + $state = @{ queries = 0; caught = $null } + $original = [InvalidOperationException]::new('shell exited with code -1073741502') + try { + Invoke-MultiProjectOwnedStartupFailure $held -1073741502 $executable $ArtifactRoot { throw $original } -observedUtc $eventEnd ` + -querySource { + param($filter, $maximum) + $state.queries++ + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('No events'), 'NoMatchingEventsFound', + [Management.Automation.ErrorCategory]::ObjectNotFound, $null) + } -fileSink { param($path, $json) } -summarySink { param($line) } -warningSink { param($line) } + } catch { $state.caught = $_ } + Assert-Ownership ($state.queries -eq 1 -and [Object]::ReferenceEquals($state.caught.Exception, $original) -and + $state.caught.Exception.Data['OwnedStartupEvents'].state -ceq 'unknown-no-matching-canonical-events') 'Actual no-match ErrorRecord changed primary or fabricated diagnosis.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2083' 1 @('held-identity-canonical-event-mock') +foreach ($mutation in @('foreign-pid', 'reused-pid', 'foreign-path', 'provider', 'outside-window', 'duplicate-id', + 'module-path', 'code-type', 'unnamed', 'execution-only', 'missing-generation', 'oversized', 'bad-xml', 'dtd')) { + Invoke-OwnershipCase $mutation { + $result = Invoke-StartupProbe @((New-OwnedEvent $mutation)) + Assert-Ownership ($result.state.queries -eq 1 -and $result.report.state -ceq 'refused-or-unavailable' -and + $result.report.events.Count -eq 0 -and $result.secondary.Count -eq 1) 'Canonical event identity/schema/size refusal was weakened.' + } +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2101' 14 @('held-identity-canonical-event-mock') +foreach ($mutation in @('missing-pid', 'missing-start', 'relative-path', 'old-window', 'future-start', 'unsafe-quotes')) { + Invoke-OwnershipCase $mutation { + $process = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } + $path = $executable + switch ($mutation) { + 'missing-pid' { $process.Id = 0 } + 'missing-start' { $process.StartTime = $null } + 'relative-path' { $path = 'relative.exe' } + 'old-window' { $process.StartTime = $eventStart.AddSeconds(-31) } + 'future-start' { $process.StartTime = $eventEnd.AddSeconds(1) } + 'unsafe-quotes' { $path = 'D:\owned\a''"b.exe' } + } + $result = Invoke-StartupProbe @() $process $path + Assert-Ownership ($result.state.queries -eq 0 -and $result.secondary.Count -eq 1 -and $result.report.events.Count -eq 0) 'An invalid held identity reached event transport.' + } +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2109' 6 @('held-identity-canonical-event-mock') +foreach ($faults in @('query', 'file', 'summary', 'query-file-summary-warning')) { + Invoke-OwnershipCase $faults { + $result = Invoke-StartupProbe @((New-OwnedEvent)) -Faults $faults + Assert-Ownership ($result.secondary.Count -eq $(if ($faults -ceq 'query-file-summary-warning') { 4 } else { 1 }) -and + $result.state.queries -eq 1) 'A secondary writer/query failure was omitted.' + foreach ($failure in $result.secondary) { + Assert-Ownership ($failure.operation.StartsWith('startup-event') -and $failure.errorType -ceq 'System.IO.IOException' -and + $failure.hresult -is [int] -and -not $failure.message.Contains('SECRET')) 'Typed privacy-preserving secondary failure metadata changed.' + } + } +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2126' 4 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'over-eight-records' { + $record = New-OwnedEvent + $result = Invoke-StartupProbe @($record, $record, $record, $record, $record, $record, $record, $record, $record) + Assert-Ownership ($result.report.events.Count -eq 0 -and $result.secondary.Count -eq 1) 'Nine selected records were silently truncated.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2137' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'duplicate-record-identity' { + $record = New-OwnedEvent + $result = Invoke-StartupProbe @($record, $record) + Assert-Ownership ($result.report.events.Count -eq 0 -and $result.secondary.Count -eq 1) 'Duplicate event record identity inflated evidence.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2143' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'non-hosted-default-transport-refused' { + $snapshot = [Environment]::GetEnvironmentVariables('Process') + $state = @{ queries = 0; caught = $null } + function Get-WinEvent { $state.queries++; throw 'Forbidden actual event transport.' } + $original = [InvalidOperationException]::new('shell exited with code -1073741502') + try { + $env:GITHUB_ACTIONS = 'false' + try { + Invoke-MultiProjectOwnedStartupFailure $held -1073741502 $executable $ArtifactRoot { throw $original } -observedUtc $eventEnd ` + -fileSink { param($path, $json) } -summarySink { param($line) } -warningSink { param($line) } + } catch { $state.caught = $_ } + } finally { Restore-OwnershipEnvironment $snapshot @('GITHUB_ACTIONS') } + Assert-Ownership ($state.queries -eq 0 -and [Object]::ReferenceEquals($state.caught.Exception, $original) -and + $state.caught.Exception.Data['OwnedStartupEvents'].state -ceq 'refused-non-hosted-query') 'Default EventLog transport ran outside hosted CI.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2149' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'exact-path-xml-xpath-escaping' { + $identity = New-MultiProjectStartupEventQuery $held "D:\owned\a'b&c.exe" $eventEnd + $xml = [xml]$identity.filterXml + Assert-Ownership ($xml.QueryList.Query.Select.InnerText.Contains('Data[@Name=''AppPath'']="D:\owned\a''b&c.exe"') -and + $identity.filterXml.Contains('&')) 'Exact executable XPath/XML escaping changed.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2165' 1 @('held-identity-canonical-event-mock') +Invoke-OwnershipCase 'actual-first-failure-branch' { + $branch = $gate.Find({ param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if ($process.HasExited)', [StringComparison]::Ordinal) -and + $node.Extent.Text.Contains('Invoke-MultiProjectOwnedStartupFailure') + }, $true) + Assert-Ownership ($null -ne $branch) 'Actual first-failure branch missing.' + $process = [pscustomobject]@{ HasExited = $true; Id = 5832; ExitCode = -1073741502; StartTime = $eventStart } + $Shell = $executable; $logDirectory = $ArtifactRoot; $settingsDirectory = $ArtifactRoot + $state = @{ legacy = 0; canonical = 0; caught = $null } + function Write-UiaStartupFailureDiagnostic { param($held, $exit, $exe, $cwd, $logs, $support) $state.legacy++ } + function Invoke-MultiProjectOwnedStartupFailure { + param($held, $exit, $exe, $logs, $failure) + $state.canonical++ + & $failure + } + try { . ([scriptblock]::Create($branch.Extent.Text)) } catch { $state.caught = $_ } + Assert-Ownership ($state.legacy -eq 1 -and $state.canonical -eq 1 -and + $state.caught.Exception.Message -ceq 'shell exited with code -1073741502') 'Actual failure branch replayed diagnostics or changed the primary.' +} +Complete-OwnershipFamily 'ownership.mixed-startup.source-line-2173' 1 @('held-identity-canonical-event-mock') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 new file mode 100644 index 00000000..917e8d15 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/MixedStream.Contracts.ps1 @@ -0,0 +1,221 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $AssemblyPath +) + +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +Import-OwnershipBindings $RepositoryRoot $AssemblyPath +$gate = Read-OwnershipProductionAst (Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1') +. Import-OwnershipProductionFunctions $gate @('Invoke-MultiProjectTypeText', 'Get-MultiProjectRetentionError', 'Get-EdgeTextAttemptDecision') +Invoke-OwnershipCase 'actual-caller-five-records-before-fifth-guard' { + $caller = $gate.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Invoke-MultiProjectNativeRename' + }, $true) + $assignment = $caller.Find({ param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Extent.Text.StartsWith('$null = Invoke-MultiProjectTypeText 9904 $typedTitle $inputEvidence -typeSource', [StringComparison]::Ordinal) + }, $true) + Assert-Ownership ($null -ne $assignment) 'Actual streaming Rename caller binding missing.' + $state = @{ calls = 0; primary = $null } + function Invoke-MultiProjectSequencedEdit { + param($modal, $processId, $prefill, $text) + $state.calls++ + foreach ($attempt in 1..5) { + Write-Host "UIA_EDGE_TEXT_STABLE id=9904 attempt=$attempt after='' expected='$text' inputAttempted=False textSent=0/0" + $null = Get-EdgeTextAttemptDecision $true '' $text $attempt 5 + } + } + $typedTitle = ' '; $modal = [IntPtr]42; $multiProcess = [pscustomobject]@{ Id = 4242 }; $prefill = 'Alpha renamed' + $inputEvidence = [Collections.Generic.List[string]]::new() + $records = @(& { + try { . ([scriptblock]::Create($assignment.Extent.Text)) } catch { $state.primary = $_ } + } 6>&1) + $attempts = @($records | Where-Object { $_ -is [Management.Automation.InformationRecord] -and + ([string]$_.MessageData).StartsWith('UIA_EDGE_TEXT_STABLE ', [StringComparison]::Ordinal) }) + Assert-Ownership ($state.calls -eq 1 -and $attempts.Count -eq 5 -and $inputEvidence.Count -eq 5 -and + $state.primary.Exception.Message -ceq "edge text mismatch attempts=5/5 expected=' ' observed=''") 'Actual caller replayed input, lost produced records, or changed the fifth guard.' +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2193' 1 @('powershell-streams-error-identity') +foreach ($scenario in @('focus-not-acquired', 'native-false-clear-only', 'native-false-full-six-empty', + 'partial-counts', 'stale-control', 'native-error', 'exact-three-spaces')) { + Invoke-OwnershipCase $scenario { + . Import-OwnershipProductionFunctions $gate @('Require', 'Edge-TypeText', 'Get-EdgeTextAttemptDecision') @{ + '[GraphCodeUiaGateState]::' = '$transport.' + } + $transport = [pscustomobject]@{ + scenario = $scenario; buffer = ''; focus = [IntPtr]::Zero; calls = 0 + LastEditClearExpected = [uint32]0; LastEditClearSent = [uint32]0 + LastEditTextExpected = [uint32]0; LastEditTextSent = [uint32]0 + } + $transport | Add-Member ScriptMethod EditTextById { param($window, $id) $this.buffer } + $transport | Add-Member ScriptMethod ControlById { + param($window, $id) + if ($this.scenario -ceq 'stale-control') { return [IntPtr]::Zero } + return [IntPtr]$id + } + $transport | Add-Member ScriptMethod WindowBounds { param($window) @(10, 20, 510, 44) } + $transport | Add-Member ScriptMethod IsControlOwnedBy { param($window, $control, $id) $control -eq [IntPtr]$id } + $transport | Add-Member ScriptMethod HasVisibleBounds { param($control) $control -ne [IntPtr]::Zero } + $transport | Add-Member ScriptMethod WindowIsVisible { param($window) $true } + $transport | Add-Member ScriptMethod WindowTextOf { param($window) ('Rename ' + [char]0x201c + 'Alpha renamed' + [char]0x201d) } + $transport | Add-Member ScriptMethod WindowProcessId { param($window) 4242 } + $transport | Add-Member ScriptMethod ControlIdOf { param($window) [int]$window.ToInt64() } + $transport | Add-Member ScriptMethod FocusedControlInDialog { param($window) $this.focus } + $transport | Add-Member ScriptMethod FocusControl { param($window, $control) $this.focus = $control; $true } + $transport | Add-Member ScriptMethod TypeEditTextById { + param($window, $id, $text) + $this.calls++ + if ($this.scenario -ceq 'native-error') { throw 'controlled original native adapter error' } + $this.LastEditClearExpected = [uint32]2; $this.LastEditClearSent = [uint32]2 + $this.LastEditTextExpected = [uint32]0; $this.LastEditTextSent = [uint32]0 + if ($this.scenario -ceq 'partial-counts') { $this.LastEditClearSent = [uint32]1; return $false } + if ($this.scenario -in @('native-false-full-six-empty', 'exact-three-spaces')) { + $this.LastEditTextExpected = [uint32]6; $this.LastEditTextSent = [uint32]6 + } + if ($this.scenario -ceq 'exact-three-spaces') { $this.buffer = $text; return $true } + return $false + } + function Ensure-ShellForeground { param($window, $label) $scenario -cne 'focus-not-acquired' } + function Start-Sleep { param($Milliseconds) } + $renameProcess = [pscustomobject]@{ Id = 4242 } + $renameProcess | Add-Member ScriptMethod WaitForInputIdle { param($timeout) $true } + $script:edgeWorkflowWindow = [IntPtr]42 + $script:edgeWorkflowTitle = 'Rename ' + [char]0x201c + 'Alpha renamed' + [char]0x201d + $evidence = [Collections.Generic.List[string]]::new() + $state = @{ sourceCalls = 0; primary = $null } + $records = @(& { + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) $state.sourceCalls++; Edge-TypeText $id $text + } + } catch { $state.primary = $_ } + } 6>&1) + $attempts = @($records | Where-Object { $_ -is [Management.Automation.InformationRecord] -and + ([string]$_.MessageData).StartsWith('UIA_EDGE_TEXT_STABLE ', [StringComparison]::Ordinal) }) + Assert-Ownership ($state.sourceCalls -eq 1) 'Stream collector replayed the actual native helper.' + switch ($scenario) { + 'exact-three-spaces' { + Assert-Ownership ($attempts.Count -eq 1 -and $evidence.Count -eq 1 -and $transport.calls -eq 1 -and + -not $state.primary -and $transport.buffer -ceq ' ') 'Exact whitespace was coerced or the input source repeated.' + } + { $_ -in @('focus-not-acquired', 'native-false-clear-only', 'native-false-full-six-empty') } { + Assert-Ownership ($attempts.Count -eq 5 -and $evidence.Count -eq 5 -and + $state.primary.Exception.Message -ceq "edge text mismatch attempts=5/5 expected=' ' observed=''" -and + $transport.calls -eq $(if ($scenario -ceq 'focus-not-acquired') { 0 } else { 5 })) 'Fifth guard or actual input/produced evidence counts changed.' + if ($scenario -ceq 'native-false-clear-only') { + Assert-Ownership (([string]$attempts[-1].MessageData).Contains('textSent=0/0') -and + ([string]$attempts[-1].MessageData).Contains('inputCountsFull=True')) 'Clear-only input availability was mislabeled.' + } + } + 'partial-counts' { + Assert-Ownership ($attempts.Count -eq 0 -and $transport.calls -eq 1 -and + $state.primary.Exception.Message.Contains('SendInput count mismatch')) 'Partial count primary was weakened.' + } + 'stale-control' { + Assert-Ownership ($attempts.Count -eq 0 -and $transport.calls -eq 0 -and + $state.primary.Exception.Message.Contains('unavailable after layout wait')) 'Stale control guard was bypassed.' + } + 'native-error' { + Assert-Ownership ($attempts.Count -eq 0 -and $transport.calls -eq 1 -and + $state.primary.Exception.ToString().Contains('controlled original native adapter error')) 'Native failure was replaced or input replayed.' + } + } + } +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2221' 7 @('powershell-streams-error-identity') +foreach ($mode in @('source-primary', 'source-plus-sink', 'sink-only')) { + Invoke-OwnershipCase $mode { + $state = @{ calls = 0; produced = 0; sinks = 0; failure = $null } + $evidence = [Collections.Generic.List[string]]::new() + $null = @(& { + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) + $state.calls++ + foreach ($attempt in 1..5) { + $state.produced++ + Write-Host "UIA_EDGE_TEXT_STABLE id=$id attempt=$attempt textSent=0/0" + Assert-Ownership ($state.sinks -eq $state.produced) 'Record was not forwarded synchronously before the next source step.' + } + if ($mode -cne 'sink-only') { throw 'controlled unchanged primary' } + } -recordSink { + param($message) + $state.sinks++ + if ($mode -cne 'source-primary') { throw 'controlled retention sink failure' } + Write-Host $message + } + } catch { $state.failure = $_ } + } 6>&1) + Assert-Ownership ($state.calls -eq 1 -and $state.produced -eq 5 -and $state.sinks -eq 5 -and + $evidence.Count -eq 5 -and $state.failure) 'Source/sink failures replayed or lost produced evidence.' + if ($mode -ceq 'sink-only') { + Assert-Ownership ($state.failure.Exception.Message.StartsWith('MULTIPROJECT_INPUT_RETENTION:', [StringComparison]::Ordinal)) 'A sink-only failure returned success.' + } else { + Assert-Ownership ($state.failure.Exception.Message -ceq 'controlled unchanged primary') 'The primary failure changed.' + if ($mode -ceq 'source-plus-sink') { + Assert-Ownership (@($state.failure.Exception.Data['MultiProjectInputRetention']).Count -eq 5) 'Secondary retention failures were lost.' + } + } + } +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2305' 3 @('powershell-streams-error-identity') +Invoke-OwnershipCase 'exact-primary-exception-object' { + $original = [InvalidOperationException]::new('exact original exception') + $state = @{ calls = 0; failure = $null } + $evidence = [Collections.Generic.List[string]]::new() + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) + $state.calls++ + Write-Host 'UIA_EDGE_TEXT_STABLE id=9904 attempt=1 textSent=0/0' + throw $original + } + } catch { $state.failure = $_ } + Assert-Ownership ($state.calls -eq 1 -and [Object]::ReferenceEquals($state.failure.Exception, $original) -and $evidence.Count -eq 1) 'The identical original exception was not retained.' +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2342' 1 @('powershell-streams-error-identity') +foreach ($faults in @('attempt', 'summary', 'attempt-summary', 'attempt-warning', 'summary-warning', 'attempt-summary-warning')) { + Invoke-OwnershipCase $faults { + $original = [InvalidOperationException]::new('original fifth/native primary') + $state = @{ calls = 0; records = 0; summaries = 0; warnings = 0; failure = $null } + $evidence = [Collections.Generic.List[string]]::new() + try { + $null = Invoke-MultiProjectTypeText 9904 ' ' $evidence -typeSource { + param($id, $text) + $state.calls++ + foreach ($attempt in 1..5) { Write-Host "UIA_EDGE_TEXT_STABLE id=9904 attempt=$attempt textSent=0/0" } + throw $original + } -recordSink { + param($message) + $state.records++ + if ($faults.Contains('attempt')) { throw [IO.IOException]::new('attempt writer IO') } + Write-Host $message + } -failureSink { + param($message) + $state.summaries++ + if ($faults.Contains('summary')) { throw [IO.IOException]::new('summary writer IO') } + Write-Host $message + } -warningSink { + param($message) + $state.warnings++ + if ($faults.Contains('warning')) { throw [IO.IOException]::new('warning writer IO') } + } + } catch { $state.failure = $_ } + $secondary = @($state.failure.Exception.Data['MultiProjectInputRetention']) + $expected = $(if ($faults.Contains('attempt')) { 5 } else { 0 }) + + $(if ($faults.Contains('summary')) { 1 } else { 0 }) + $(if ($faults.Contains('warning')) { 1 } else { 0 }) + Assert-Ownership ([Object]::ReferenceEquals($state.failure.Exception, $original) -and + $state.failure.Exception.Message -ceq 'original fifth/native primary' -and $state.calls -eq 1 -and + $state.records -eq 5 -and $state.summaries -eq 1 -and $state.warnings -eq 1 -and $evidence.Count -eq 5 -and + $secondary.Count -eq $expected) 'Production catch lost primary identity, typed diagnostics, attempt records or replayed source.' + foreach ($error in $secondary) { + Assert-Ownership ($error.operation -in @('attempt-record', 'failure-summary', 'warning') -and + $error.errorType -ceq 'System.IO.IOException' -and $error.hresult -is [int] -and + $error.message.Contains('writer IO')) 'Secondary writer failure lost its operation/type/HResult/message.' + } + } +} +Complete-OwnershipFamily 'ownership.mixed-stream.source-line-2359' 6 @('powershell-streams-error-identity') +if ($script:OwnershipAdapterFailed) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Ownership/README.md b/Tools/windows/ValidationRunner/Ownership/README.md index d907e690..ffaf36be 100644 --- a/Tools/windows/ValidationRunner/Ownership/README.md +++ b/Tools/windows/ValidationRunner/Ownership/README.md @@ -66,14 +66,29 @@ transport is the host's authoritative held-job supervisor. Scope, ScriptMethod, ErrorRecord identity, information streams and missing-versus-empty environment membership remain PowerShell assertions. -The full mixed family remains mandatory and `Incomplete`; it cannot be -replaced by the compiled helper tests. +The full mixed family remains mandatory and cannot be replaced by compiled +helper tests. `mixed-source-map.json` maps all 126 original invocation +templates onto 133 unique dependency units, with 465 expected original +expanded cases. The host derives this family only after every dependency +actually passes its exact count and the map is complete, unique and matches +the manifest. The two added primitive-focus failure controls remain separate +required units, not an inflation of an original template. `source-correspondence.json` records every one of the immutable mixed source's 126 invocation templates and 192 assertion commands, with stable source IDs and zero claimed execution. Those source occurrences are not runtime counts. The mixed eight-mode capture/cleanup negative controls remain required. +The original mixed ownership/protocol assertions run in focused adapters: +canonical startup event identity and privacy (33), streamed record/ErrorRecord +identity (18), whole accepted peer receipt and canonical negative controls +(65), exact request/response/publication and clipping policy (42), and five +real production-stub pipe obligations. The UI implementation supplies the +other original 83 templates in its own files (116 policy and 178 native-policy +expanded cases), plus two independent primitive failure controls. Counts +describe actual executed cases; the source invocation/assertion census does +not count as execution. + The three mixed source-boundary assertions now have actual protected PowerShell source checks. Six newly reconciled startup/tree units also run: thirteen complete helper-presence checks (source evidence), three bounded file diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs index 5e8ba705..91e51f7a 100644 --- a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -14,6 +14,13 @@ namespace GraphCode.Validation.Ownership; public static class SuiteRegistration { + private static readonly string[] mixedUiPolicyIds = Array.ConvertAll(new[] + { + 1724, 1733, 1749, 1757, 1769, 1777, 1789, 1804, 1811, 1818, 1824, 1837, 1843, 1854, 1870, 1879, 1917, 1924, 1951, + 3392, 3423, 3443, 3458, 3469, 3482, 3487, 3497, 3524, 3530, 3535, 3540, 3545, 3550, 3555, 3560, 3565, 3570, 3575, + 3596, 3601, 3608, 3625, 3635, 3732, 3755, 3763, 3771, 3792, 3816, 3846 + }, line => "ownership.mixed-ui.source-line-" + line); + public static IReadOnlyList CreateSuites() => [ new OwnershipSuite(), @@ -38,7 +45,28 @@ public static IReadOnlyList CreateSuites() => "ownership.mixed-capture.primary-secondary", "ownership.mixed-capture.cleanup-only", "ownership.ui-process-tree.controlled-child", "ownership.ui-process-tree.cleanup-complete"]), new Adapters.StandaloneSuites.CaseScriptSuite("ownership.production-linkage", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\ProductionLinkage.Contracts.ps1", - "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.packaging-shared-linkage", "ownership.legacy.uia-shared-linkage"]) + "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.packaging-shared-linkage", "ownership.legacy.uia-shared-linkage"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-receipt", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedReceipt.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", ["ownership.mixed-receipt.source-line-2411", "ownership.mixed-receipt.source-line-2439", + "ownership.mixed-receipt.source-line-2445", "ownership.mixed-receipt.source-line-2460", "ownership.mixed-receipt.source-line-2520"]), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-ui-policy", "Tools\\windows\\ValidationRunner\\Ui\\MixedPolicyContracts.ps1", + "OWNERSHIP_CASE_RESULT=", mixedUiPolicyIds), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-protocol", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedProtocol.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 3353, 3368, 3373, 3383, 3889, 3894, 3897, 3908, 3930, 3941, 3943, 3948, 3954, 3973, 3988 }, + line => "ownership.mixed-protocol.source-line-" + line)), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-stream", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedStream.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 2193, 2221, 2305, 2342, 2359 }, + line => "ownership.mixed-stream.source-line-" + line)), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-startup", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedStartup.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 2060, 2068, 2074, 2083, 2101, 2109, 2126, 2137, 2143, 2149, 2165, 2173 }, + line => "ownership.mixed-startup.source-line-" + line)), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-ui-native", "Tools\\windows\\ValidationRunner\\Ui\\MixedNativeContracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 2706, 2724, 2732, 2742, 2751, 2785, 2808, 2845, 2850, 2857, 2871, 2897, 2906, 2924, 3013, + 3024, 3040, 3057, 3129, 3138, 3154, 3202, 3214, 3224, 3237, 3268, 3278, 3283, 3291, 3296, 3314, 3331, 3341 }, + line => "ownership.mixed-ui.source-line-" + line).Concat(new[] { "ownership.mixed-ui.focus-control-false", "ownership.mixed-ui.focus-control-throw" }).ToArray()), + new Adapters.StandaloneSuites.CaseScriptSuite("ownership.mixed-pipe", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\MixedPipe.Contracts.ps1", + "OWNERSHIP_CASE_RESULT=", Array.ConvertAll(new[] { 4053, 4056, 4063, 4072, 4093 }, + line => "ownership.mixed-pipe.source-line-" + line)) ]; } @@ -69,14 +97,9 @@ public sealed class OwnershipSuite : IValidationSuite ]; private static readonly string[] nativeIds = ["normal", "abrupt", "timeout", "inherited", "inherited-timeout", "arguments-environment", "real-pipe", "bytes-utf8-bom", "bytes-utf16-bom", "bytes-invalid-utf8", "bytes-utf8-supplementary"]; - private static readonly (string Id, string Evidence)[] unported = - [ - ("legacy.mixed-multiproject", "Complete mixed source 1672-4245 remains mandatory: UI predicates, native/fresh snapshot policies, receipts, strict protocol, owned real pipes, eight bounded-capture and primary/cleanup negative controls. Source invocation inventories are not executed counts.") - ]; - public string Id => "ownership"; public IReadOnlyList UnitIds { get; } = managedIds.Select(id => "ownership.managed." + id) - .Concat(nativeIds.Select(id => "ownership.native." + id)).Concat(unported.Select(p => "ownership." + p.Id)).ToArray(); + .Concat(nativeIds.Select(id => "ownership.native." + id)).ToArray(); public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) { @@ -96,8 +119,6 @@ public async Task> RunAsync(ValidationContext context, cancellationToken.ThrowIfCancellationRequested(); await ExecuteAsync("ownership.native." + id, () => NativeAsync(id, root, alpha, beta, cancellationToken), results, "controlled-native").ConfigureAwait(false); } - results.AddRange(unported.Select(p => new UnitResult("ownership." + p.Id, UnitStatus.Incomplete, 0, - p.Evidence + " Not replaced by the separately counted compiled cases."))); return results; } diff --git a/Tools/windows/ValidationRunner/Ownership/mixed-source-map.json b/Tools/windows/ValidationRunner/Ownership/mixed-source-map.json new file mode 100644 index 00000000..36100a8a --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/mixed-source-map.json @@ -0,0 +1,893 @@ +{ + "templates": [ + { + "sourceId": "line-1724", + "unitIds": [ + "ownership.mixed-ui.source-line-1724" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1733", + "unitIds": [ + "ownership.mixed-ui.source-line-1733" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1749", + "unitIds": [ + "ownership.mixed-ui.source-line-1749" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-1757", + "unitIds": [ + "ownership.mixed-ui.source-line-1757" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1769", + "unitIds": [ + "ownership.mixed-ui.source-line-1769" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-1777", + "unitIds": [ + "ownership.mixed-ui.source-line-1777" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1789", + "unitIds": [ + "ownership.mixed-ui.source-line-1789" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1804", + "unitIds": [ + "ownership.mixed-ui.source-line-1804" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1811", + "unitIds": [ + "ownership.mixed-ui.source-line-1811" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1818", + "unitIds": [ + "ownership.mixed-ui.source-line-1818" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1824", + "unitIds": [ + "ownership.mixed-ui.source-line-1824" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1837", + "unitIds": [ + "ownership.mixed-ui.source-line-1837" + ], + "expectedCount": 5 + }, + { + "sourceId": "line-1843", + "unitIds": [ + "ownership.mixed-ui.source-line-1843" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1854", + "unitIds": [ + "ownership.mixed-ui.source-line-1854" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-1870", + "unitIds": [ + "ownership.mixed-ui.source-line-1870" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1879", + "unitIds": [ + "ownership.mixed-ui.source-line-1879" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-1917", + "unitIds": [ + "ownership.mixed-ui.source-line-1917" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-1924", + "unitIds": [ + "ownership.mixed-ui.source-line-1924" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-1951", + "unitIds": [ + "ownership.mixed-ui.source-line-1951" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2060", + "unitIds": [ + "ownership.mixed-startup.source-line-2060" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2068", + "unitIds": [ + "ownership.mixed-startup.source-line-2068" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2074", + "unitIds": [ + "ownership.mixed-startup.source-line-2074" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2083", + "unitIds": [ + "ownership.mixed-startup.source-line-2083" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2101", + "unitIds": [ + "ownership.mixed-startup.source-line-2101" + ], + "expectedCount": 14 + }, + { + "sourceId": "line-2109", + "unitIds": [ + "ownership.mixed-startup.source-line-2109" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-2126", + "unitIds": [ + "ownership.mixed-startup.source-line-2126" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-2137", + "unitIds": [ + "ownership.mixed-startup.source-line-2137" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2143", + "unitIds": [ + "ownership.mixed-startup.source-line-2143" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2149", + "unitIds": [ + "ownership.mixed-startup.source-line-2149" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2165", + "unitIds": [ + "ownership.mixed-startup.source-line-2165" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2173", + "unitIds": [ + "ownership.mixed-startup.source-line-2173" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2193", + "unitIds": [ + "ownership.mixed-stream.source-line-2193" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2221", + "unitIds": [ + "ownership.mixed-stream.source-line-2221" + ], + "expectedCount": 7 + }, + { + "sourceId": "line-2305", + "unitIds": [ + "ownership.mixed-stream.source-line-2305" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-2342", + "unitIds": [ + "ownership.mixed-stream.source-line-2342" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2359", + "unitIds": [ + "ownership.mixed-stream.source-line-2359" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-2411", + "unitIds": [ + "ownership.mixed-receipt.source-line-2411" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2439", + "unitIds": [ + "ownership.mixed-receipt.source-line-2439" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2445", + "unitIds": [ + "ownership.mixed-receipt.source-line-2445" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2460", + "unitIds": [ + "ownership.mixed-receipt.source-line-2460" + ], + "expectedCount": 51 + }, + { + "sourceId": "line-2520", + "unitIds": [ + "ownership.mixed-receipt.source-line-2520" + ], + "expectedCount": 11 + }, + { + "sourceId": "line-2706", + "unitIds": [ + "ownership.mixed-ui.source-line-2706" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-2724", + "unitIds": [ + "ownership.mixed-ui.source-line-2724" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2732", + "unitIds": [ + "ownership.mixed-ui.source-line-2732" + ], + "expectedCount": 2 + }, + { + "sourceId": "line-2742", + "unitIds": [ + "ownership.mixed-ui.source-line-2742" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2751", + "unitIds": [ + "ownership.mixed-ui.source-line-2751" + ], + "expectedCount": 10 + }, + { + "sourceId": "line-2785", + "unitIds": [ + "ownership.mixed-ui.source-line-2785" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-2808", + "unitIds": [ + "ownership.mixed-ui.source-line-2808" + ], + "expectedCount": 22 + }, + { + "sourceId": "line-2845", + "unitIds": [ + "ownership.mixed-ui.source-line-2845" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2850", + "unitIds": [ + "ownership.mixed-ui.source-line-2850" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2857", + "unitIds": [ + "ownership.mixed-ui.source-line-2857" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2871", + "unitIds": [ + "ownership.mixed-ui.source-line-2871" + ], + "expectedCount": 2 + }, + { + "sourceId": "line-2897", + "unitIds": [ + "ownership.mixed-ui.source-line-2897" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2906", + "unitIds": [ + "ownership.mixed-ui.source-line-2906" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-2924", + "unitIds": [ + "ownership.mixed-ui.source-line-2924" + ], + "expectedCount": 59 + }, + { + "sourceId": "line-3013", + "unitIds": [ + "ownership.mixed-ui.source-line-3013" + ], + "expectedCount": 2 + }, + { + "sourceId": "line-3024", + "unitIds": [ + "ownership.mixed-ui.source-line-3024" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-3040", + "unitIds": [ + "ownership.mixed-ui.source-line-3040" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3057", + "unitIds": [ + "ownership.mixed-ui.source-line-3057" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3129", + "unitIds": [ + "ownership.mixed-ui.source-line-3129" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3138", + "unitIds": [ + "ownership.mixed-ui.source-line-3138" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3154", + "unitIds": [ + "ownership.mixed-ui.source-line-3154" + ], + "expectedCount": 29 + }, + { + "sourceId": "line-3202", + "unitIds": [ + "ownership.mixed-ui.source-line-3202" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3214", + "unitIds": [ + "ownership.mixed-ui.source-line-3214" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3224", + "unitIds": [ + "ownership.mixed-ui.source-line-3224" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3237", + "unitIds": [ + "ownership.mixed-ui.source-line-3237" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-3268", + "unitIds": [ + "ownership.mixed-ui.source-line-3268" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3278", + "unitIds": [ + "ownership.mixed-ui.source-line-3278" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3283", + "unitIds": [ + "ownership.mixed-ui.source-line-3283" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3291", + "unitIds": [ + "ownership.mixed-ui.source-line-3291" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3296", + "unitIds": [ + "ownership.mixed-ui.source-line-3296" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3314", + "unitIds": [ + "ownership.mixed-ui.source-line-3314" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-3331", + "unitIds": [ + "ownership.mixed-ui.source-line-3331" + ], + "expectedCount": 7 + }, + { + "sourceId": "line-3341", + "unitIds": [ + "ownership.mixed-ui.source-line-3341" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3353", + "unitIds": [ + "ownership.mixed-protocol.source-line-3353" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3368", + "unitIds": [ + "ownership.mixed-protocol.source-line-3368" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3373", + "unitIds": [ + "ownership.mixed-protocol.source-line-3373" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3383", + "unitIds": [ + "ownership.mixed-protocol.source-line-3383" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3392", + "unitIds": [ + "ownership.mixed-ui.source-line-3392" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3423", + "unitIds": [ + "ownership.mixed-ui.source-line-3423" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3443", + "unitIds": [ + "ownership.mixed-ui.source-line-3443" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3458", + "unitIds": [ + "ownership.mixed-ui.source-line-3458" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3469", + "unitIds": [ + "ownership.mixed-ui.source-line-3469" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3482", + "unitIds": [ + "ownership.mixed-ui.source-line-3482" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3487", + "unitIds": [ + "ownership.mixed-ui.source-line-3487" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3497", + "unitIds": [ + "ownership.mixed-ui.source-line-3497" + ], + "expectedCount": 17 + }, + { + "sourceId": "line-3524", + "unitIds": [ + "ownership.mixed-ui.source-line-3524" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3530", + "unitIds": [ + "ownership.mixed-ui.source-line-3530" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3535", + "unitIds": [ + "ownership.mixed-ui.source-line-3535" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3540", + "unitIds": [ + "ownership.mixed-ui.source-line-3540" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3545", + "unitIds": [ + "ownership.mixed-ui.source-line-3545" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3550", + "unitIds": [ + "ownership.mixed-ui.source-line-3550" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3555", + "unitIds": [ + "ownership.mixed-ui.source-line-3555" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3560", + "unitIds": [ + "ownership.mixed-ui.source-line-3560" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3565", + "unitIds": [ + "ownership.mixed-ui.source-line-3565" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3570", + "unitIds": [ + "ownership.mixed-ui.source-line-3570" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3575", + "unitIds": [ + "ownership.mixed-ui.source-line-3575" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3596", + "unitIds": [ + "ownership.mixed-ui.source-line-3596" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3601", + "unitIds": [ + "ownership.mixed-ui.source-line-3601" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3608", + "unitIds": [ + "ownership.mixed-ui.source-line-3608" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3625", + "unitIds": [ + "ownership.mixed-ui.source-line-3625" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3635", + "unitIds": [ + "ownership.mixed-ui.source-line-3635" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3732", + "unitIds": [ + "ownership.mixed-ui.source-line-3732" + ], + "expectedCount": 8 + }, + { + "sourceId": "line-3755", + "unitIds": [ + "ownership.mixed-ui.source-line-3755" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3763", + "unitIds": [ + "ownership.mixed-ui.source-line-3763" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3771", + "unitIds": [ + "ownership.mixed-ui.source-line-3771" + ], + "expectedCount": 9 + }, + { + "sourceId": "line-3792", + "unitIds": [ + "ownership.mixed-ui.source-line-3792" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3816", + "unitIds": [ + "ownership.mixed-ui.source-line-3816" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3846", + "unitIds": [ + "ownership.mixed-ui.source-line-3846" + ], + "expectedCount": 11 + }, + { + "sourceId": "line-3889", + "unitIds": [ + "ownership.mixed-protocol.source-line-3889" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3894", + "unitIds": [ + "ownership.mixed-protocol.source-line-3894" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3897", + "unitIds": [ + "ownership.mixed-protocol.source-line-3897" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3908", + "unitIds": [ + "ownership.mixed-protocol.source-line-3908" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3930", + "unitIds": [ + "ownership.mixed-protocol.source-line-3930" + ], + "expectedCount": 9 + }, + { + "sourceId": "line-3941", + "unitIds": [ + "ownership.mixed-protocol.source-line-3941" + ], + "expectedCount": 3 + }, + { + "sourceId": "line-3943", + "unitIds": [ + "ownership.mixed-protocol.source-line-3943" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3948", + "unitIds": [ + "ownership.mixed-protocol.source-line-3948" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-3954", + "unitIds": [ + "ownership.mixed-protocol.source-line-3954" + ], + "expectedCount": 6 + }, + { + "sourceId": "line-3973", + "unitIds": [ + "ownership.mixed-protocol.source-line-3973" + ], + "expectedCount": 4 + }, + { + "sourceId": "line-3988", + "unitIds": [ + "ownership.mixed-protocol.source-line-3988" + ], + "expectedCount": 10 + }, + { + "sourceId": "line-4053", + "unitIds": [ + "ownership.mixed-pipe.source-line-4053" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4056", + "unitIds": [ + "ownership.mixed-pipe.source-line-4056" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4063", + "unitIds": [ + "ownership.mixed-pipe.source-line-4063" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4072", + "unitIds": [ + "ownership.mixed-pipe.source-line-4072" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4093", + "unitIds": [ + "ownership.mixed-pipe.source-line-4093" + ], + "expectedCount": 1 + }, + { + "sourceId": "line-4152", + "unitIds": [ + "ownership.mixed-capture.normal", + "ownership.mixed-capture.stop", + "ownership.mixed-capture.inherited", + "ownership.mixed-capture.abrupt", + "ownership.mixed-capture.timeout", + "ownership.mixed-capture.inherited-timeout", + "ownership.mixed-capture.primary-secondary", + "ownership.mixed-capture.cleanup-only" + ], + "expectedCount": 8 + } + ] +} From 6b663f25aaf1ccec87eaf767a77aba8450e68daa Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 14:53:16 -0700 Subject: [PATCH 29/46] Cut Windows validation CI over to the full compiled runner Preserve the dedicated fail-closed Windows 2022 PR job and single non-PR hardening fallback. Install the exact SDK, build from the global.json directory, invoke the normal compiled host, and require fresh complete positive coverage with exactly one full PASS and execution marker. Keep compatibility entry compiled-only and preserve evidence artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .github/workflows/windows-hardening.yml | 77 ++++++++++++++-- .github/workflows/windows-port-validation.yml | 70 ++++++++++++-- .../Invoke-ValidationRunnerContract.ps1 | 33 +++++-- .../windows/Tests/WindowsCiTopology.Tests.ps1 | 92 +++++++++++++------ .../Host/CiContracts/Focused.ps1 | 1 + .../Host/CiContracts/README.md | 11 +++ .../Host/CiContracts/ResidualScopeFocused.ps1 | 1 + 7 files changed, 237 insertions(+), 48 deletions(-) diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index 79bb4d86..e08dadbe 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -58,7 +58,7 @@ jobs: env: HARDENING_REQUIRED: ${{ github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true' }} runs-on: ${{ (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') && matrix.os || 'ubuntu-latest' }} - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: No Windows-relevant changes if: env.HARDENING_REQUIRED != 'true' @@ -69,13 +69,78 @@ jobs: if: env.HARDENING_REQUIRED == 'true' shell: pwsh run: ./Tools/windows/validate.ps1 -Task hardening - - name: Verify runner contract - # Pull requests run this source contract in the windows-spikes aggregate - # once with pinned Swift. Keep one fallback for this workflow's independent - # scheduled and manual runs. + # Pull requests run the complete compiled host once in windows-spikes. + # Independent scheduled/manual runs retain one Windows 2022 fallback. + - name: Install pinned Swift for runner fallback + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 # v0.4.1 + timeout-minutes: 10 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + cache: true + - name: Install pinned compiled validation SDK + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + timeout-minutes: 10 + with: + dotnet-version: 10.0.403 + - name: Build compiled validation runner if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' shell: pwsh - run: ./Tools/windows/Invoke-ValidationRunnerContract.ps1 + working-directory: Tools/windows/ValidationRunner + timeout-minutes: 10 + run: | + $ErrorActionPreference = 'Stop' + $version = dotnet --version + if ($LASTEXITCODE -ne 0 -or $version -cne '10.0.403') { + throw 'Compiled validation requires SDK 10.0.403 selected by global.json.' + } + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + - name: Verify complete compiled validation runner contract + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + shell: pwsh + timeout-minutes: 15 + run: | + $ErrorActionPreference = 'Stop' + if (Test-Path -LiteralPath .ci-validation-runner\coverage.json) { + throw 'Compiled validation requires a fresh coverage report; prior evidence was retained.' + } + $output = [Collections.Generic.List[string]]::new() + $exitCode = $null + & { + .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner + $script:exitCode = $LASTEXITCODE + } 2>&1 | ForEach-Object { + $line = "$_" + $output.Add($line) + Write-Host $line + } + if ($exitCode -ne 0) { throw "Compiled validation failed: exit=$exitCode" } + $report = Get-Content -LiteralPath .ci-validation-runner\coverage.json -Raw | ConvertFrom-Json + if ($report.runner -cne 'CompiledValidationRunner' -or + $report.complete -isnot [bool] -or -not $report.complete -or + -not ($report.requiredCount -is [int] -or $report.requiredCount -is [long]) -or + -not ($report.passedCount -is [int] -or $report.passedCount -is [long]) -or + $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount -or + @($report.units).Count -ne $report.requiredCount -or @($report.errors).Count -ne 0) { + throw 'Compiled validation did not report complete positive full coverage.' + } + $passMarkers = @($output | Where-Object { $_ -ceq 'CompiledValidationRunner: PASS' }).Count + $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count + if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { + throw 'Compiled validation must report exactly one full PASS and one positive execution marker.' + } + - name: Retain compiled validation fallback evidence + if: ${{ always() && env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + timeout-minutes: 5 + with: + name: compiled-validation-hardening-${{ matrix.os }}-${{ github.run_attempt }} + path: .ci-validation-runner + if-no-files-found: warn + retention-days: 7 - name: Resolve capture contract failure diagnostics root id: capture_temp if: failure() diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index 74983bc8..d64cd0a5 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -21,7 +21,7 @@ env: # on every Windows-relevant pull request: windows-shell and packaging run in # windows-shell.yml (required check `windows-shell`), and hardening runs in # windows-hardening.yml (required `Deterministic hardening (...)` checks). The -# remaining tasks and the source-only runner contract are split across three +# remaining tasks and the compiled runner contract are split across three # parallel parts behind the required # `windows-spikes` aggregate job. jobs: @@ -59,8 +59,8 @@ jobs: shell: pwsh run: ./Tools/windows/validate.ps1 -Task privacy - # ValidationRunner.Tests.ps1 is source-only but deliberately uses the real - # pinned Swift formatter for its negative control. Run it once on Windows + # The compiled contract host uses the real pinned Swift formatter for its + # negative control. Run the full host once on Windows # 2022 in parallel instead of repeating it inside spikes-swift and both # deterministic hardening legs. Windows 2025 remains covered by hardening. validation-runner: @@ -68,7 +68,7 @@ jobs: needs: changes if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') }} runs-on: windows-2022 - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -82,10 +82,68 @@ jobs: swift-build: 6.3.3-RELEASE cache: true - - name: Verify validation runner contract + - name: Install pinned compiled validation SDK + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + timeout-minutes: 10 + with: + dotnet-version: 10.0.403 + + - name: Build compiled validation runner + shell: pwsh + working-directory: Tools/windows/ValidationRunner + timeout-minutes: 10 + run: | + $ErrorActionPreference = 'Stop' + $version = dotnet --version + if ($LASTEXITCODE -ne 0 -or $version -cne '10.0.403') { + throw 'Compiled validation requires SDK 10.0.403 selected by global.json.' + } + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + + - name: Verify complete compiled validation runner contract shell: pwsh timeout-minutes: 15 - run: ./Tools/windows/Invoke-ValidationRunnerContract.ps1 + run: | + $ErrorActionPreference = 'Stop' + if (Test-Path -LiteralPath .ci-validation-runner\coverage.json) { + throw 'Compiled validation requires a fresh coverage report; prior evidence was retained.' + } + $output = [Collections.Generic.List[string]]::new() + $exitCode = $null + & { + .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe --repository-root $PWD --artifact-root .ci-validation-runner + $script:exitCode = $LASTEXITCODE + } 2>&1 | ForEach-Object { + $line = "$_" + $output.Add($line) + Write-Host $line + } + if ($exitCode -ne 0) { throw "Compiled validation failed: exit=$exitCode" } + $report = Get-Content -LiteralPath .ci-validation-runner\coverage.json -Raw | ConvertFrom-Json + if ($report.runner -cne 'CompiledValidationRunner' -or + $report.complete -isnot [bool] -or -not $report.complete -or + -not ($report.requiredCount -is [int] -or $report.requiredCount -is [long]) -or + -not ($report.passedCount -is [int] -or $report.passedCount -is [long]) -or + $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount -or + @($report.units).Count -ne $report.requiredCount -or @($report.errors).Count -ne 0) { + throw 'Compiled validation did not report complete positive full coverage.' + } + $passMarkers = @($output | Where-Object { $_ -ceq 'CompiledValidationRunner: PASS' }).Count + $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count + if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { + throw 'Compiled validation must report exactly one full PASS and one positive execution marker.' + } + + - name: Retain compiled validation evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + timeout-minutes: 5 + with: + name: compiled-validation-runner-${{ github.run_attempt }} + path: .ci-validation-runner + if-no-files-found: warn + retention-days: 7 - name: Resolve capture contract failure diagnostics root id: capture_temp diff --git a/Tools/windows/Invoke-ValidationRunnerContract.ps1 b/Tools/windows/Invoke-ValidationRunnerContract.ps1 index 38e68be2..e134acae 100644 --- a/Tools/windows/Invoke-ValidationRunnerContract.ps1 +++ b/Tools/windows/Invoke-ValidationRunnerContract.ps1 @@ -2,20 +2,37 @@ param() $ErrorActionPreference = "Stop" -$runner = Join-Path $PSScriptRoot "Tests\ValidationRunner.Tests.ps1" -$pwsh = (Get-Process -Id $PID).Path +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$assembly = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") -Configuration Release +$runner = Join-Path (Split-Path $assembly -Parent) "ValidationRunner.exe" +if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "Compiled validation build produced no executable" +} +$artifactRoot = Join-Path $repoRoot (".build\validation-runner-contract-" + [guid]::NewGuid().ToString("N")) +$output = [Collections.Generic.List[string]]::new() $passMarkers = 0 -& $pwsh -NoProfile -File $runner 2>&1 | ForEach-Object { +$executedMarkers = 0 +& $runner --repository-root $repoRoot --artifact-root $artifactRoot 2>&1 | ForEach-Object { $line = "$_" - if ($line -ceq "ValidationRunner.Tests.ps1: PASS") { $script:passMarkers++ } + $output.Add($line) Write-Host $line } $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { - throw "Validation runner contract failed with exit code $exitCode" + throw "Compiled validation failed with exit code $exitCode; evidence retained at $artifactRoot" +} +$report = Get-Content -LiteralPath (Join-Path $artifactRoot "coverage.json") -Raw | ConvertFrom-Json +if ($report.runner -cne "CompiledValidationRunner" -or + $report.complete -isnot [bool] -or -not $report.complete -or + -not ($report.requiredCount -is [int] -or $report.requiredCount -is [long]) -or + -not ($report.passedCount -is [int] -or $report.passedCount -is [long]) -or + $report.requiredCount -le 0 -or $report.passedCount -ne $report.requiredCount -or + @($report.units).Count -ne $report.requiredCount -or @($report.errors).Count -ne 0) { + throw "Compiled validation did not report complete positive full coverage; evidence retained at $artifactRoot" } -if ($passMarkers -ne 1) { - throw "Validation runner contract did not report exactly one PASS marker" +$passMarkers = @($output | Where-Object { $_ -ceq "CompiledValidationRunner: PASS" }).Count +$executedMarkers = @($output | Where-Object { $_ -ceq "VALIDATION_RUNNER_EXECUTED=1" }).Count +if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { + throw "Compiled validation must report exactly one full PASS and one positive execution marker" } -Write-Host "VALIDATION_RUNNER_EXECUTED=1" exit 0 diff --git a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 index 048b9e3f..f18a995e 100644 --- a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 +++ b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 @@ -7,6 +7,8 @@ $portWorkflow = Get-Content ` (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw $hardeningWorkflow = Get-Content ` (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw +$portWorkflow = $portWorkflow.Replace("`r`n", "`n") +$hardeningWorkflow = $hardeningWorkflow.Replace("`r`n", "`n") $checks = 0 function Assert-Contract([object] $Condition, [string] $Message) { @@ -32,6 +34,41 @@ function Get-WorkflowJobs([string] $Workflow) { $jobs } +function Get-CompiledInvocationCount([string] $Text) { + [regex]::Matches($Text, + '(?m)^\s*\.\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10\.0-windows\\ValidationRunner\.exe --repository-root \$PWD --artifact-root \.ci-validation-runner[ \t]*$').Count +} + +function Assert-CompiledBuild([string] $Job) { + Assert-Contract ($Job -match 'actions/setup-dotnet@[0-9a-f]{40}' -and + $Job -match '(?m)^\s+dotnet-version: 10\.0\.403[ \t]*$') ` + "the compiled runner must install the exact pinned SDK" + $steps = @([regex]::Matches($Job, '(?ms)^ - .*?(?=^ - |\z)') | + ForEach-Object Value | Where-Object { $_ -match '(?m)^\s+dotnet build -c Release[ \t]*$' }) + Assert-Contract ($steps.Count -eq 1 -and + $steps[0] -match '(?m)^ working-directory: Tools/windows/ValidationRunner[ \t]*$' -and + $steps[0] -match 'dotnet --version' -and + $steps[0] -match "\`$version -cne '10\.0\.403'" -and + $steps[0] -match '\$LASTEXITCODE -ne 0') ` + "the host build must select global.json from its nested directory and fail on an SDK/build error" +} + +function Assert-CompleteProof([string] $Text) { + Assert-Contract ($Text -match '\$exitCode -ne 0' -and + $Text.Contains('coverage.json') -and + $Text -match '-not \$report\.complete' -and + $Text -match '\$report\.requiredCount -le 0' -and + $Text -match '\$report\.passedCount -ne \$report\.requiredCount' -and + $Text -match '@\(\$report\.units\)\.Count -ne \$report\.requiredCount' -and + $Text -match '@\(\$report\.errors\)\.Count -ne 0') ` + "normal execution must require exit zero and complete positive full coverage, not an empty or filtered report" + Assert-Contract ($Text.Contains('CompiledValidationRunner: PASS') -and + $Text.Contains('VALIDATION_RUNNER_EXECUTED=1') -and + $Text -match '\$passMarkers -ne 1' -and + $Text -match '\$executedMarkers -ne 1') ` + "normal execution must require exactly one full PASS marker and one positive execution marker" +} + $portJobs = Get-WorkflowJobs $portWorkflow $hardeningJobs = Get-WorkflowJobs $hardeningWorkflow @@ -39,7 +76,8 @@ Assert-Contract $portJobs.Contains("validation-runner") ` "Windows port CI needs a dedicated validation-runner job" $runnerJob = $portJobs["validation-runner"] Assert-Contract ($runnerJob -match '(?m)^ needs: changes\s*$' -and - $runnerJob -match "needs\.changes\.outputs\.windows == 'true'") ` + $runnerJob -match "needs\.changes\.outputs\.windows == 'true'" -and + $runnerJob -match "needs\.changes\.result != 'success'") ` "the validation-runner job must use the fail-closed Windows path gate" Assert-Contract ($runnerJob -match '(?m)^ runs-on: windows-2022\s*$' -and $runnerJob -notmatch '(?m)^\s+(strategy|matrix):') ` @@ -49,28 +87,26 @@ Assert-Contract ($runnerJob -match $runnerJob -match 'swift-version: swift-6\.3\.3-release' -and $runnerJob -match 'swift-build: 6\.3\.3-RELEASE') ` "the validation-runner job must install the pinned Swift formatter" -Assert-Contract ([regex]::Matches( - $runnerJob, - '(?m)^\s+run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' - ).Count -eq 1) ` - "the dedicated job must run the counted validation runner contract exactly once" +Assert-CompiledBuild $runnerJob +Assert-Contract ((Get-CompiledInvocationCount $runnerJob) -eq 1 -and + $runnerJob -notmatch '--suite\b|--self-test\b|--fixture\b|ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1') ` + "the dedicated job must run the normal full compiled host exactly once, never a filtered, fixture, or legacy entry" +Assert-CompleteProof $runnerJob Assert-Contract ($runnerJob -notmatch 'bootstrap\.ps1|\.ci-providers|validate\.ps1|continue-on-error') ` "the source-only validation runner job must not bootstrap providers or hide failures" $spikesSwift = $portJobs["spikes-swift"] Assert-Contract ($spikesSwift -notmatch - 'ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1') ` + 'ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1|ValidationRunner\.exe') ` "the sequential Swift job must not repeat the validation runner contract" Assert-Contract ([regex]::Matches( $spikesSwift, '(?m)^\s+run: \./Tools/windows/Tests/WindowsCiTopology\.Tests\.ps1\s*$' ).Count -eq 1) ` "the Swift job must verify runner scheduling before expensive setup" -Assert-Contract ([regex]::Matches( - $portWorkflow, - '(?m)^\s+run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' - ).Count -eq 1) ` - "Windows port CI must declare exactly one validation runner step" +Assert-Contract ((Get-CompiledInvocationCount $portWorkflow) -eq 1 -and + $portWorkflow -notmatch '(?m)^\s+run:.*(?:ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1)') ` + "Windows port CI must declare exactly one direct compiled validation run and no legacy invocation" $aggregate = $portJobs["windows-spikes"] Assert-Contract ($aggregate -match @@ -81,35 +117,35 @@ Assert-Contract ($aggregate -match $hardeningRunnerStep = [regex]::Match( $hardeningJobs["deterministic"], - '(?ms)^ - name: Verify runner contract\s+.*?(?=^ - |\z)' + '(?ms)^ - name: Verify complete compiled validation runner contract\s+.*?(?=^ - |\z)' ) $hardeningRunnerIf = [regex]::Match( $hardeningRunnerStep.Value, '(?m)^ if: ([^\r\n]+)\s*$' ) Assert-Contract ($hardeningRunnerStep.Success -and $hardeningRunnerIf.Success -and - $hardeningRunnerStep.Value -match - '(?m)^ run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' -and + (Get-CompiledInvocationCount $hardeningRunnerStep.Value) -eq 1 -and $hardeningRunnerIf.Groups[1].Value -match "env\.HARDENING_REQUIRED == 'true'" -and $hardeningRunnerIf.Groups[1].Value -match "github\.event_name != 'pull_request'" -and $hardeningRunnerIf.Groups[1].Value -match "matrix\.os == 'windows-2022'") ` "hardening must keep one scheduled/manual fallback without duplicating the PR contract" -Assert-Contract ([regex]::Matches( - $hardeningWorkflow, - '(?m)^\s+run: \./Tools/windows/Invoke-ValidationRunnerContract\.ps1\s*$' - ).Count -eq 1) ` - "hardening must declare exactly one non-PR validation runner fallback" +Assert-Contract ((Get-CompiledInvocationCount $hardeningWorkflow) -eq 1 -and + $hardeningWorkflow -notmatch '(?m)^\s+run:.*(?:ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1)') ` + "hardening must declare exactly one non-PR compiled runner fallback and no legacy invocation" +Assert-CompiledBuild $hardeningJobs["deterministic"] +Assert-CompleteProof $hardeningRunnerStep.Value +Assert-Contract ($runnerJob.Contains('path: .ci-validation-runner') -and + $hardeningJobs["deterministic"].Contains('path: .ci-validation-runner')) ` + "both canonical execution paths must retain their compiled coverage and payload evidence" $wrapper = Get-Content ` (Join-Path $repoRoot "Tools\windows\Invoke-ValidationRunnerContract.ps1") -Raw -Assert-Contract ([regex]::Matches( - $wrapper, - '(?m)^& \$pwsh -NoProfile -File \$runner 2>&1 \| ForEach-Object \{\s*$' - ).Count -eq 1 -and - $wrapper -match '\$exitCode -ne 0' -and - $wrapper -match '\$passMarkers -ne 1' -and - $wrapper -match 'VALIDATION_RUNNER_EXECUTED=1') ` - "the validation runner wrapper must execute once, fail closed, and report a positive count" +Assert-Contract ($wrapper.Contains('Build-CompiledValidationRunner.ps1') -and + $wrapper.Contains('ValidationRunner.exe') -and + $wrapper -match '(?m)^& \$runner --repository-root \$repoRoot --artifact-root \$artifactRoot 2>&1 \| ForEach-Object \{' -and + $wrapper -notmatch 'ValidationRunner\.Tests\.ps1|--suite\b|--self-test\b|--fixture\b') ` + "the compatibility wrapper must build and execute only the normal compiled host" +Assert-CompleteProof $wrapper Write-Host "Windows CI topology: $checks checks, 0 failures" exit 0 diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 index b4427fef..0315dac2 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 +++ b/Tools/windows/ValidationRunner/Host/CiContracts/Focused.ps1 @@ -6,6 +6,7 @@ param( ) $ErrorActionPreference = 'Stop' $null = [Reflection.Assembly]::LoadFrom($AssemblyPath) +$null = [Reflection.Assembly]::LoadFrom((Join-Path (Split-Path $AssemblyPath -Parent) 'WindowsValidationBindings.dll')) $exitCode = 1 try { $context = [GraphCode.Validation.ValidationContext]::new($RepositoryRoot, $ArtifactRoot) diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/README.md b/Tools/windows/ValidationRunner/Host/CiContracts/README.md index 7ede7b3d..a9b2f3e0 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/README.md +++ b/Tools/windows/ValidationRunner/Host/CiContracts/README.md @@ -60,3 +60,14 @@ focused execution passed the five behavioral families and all topology controls, while four topology observations correctly remained RED against the legacy workflow/wrapper before the host owner's cutover. No full coverage or CI success is claimed by that partial result. + +After the four-file CI/caller lease was transferred to this lane, the actual +workflow and wrapper cutover made all 35 topology cases pass. The six families +plus the deduplicated residual suite then executed **143/143 passing cases**; +the standalone topology script passed 24 checks and the integrated host passed +61 self-tests. The initial topology-only focused invocation exposed a lazy +binding-load error in the harness's exit barrier after its 35 cases had passed. +Explicitly loading the real Windows bindings fixed that harness error without +replacing or weakening the retained-authority barrier. These focused results +remain distinct from the integration owner's normal full-host run and from +hosted CI, neither of which this lane ran. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 index 87b77855..48c8464e 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 +++ b/Tools/windows/ValidationRunner/Host/CiContracts/ResidualScopeFocused.ps1 @@ -5,6 +5,7 @@ param( ) $ErrorActionPreference = 'Stop' $null = [Reflection.Assembly]::LoadFrom($AssemblyPath) +$null = [Reflection.Assembly]::LoadFrom((Join-Path (Split-Path $AssemblyPath -Parent) 'WindowsValidationBindings.dll')) $exitCode = 1 try { $context = [GraphCode.Validation.ValidationContext]::new($RepositoryRoot, $ArtifactRoot) From fd0c35a6960d6a52afee4a166e0822f17caba796 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:02:49 -0700 Subject: [PATCH 30/46] Scope private SDK runtime roots for the compiled validation caller Let the apphost inherit the selected private SDK runtime without changing normal arguments or persistent settings. Restore exact process environment state after success or failure and exercise the production helper in sixteen targeted controls wired into topology validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Invoke-ValidationRunnerContract.ps1 | 40 ++++- .../windows/Tests/WindowsCiTopology.Tests.ps1 | 7 +- .../CiContracts/PrivateSdkCaller.Tests.ps1 | 156 ++++++++++++++++++ .../Host/CiContracts/README.md | 11 ++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 diff --git a/Tools/windows/Invoke-ValidationRunnerContract.ps1 b/Tools/windows/Invoke-ValidationRunnerContract.ps1 index e134acae..eadbf6a6 100644 --- a/Tools/windows/Invoke-ValidationRunnerContract.ps1 +++ b/Tools/windows/Invoke-ValidationRunnerContract.ps1 @@ -1,6 +1,33 @@ [CmdletBinding()] param() +function Invoke-WithValidationRunnerRuntime([scriptblock] $Action) { + $selected = [Environment]::GetEnvironmentVariable("GRAPHCODE_DOTNET10403", "Process") + $prior = @{} + if (-not [string]::IsNullOrWhiteSpace($selected)) { + if (-not [IO.Path]::IsPathFullyQualified($selected) -or + -not (Test-Path -LiteralPath $selected -PathType Leaf)) { + throw "GRAPHCODE_DOTNET10403 must identify the selected private dotnet executable" + } + $runtimeRoot = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($selected)) + $environment = [Environment]::GetEnvironmentVariables("Process") + foreach ($name in @("DOTNET_ROOT", "DOTNET_ROOT_X64")) { + $prior[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } + } + } + try { + foreach ($name in $prior.Keys) { + [Environment]::SetEnvironmentVariable($name, $runtimeRoot, "Process") + } + & $Action + } finally { + foreach ($name in $prior.Keys) { + $value = if ($prior[$name].present) { $prior[$name].value } else { $null } + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } + } +} + $ErrorActionPreference = "Stop" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $assembly = & (Join-Path $PSScriptRoot "Build-CompiledValidationRunner.ps1") -Configuration Release @@ -12,12 +39,15 @@ $artifactRoot = Join-Path $repoRoot (".build\validation-runner-contract-" + [gui $output = [Collections.Generic.List[string]]::new() $passMarkers = 0 $executedMarkers = 0 -& $runner --repository-root $repoRoot --artifact-root $artifactRoot 2>&1 | ForEach-Object { - $line = "$_" - $output.Add($line) - Write-Host $line +$exitCode = $null +Invoke-WithValidationRunnerRuntime { + & $runner --repository-root $repoRoot --artifact-root $artifactRoot 2>&1 | ForEach-Object { + $line = "$_" + $output.Add($line) + Write-Host $line + } + $script:exitCode = $LASTEXITCODE } -$exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "Compiled validation failed with exit code $exitCode; evidence retained at $artifactRoot" } diff --git a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 index f18a995e..9eab560e 100644 --- a/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 +++ b/Tools/windows/Tests/WindowsCiTopology.Tests.ps1 @@ -142,10 +142,15 @@ $wrapper = Get-Content ` (Join-Path $repoRoot "Tools\windows\Invoke-ValidationRunnerContract.ps1") -Raw Assert-Contract ($wrapper.Contains('Build-CompiledValidationRunner.ps1') -and $wrapper.Contains('ValidationRunner.exe') -and - $wrapper -match '(?m)^& \$runner --repository-root \$repoRoot --artifact-root \$artifactRoot 2>&1 \| ForEach-Object \{' -and + $wrapper -match '(?m)^\s*& \$runner --repository-root \$repoRoot --artifact-root \$artifactRoot 2>&1 \| ForEach-Object \{' -and $wrapper -notmatch 'ValidationRunner\.Tests\.ps1|--suite\b|--self-test\b|--fixture\b') ` "the compatibility wrapper must build and execute only the normal compiled host" Assert-CompleteProof $wrapper +$privateSdkArtifacts = Join-Path $repoRoot (".build\private-sdk-caller-" + [guid]::NewGuid().ToString("N")) +$null = New-Item -ItemType Directory -Path $privateSdkArtifacts +& (Join-Path $repoRoot "Tools\windows\ValidationRunner\Host\CiContracts\PrivateSdkCaller.Tests.ps1") ` + -RepositoryRoot $repoRoot.Path -ArtifactRoot $privateSdkArtifacts + Write-Host "Windows CI topology: $checks checks, 0 failures" exit 0 diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 b/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 new file mode 100644 index 00000000..4eaff3d2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Host/CiContracts/PrivateSdkCaller.Tests.ps1 @@ -0,0 +1,156 @@ +param([Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string] $ArtifactRoot) +. (Join-Path $PSScriptRoot 'AdapterSupport.ps1') +$wrapper = Join-Path $RepositoryRoot 'Tools\windows\Invoke-ValidationRunnerContract.ps1' +. (Get-ProductionFunction $wrapper 'Invoke-WithValidationRunnerRuntime') +$sdk = 'C:\fixture\private-sdk\dotnet.exe' +$expectedRoot = 'C:\fixture\private-sdk' +$names = @('GRAPHCODE_DOTNET10403', 'DOTNET_ROOT', 'DOTNET_ROOT_X64') +$before = @{} +$priorExit = Get-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue +$priorExitValue = if ($null -ne $priorExit) { $priorExit.Value } else { $null } +$environment = [Environment]::GetEnvironmentVariables('Process') +foreach ($name in $names) { $before[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } } +function Test-Path($LiteralPath, $PathType) { $LiteralPath -ceq $sdk -and $PathType -ceq 'Leaf' } + +function Set-PrivateSdkTestState([string] $Kind) { + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $sdk, 'Process') + foreach ($name in @('DOTNET_ROOT', 'DOTNET_ROOT_X64')) { + $value = switch ($Kind) { + 'absent' { $null } + 'empty' { '' } + 'valued' { "C:\existing\$name" } + 'mixed' { if ($name -ceq 'DOTNET_ROOT') { $null } else { 'C:\existing\x64' } } + } + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + $environment = [Environment]::GetEnvironmentVariables('Process') + if ($Kind -ceq 'empty') { + Assert-Root ($environment.Contains('DOTNET_ROOT') -and $environment.Contains('DOTNET_ROOT_X64')) ` + 'This empty-state case must actually begin with present empty environment entries' + } + $snapshot = @{} + foreach ($name in @('DOTNET_ROOT', 'DOTNET_ROOT_X64')) { + $snapshot[$name] = @{ present = $environment.Contains($name); value = $environment[$name] } + } + return $snapshot +} + +function Assert-PrivateSdkStateRestored($Snapshot) { + $environment = [Environment]::GetEnvironmentVariables('Process') + foreach ($name in @('DOTNET_ROOT', 'DOTNET_ROOT_X64')) { + Assert-Root ($environment.Contains($name) -eq $Snapshot[$name].present -and + $environment[$name] -ceq $Snapshot[$name].value) "Exact process environment state was not restored: $name" + } +} + +try { + foreach ($kind in @('absent', 'empty', 'valued', 'mixed')) { + foreach ($outcome in @('success', 'failure')) { + Test-RootCase "private-sdk.$kind.$outcome" { + $snapshot = Set-PrivateSdkTestState $kind + $calls = @{ Count = 0 } + $primary = [InvalidOperationException]::new('PRIVATE_SDK_PRIMARY_FAILURE') + $failure = $null + try { + Invoke-WithValidationRunnerRuntime { + $calls.Count++ + Assert-Root ([Environment]::GetEnvironmentVariable('DOTNET_ROOT', 'Process') -ceq $expectedRoot -and + [Environment]::GetEnvironmentVariable('DOTNET_ROOT_X64', 'Process') -ceq $expectedRoot) ` + 'The apphost action did not observe the selected private runtime in both resolution variables' + if ($outcome -ceq 'failure') { throw $primary } + } + } catch { $failure = $_ } + Assert-Root ($calls.Count -eq 1) 'The apphost action did not execute exactly once' + Assert-Root (($outcome -ceq 'success' -and $null -eq $failure) -or + ($outcome -ceq 'failure' -and [object]::ReferenceEquals($failure.Exception, $primary))) ` + 'The original apphost action failure was swallowed or replaced' + Assert-PrivateSdkStateRestored $snapshot + } + } + } + Test-RootCase 'private-sdk.unconfigured-ci-root' { + $snapshot = Set-PrivateSdkTestState 'valued' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $null, 'Process') + Invoke-WithValidationRunnerRuntime { Assert-PrivateSdkStateRestored $snapshot } + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.empty-selection-ci-root' { + $snapshot = Set-PrivateSdkTestState 'valued' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', '', 'Process') + Invoke-WithValidationRunnerRuntime { Assert-PrivateSdkStateRestored $snapshot } + Assert-PrivateSdkStateRestored $snapshot + } + foreach ($entry in @( + @{ Id = 'relative'; Path = 'dotnet.exe' }, + @{ Id = 'missing'; Path = 'C:\fixture\missing\dotnet.exe' })) { + Test-RootCase "private-sdk.invalid.$($entry.Id)" { + $snapshot = Set-PrivateSdkTestState 'valued' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $entry.Path, 'Process') + $calls = @{ Count = 0 } + $failure = $null + try { Invoke-WithValidationRunnerRuntime { $calls.Count++ } } catch { $failure = $_ } + Assert-Root ($calls.Count -eq 0 -and $null -ne $failure -and + $failure.Exception.Message.Contains('GRAPHCODE_DOTNET10403')) 'Invalid private SDK selection was silently accepted' + Assert-PrivateSdkStateRestored $snapshot + } + } + Test-RootCase 'private-sdk.action-output-only' { + $snapshot = Set-PrivateSdkTestState 'mixed' + $output = @(Invoke-WithValidationRunnerRuntime { 'first'; 'second' }) + Assert-Root ($output.Count -eq 2 -and ($output -join ',') -ceq 'first,second') 'Runtime scoping polluted the apphost output stream' + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.error-stream-and-native-exit' { + $snapshot = Set-PrivateSdkTestState 'empty' + $output = @(Invoke-WithValidationRunnerRuntime { + 'stdout' + Write-Error 'PRIVATE_SDK_TEST_STDERR' -ErrorAction Continue + $global:LASTEXITCODE = 17 + } 2>&1) + Assert-Root ($output.Count -eq 2 -and $output[0] -ceq 'stdout' -and + $output[1] -is [Management.Automation.ErrorRecord] -and $global:LASTEXITCODE -eq 17) ` + 'Runtime scoping changed output/error identity or native exit state' + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.unconfigured-absent-root' { + $snapshot = Set-PrivateSdkTestState 'absent' + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', $null, 'Process') + Invoke-WithValidationRunnerRuntime { Assert-PrivateSdkStateRestored $snapshot } + Assert-PrivateSdkStateRestored $snapshot + } + Test-RootCase 'private-sdk.actual-normal-command-is-scoped' { + $tokens = $null + $errors = $null + $tree = [Management.Automation.Language.Parser]::ParseFile($wrapper, [ref]$tokens, [ref]$errors) + Assert-Root ($errors.Count -eq 0) 'Actual wrapper does not parse' + $scopes = @($tree.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-WithValidationRunnerRuntime' + }, $true)) + Assert-Root ($scopes.Count -eq 1 -and + $scopes[0].CommandElements[1] -is [Management.Automation.Language.ScriptBlockExpressionAst]) ` + 'Actual wrapper must scope exactly one action' + $calls = @($scopes[0].CommandElements[1].ScriptBlock.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.InvocationOperator -eq [Management.Automation.Language.TokenKind]::Ampersand -and + $node.CommandElements[0].Extent.Text -ceq '$runner' + }, $true)) + Assert-Root ($calls.Count -eq 1 -and + (@($calls[0].CommandElements | ForEach-Object { $_.Extent.Text }) -join ' ') -ceq + '$runner --repository-root $repoRoot --artifact-root $artifactRoot') 'Runtime scope changed the normal unfiltered apphost arguments' + } +} finally { + foreach ($name in $names) { + $value = if ($before[$name].present) { $before[$name].value } else { $null } + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + if ($null -eq $priorExit) { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue } + else { $global:LASTEXITCODE = $priorExitValue } +} +Write-RootSummary 'host.private-sdk-caller-controls' +if ($script:RootCases.Count -ne 16 -or @($script:RootCases | Where-Object { -not $_.passed }).Count -ne 0) { + throw 'Private SDK caller controls require exactly 16 passing cases' +} diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/README.md b/Tools/windows/ValidationRunner/Host/CiContracts/README.md index a9b2f3e0..de3ca8ed 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/README.md +++ b/Tools/windows/ValidationRunner/Host/CiContracts/README.md @@ -71,3 +71,14 @@ Explicitly loading the real Windows bindings fixed that harness error without replacing or weakening the retained-authority barrier. These focused results remain distinct from the integration owner's normal full-host run and from hosted CI, neither of which this lane ran. + +The compatibility wrapper scopes `DOTNET_ROOT` and `DOTNET_ROOT_X64` to the +directory of a configured `GRAPHCODE_DOTNET10403` executable while launching +the apphost, then restores exact process-level presence and values in `finally`. +An unconfigured caller retains its existing CI/global runtime resolution. +`PrivateSdkCaller.Tests.ps1` extracts that actual production helper and runs +16 in-memory action/environment controls, including absent, empty, valued and +mixed roots, original failure identity, output/error streams, native exit state +and unchanged normal arguments. The standalone topology driver runs these +controls too. They are reported separately from the seven mandatory root units +and do not claim an actual private SDK installation or full-host execution. From 55bdfdd92b0ed64c3766f262ca886d8ec7eeff97 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:14:07 -0700 Subject: [PATCH 31/46] Bootstrap the exact compiled validation SDK in an owned tool root Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Build-CompiledValidationRunner.ps1 | 23 +- Tools/windows/DotNetSdk.ps1 | 229 +++++++++++ Tools/windows/Tests/DotNetSdk.Tests.ps1 | 369 ++++++++++++++++++ Tools/windows/bootstrap.ps1 | 4 + graphcode-windows/README.md | 22 ++ 5 files changed, 642 insertions(+), 5 deletions(-) create mode 100644 Tools/windows/DotNetSdk.ps1 create mode 100644 Tools/windows/Tests/DotNetSdk.Tests.ps1 diff --git a/Tools/windows/Build-CompiledValidationRunner.ps1 b/Tools/windows/Build-CompiledValidationRunner.ps1 index 0510b82c..b6e9f1d0 100644 --- a/Tools/windows/Build-CompiledValidationRunner.ps1 +++ b/Tools/windows/Build-CompiledValidationRunner.ps1 @@ -4,10 +4,13 @@ param( ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "DotNetSdk.ps1") +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) $projectRoot = Join-Path $PSScriptRoot "ValidationRunner" $local = Join-Path $projectRoot ".local" $names = @("DOTNET_CLI_HOME", "NUGET_PACKAGES", "TEMP", "TMP", "DOTNET_CLI_TELEMETRY_OPTOUT", - "DOTNET_GENERATE_ASPNET_CERTIFICATE", "DOTNET_SKIP_FIRST_TIME_EXPERIENCE") + "DOTNET_GENERATE_ASPNET_CERTIFICATE", "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "DOTNET_ROOT", + "DOTNET_MULTILEVEL_LOOKUP", "DOTNET_ROLL_FORWARD", "DOTNET_NOLOGO") $before = @{} $environment = [Environment]::GetEnvironmentVariables("Process") foreach ($name in $names) { @@ -21,13 +24,21 @@ try { $env:DOTNET_CLI_TELEMETRY_OPTOUT = "1" $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" + $env:DOTNET_NOLOGO = "1" [void][IO.Directory]::CreateDirectory($env:TEMP) + # A loaded bootstrap environment supplies GRAPHCODE_DOTNET10403. The resolver + # also admits an installed exact SDK, but never downloads during a build. + $dotnet = Resolve-GraphCodeDotNetSdk -RepositoryRoot $repositoryRoot ` + -ToolRoot (Join-Path $repositoryRoot ".graphcode-tools") + $expectedVersion = Get-GraphCodeDotNetPin $repositoryRoot + $env:DOTNET_ROOT = [IO.Path]::GetDirectoryName($dotnet) + $env:DOTNET_MULTILEVEL_LOOKUP = "0" + $env:DOTNET_ROLL_FORWARD = "LatestPatch" Push-Location $projectRoot try { - $dotnet = (Get-Command dotnet -CommandType Application -ErrorAction Stop).Source $version = & $dotnet --version - if ($LASTEXITCODE -ne 0 -or $version -cne "10.0.403") { - throw "Compiled validation requires the pinned .NET SDK 10.0.403; install it before building" + if ($LASTEXITCODE -ne 0 -or $version -cne $expectedVersion) { + throw "Compiled validation requires the pinned .NET SDK $expectedVersion; rerun Tools\windows\bootstrap.ps1" } & $dotnet build ValidationRunner.csproj --configuration $Configuration --nologo --verbosity quiet | ForEach-Object { Write-Host $_ } @@ -41,7 +52,9 @@ try { if ($before[$name].present) { [Environment]::SetEnvironmentVariable($name, $before[$name].value, "Process") } else { - [Environment]::SetEnvironmentVariable($name, $null, "Process") + if ([Environment]::GetEnvironmentVariables("Process").Contains($name)) { + Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop + } } } } diff --git a/Tools/windows/DotNetSdk.ps1 b/Tools/windows/DotNetSdk.ps1 new file mode 100644 index 00000000..7761587b --- /dev/null +++ b/Tools/windows/DotNetSdk.ps1 @@ -0,0 +1,229 @@ +$ErrorActionPreference = 'Stop' + +function Get-GraphCodeDotNetPin([string] $RepositoryRoot) { + $path = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\global.json' + $pin = [IO.File]::ReadAllText($path) | ConvertFrom-Json + if ($pin.sdk.version -isnot [string] -or $pin.sdk.version -notmatch '^10\.0\.\d+$' -or + $pin.sdk.rollForward -isnot [string] -or $pin.sdk.rollForward -cne 'disable' -or $pin.sdk.allowPrerelease -isnot [bool] -or + $pin.sdk.allowPrerelease -ne $false) { + throw "Invalid release SDK pin in $path; an exact 10.0 SDK with rollForward=disable is required" + } + return [string]$pin.sdk.version +} + +function Invoke-GraphCodeDotNetCommand( + [string] $Executable, [string] $WorkingDirectory, [string[]] $Arguments +) { + $PSNativeCommandUseErrorActionPreference = $false + Push-Location -LiteralPath $WorkingDirectory + try { + $output = @(& $Executable @Arguments 2>&1 | ForEach-Object { [string]$_ }) + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { Pop-Location } +} + +function Get-GraphCodeDotNetSdkProbe( + [string] $Executable, [string] $RepositoryRoot, [string] $ToolRoot, [string] $Version +) { + $invalid = { param($reason) [pscustomobject]@{ Valid = $false; Reason = $reason; Executable = $Executable } } + if (-not [IO.Path]::IsPathFullyQualified($Executable) -or + -not [IO.File]::Exists($Executable) -or [IO.Path]::GetExtension($Executable) -ine '.exe') { + return & $invalid "SDK host is not an existing absolute executable: $Executable" + } + $root = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Executable)) + $sdkRoot = Join-Path $root "sdk\$Version" + $propsPath = Join-Path $sdkRoot 'Microsoft.NETCoreSdk.BundledVersions.props' + if (-not [IO.File]::Exists((Join-Path $sdkRoot 'MSBuild.dll')) -or -not [IO.File]::Exists($propsPath)) { + return & $invalid "SDK $Version inputs are missing below $root" + } + [xml]$props = [IO.File]::ReadAllText($propsPath) + $sdkNodes = @($props.SelectNodes("//*[local-name()='NETCoreSdkVersion']")) + if ($sdkNodes.Count -ne 1 -or $sdkNodes[0].InnerText -cne $Version) { + return & $invalid "SDK metadata below $root does not declare $Version" + } + foreach ($framework in @('Microsoft.NETCore.App', 'Microsoft.WindowsDesktop.App')) { + $nodes = @($props.SelectNodes("//*[local-name()='KnownFrameworkReference' and @Include='$framework' and @TargetFramework='net10.0']")) + if ($nodes.Count -ne 1 -or $nodes[0].TargetingPackVersion -notmatch '^10\.0\.\d+$' -or + $nodes[0].TargetingPackName -cne "$framework.Ref") { + return & $invalid "SDK metadata is missing the net10.0 $framework targeting-pack declaration" + } + $referenceRoot = Join-Path $root ("packs\{0}\{1}\ref\net10.0" -f $nodes[0].TargetingPackName, $nodes[0].TargetingPackVersion) + $files = if ($framework -ceq 'Microsoft.NETCore.App') { @('System.Runtime.dll') } + else { @('UIAutomationClient.dll', 'UIAutomationTypes.dll', 'WindowsBase.dll') } + foreach ($file in $files) { + if (-not [IO.File]::Exists((Join-Path $referenceRoot $file))) { + return & $invalid "Missing $framework reference input: $(Join-Path $referenceRoot $file)" + } + } + } + + $stateRoot = Join-Path $ToolRoot 'dotnet-state' + $values = @{ + DOTNET_ROOT = $root; DOTNET_MULTILEVEL_LOOKUP = '0'; DOTNET_ROLL_FORWARD = 'LatestPatch' + DOTNET_CLI_HOME = (Join-Path $stateRoot 'home'); NUGET_PACKAGES = (Join-Path $stateRoot 'nuget') + TEMP = (Join-Path $stateRoot 'temp'); TMP = (Join-Path $stateRoot 'temp') + DOTNET_CLI_TELEMETRY_OPTOUT = '1'; DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'; DOTNET_NOLOGO = '1' + } + $before = [Environment]::GetEnvironmentVariables('Process') + try { + $null = [IO.Directory]::CreateDirectory($values.TEMP) + foreach ($name in $values.Keys) { [Environment]::SetEnvironmentVariable($name, $values[$name], 'Process') } + $workingDirectory = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner' + $versionProbe = Invoke-GraphCodeDotNetCommand $Executable $workingDirectory @('--version') + if ($versionProbe.ExitCode -ne 0 -or ($versionProbe.Output -join "`n").Trim() -cne $Version) { + return & $invalid "SDK pin resolution from $workingDirectory did not select $Version`: $($versionProbe.Output -join '; ')" + } + $runtimes = Invoke-GraphCodeDotNetCommand $Executable $workingDirectory @('--list-runtimes') + if ($runtimes.ExitCode -ne 0) { return & $invalid "Listing runtimes failed for $Executable" } + foreach ($framework in @('Microsoft.NETCore.App', 'Microsoft.WindowsDesktop.App')) { + $runtimeRoot = Join-Path $root "shared\$framework" + $present = $false + foreach ($line in $runtimes.Output) { + if ($line -match ('^' + [regex]::Escape($framework) + ' (10\.0\.\d+) \[(.+)\]$')) { + $runtimeVersion = $Matches[1] + if ([IO.Path]::GetFullPath($Matches[2]) -ieq [IO.Path]::GetFullPath($runtimeRoot)) { + $runtimeFile = if ($framework -ceq 'Microsoft.NETCore.App') { 'coreclr.dll' } else { 'PresentationCore.dll' } + if ([IO.File]::Exists((Join-Path $runtimeRoot "$runtimeVersion\$runtimeFile"))) { $present = $true } + } + } + } + if (-not $present) { return & $invalid "Missing usable $framework 10.0 runtime below $root; rerun Tools\windows\bootstrap.ps1" } + } + return [pscustomobject]@{ Valid = $true; Reason = ''; Executable = [IO.Path]::GetFullPath($Executable) } + } finally { + foreach ($name in $values.Keys) { + if ($before.Contains($name)) { [Environment]::SetEnvironmentVariable($name, $before[$name], 'Process') } + elseif ([Environment]::GetEnvironmentVariables('Process').Contains($name)) { + Remove-Item -LiteralPath "Env:\$name" -ErrorAction Stop + } + } + } +} + +function Get-GraphCodeDotNetCandidates([string] $ToolRoot, [string] $Version) { + $candidates = [Collections.Generic.List[string]]::new() + $candidates.Add((Join-Path $ToolRoot "dotnet\$Version\dotnet.exe")) + foreach ($command in @(Get-Command dotnet -CommandType Application -All -ErrorAction SilentlyContinue)) { + if ($command.Source) { $candidates.Add($command.Source) } + } + foreach ($root in @($env:DOTNET_ROOT, $(if ($env:ProgramFiles) { Join-Path $env:ProgramFiles 'dotnet' }))) { + if ($root) { $candidates.Add((Join-Path $root 'dotnet.exe')) } + } + return @($candidates | Select-Object -Unique) +} + +function Get-GraphCodeDotNetAsset([string] $Version) { + $rid = switch ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + X64 { 'win-x64' }; Arm64 { 'win-arm64' } + default { throw 'SDK bootstrap requires a 64-bit Windows host (x64 or Arm64)' } + } + $metadata = Invoke-RestMethod -Uri 'https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/10.0/releases.json' ` + -TimeoutSec 60 -MaximumRedirection 0 -ErrorAction Stop + $assets = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($release in $metadata.releases) { + $sdks = @($release.sdks) + @($release.sdk) + foreach ($sdk in $sdks) { + if ($null -eq $sdk -or $sdk.version -cne $Version) { continue } + foreach ($file in $sdk.files) { + if ($file.rid -cne $rid -or $file.name -cne "dotnet-sdk-$rid.zip") { continue } + $uri = [uri]$file.url + if (-not $uri.IsAbsoluteUri -or $uri.Scheme -cne 'https' -or $uri.Port -ne 443 -or + $uri.UserInfo -or $uri.Query -or $uri.Fragment -or + $uri.Host -cnotin @('builds.dotnet.microsoft.com', 'dotnetcli.blob.core.windows.net', + 'dotnetcli.azureedge.net', 'download.visualstudio.microsoft.com', 'download.microsoft.com') -or + -not $uri.AbsolutePath.EndsWith('.zip', [StringComparison]::OrdinalIgnoreCase)) { + throw "SDK metadata contains a non-official ZIP URL for $Version" + } + if ($file.hash -notmatch '^[0-9A-Fa-f]{128}$') { throw "SDK metadata has no valid SHA-512 checksum for $Version" } + $key = $uri.AbsoluteUri + ':' + $file.hash.ToUpperInvariant() + $assets[$key] = [pscustomobject]@{ Uri = $uri; Sha512 = $file.hash.ToUpperInvariant() } + } + } + } + if ($assets.Count -ne 1) { throw "Official Microsoft metadata must provide exactly one SDK $Version $rid ZIP and SHA-512; found $($assets.Count)" } + return @($assets.Values)[0] +} + +function Assert-GraphCodeDotNetArchive([string] $Archive, [string] $Destination) { + $prefix = [IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\' + $zip = [IO.Compression.ZipFile]::OpenRead($Archive) + try { + if ($zip.Entries.Count -eq 0) { throw 'SDK ZIP is empty' } + foreach ($entry in $zip.Entries) { + $name = $entry.FullName.Replace('/', '\') + if ([IO.Path]::IsPathRooted($name) -or $name.Contains(':') -or + -not [IO.Path]::GetFullPath((Join-Path $Destination $name)).StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -or + (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { + throw "SDK ZIP contains an unsafe archive entry: $($entry.FullName)" + } + } + } finally { $zip.Dispose() } +} + +function Install-GraphCodeDotNetSdk([string] $RepositoryRoot, [string] $ToolRoot, [string] $Version) { + $sdkParent = Join-Path $ToolRoot 'dotnet' + $destination = Join-Path $sdkParent $Version + if (Test-Path -LiteralPath $destination) { + throw "Private SDK destination already exists but was not verified: $destination; preserve it and select a fresh -ToolRoot" + } + $null = [IO.Directory]::CreateDirectory($sdkParent) + $scratch = Join-Path $sdkParent ('.install-' + [guid]::NewGuid().ToString('N')) + $null = [IO.Directory]::CreateDirectory($scratch) + $primary = $null + try { + $asset = Get-GraphCodeDotNetAsset $Version + $archive = Join-Path $scratch 'sdk.zip' + Invoke-WebRequest -Uri $asset.Uri -OutFile $archive -TimeoutSec 900 -MaximumRedirection 0 -ErrorAction Stop + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA512).Hash -cne $asset.Sha512) { + throw "SDK $Version SHA-512 checksum mismatch; archive was not extracted" + } + $payload = Join-Path $scratch 'payload' + Assert-GraphCodeDotNetArchive $archive $payload + Expand-Archive -LiteralPath $archive -DestinationPath $payload -ErrorAction Stop + $probe = Get-GraphCodeDotNetSdkProbe (Join-Path $payload 'dotnet.exe') $RepositoryRoot $ToolRoot $Version + if (-not $probe.Valid) { throw "Downloaded SDK failed verification: $($probe.Reason)" } + [IO.Directory]::Move($payload, $destination) + return Join-Path $destination 'dotnet.exe' + } catch { + $primary = $_.Exception + throw + } finally { + try { Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop } + catch { + if ($primary) { + $primary.Data['GraphCodeDotNetCleanupFailure'] = $_.Exception.ToString() + Write-Warning "SDK scratch cleanup failed at $scratch; the primary failure is preserved" + } else { throw } + } + } +} + +function Resolve-GraphCodeDotNetSdk { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $ToolRoot, + [switch] $InstallIfMissing + ) + $RepositoryRoot = [IO.Path]::GetFullPath($RepositoryRoot) + $ToolRoot = [IO.Path]::GetFullPath($ToolRoot) + $version = Get-GraphCodeDotNetPin $RepositoryRoot + $environment = [Environment]::GetEnvironmentVariables('Process') + if ($environment.Contains('GRAPHCODE_DOTNET10403')) { + $selected = [string]$environment['GRAPHCODE_DOTNET10403'] + if ([string]::IsNullOrWhiteSpace($selected)) { throw 'GRAPHCODE_DOTNET10403 is explicitly empty; reload the bootstrap environment' } + $probe = Get-GraphCodeDotNetSdkProbe $selected $RepositoryRoot $ToolRoot $version + if (-not $probe.Valid) { throw "GRAPHCODE_DOTNET10403 is invalid: $($probe.Reason)" } + return $probe.Executable + } + $rejected = [Collections.Generic.List[string]]::new() + foreach ($candidate in @(Get-GraphCodeDotNetCandidates $ToolRoot $version)) { + $probe = Get-GraphCodeDotNetSdkProbe $candidate $RepositoryRoot $ToolRoot $version + if ($probe.Valid) { return $probe.Executable } + $rejected.Add($probe.Reason) + Write-Verbose $probe.Reason + } + if ($InstallIfMissing) { return Install-GraphCodeDotNetSdk $RepositoryRoot $ToolRoot $version } + throw "Verified .NET SDK $version with .NETCore/WindowsDesktop 10.0 runtimes and reference inputs is required. Run Tools\windows\bootstrap.ps1. $($rejected -join '; ')" +} diff --git a/Tools/windows/Tests/DotNetSdk.Tests.ps1 b/Tools/windows/Tests/DotNetSdk.Tests.ps1 new file mode 100644 index 00000000..2c6dca23 --- /dev/null +++ b/Tools/windows/Tests/DotNetSdk.Tests.ps1 @@ -0,0 +1,369 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '..\Import-PinnedPester.ps1') + +BeforeAll { + $script:repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) + . (Join-Path $repositoryRoot 'Tools\windows\DotNetSdk.ps1') + function New-DotNetTestFixture { + $script:caseRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $script:fixtureRepo = Join-Path $caseRoot 'repo' + $script:toolRoot = Join-Path $caseRoot 'tools' + $script:sdkRoot = Join-Path $caseRoot 'installed' + $script:exe = Join-Path $sdkRoot 'dotnet.exe' + $script:propsPath = Join-Path $sdkRoot 'sdk\10.0.403\Microsoft.NETCoreSdk.BundledVersions.props' + $files = @('dotnet.exe', 'sdk\10.0.403\MSBuild.dll', + 'packs\Microsoft.NETCore.App.Ref\10.0.14\ref\net10.0\System.Runtime.dll', + 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\UIAutomationClient.dll', + 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\UIAutomationTypes.dll', + 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\WindowsBase.dll', + 'shared\Microsoft.NETCore.App\10.0.14\coreclr.dll', + 'shared\Microsoft.WindowsDesktop.App\10.0.14\PresentationCore.dll') + foreach ($file in $files) { + $path = Join-Path $sdkRoot $file + $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path)) + [IO.File]::WriteAllText($path, 'deliberate test input; never executed') + } + [IO.File]::WriteAllText($propsPath, @' +10.0.403 + + + +'@) + $runner = Join-Path $fixtureRepo 'Tools\windows\ValidationRunner' + $null = [IO.Directory]::CreateDirectory($runner) + [IO.File]::WriteAllText((Join-Path $runner 'global.json'), + '{"sdk":{"version":"10.0.403","rollForward":"disable","allowPrerelease":false}}') + $script:runtimeOutput = @("Microsoft.NETCore.App 10.0.14 [$sdkRoot\shared\Microsoft.NETCore.App]", + "Microsoft.WindowsDesktop.App 10.0.14 [$sdkRoot\shared\Microsoft.WindowsDesktop.App]") + } +} + +Describe 'Pinned .NET prerequisite production wiring' { + It 'resolves the SDK during the existing bootstrap and exports its executable' { + $text = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'Tools\windows\bootstrap.ps1')) + $text | Should -Match 'Resolve-GraphCodeDotNetSdk' + $text | Should -Match 'GRAPHCODE_DOTNET10403' + } + It 'uses the shared SDK resolver in the actual compiled build wrapper' { + $text = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'Tools\windows\Build-CompiledValidationRunner.ps1')) + $text | Should -Match 'Resolve-GraphCodeDotNetSdk' + $text | Should -Match 'GRAPHCODE_DOTNET10403' + $text | Should -Match 'DOTNET_ROOT' + } + It 'documents the pinned SDK in the Windows contributor prerequisites' { + $text = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'graphcode-windows\README.md')) + $start = $text.IndexOf('## Windows contributor quick start', [StringComparison]::Ordinal) + $text.Substring($start, [Math]::Min(6000, $text.Length - $start)) | Should -Match '\.NET SDK.*10\.0\.403' + } +} + +Describe 'Exact SDK discovery and prerequisite probes' { + BeforeEach { + New-DotNetTestFixture + $script:savedEnvironment = [Environment]::GetEnvironmentVariables('Process') + if ([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_DOTNET10403')) { + Remove-Item -LiteralPath Env:\GRAPHCODE_DOTNET10403 + } + Mock Get-GraphCodeDotNetCandidates { @($exe) } + Mock Install-GraphCodeDotNetSdk { throw 'Unexpected installation' } + Mock Invoke-RestMethod { throw 'Unexpected network' } + Mock Invoke-GraphCodeDotNetCommand { + if ($Arguments[0] -ceq '--version') { return [pscustomobject]@{ ExitCode=0; Output=@('10.0.403') } } + [pscustomobject]@{ ExitCode=0; Output=$runtimeOutput } + } + } + AfterEach { + foreach ($name in @('GRAPHCODE_DOTNET10403','DOTNET_ROOT','DOTNET_CLI_HOME','DOTNET_ROLL_FORWARD','TEMP','TMP', + 'GRAPHCODE_ZIG0152','GRAPHCODE_ZIG0160','GRAPHCODE_WINGHOSTTY_ROOT','GRAPHCODE_ZMX_ROOT','GITHUB_ENV')) { + if ($savedEnvironment.Contains($name)) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } elseif ([Environment]::GetEnvironmentVariables('Process').Contains($name)) { + Remove-Item -LiteralPath "Env:\$name" + } + } + } + It 'reuses a verified installed exact SDK without installing or fetching metadata' { + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing | Should -BeExactly $exe + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + Should -Invoke Invoke-RestMethod -Times 0 + } + It 'prefers the explicitly exported full executable and bypasses discovery' { + $env:GRAPHCODE_DOTNET10403 = $exe + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + Should -Invoke Get-GraphCodeDotNetCandidates -Times 0 + } + It 'rejects an explicitly empty override instead of treating it as absent' { + [Environment]::SetEnvironmentVariable('GRAPHCODE_DOTNET10403', '', 'Process') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*explicitly empty*' + } + It 'rejects an invalid explicit override without falling back to another executable' { + $env:GRAPHCODE_DOTNET10403 = Join-Path $TestDrive 'missing.exe' + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing } | Should -Throw '*GRAPHCODE_DOTNET10403 is invalid*' + Should -Invoke Get-GraphCodeDotNetCandidates -Times 0 + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'resolves global.json from the actual validator directory, never a project argument' { + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + Should -Invoke Invoke-GraphCodeDotNetCommand -Times 1 -ParameterFilter { + $WorkingDirectory -ceq (Join-Path $fixtureRepo 'Tools\windows\ValidationRunner') -and + $Arguments.Count -eq 1 -and $Arguments[0] -ceq '--version' + } + } + It 'does not accept an SDK version fallback even when the host exits successfully' { + Mock Invoke-GraphCodeDotNetCommand { [pscustomobject]@{ ExitCode=0; Output=@('10.0.404') } } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*did not select 10.0.403*' + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'rejects a failing exact SDK command and preserves actionable diagnostics' { + Mock Invoke-GraphCodeDotNetCommand { [pscustomobject]@{ ExitCode=145; Output=@('SDK selection failed') } } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*SDK selection failed*' + } + It 'rejects missing WindowsDesktop runtime rather than admitting compile-only evidence' { + $script:runtimeOutput = @($runtimeOutput[0]) + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Microsoft.WindowsDesktop.App 10.0 runtime*' + } + It 'rejects empty runtime enumeration instead of passing vacuously' { + $script:runtimeOutput = @() + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Missing usable Microsoft.NETCore.App*' + } + It 'rejects a runtime reported from another installation root' { + $script:runtimeOutput = @("Microsoft.NETCore.App 10.0.14 [C:\foreign\shared\Microsoft.NETCore.App]", + $runtimeOutput[1]) + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Missing usable Microsoft.NETCore.App*' + } + It 'requires the exact SDK-declared reference-pack version' { + Remove-Item -LiteralPath (Join-Path $sdkRoot 'packs\Microsoft.WindowsDesktop.App.Ref\10.0.14\ref\net10.0\UIAutomationTypes.dll') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Missing Microsoft.WindowsDesktop.App reference input*' + } + It 'restores environment membership and empty values after successful probes' { + $names=@('DOTNET_ROOT','DOTNET_CLI_HOME','TEMP','TMP','DOTNET_ROLL_FORWARD') + $before=[Environment]::GetEnvironmentVariables('Process') + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + $after=[Environment]::GetEnvironmentVariables('Process') + foreach($name in $names) { + $after.Contains($name) | Should -Be $before.Contains($name) + if($before.Contains($name)) { $after[$name] | Should -BeExactly $before[$name] } + } + } + It 'restores process environment when a native probe throws, without alternate downloads' { + $before=[Environment]::GetEnvironmentVariables('Process') + Mock Invoke-GraphCodeDotNetCommand { throw [IO.IOException]::new('controlled probe error') } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing } | Should -Throw '*controlled probe error*' + $after=[Environment]::GetEnvironmentVariables('Process') + foreach($name in @('DOTNET_ROOT','TEMP','TMP','DOTNET_CLI_HOME')) { + $after.Contains($name) | Should -Be $before.Contains($name) + if($before.Contains($name)) { $after[$name] | Should -BeExactly $before[$name] } + } + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'fails actionably when no exact SDK exists and installation was not requested' { + Mock Get-GraphCodeDotNetCandidates { @() } + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Run Tools\windows\bootstrap.ps1*' + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + } + It 'routes missing prerequisites through the repository private installer only when requested' { + Mock Get-GraphCodeDotNetCandidates { @() } + Mock Install-GraphCodeDotNetSdk { Join-Path $ToolRoot "dotnet\$Version\dotnet.exe" } + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing | + Should -BeExactly (Join-Path $toolRoot 'dotnet\10.0.403\dotnet.exe') + Should -Invoke Install-GraphCodeDotNetSdk -Times 1 -ParameterFilter { $Version -ceq '10.0.403' } + } + It 'keeps the pin as the single source of truth and rejects roll-forward' { + $path=Join-Path $fixtureRepo 'Tools\windows\ValidationRunner\global.json' + [IO.File]::WriteAllText($path,'{"sdk":{"version":"10.0.403","rollForward":"latestPatch","allowPrerelease":false}}') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Invalid release SDK pin*' + } + It 'restores an absent environment variable as absent rather than present-empty' { + if ([Environment]::GetEnvironmentVariables('Process').Contains('DOTNET_ROOT')) { Remove-Item Env:\DOTNET_ROOT } + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + [Environment]::GetEnvironmentVariables('Process').Contains('DOTNET_ROOT') | Should -BeFalse + } + It 'preserves a genuinely empty environment value across the probe' { + [Environment]::SetEnvironmentVariable('DOTNET_ROLL_FORWARD', '', 'Process') + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot | Should -BeExactly $exe + $after=[Environment]::GetEnvironmentVariables('Process') + $after.Contains('DOTNET_ROLL_FORWARD') | Should -BeTrue + $after['DOTNET_ROLL_FORWARD'] | Should -BeExactly '' + } + It 'rejects a string-valued prerelease flag instead of coercing it' { + [IO.File]::WriteAllText((Join-Path $fixtureRepo 'Tools\windows\ValidationRunner\global.json'), + '{"sdk":{"version":"10.0.403","rollForward":"disable","allowPrerelease":"false"}}') + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*Invalid release SDK pin*' + } + It 'reads the version from global.json rather than returning a baked-in SDK version' { + [IO.File]::WriteAllText((Join-Path $fixtureRepo 'Tools\windows\ValidationRunner\global.json'), + '{"sdk":{"version":"10.0.404","rollForward":"disable","allowPrerelease":false}}') + Get-GraphCodeDotNetPin $fixtureRepo | Should -BeExactly '10.0.404' + { Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot } | Should -Throw '*SDK 10.0.404 inputs are missing*' + } + It 'reuses a private exact SDK without installing or consulting remote metadata' { + $private=Join-Path $toolRoot 'dotnet\10.0.403' + $null=[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($private)) + [IO.Directory]::Move($sdkRoot,$private) + $script:sdkRoot=$private + $script:exe=Join-Path $private 'dotnet.exe' + $script:runtimeOutput=@("Microsoft.NETCore.App 10.0.14 [$private\shared\Microsoft.NETCore.App]", + "Microsoft.WindowsDesktop.App 10.0.14 [$private\shared\Microsoft.WindowsDesktop.App]") + $env:GRAPHCODE_DOTNET10403=$exe + Resolve-GraphCodeDotNetSdk $fixtureRepo $toolRoot -InstallIfMissing | Should -BeExactly $exe + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + Should -Invoke Invoke-RestMethod -Times 0 + } + It 'runs the actual bootstrap SDK assignment and environment producers without provider installation' { + $bootstrapAst=[Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repositoryRoot 'Tools\windows\bootstrap.ps1'),[ref]$null,[ref]$null) + $sdkAssignment=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$dotnet' + },$true)) + $valuesAssignment=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$values' + },$true)) + $exportLoop=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.ForEachStatementAst] -and + $node.Variable.VariablePath.UserPath -ceq 'entry' -and + $node.Condition.Extent.Text -ceq '$values.GetEnumerator()' + },$true)) + $environmentWriter=@($bootstrapAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.PipelineAst] -and + $node.Extent.Text.Contains('Set-Content -LiteralPath $environmentScript') + },$true)) + $sdkAssignment.Count | Should -Be 1 + $valuesAssignment.Count | Should -Be 1 + $exportLoop.Count | Should -Be 1 + $environmentWriter.Count | Should -Be 1 + $setters=@($exportLoop[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Member.Value -ceq 'SetEnvironmentVariable' + },$true)) + $setters.Count | Should -Be 1 + $setters[0].Arguments.Count | Should -Be 2 + $repoRoot=$fixtureRepo + $ToolRoot=$script:toolRoot + $zig0152='fixture-zig0152';$zig0160='fixture-zig0160';$winghosttyRoot='fixture-winghostty';$zmxRoot='fixture-zmx' + $environmentScript=Join-Path $caseRoot 'environment.ps1' + $env:GITHUB_ENV=Join-Path $caseRoot 'github.env' + . ([scriptblock]::Create($sdkAssignment[0].Extent.Text)) + . ([scriptblock]::Create($valuesAssignment[0].Extent.Text)) + . ([scriptblock]::Create($exportLoop[0].Extent.Text)) + . ([scriptblock]::Create($environmentWriter[0].Extent.Text)) + $env:GRAPHCODE_DOTNET10403 | Should -BeExactly $exe + $env:DOTNET_ROOT | Should -BeExactly $sdkRoot + $text=[IO.File]::ReadAllText($environmentScript) + $text | Should -Match ('\$env:GRAPHCODE_DOTNET10403 = ' + [regex]::Escape("'$exe'")) + $text | Should -Match ('\$env:DOTNET_ROOT = ' + [regex]::Escape("'$sdkRoot'")) + [IO.File]::ReadAllText($env:GITHUB_ENV) | Should -Match ([regex]::Escape("GRAPHCODE_DOTNET10403=$exe")) + Should -Invoke Install-GraphCodeDotNetSdk -Times 0 + Should -Invoke Invoke-RestMethod -Times 0 + } +} + +Describe 'Official private SDK acquisition' { + BeforeEach { + New-DotNetTestFixture + $rid = if([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ceq 'Arm64') {'win-arm64'} else {'win-x64'} + $script:metadataSdk=[pscustomobject]@{version='10.0.403';files=@([pscustomobject]@{ + name="dotnet-sdk-$rid.zip";rid=$rid;url="https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.403/dotnet-sdk-10.0.403-$rid.zip";hash=('A'*128)})} + Mock Invoke-RestMethod { [pscustomobject]@{releases=@([pscustomobject]@{sdks=@($metadataSdk);sdk=$metadataSdk})} } + } + It 'selects the exact official version and deduplicates identical sdk/sdks metadata' { + $asset=Get-GraphCodeDotNetAsset '10.0.403' + $asset.Sha512 | Should -BeExactly ('A'*128) + $asset.Uri.Host | Should -BeExactly 'builds.dotnet.microsoft.com' + Should -Invoke Invoke-RestMethod -Times 1 -ParameterFilter { $TimeoutSec -eq 60 -and $MaximumRedirection -eq 0 } + } + It 'does not substitute a newer release when the requested SDK is absent' { + $metadataSdk.version='10.0.404' + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*exactly one SDK 10.0.403*found 0*' + } + It 'rejects missing or malformed checksums' { + $metadataSdk.files[0].hash='not-a-sha512' + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*valid SHA-512*' + } + It 'rejects non-Microsoft metadata URLs before downloading' { + $metadataSdk.files[0].url='https://example.invalid/sdk.zip' + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*non-official ZIP URL*' + } + It 'rejects conflicting exact-version assets rather than picking the first' { + $other=[pscustomobject]@{name=$metadataSdk.files[0].name;rid=$metadataSdk.files[0].rid;url='https://builds.dotnet.microsoft.com/other.zip';hash=('B'*128)} + $metadataSdk.files += $other + { Get-GraphCodeDotNetAsset '10.0.403' } | Should -Throw '*found 2*' + } +} + +Describe 'Private SDK integrity and atomic publication' { + BeforeEach { + New-DotNetTestFixture + $script:archiveSource=Join-Path $caseRoot 'source.zip' + $zip=[IO.Compression.ZipFile]::Open($archiveSource,[IO.Compression.ZipArchiveMode]::Create) + try { $entry=$zip.CreateEntry('dotnet.exe');$writer=[IO.StreamWriter]::new($entry.Open());try{$writer.Write('fixture only')}finally{$writer.Dispose()} } finally {$zip.Dispose()} + $script:archiveHash=(Get-FileHash -LiteralPath $archiveSource -Algorithm SHA512).Hash + Mock Get-GraphCodeDotNetAsset { [pscustomobject]@{Uri=[uri]'https://builds.dotnet.microsoft.com/sdk.zip';Sha512=$archiveHash} } + Mock Invoke-WebRequest { Copy-Item -LiteralPath $archiveSource -Destination $OutFile } + Mock Expand-Archive { + $null=[IO.Directory]::CreateDirectory($DestinationPath) + [IO.File]::WriteAllText((Join-Path $DestinationPath 'dotnet.exe'),'fixture only; not executed') + } + Mock Get-GraphCodeDotNetSdkProbe { [pscustomobject]@{Valid=$true;Reason='';Executable=$Executable} } + } + It 'verifies checksum before extraction and publishes only a verified staged SDK' { + $result=Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' + $result | Should -BeExactly (Join-Path $toolRoot 'dotnet\10.0.403\dotnet.exe') + Test-Path -LiteralPath $result -PathType Leaf | Should -BeTrue + Should -Invoke Expand-Archive -Times 1 + Should -Invoke Get-GraphCodeDotNetSdkProbe -Times 1 -ParameterFilter { $Version -ceq '10.0.403' -and $Executable -like '*payload\dotnet.exe' } + @(Get-ChildItem (Join-Path $toolRoot 'dotnet') -Directory | Where-Object Name -Like '.install-*').Count | Should -Be 0 + } + It 'never extracts a checksum-mismatched archive' { + $script:archiveHash='0'*128 + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*SHA-512 checksum mismatch*' + Should -Invoke Expand-Archive -Times 0 + Test-Path (Join-Path $toolRoot 'dotnet\10.0.403') | Should -BeFalse + } + It 'surfaces download failure and leaves no published SDK' { + Mock Invoke-WebRequest { throw [IO.IOException]::new('controlled download failure') } + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*controlled download failure*' + Should -Invoke Expand-Archive -Times 0 + } + It 'rejects a downloaded payload with missing runtime/reference inputs before publication' { + Mock Get-GraphCodeDotNetSdkProbe { [pscustomobject]@{Valid=$false;Reason='missing WindowsDesktop runtime';Executable=$Executable} } + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*Downloaded SDK failed verification*' + Test-Path (Join-Path $toolRoot 'dotnet\10.0.403') | Should -BeFalse + } + It 'preserves an existing private directory instead of overwriting unknown contents' { + $directory=Join-Path $toolRoot 'dotnet\10.0.403';$null=[IO.Directory]::CreateDirectory($directory) + [IO.File]::WriteAllText((Join-Path $directory 'preserve.txt'),'owned elsewhere') + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*already exists but was not verified*' + [IO.File]::ReadAllText((Join-Path $directory 'preserve.txt')) | Should -BeExactly 'owned elsewhere' + Should -Invoke Invoke-WebRequest -Times 0 + } + It 'rejects traversal entries before extraction even if the archive hash matches' { + Remove-Item -LiteralPath $archiveSource + $zip=[IO.Compression.ZipFile]::Open($archiveSource,[IO.Compression.ZipArchiveMode]::Create) + try {$null=$zip.CreateEntry('../outside.exe')} finally {$zip.Dispose()} + $script:archiveHash=(Get-FileHash -LiteralPath $archiveSource -Algorithm SHA512).Hash + { Install-GraphCodeDotNetSdk $fixtureRepo $toolRoot '10.0.403' } | Should -Throw '*unsafe archive entry*' + Should -Invoke Expand-Archive -Times 0 + } +} + +Describe 'Installed exact SDK live prerequisite probe' { + It 'verifies this machine without installing dependencies or changing process environment' { + $before=[Environment]::GetEnvironmentVariables('Process') + $exe=(Get-Command dotnet -CommandType Application -ErrorAction Stop).Source + $result=Get-GraphCodeDotNetSdkProbe $exe $repositoryRoot (Join-Path $TestDrive 'actual-probe') (Get-GraphCodeDotNetPin $repositoryRoot) + $result.Valid | Should -BeTrue -Because $result.Reason + $result.Executable | Should -BeExactly $exe + $after=[Environment]::GetEnvironmentVariables('Process') + foreach($name in @('DOTNET_ROOT','DOTNET_CLI_HOME','TEMP','TMP','DOTNET_ROLL_FORWARD')) { + $after.Contains($name) | Should -Be $before.Contains($name) + if($before.Contains($name)){$after[$name]|Should -BeExactly $before[$name]} + } + } +} diff --git a/Tools/windows/bootstrap.ps1 b/Tools/windows/bootstrap.ps1 index ac3075ff..cfcd8d92 100644 --- a/Tools/windows/bootstrap.ps1 +++ b/Tools/windows/bootstrap.ps1 @@ -17,6 +17,7 @@ $ErrorActionPreference = "Stop" # lines instead of PowerShell's high-overhead per-record rendering. $ProgressPreference = "SilentlyContinue" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +. (Join-Path $PSScriptRoot "DotNetSdk.ps1") if (-not $ToolRoot) { $ToolRoot = Join-Path $repoRoot ".graphcode-tools" } @@ -68,6 +69,7 @@ function Test-BootstrapPathBudget( Test-BootstrapPathBudget $repoRoot $ProviderRoot New-Item -ItemType Directory -Force $ToolRoot, $ProviderRoot | Out-Null +$dotnet = Resolve-GraphCodeDotNetSdk -RepositoryRoot $repoRoot -ToolRoot $ToolRoot -InstallIfMissing function Format-ZigDownloadSize([long] $Bytes) { if ($Bytes -lt 1KB) { @@ -506,6 +508,8 @@ if (-not $swift -and -not $SkipSwift) { } $values = [ordered]@{ + GRAPHCODE_DOTNET10403 = $dotnet + DOTNET_ROOT = [IO.Path]::GetDirectoryName($dotnet) GRAPHCODE_ZIG0152 = $zig0152 GRAPHCODE_ZIG0160 = $zig0160 GRAPHCODE_WINGHOSTTY_ROOT = $winghosttyRoot diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 4a4d663e..23b7c8c4 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -870,6 +870,16 @@ this checkout by default. - PowerShell 7 (`pwsh`), Git, and `winget`. - A short checkout path when possible, such as `C:\src\GraphCode`. +The compiled Windows validator requires **.NET SDK 10.0.403**, pinned by +`Tools\windows\ValidationRunner\global.json` with SDK roll-forward disabled. +Bootstrap first reuses a verified installed exact SDK (global or private). +It checks the .NETCore and WindowsDesktop 10.0 runtimes and the SDK-declared +reference packs, including UI Automation references. If none is usable, it +downloads that exact Windows SDK ZIP from official Microsoft release metadata, +verifies its SHA-512 checksum before extraction, and installs it privately under +`ToolRoot\dotnet\10.0.403`. It does not install a global SDK or change user/machine +environment variables. + The bootstrap installs Swift 6.3.3 through `winget` when it is absent. The current `Swift.Toolchain` 6.3.3 package declares Python 3.10 and the x64 Visual C++ redistributable as dependencies. It also downloads Zig 0.15.2 for the @@ -888,6 +898,13 @@ pwsh -NoProfile -File Tools\windows\bootstrap.ps1 ` Load `environment.ps1` again in each new shell. The explicit roots keep every download and provider checkout owned by this worktree and are the locations `Tools\windows\dev.ps1` expects. +The generated environment exports `GRAPHCODE_DOTNET10403` as the verified full +`dotnet.exe` path and process-only `DOTNET_ROOT`, so the compiled validator's +apphost also works with a private-only runtime installation. Build the validator +with `pwsh -NoProfile -File Tools\windows\Build-CompiledValidationRunner.ps1`; +that wrapper honors the exported SDK, resolves the pin from the validator +directory, and restores its temporary process environment. A build never +downloads an SDK implicitly; rerun bootstrap for a missing prerequisite. Reference timings from one Windows x64 worktree on 2026-10-01 are not performance guarantees: the initial bootstrap took **34 minutes 24 seconds** @@ -953,6 +970,11 @@ mismatch rather than terminating a reused PID or an unrelated process. Running different Zig from `PATH` is not a supported workaround. - **Missing or invalid pinned environment:** rerun bootstrap and reload `.graphcode-tools\environment.ps1`; do not hand-build an environment. +- **SDK or WindowsDesktop/reference-pack prerequisite refused:** rerun bootstrap + and reload its environment; do not substitute another SDK or weaken + `global.json`. An explicitly invalid `GRAPHCODE_DOTNET10403` is rejected, not + silently replaced. An invalid existing private SDK directory is preserved; + select a fresh owned `-ToolRoot` rather than overwriting unknown contents. - **An existing `run.json` blocks build or launch:** run `pwsh -NoProfile -File Tools\windows\dev.ps1 -Stop`. If stop reports an identity mismatch, preserve the message and inspect the record instead of From c5be4f5ac5720475e06c153d790880e5e6ee3847 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:26:21 -0700 Subject: [PATCH 32/46] Prepare owned Pester 5 before canonical compiled validation Reuse loaded or available Pester 5 and save pinned 5.7.1 only when missing, scoped to workspace modules and the current job environment. Guard prerequisite ordering and gating, and reject a missing loader without changing canonical execution or deadlines. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .github/workflows/windows-hardening.yml | 20 ++++++++ .github/workflows/windows-port-validation.yml | 20 ++++++++ .../Host/CiContracts/README.md | 10 ++++ .../Host/CiContracts/SourceContracts.cs | 48 +++++++++++++++++++ .../Host/CiContracts/SuiteRegistration.cs | 2 +- .../Host/CiContracts/manifest-fragment.json | 2 +- .../CiContracts/source-correspondence.json | 4 +- 7 files changed, 102 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index e08dadbe..2dfb1ed8 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -98,6 +98,26 @@ jobs: } dotnet build -c Release if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + - name: Prepare owned Pester 5 prerequisite + if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' + shell: pwsh + timeout-minutes: 5 + run: | + $ErrorActionPreference = 'Stop' + $pester5 = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($pester5.Count -eq 0) { + $pester5 = @(Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + } + if ($pester5.Count -eq 0) { + $moduleRoot = Join-Path $PWD '.ci-modules' + $null = New-Item -ItemType Directory -Force -Path $moduleRoot + Save-Module -Name Pester -RequiredVersion 5.7.1 -Path $moduleRoot -ErrorAction Stop + $env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath + [IO.File]::AppendAllText($env:GITHUB_ENV, "PSModulePath=$env:PSModulePath`n", [Text.UTF8Encoding]::new($false)) + } + . .\Tools\windows\Import-PinnedPester.ps1 + $loaded = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($loaded.Count -ne 1) { throw 'Compiled validation requires exactly one loaded Pester 5 module.' } - name: Verify complete compiled validation runner contract if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' shell: pwsh diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index d64cd0a5..196c85b9 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -101,6 +101,26 @@ jobs: dotnet build -c Release if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed: exit=$LASTEXITCODE" } + - name: Prepare owned Pester 5 prerequisite + shell: pwsh + timeout-minutes: 5 + run: | + $ErrorActionPreference = 'Stop' + $pester5 = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($pester5.Count -eq 0) { + $pester5 = @(Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + } + if ($pester5.Count -eq 0) { + $moduleRoot = Join-Path $PWD '.ci-modules' + $null = New-Item -ItemType Directory -Force -Path $moduleRoot + Save-Module -Name Pester -RequiredVersion 5.7.1 -Path $moduleRoot -ErrorAction Stop + $env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath + [IO.File]::AppendAllText($env:GITHUB_ENV, "PSModulePath=$env:PSModulePath`n", [Text.UTF8Encoding]::new($false)) + } + . .\Tools\windows\Import-PinnedPester.ps1 + $loaded = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($loaded.Count -ne 1) { throw 'Compiled validation requires exactly one loaded Pester 5 module.' } + - name: Verify complete compiled validation runner contract shell: pwsh timeout-minutes: 15 diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/README.md b/Tools/windows/ValidationRunner/Host/CiContracts/README.md index de3ca8ed..478d1951 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/README.md +++ b/Tools/windows/ValidationRunner/Host/CiContracts/README.md @@ -82,3 +82,13 @@ mixed roots, original failure identity, output/error streams, native exit state and unchanged normal arguments. The standalone topology driver runs these controls too. They are reported separately from the seven mandatory root units and do not claim an actual private SDK installation or full-host execution. + +Both canonical CI jobs now prepare Pester through the existing pinned loader. +They reuse loaded or available major 5 first; only its absence saves version +5.7.1 under the workspace's `.ci-modules` and propagates that process-only module +path through `GITHUB_ENV`. No global installation or user environment setting +is used. The compiled topology family is now **36 cases**, including a new +missing-prerequisite control, making the seven-family focused total **144**. +Local verification reused real Pester 5.7.1 in both actual step bodies without +downloading anything. Eight separate workflow-body mocks covered loaded, +available, missing and save-failure branches; they are not full-host cases. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs index b6808615..5c474a6a 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs @@ -270,9 +270,36 @@ private static void AssertCanonicalJob(string job) SourceCases.Require(ExeInvocations(job) == 1 && !SourceCases.Match(job, @"--suite\b|--self-test\b|--fixture\b|ValidationRunner\.Tests\.ps1|Invoke-ValidationRunnerContract\.ps1"), "CI must execute exactly one normal full compiled host, not a filtered/self-test/legacy entry"); + AssertPesterPrerequisite(job); AssertProof(job); } + private static void AssertPesterPrerequisite(string job) + { + var steps = WorkflowText.Steps(job); + var preparation = steps.Where(step => step.Contains( + @". .\Tools\windows\Import-PinnedPester.ps1", StringComparison.Ordinal)).ToArray(); + SourceCases.Require(preparation.Length == 1, "The canonical host requires one explicit pinned Pester prerequisite"); + var prerequisite = preparation[0]; + var execution = steps.Single(step => ExeInvocations(step) == 1); + SourceCases.Require(job.IndexOf(prerequisite, StringComparison.Ordinal) < job.IndexOf(execution, StringComparison.Ordinal) && + Regex.Match(prerequisite, @"(?m)^ if: (.+)$").Value == + Regex.Match(execution, @"(?m)^ if: (.+)$").Value, + "Pester preparation must precede and share the canonical execution gate"); + SourceCases.Require(prerequisite.Contains("Get-Module -Name Pester", StringComparison.Ordinal) && + prerequisite.Contains("Get-Module -ListAvailable -Name Pester", StringComparison.Ordinal) && + prerequisite.Contains("$_.Version.Major -eq 5", StringComparison.Ordinal) && + SourceCases.Match(prerequisite, + @"(?s)if \(\$pester5\.Count -eq 0\) \{.*?Save-Module -Name Pester -RequiredVersion 5\.7\.1 -Path \$moduleRoot") && + prerequisite.Contains("Join-Path $PWD '.ci-modules'", StringComparison.Ordinal) && + prerequisite.Contains("$env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath", StringComparison.Ordinal) && + prerequisite.Contains("AppendAllText($env:GITHUB_ENV", StringComparison.Ordinal) && + prerequisite.Contains("PSModulePath=$env:PSModulePath", StringComparison.Ordinal) && + prerequisite.Contains("$loaded.Count -ne 1", StringComparison.Ordinal) && + !prerequisite.Contains("Install-Module", StringComparison.OrdinalIgnoreCase), + "Pester preparation must reuse major 5, conditionally save 5.7.1 only to an owned path, propagate its process path and verify the pinned loader"); + } + private static void AssertProof(string text) { SourceCases.Require(text.Contains("coverage.json", StringComparison.Ordinal) && @@ -311,11 +338,32 @@ dotnet build -c Release $executedMarkers = @($output | Where-Object { $_ -ceq 'VALIDATION_RUNNER_EXECUTED=1' }).Count if ($passMarkers -ne 1 -or $executedMarkers -ne 1) { throw 'markers' } """.Replace("\r\n", "\n", StringComparison.Ordinal); + const string prerequisite = """ + - name: Prepare owned Pester 5 prerequisite + run: | + $pester5 = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($pester5.Count -eq 0) { + $pester5 = @(Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + } + if ($pester5.Count -eq 0) { + $moduleRoot = Join-Path $PWD '.ci-modules' + Save-Module -Name Pester -RequiredVersion 5.7.1 -Path $moduleRoot + $env:PSModulePath = $moduleRoot + [IO.Path]::PathSeparator + $env:PSModulePath + [IO.File]::AppendAllText($env:GITHUB_ENV, "PSModulePath=$env:PSModulePath`n") + } + . .\Tools\windows\Import-PinnedPester.ps1 + $loaded = @(Get-Module -Name Pester | Where-Object { $_.Version.Major -eq 5 }) + if ($loaded.Count -ne 1) { throw 'Pester' } + + """; + fixture = fixture.Replace(" - name: Run compiled host", + prerequisite.Replace("\r\n", "\n", StringComparison.Ordinal) + " - name: Run compiled host", StringComparison.Ordinal); cases.Check("control.canonical.valid", () => AssertCanonicalJob(fixture)); foreach (var (id, before, after) in new[] { ("wrong-sdk", "dotnet-version: 10.0.403", "dotnet-version: 10.0.x"), ("wrong-build-cwd", "working-directory: Tools/windows/ValidationRunner", "working-directory: ."), + ("missing-pester-prerequisite", @". .\Tools\windows\Import-PinnedPester.ps1", "Write-Output 'missing Pester prerequisite'"), ("filtered-run", "--artifact-root .ci-validation-runner", "--artifact-root .ci-validation-runner --suite ui.activation"), ("missing-run", @".\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe", "Write-Output"), ("missing-complete", "$report.complete", "$report.unused"), diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs index 53aa90ef..18534b30 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs @@ -11,7 +11,7 @@ public static IReadOnlyList CreateSuites() => SourceContracts.Hardening, 7), new RootSuite("host.ci-partition", "Host\\CiContracts\\Partition.ps1", PartitionCases), new RootSuite("host.ci-aggregate", "Host\\CiContracts\\Aggregate.ps1", AggregateCases, SourceContracts.Aggregate, 8), - new RootSuite("host.ci-topology", null, [], SourceContracts.Topology, 35), + new RootSuite("host.ci-topology", null, [], SourceContracts.Topology, 36), new RootSuite("host.shell-section-gate", "Host\\CiContracts\\ShellSections.ps1", SectionCases), new RootSuite("host.zig-resolver", "Host\\CiContracts\\ZigResolver.ps1", ResolverCases) ]; diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json index 230cd419..c34e63af 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json +++ b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json @@ -3,7 +3,7 @@ { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 22, "exactCount": 22 }, { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, - { "id": "host.ci-topology", "owner": "Host", "minimumCount": 35, "exactCount": 35 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 36, "exactCount": 36 }, { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 15, "exactCount": 15 }, { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 21, "exactCount": 21 } ] diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json index e71f0164..32db11b8 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json +++ b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json @@ -44,7 +44,7 @@ }, { "unit": "host.ci-topology", - "expectedExpandedCount": 35, + "expectedExpandedCount": 36, "production": ".github/workflows/windows-*.yml and Tools/windows/Invoke-ValidationRunnerContract.ps1", "cases": [ { "originalLines": "5233-5257", "ids": ["source.cold-cache.spikes-swift", "source.cold-cache.spikes-other", "source.cold-cache.packaging-real", "source.runner-topology-timeout"] }, @@ -53,7 +53,7 @@ { "originalLines": "5283-5291", "ids": ["source.pinned-swift.shell", "source.pinned-swift.port", "source.pinned-swift.hardening"] }, { "originalLines": "5292-5307", "ids": ["source.shell-bootstrap", "source.hosted-explicit-skip-limits", "source.shell-live-task"] }, { "originalSource": "Tools/windows/Tests/WindowsCiTopology.Tests.ps1:39-115", "purpose": "Preserve scheduling and required names; replace old monolithic execution assertions with the compiled host contract", "ids": ["source.runner-single-win2022-fail-closed", "source.runner-no-providers-or-hidden-failure", "source.runner-new-sdk-build-execution", "source.runner-port-exactly-once", "source.runner-aggregate-required", "source.runner-hardening-single-fallback", "source.runner-compatibility-no-monolith"] }, - { "originalLines": null, "purpose": "New SDK/CWD/full-execution/coverage/exit/marker positive and negative controls; not executions of GitHub Actions", "ids": ["control.canonical.valid", "control.canonical.wrong-sdk", "control.canonical.wrong-build-cwd", "control.canonical.filtered-run", "control.canonical.missing-run", "control.canonical.missing-complete", "control.canonical.missing-positive-count", "control.canonical.missing-full-count-equality", "control.canonical.inverted-complete", "control.canonical.missing-pass-count", "control.canonical.missing-executed-count", "control.canonical.ignored-exit", "control.canonical.duplicate-run"] } + { "originalLines": null, "purpose": "New SDK/CWD/Pester-prerequisite/full-execution/coverage/exit/marker positive and negative controls; not executions of GitHub Actions", "ids": ["control.canonical.valid", "control.canonical.wrong-sdk", "control.canonical.wrong-build-cwd", "control.canonical.missing-pester-prerequisite", "control.canonical.filtered-run", "control.canonical.missing-run", "control.canonical.missing-complete", "control.canonical.missing-positive-count", "control.canonical.missing-full-count-equality", "control.canonical.inverted-complete", "control.canonical.missing-pass-count", "control.canonical.missing-executed-count", "control.canonical.ignored-exit", "control.canonical.duplicate-run"] } ] }, { From a36ac1d67d15432aad52435b54de6956151c1307 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:24:30 -0700 Subject: [PATCH 33/46] Load compatible UIA runtime dependencies and expose recovery fixture failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Ui/UiPowerShellContracts.ps1 | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 index 6d3b2e70..fa52c402 100644 --- a/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 @@ -4,9 +4,31 @@ param( [Parameter(Mandatory)][string] $AssemblyPath ) $ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$OutputEncoding = [Text.UTF8Encoding]::new($false) $null = [IO.Directory]::CreateDirectory($ArtifactRoot) & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null +$frameworkAssemblies = @{} +foreach ($name in @('WindowsBase', 'UIAutomationTypes', 'UIAutomationClient')) { + $path = Join-Path $PSHOME "$name.dll" + $identity = [Reflection.AssemblyName]::GetAssemblyName($path) + if ($identity.Version.Major -ne [Environment]::Version.Major) { + throw "UIA runtime dependency $path does not match PowerShell CLR $([Environment]::Version); reference-pack assemblies are not supported" + } + $assembly = [Reflection.Assembly]::LoadFrom($path) + if ($assembly.GetName().Version.Major -ne [Environment]::Version.Major -or + @($assembly.GetCustomAttributesData() | Where-Object { + $_.AttributeType.FullName -ceq 'System.Runtime.CompilerServices.ReferenceAssemblyAttribute' + }).Count -ne 0) { + throw "UIA dependency is not a compatible runtime assembly: $path" + } + $frameworkAssemblies[$name] = $assembly +} +if ([System.Windows.Automation.ControlType].Assembly -ne $frameworkAssemblies.UIAutomationTypes -or + [System.Windows.Automation.InvokePattern].Assembly -ne $frameworkAssemblies.UIAutomationClient) { + throw 'UIA fixture types do not belong to the explicitly selected PowerShell runtime assemblies' +} $source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) $errors = $null $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) @@ -209,11 +231,26 @@ function Confirm-RecoveryCase($Case, [string] $Definition) { else { 'Unwired recovery actions remained unavailable after Fit canvas' } $passed = $passed -and $failure -and $failure.Exception.Message -ceq $expectedDiagnostic } - return [bool]$passed + return [pscustomobject]@{ + passed = [bool]$passed + name = $Case.name + expectedAccepted = $Case.expected + actualAccepted = ($null -eq $failure) + expectedFits = $Case.fits + expectedReconnects = $Case.reconnects + actual = [ordered]@{ + fits = $state.fits; reconnects = $state.reconnects; targetLookups = $state.targetLookups + patternReads = $state.patternReads; boundsReads = $state.boundsReads + offscreenBoundsReads = $state.offscreenBoundsReads; recoveryInvokes = $state.recoveryInvokes; sleeps = $state.sleeps + } + failure = $(if ($failure) { $failure.Exception.ToString() } else { $null }) + } } foreach ($case in $recoveryCases) { Invoke-UiCase "ui.recovery.visibility.$($case.name)" { - Assert-UiContract (Confirm-RecoveryCase $case $recoveryDefinition) "Recovery visibility behavior differs: $($case.name)" + $observation = Confirm-RecoveryCase $case $recoveryDefinition + Assert-UiContract $observation.passed ("Recovery visibility behavior differs: " + + ($observation | ConvertTo-Json -Compress -Depth 6 -EscapeHandling EscapeNonAscii)) } } foreach ($mutant in @( @@ -223,17 +260,22 @@ foreach ($mutant in @( @{ name = 'outside-bounds-accepted'; case = 'outside-after-fit'; old = '$bounds.Left -lt $graphBounds.Left -or'; replacement = '$false -or' } )) { Invoke-UiCase "ui.recovery.visibility.mutant-$($mutant.name)" { + $case = $recoveryCases | Where-Object name -CEQ $mutant.case + $baseline = Confirm-RecoveryCase $case $recoveryDefinition + Assert-UiContract $baseline.passed ("Recovery mutant requires a valid unmodified baseline: " + + ($baseline | ConvertTo-Json -Compress -Depth 6 -EscapeHandling EscapeNonAscii)) Assert-UiContract ([regex]::Matches($recoveryDefinition, [regex]::Escape($mutant.old)).Count -eq 1) 'Recovery mutation anchor is not unique' $definition = $recoveryDefinition.Replace($mutant.old, $mutant.replacement) $parseErrors = $null $null = [Management.Automation.Language.Parser]::ParseInput($definition, [ref]$null, [ref]$parseErrors) Assert-UiContract ($parseErrors.Count -eq 0) 'Recovery mutant parser failure' - Assert-UiContract (-not (Confirm-RecoveryCase ($recoveryCases | Where-Object name -CEQ $mutant.case) $definition)) ` - "Recovery mutant unexpectedly preserved visibility contract: $($mutant.name)" + $observation = Confirm-RecoveryCase $case $definition + Assert-UiContract (-not $observation.passed) ("Recovery mutant unexpectedly preserved visibility contract: " + + ($observation | ConvertTo-Json -Compress -Depth 6 -EscapeHandling EscapeNonAscii)) } } Assert-UiContract ($results.Count -eq 25) 'PowerShell UI fixture requires five isolation and twenty recovery cases' [IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-powershell-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) -foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } +foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8 -EscapeHandling EscapeNonAscii)) } if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } From a3ead8af419a0c886d378ecf0dae3bc370b9056f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:29:39 -0700 Subject: [PATCH 34/46] Load UIA runtime dependencies at the verified production assembly boundary Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Import-CompiledValidationAssembly.ps1 | 23 +++++++++++++++++++ .../Ui/UiPowerShellContracts.ps1 | 20 ---------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Tools/windows/Import-CompiledValidationAssembly.ps1 b/Tools/windows/Import-CompiledValidationAssembly.ps1 index 3888c748..6fb8d6fe 100644 --- a/Tools/windows/Import-CompiledValidationAssembly.ps1 +++ b/Tools/windows/Import-CompiledValidationAssembly.ps1 @@ -111,4 +111,27 @@ try { } } finally { $pe.Dispose() } } finally { $file.Dispose() } +if ($RequiredType -cin @("GraphCodeUiaGateState", "GraphCodeUiaAttribution")) { + $uiFrameworkAssemblies = @{} + foreach ($runtimeName in @("WindowsBase", "UIAutomationTypes", "UIAutomationClient")) { + $runtimePath = Join-Path $PSHOME "$runtimeName.dll" + $runtimeIdentity = [Reflection.AssemblyName]::GetAssemblyName($runtimePath) + if ($runtimeIdentity.Version.Major -ne [Environment]::Version.Major) { + throw "UIA runtime dependency $runtimePath does not match PowerShell CLR $([Environment]::Version); reference-pack assemblies are not supported" + } + $runtimeAssembly = [Reflection.Assembly]::LoadFrom($runtimePath) + if ($runtimeAssembly.GetName().Version.Major -ne [Environment]::Version.Major -or + @($runtimeAssembly.GetCustomAttributesData() | Where-Object { + $_.AttributeType.FullName -ceq "System.Runtime.CompilerServices.ReferenceAssemblyAttribute" + }).Count -ne 0) { + throw "UIA dependency is not a compatible runtime assembly: $runtimePath" + } + $uiFrameworkAssemblies[$runtimeName] = $runtimeAssembly + } + if ([System.Windows.Rect].Assembly -ne $uiFrameworkAssemblies.WindowsBase -or + [System.Windows.Automation.ControlType].Assembly -ne $uiFrameworkAssemblies.UIAutomationTypes -or + [System.Windows.Automation.InvokePattern].Assembly -ne $uiFrameworkAssemblies.UIAutomationClient) { + throw "UIA types do not belong to the explicitly selected PowerShell runtime assemblies" + } +} Write-Output $assembly diff --git a/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 index fa52c402..d98e1d90 100644 --- a/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiPowerShellContracts.ps1 @@ -9,26 +9,6 @@ $OutputEncoding = [Text.UTF8Encoding]::new($false) $null = [IO.Directory]::CreateDirectory($ArtifactRoot) & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null -$frameworkAssemblies = @{} -foreach ($name in @('WindowsBase', 'UIAutomationTypes', 'UIAutomationClient')) { - $path = Join-Path $PSHOME "$name.dll" - $identity = [Reflection.AssemblyName]::GetAssemblyName($path) - if ($identity.Version.Major -ne [Environment]::Version.Major) { - throw "UIA runtime dependency $path does not match PowerShell CLR $([Environment]::Version); reference-pack assemblies are not supported" - } - $assembly = [Reflection.Assembly]::LoadFrom($path) - if ($assembly.GetName().Version.Major -ne [Environment]::Version.Major -or - @($assembly.GetCustomAttributesData() | Where-Object { - $_.AttributeType.FullName -ceq 'System.Runtime.CompilerServices.ReferenceAssemblyAttribute' - }).Count -ne 0) { - throw "UIA dependency is not a compatible runtime assembly: $path" - } - $frameworkAssemblies[$name] = $assembly -} -if ([System.Windows.Automation.ControlType].Assembly -ne $frameworkAssemblies.UIAutomationTypes -or - [System.Windows.Automation.InvokePattern].Assembly -ne $frameworkAssemblies.UIAutomationClient) { - throw 'UIA fixture types do not belong to the explicitly selected PowerShell runtime assemblies' -} $source = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\uia-live-gate.ps1')) $errors = $null $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$errors) From 29beceec52f0f60308db4c8a4f1d00834e0705e7 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:34:59 -0700 Subject: [PATCH 35/46] Require normal full proof and cover final host prerequisites Preserve exact native dry-run exit status and no-effect source controls, required SDK regression counts, and non-double-counted composite coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Adapters/HostTasks.ps1 | 86 +++++++++- .../Adapters/LegacyOwnership.Contracts.ps1 | 21 ++- .../Adapters/StandaloneSuites.cs | 5 +- .../windows/ValidationRunner/Host/Coverage.cs | 50 +++--- .../ValidationRunner/Host/HostSelfTests.cs | 34 +++- .../ValidationRunner/Manifests/host.json | 18 +- Tools/windows/ValidationRunner/Program.cs | 159 +++++++++--------- Tools/windows/ValidationRunner/README.md | 90 ++++++++++ 8 files changed, 342 insertions(+), 121 deletions(-) create mode 100644 Tools/windows/ValidationRunner/README.md diff --git a/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 b/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 index 7be97661..48f1f5b6 100644 --- a/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 +++ b/Tools/windows/ValidationRunner/Adapters/HostTasks.ps1 @@ -1,17 +1,43 @@ [CmdletBinding()] param( [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][ValidateSet("inventory", "dry-run", "unknown", "untracked-format", "foreign-fixture")][string] $Contract + [Parameter(Mandatory)][ValidateSet("inventory", "dry-run", "unknown", "untracked-format", "foreign-fixture", "missing-providers")][string] $Contract ) $ErrorActionPreference = "Stop" $runner = Join-Path $RepositoryRoot "Tools\windows\validate.ps1" +$pwsh = [Environment]::ProcessPath $executed = 0 +$toolchain = $null function Assert-HostTask([bool] $Condition, [string] $Message) { if (-not $Condition) { throw "Compiled host task contract: $Message" } } +function Assert-DryRunBeforeEffects([string] $Source) { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($Source, [ref]$null, [ref]$errors) + Assert-HostTask ($errors.Count -eq 0) "dry-run producer does not parse" + $functions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq "Invoke-Task" + }, $true)) + Assert-HostTask ($functions.Count -eq 1) "task dispatcher missing or duplicated" + $first = $functions[0].Body.EndBlock.Statements[0] + Assert-HostTask ($first -is [Management.Automation.Language.IfStatementAst] -and + $first.Clauses.Count -eq 1 -and $first.Clauses[0].Item1.Extent.Text -ceq '$DryRun') ` + "dry-run does not precede task effects" + $body = $first.Clauses[0].Item2 + Assert-HostTask ($body.Statements[-1] -is [Management.Automation.Language.ReturnStatementAst]) "dry-run falls through to execution" + $effects = @($body.FindAll({ + param($node) + ($node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -cne "Write-Output") -or + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -or + $node -is [Management.Automation.Language.FileRedirectionAst] + }, $true)) + Assert-HostTask ($effects.Count -eq 0) "dry-run performs an invocation, file write, or task action" +} + switch ($Contract) { "inventory" { $expected = @("swift-portable", "swift-contracts", "swift-production", "swift-paths", @@ -27,19 +53,34 @@ switch ($Contract) { } } "dry-run" { - $single = @(& $runner -Task swift-paths -DryRun) + $source = [IO.File]::ReadAllText($runner) + Assert-DryRunBeforeEffects $source + $executed++ + $mutations = @( + $source.Replace('function Invoke-Task([string] $name) {', + 'function Invoke-Task([string] $name) { Invoke-Native "unexpected build" {}'), + $source.Replace('if ($DryRun) {', 'if ($DryRun) { [IO.File]::WriteAllText("unexpected", "effect");') + ) + foreach ($mutation in $mutations) { + Assert-HostTask ($mutation -cne $source) "ineffective dry-run negative control" + $refused = $false + try { Assert-DryRunBeforeEffects $mutation } catch { $refused = $true } + Assert-HostTask $refused "dry-run source admitted a build/write negative control" + $executed++ + } + $single = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task swift-paths -DryRun) Assert-HostTask ($LASTEXITCODE -eq 0 -and $single.Count -eq 1 -and $single[0] -ceq "task=swift-paths") ` "single-task dry run did not select exactly swift-paths" $executed++ - $provider = @(& $runner -Task provider-build -DryRun) + $provider = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task provider-build -DryRun) Assert-HostTask ($LASTEXITCODE -eq 0 -and $provider.Count -eq 1 -and $provider[0] -ceq "task=provider-build") ` "build-only dry run changed" $executed++ - $all = @(& $runner -Task all -DryRun) + $all = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task all -DryRun) Assert-HostTask ($LASTEXITCODE -eq 0 -and $all.Count -eq 16 -and $all -cnotcontains "task=provider-build" -and $all -ccontains "task=terminal-gate") "all-task selection changed" $executed++ - $skipped = @(& $runner -Task all -SkipTask swift-paths -DryRun) + $skipped = @(& $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task all -SkipTask swift-paths -DryRun) Assert-HostTask ($LASTEXITCODE -eq 0 -and $skipped.Count -eq 15 -and $skipped -cnotcontains "task=swift-paths") "skip-task selection changed" $executed++ @@ -52,6 +93,23 @@ switch ($Contract) { $executed++ } "untracked-format" { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($runner, [ref]$null, [ref]$errors) + Assert-HostTask ($errors.Count -eq 0) "validation source does not parse" + foreach ($name in @("Resolve-SwiftExecutable", "Initialize-SwiftEnvironment")) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + Assert-HostTask ($definitions.Count -eq 1) "missing or duplicated actual toolchain helper: $name" + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + $SwiftExecutable = $env:GRAPHCODE_SWIFT633 + $toolchain = Resolve-SwiftExecutable + Initialize-SwiftEnvironment $toolchain + $version = @(& $toolchain --version 2>&1) + Assert-HostTask ($LASTEXITCODE -eq 0 -and $version.Count -gt 0 -and + [string]$version[0] -cmatch '^Swift version 6\.3\.3(?: \(|$)') "real formatter control requires pinned Swift 6.3.3" . (Join-Path $RepositoryRoot "Tools\windows\Tests\ValidationFixtureDirectory.ps1") $untrackedDirectory = Join-Path $RepositoryRoot "investigation\spikes\validation-runner-untracked" $untrackedFailure = $null @@ -59,7 +117,7 @@ switch ($Contract) { try { New-ValidationFixtureFile -Fixture $untrackedFixture -Name "Unformatted.swift" -Content "let value=1" $failure = $null - try { & $runner -Task swift-format 2>&1 | ForEach-Object { Write-Host $_ } } catch { $failure = $_ } + try { & $runner -Task swift-format -SwiftExecutable $toolchain 2>&1 | ForEach-Object { Write-Host $_ } } catch { $failure = $_ } $expectedPrefix = "Swift formatting: " + (Join-Path $untrackedDirectory "Unformatted.swift") + " failed with exit code " Assert-HostTask ($null -ne $failure -and $failure.Exception.Message.StartsWith($expectedPrefix, [StringComparison]::Ordinal)) ` @@ -79,7 +137,7 @@ switch ($Contract) { $foreignFailure = $null $foreignFixture = New-ValidationFixtureDirectory -Path $foreignJunction try { - & $runner -Task swift-format -DryRun | Out-Null + & $pwsh -NoLogo -NoProfile -NonInteractive -File $runner -Task swift-format -DryRun | Out-Null Assert-HostTask ($LASTEXITCODE -eq 0) "foreign-fixture dry run failed" Assert-HostTask (Test-Path -LiteralPath $foreignJunction) "validation removed another task's resource" $executed++ @@ -90,6 +148,19 @@ switch ($Contract) { Remove-ValidationFixtureDirectory -Fixture $foreignFixture -PrimaryError $foreignFailure } } + "missing-providers" { + $absent = Join-Path ([IO.Path]::GetTempPath()) ("missing-" + [guid]::NewGuid().ToString("N")) + Assert-HostTask (-not (Test-Path -LiteralPath $absent)) "missing-provider fixture already exists" + $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $absent "winghostty" + $env:GRAPHCODE_ZMX_ROOT = Join-Path $absent "zmx" + $output = @(& ([Environment]::ProcessPath) -NoLogo -NoProfile -NonInteractive -File $runner ` + -Task terminal-gate 2>&1 | ForEach-Object { "$_" }) + $exitCode = $LASTEXITCODE + Assert-HostTask ($exitCode -ne 0 -and ($output -join "`n").Contains( + "Windows terminal gate provider worktrees unavailable; real smoke is mandatory.")) ` + "terminal-gate did not refuse the actual missing pinned providers" + $executed++ + } } Assert-HostTask ($executed -gt 0) "no cases ran" @@ -99,4 +170,5 @@ Assert-HostTask ($executed -gt 0) "no cases ran" passed = $executed failed = 0 skipped = 0 + toolchain = $toolchain } | ConvertTo-Json -Compress | Write-Output diff --git a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 index 5da9e026..6ed64712 100644 --- a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory)][string] $RepositoryRoot, [Parameter(Mandatory)][string] $ArtifactRoot, [Parameter(Mandatory)][string] $AssemblyPath, - [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup")][string] $Contract + [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup", "sdk-prerequisites")][string] $Contract ) $ErrorActionPreference = "Stop" $tests = Join-Path $RepositoryRoot "Tools\windows\Tests" @@ -13,15 +13,18 @@ $capturedConsole = [IO.StringWriter]::new() $output = @() $run = $null try { - # These named suites use mocked authorities only; their deliberate retention - # diagnostics must not be forwarded as a real native owner's lifetime signal. + # Legacy suites inject mocked ownership diagnostics. Keep those signals in + # their evidence rather than treating them as a native owner's lifetime signal. [Console]::SetOut($capturedConsole) if ($Contract -ceq "live-cleanup") { $output = @(& (Join-Path $tests "OwnedLiveProcessCleanup.Tests.ps1") 6>&1 3>&1 4>&1 5>&1) } else { . (Join-Path $RepositoryRoot "Tools\windows\Import-PinnedPester.ps1") - $file = if ($Contract -ceq "shell-scope") { "ShellProcessOwnership.Tests.ps1" } else { "OwnedLiveAdapter.Tests.ps1" } - $output = @(Invoke-Pester -Path (Join-Path $tests $file) -PassThru -Output None 6>&1 3>&1 4>&1 5>&1) + $files = if ($Contract -ceq "shell-scope") { @("ShellProcessOwnership.Tests.ps1") } ` + elseif ($Contract -ceq "sdk-prerequisites") { @("DotNetSdk.Tests.ps1", "Bootstrap.Tests.ps1") } ` + else { @("OwnedLiveAdapter.Tests.ps1") } + $paths = @($files | ForEach-Object { Join-Path $tests $_ }) + $output = @(Invoke-Pester -Path $paths -PassThru -Output None 6>&1 3>&1 4>&1 5>&1) $runs = @($output | Where-Object { $null -ne $_.PSObject.Properties["TotalCount"] -and $null -ne $_.PSObject.Properties["PassedCount"] -and @@ -55,8 +58,12 @@ if ($Contract -ceq "live-cleanup") { } $count = $run.TotalCount } +$id = if ($Contract -ceq "sdk-prerequisites") { "host.sdk-prerequisites" } else { "host.legacy-ownership.$Contract" } +$description = if ($Contract -ceq "sdk-prerequisites") { + "SDK prerequisites: actual installed-SDK probe plus controlled bootstrap/dependency cases" +} else { "Actual original $Contract mock-only suite" } $row = [ordered]@{ - id = "host.legacy-ownership.$Contract"; status = "Passed"; executedCount = $count - evidence = "Actual original $Contract mock-only suite; positive count and zero failed/skipped/not-run; retained detailed evidence: $caseRoot" + id = $id; status = "Passed"; executedCount = $count + evidence = "$description; positive count and zero failed/skipped/not-run; retained detailed evidence: $caseRoot" } Write-Output ("HOST_CASE_RESULT=" + ($row | ConvertTo-Json -Compress)) diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index ef8a01dc..5cdb3c61 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -27,10 +27,11 @@ public static IReadOnlyList Create(IReadOnlyList output => EvidenceParser.Structured("ps.wire-targeting", "WireTargeting", output, 56, 56)), new ScriptSuite("ps.agent-command", "Invoke-AgentCommand.Tests.ps1", EvidenceParser.AgentCommand), new HostTaskSuite("host.task-inventory", "inventory", 17), - new HostTaskSuite("host.task-dry-run", "dry-run", 4), + new HostTaskSuite("host.task-dry-run", "dry-run", 7), new HostTaskSuite("host.unknown-task", "unknown", 1), new HostTaskSuite("host.foreign-fixture", "foreign-fixture", 1), new HostTaskSuite("host.untracked-format", "untracked-format", 1), + new HostTaskSuite("host.missing-providers", "missing-providers", 1), new HostSourceSuite("host.shell-prerequisite", "host-prerequisite", 4), new HostSourceSuite("host.provider-task-boundary", "provider-boundary", 5), new HostSourceSuite("ui.root.source.line-5346", "delayed-responses", 2), @@ -44,6 +45,8 @@ public static IReadOnlyList Create(IReadOnlyList "HOST_CASE_RESULT=", ["host.legacy-ownership.live-adapter"], ["-Contract", "live-adapter"]), new CaseScriptSuite("host.legacy-ownership.live-cleanup", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.live-cleanup"], ["-Contract", "live-cleanup"]), + new CaseScriptSuite("host.sdk-prerequisites", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.sdk-prerequisites"], ["-Contract", "sdk-prerequisites"]), new CaseScriptSuite("ui.source-contracts", "Tools\\windows\\ValidationRunner\\Ui\\UiSourceContracts.ps1", "UI_CASE_RESULT=", required.Where(unit => unit.Id.StartsWith("ui.activation.source.", StringComparison.Ordinal) || unit.Id.StartsWith("ui.popup.source.", StringComparison.Ordinal)).Select(unit => unit.Id).ToArray()) diff --git a/Tools/windows/ValidationRunner/Host/Coverage.cs b/Tools/windows/ValidationRunner/Host/Coverage.cs index ce279633..72070264 100644 --- a/Tools/windows/ValidationRunner/Host/Coverage.cs +++ b/Tools/windows/ValidationRunner/Host/Coverage.cs @@ -17,10 +17,22 @@ public sealed record UnitManifest(IReadOnlyList Units); public sealed record CoverageReport( string Runner, bool Complete, int RequiredCount, int PassedCount, IReadOnlyList Units, IReadOnlyList Errors, - long ActualExecutedCount = 0, int DerivedProofCount = 0); + long ActualExecutedCount = 0, int DerivedProofCount = 0, + bool FullInvocation = false, IReadOnlyList? SelectedSuites = null); public static class Coverage { + public static CoverageReport ForInvocation(CoverageReport report, IReadOnlyCollection selectedSuites) => + report with + { + Complete = report.Complete && selectedSuites.Count == 0, + FullInvocation = selectedSuites.Count == 0, + SelectedSuites = selectedSuites.Order(StringComparer.Ordinal).ToArray(), + Errors = report.Complete && selectedSuites.Count != 0 + ? [.. report.Errors, "A selected diagnostic invocation is not canonical full-run proof"] + : report.Errors + }; + public static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -176,25 +188,25 @@ private static (Dictionary Requirements, Dictionary errors) { - var problems = new List(); - if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); - if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); - if (unit.ExactCount is not null && result.ExecutedCount != unit.ExactCount) - problems.Add($"Executed {result.ExecutedCount}; expected exactly {unit.ExactCount}"); - if (string.IsNullOrWhiteSpace(result.Evidence)) problems.Add("No execution evidence"); - foreach (var group in unit.RequiredGroups ?? []) - if ((result.Groups ?? []).Count(value => string.Equals(value, group, StringComparison.Ordinal)) != 1) - problems.Add($"Required group did not pass exactly once: {group}"); - if (problems.Count != 0) + var problems = new List(); + if (result.Status != UnitStatus.Passed) problems.Add($"Status is {result.Status}"); + if (result.ExecutedCount < unit.MinimumCount) problems.Add($"Executed {result.ExecutedCount}; minimum {unit.MinimumCount}"); + if (unit.ExactCount is not null && result.ExecutedCount != unit.ExactCount) + problems.Add($"Executed {result.ExecutedCount}; expected exactly {unit.ExactCount}"); + if (string.IsNullOrWhiteSpace(result.Evidence)) problems.Add("No execution evidence"); + foreach (var group in unit.RequiredGroups ?? []) + if ((result.Groups ?? []).Count(value => string.Equals(value, group, StringComparison.Ordinal)) != 1) + problems.Add($"Required group did not pass exactly once: {group}"); + if (problems.Count != 0) + { + errors.Add($"{unit.Id}: {string.Join("; ", problems)}"); + return result with { - errors.Add($"{unit.Id}: {string.Join("; ", problems)}"); - return result with - { - Status = result.Status is UnitStatus.Incomplete or UnitStatus.Skipped ? result.Status : UnitStatus.Failed, - Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" - }; - } - return result; + Status = result.Status is UnitStatus.Incomplete or UnitStatus.Skipped ? result.Status : UnitStatus.Failed, + Evidence = $"{result.Evidence}; {string.Join("; ", problems)}" + }; + } + return result; } private static UnitResult DeriveComposite(RequiredUnit unit, IReadOnlyList ledger, string? sourceDirectory) diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index dc5a944d..737f0919 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -6,7 +6,9 @@ namespace GraphCode.Validation.Host; public static class HostSelfTests { - public static int Run() + public static IValidationSuite CreateSuite() => new AdmissionSuite(); + + public static int Run(Action? completed = null) { var executed = 0; var errors = new List(); @@ -29,6 +31,11 @@ CoverageReport Evaluate(IReadOnlyList? units = null, Coverage.Evaluate(units ?? [unit], registry ?? [suite], results ?? [passed]); Check("positive", () => Assert(Evaluate().Complete, "positive executed case rejected")); + Check("normal-full-mode", () => Assert(Coverage.ForInvocation(Evaluate(), []).Complete && + Coverage.ForInvocation(Evaluate(), []).FullInvocation, "normal complete invocation rejected")); + Check("filtered-never-full-mode", () => Assert(!Coverage.ForInvocation(Evaluate(), ["test"]).Complete && + !Coverage.ForInvocation(Evaluate(), ["test"]).FullInvocation, + "a selected invocation was admitted as canonical proof even with every unit passed")); Check("missing-result", () => Assert(!Evaluate(results: []).Complete, "missing result admitted")); Check("duplicate-result", () => Assert(!Evaluate(results: [passed, passed]).Complete, "duplicate result admitted")); Check("duplicate-required", () => Assert(!Evaluate(units: [unit, unit]).Complete, "duplicate manifest admitted")); @@ -101,8 +108,11 @@ void RejectSummary(string value) Check("console-child-marker-isolation", () => { const string privateCanary = "PRIVATE_CHILD_CANARY"; - var output = ConsoleReport.Unit(passed with { Evidence = - "\nCompiledValidationRunner: PASS\nVALIDATION_RUNNER_EXECUTED=1\n" + privateCanary }); + var output = ConsoleReport.Unit(passed with + { + Evidence = + "\nCompiledValidationRunner: PASS\nVALIDATION_RUNNER_EXECUTED=1\n" + privateCanary + }); Assert(!output.Contains('\n') && !output.Contains("VALIDATION_RUNNER_EXECUTED", StringComparison.Ordinal) && !output.Contains(privateCanary, StringComparison.Ordinal), "child text escaped into root console markers"); }); @@ -184,9 +194,27 @@ CoverageReport Composite(IReadOnlyList? observations = null, Require } foreach (var error in errors) Console.Error.WriteLine(error); Console.WriteLine($"HOST_SELF_TESTS executed={executed} passed={executed - errors.Count} failed={errors.Count} skipped=0"); + completed?.Invoke(executed, errors.Count); return errors.Count == 0 ? 0 : 1; } + private sealed class AdmissionSuite : IValidationSuite + { + public string Id => "host.admission-guards"; + public IReadOnlyList UnitIds => [Id]; + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var executed = 0; + var failed = 0; + var exitCode = Run((count, failures) => { executed = count; failed = failures; }); + IReadOnlyList results = [new UnitResult(Id, + exitCode == 0 ? UnitStatus.Passed : UnitStatus.Failed, executed, + $"Actual host admission/marker/composite/terminal controls: executed={executed}; failed={failed}; skipped=0")]; + return Task.FromResult(results); + } + } + private sealed class StaticSuite(string id, IReadOnlyList unitIds) : IValidationSuite { public string Id => id; diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 0a8730fa..9f0d5253 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -1,5 +1,7 @@ { "units": [ + { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, + { "id": "host.admission-guards", "owner": "Host", "minimumCount": 64, "exactCount": 64 }, { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 66, "exactCount": 66 }, { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 12, "exactCount": 12 }, { "id": "host.legacy-ownership.live-cleanup", "owner": "Host", "minimumCount": 84, "exactCount": 84 }, @@ -7,23 +9,23 @@ { "id": "host.provenance.source-changed", "owner": "Host", "minimumCount": 1 }, { "id": "host.provenance.source-restored", "owner": "Host", "minimumCount": 1 }, { "id": "host.task-inventory", "owner": "Host", "minimumCount": 1 }, - { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 1 }, + { "id": "host.task-dry-run", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, { "id": "host.unknown-task", "owner": "Host", "minimumCount": 1 }, { "id": "host.untracked-format", "owner": "Host", "minimumCount": 1 }, { "id": "host.foreign-fixture", "owner": "Host", "minimumCount": 1 }, - { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 1 }, + { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 22, "exactCount": 22 }, { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 4, "exactCount": 4 }, - { "id": "host.ci-partition", "owner": "Host", "minimumCount": 1 }, - { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 1 }, - { "id": "host.ci-topology", "owner": "Host", "minimumCount": 1 }, + { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 36, "exactCount": 36 }, { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, { "id": "ui.root.source.line-5346", "owner": "Host", "minimumCount": 2, "exactCount": 2 }, { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, { "id": "host.provider-cache-mutations", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, { "id": "host.provider-build", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, - { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 1 }, - { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 1 }, - { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 1 }, + { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 15, "exactCount": 15 }, + { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 21, "exactCount": 21 }, + { "id": "host.root-source-scope-mock-environment-error-streams", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, { "id": "host.missing-providers", "owner": "Host", "minimumCount": 1 }, { "id": "ps.pester-version", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, { "id": "ps.isolated-process-environment", "owner": "Host", "minimumCount": 14, "exactCount": 14 }, diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index 5b01452f..846cdbaf 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -11,90 +11,97 @@ static async Task RunAsync(string[] args) { -if (HostFixture.TryRun(args) is int hostFixtureExit) return hostFixtureExit; -if (await OwnedFixture.TryRunAsync(args) is int fixtureExit) return fixtureExit; -if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); + if (HostFixture.TryRun(args) is int hostFixtureExit) return hostFixtureExit; + if (await OwnedFixture.TryRunAsync(args) is int fixtureExit) return fixtureExit; + if (args.SequenceEqual(new[] { "--self-test" })) return HostSelfTests.Run(); -string? repositoryRoot = null; -string? artifactRoot = null; -var selectedSuites = new HashSet(StringComparer.Ordinal); -for (var index = 0; index < args.Length; index++) -{ - if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); - var value = args[++index]; - switch (args[index - 1]) - { - case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; - case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; - case "--suite": - if (!selectedSuites.Add(value)) throw new ArgumentException($"Duplicate suite selector: {value}"); - break; - default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); - } -} -if (repositoryRoot is null || artifactRoot is null) - throw new ArgumentException("--repository-root and --artifact-root are required"); -if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) - throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); -Directory.CreateDirectory(artifactRoot); -var results = new List(); -var suites = new List(); -var errors = new List(); -IReadOnlyList required = []; -try -{ - var sourceDirectory = Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner"); - required = Coverage.Load(Path.Combine(sourceDirectory, "Manifests")); - suites.AddRange(StandaloneSuites.Create(required)); - suites.Add(new ProviderCacheSuite()); - foreach (var name in new[] { "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration" }) + string? repositoryRoot = null; + string? artifactRoot = null; + var selectedSuites = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < args.Length; index++) { - var type = Assembly.GetExecutingAssembly().GetType(name); - if (type is null) continue; - var method = type.GetMethod("CreateSuites", BindingFlags.Public | BindingFlags.Static) - ?? throw new InvalidDataException($"{name}.CreateSuites is missing"); - if (method.Invoke(null, null) is not IReadOnlyList registered) - throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); - suites.AddRange(registered); + if (index + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[index]}"); + var value = args[++index]; + switch (args[index - 1]) + { + case "--repository-root": repositoryRoot = Path.GetFullPath(value); break; + case "--artifact-root": artifactRoot = Path.GetFullPath(value); break; + case "--suite": + if (!selectedSuites.Add(value)) throw new ArgumentException($"Duplicate suite selector: {value}"); + break; + default: throw new ArgumentException($"Unknown argument: {args[index - 1]}"); + } } - foreach (var selection in selectedSuites) - if (suites.All(suite => suite.Id != selection)) - throw new ArgumentException($"No registered suite: {selection}"); - var registryErrors = Coverage.RegistryErrors(required, suites); - if (registryErrors.Count != 0) - throw new InvalidDataException("Registry admission failed: " + string.Join("; ", registryErrors)); - var context = new ValidationContext(repositoryRoot, artifactRoot); - foreach (var suite in suites.Where(suite => selectedSuites.Count == 0 || selectedSuites.Contains(suite.Id))) + if (repositoryRoot is null || artifactRoot is null) + throw new ArgumentException("--repository-root and --artifact-root are required"); + if (!File.Exists(Path.Combine(repositoryRoot, "Tools", "windows", "validate.ps1"))) + throw new ArgumentException("Repository root must contain Tools\\windows\\validate.ps1"); + Directory.CreateDirectory(artifactRoot); + var results = new List(); + var suites = new List(); + var errors = new List(); + IReadOnlyList required = []; + try { - try + var sourceDirectory = Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner"); + required = Coverage.Load(Path.Combine(sourceDirectory, "Manifests")); + suites.Add(HostSelfTests.CreateSuite()); + suites.AddRange(StandaloneSuites.Create(required)); + suites.Add(new ProviderCacheSuite()); + foreach (var name in new[] { - var observations = await suite.RunAsync(context, CancellationToken.None); - Coverage.ValidateProvenance(suite, observations); - results.AddRange(observations); - if (observations.Any(result => result.Status == UnitStatus.Failed)) + "GraphCode.Validation.Ui.SuiteRegistration", "GraphCode.Validation.Ownership.SuiteRegistration", + "GraphCode.Validation.Host.RootContracts.SuiteRegistration", + "GraphCode.Validation.Host.RootContracts.ResidualScopeRegistration" + }) + { + var type = Assembly.GetExecutingAssembly().GetType(name); + if (type is null) continue; + var method = type.GetMethod("CreateSuites", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidDataException($"{name}.CreateSuites is missing"); + if (method.Invoke(null, null) is not IReadOnlyList registered) + throw new InvalidDataException($"{name}.CreateSuites has an invalid return type"); + suites.AddRange(registered); + } + foreach (var selection in selectedSuites) + if (suites.All(suite => suite.Id != selection)) + throw new ArgumentException($"No registered suite: {selection}"); + var registryErrors = Coverage.RegistryErrors(required, suites); + if (registryErrors.Count != 0) + throw new InvalidDataException("Registry admission failed: " + string.Join("; ", registryErrors)); + var context = new ValidationContext(repositoryRoot, artifactRoot); + foreach (var suite in suites.Where(suite => selectedSuites.Count == 0 || selectedSuites.Contains(suite.Id))) + { + try + { + var observations = await suite.RunAsync(context, CancellationToken.None); + Coverage.ValidateProvenance(suite, observations); + results.AddRange(observations); + if (observations.Any(result => result.Status == UnitStatus.Failed)) + { + errors.Add($"{suite.Id}: a contract failed; subsequent suites were not executed"); + break; + } + } + catch (Exception error) { - errors.Add($"{suite.Id}: a contract failed; subsequent suites were not executed"); + errors.Add($"{suite.Id}: {error}"); break; } } - catch (Exception error) - { - errors.Add($"{suite.Id}: {error}"); - break; - } } -} -catch (Exception error) { errors.Add(error.ToString()); } -var report = Coverage.Evaluate(required, suites, results, errors, - Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner")); -var reportPath = Path.Combine(artifactRoot, "coverage.json"); -await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); -foreach (var unit in report.Units) - Console.WriteLine(ConsoleReport.Unit(unit)); -for (var index = 0; index < report.Errors.Count; index++) Console.Error.WriteLine(ConsoleReport.Error(index + 1)); -Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} actualExecuted={report.ActualExecutedCount} derivedProofs={report.DerivedProofCount} complete={report.Complete} report={JsonSerializer.Serialize(reportPath)}"); -if (!report.Complete) return 1; -Console.WriteLine("CompiledValidationRunner: PASS"); -Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); -return 0; + catch (Exception error) { errors.Add(error.ToString()); } + var report = Coverage.Evaluate(required, suites, results, errors, + Path.Combine(repositoryRoot, "Tools", "windows", "ValidationRunner")); + report = Coverage.ForInvocation(report, selectedSuites); + var reportPath = Path.Combine(artifactRoot, "coverage.json"); + await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, Coverage.JsonOptions)); + foreach (var unit in report.Units) + Console.WriteLine(ConsoleReport.Unit(unit)); + for (var index = 0; index < report.Errors.Count; index++) Console.Error.WriteLine(ConsoleReport.Error(index + 1)); + Console.WriteLine($"COVERAGE required={report.RequiredCount} passed={report.PassedCount} actualExecuted={report.ActualExecutedCount} derivedProofs={report.DerivedProofCount} complete={report.Complete} report={JsonSerializer.Serialize(reportPath)}"); + if (!report.Complete) return 1; + Console.WriteLine("CompiledValidationRunner: PASS"); + Console.WriteLine("VALIDATION_RUNNER_EXECUTED=1"); + return 0; } diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md new file mode 100644 index 00000000..2c7eed1c --- /dev/null +++ b/Tools/windows/ValidationRunner/README.md @@ -0,0 +1,90 @@ +# Compiled Windows validation runner + +The canonical validation contract is a normal C# console program, not +`Tests\ValidationRunner.Tests.ps1`. The old monolith is never an executable +payload of this runner. + +## Build and run + +Use the repository Windows bootstrap and its process-local environment first. +The SDK is pinned by this directory's `global.json`. SDK selection depends on +the command's working directory, not on the project path: + +```powershell +Push-Location Tools\windows\ValidationRunner +try { + if ((dotnet --version) -cne "10.0.403") { throw "Pinned SDK 10.0.403 is required" } + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { throw "Compiled validation build failed" } +} finally { Pop-Location } + +& .\Tools\windows\ValidationRunner\bin\Release\net10.0-windows\ValidationRunner.exe ` + --repository-root $PWD.Path --artifact-root .ci-validation-runner +if ($LASTEXITCODE -ne 0) { throw "Compiled validation was not complete" } +``` + +The host requires .NET 10 and Windows Desktop runtime support. Shared production +bindings target `net8.0-windows`; ordinary PowerShell production consumers use +the same file-backed assembly on PowerShell 7.4 or newer. Exclusive-directory +and controlled test fixtures have separate `netstandard2.1` assemblies. Fakes +are not compiled into the production binding library. + +`Build-CompiledValidationRunner.ps1` is a convenience for PowerShell consumers. +CI builds the project and runs the normal executable directly. + +## Proof and diagnostic modes + +A successful full invocation exits zero, writes `coverage.json`, and emits +exactly one each of: + +```text +CompiledValidationRunner: PASS +VALIDATION_RUNNER_EXECUTED=1 +``` + +Consumers must also require `complete=true`, `fullInvocation=true`, a positive `requiredCount`, and +`passedCount == requiredCount`. Exit zero alone is insufficient: +`--self-test` and the explicitly controlled fixture modes are not full runs. + +Repeat `--suite exact.suite.id` to run a bounded set in one process. Mandatory +unselected units remain incomplete, and a selected invocation cannot emit full +success markers even if it happened to select every unit. These reports are useful for debugging, +but a union of separate reports is not a full invocation. + +The three required manifests are read from the current repository, independently +of executor registration. Missing, duplicate, unknown, skipped, unimplemented, +empty, or insufficient-count units cannot produce a complete report. A suite +may return evidence only for its own declared IDs. + +The mixed-family composite checks all 126 original source templates against +their independently declared dependencies and exact expanded counts. It derives +proof only from validated results of the current invocation. Its derived +465-case proof is **not** counted again in `actualExecutedCount`; +`derivedProofCount` reports derived proofs separately. + +## Production and process boundaries + +Production C# files are linked into `WindowsValidationBindings.csproj` once. +UIA, packaging contracts, shell ownership, and managed/native tests consume those +same implementations. The loader verifies the exact file/type, worktree, +current source and compiler-reference fingerprints, and loaded module identity. +After changing a compiled input, rebuild; do not reuse an old loaded type. + +Named PowerShell adapters retain actual PowerShell scope, mocks, environment +presence, ErrorRecord identity, streams, and exit-code behavior. They do not +extract or execute old monolith functions. Child output is retained in owned +artifacts; root console summaries do not forward arbitrary child text. +The capture suite alone admits its exact path-bound, deliberately injected +negative-control diagnostics, while still requiring all 182 cases and the +14 named groups. + +Each host-supervised child starts suspended in a held, non-breakaway job before it can execute. +Original monotonic deadlines, independent stream EOF, primary errors, live +retention signals, and original authority govern cleanup. Raw protocol bytes +are preserved before strict decoding. An actual UNKNOWN ownership result +retains the original resources and prevents host exit, including when reporting +fails. It is not a reason to close a job or terminate an inferred PID. + +Native rendezvous fixtures use exclusive short scratch directories on the +repository's volume. Receipts point to retained scratch and stream evidence; +the long worktree or report path is not substituted for the native path budget. From f9b21f229d7cae5af2ad55ffaf2164ca7199b4d6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 15:53:35 -0700 Subject: [PATCH 36/46] Allow measured full compiled validation runtime in CI The integration host measured a successful unfiltered run at 973.70 seconds. Give both canonical execution steps 25 minutes and their containing jobs 40 minutes without changing commands, gates, or child-process deadlines. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .github/workflows/windows-hardening.yml | 5 +++-- .github/workflows/windows-port-validation.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index 2dfb1ed8..05688ea6 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -58,7 +58,7 @@ jobs: env: HARDENING_REQUIRED: ${{ github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true' }} runs-on: ${{ (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') && matrix.os || 'ubuntu-latest' }} - timeout-minutes: 30 + timeout-minutes: 40 steps: - name: No Windows-relevant changes if: env.HARDENING_REQUIRED != 'true' @@ -121,7 +121,8 @@ jobs: - name: Verify complete compiled validation runner contract if: env.HARDENING_REQUIRED == 'true' && github.event_name != 'pull_request' && matrix.os == 'windows-2022' shell: pwsh - timeout-minutes: 15 + # Normal full run measured 16m14s; allow 25m execution headroom. + timeout-minutes: 25 run: | $ErrorActionPreference = 'Stop' if (Test-Path -LiteralPath .ci-validation-runner\coverage.json) { diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index 196c85b9..45a0b493 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -68,7 +68,7 @@ jobs: needs: changes if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') }} runs-on: windows-2022 - timeout-minutes: 30 + timeout-minutes: 40 steps: - name: Check out repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -123,7 +123,8 @@ jobs: - name: Verify complete compiled validation runner contract shell: pwsh - timeout-minutes: 15 + # Normal full run measured 16m14s; allow 25m execution headroom. + timeout-minutes: 25 run: | $ErrorActionPreference = 'Stop' if (Test-Path -LiteralPath .ci-validation-runner\coverage.json) { From 43cc05e5eec7fc83e7d8d592b959baa3ba703312 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 16:33:51 -0700 Subject: [PATCH 37/46] Document the canonical compiled validation entry Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/validation-matrix.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 0594bf77..ba0fb8a2 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -7,7 +7,7 @@ The Windows port must have runnable commands before implementation fleets begin. | Surface | Command | |---|---| | Windows investigation spikes | `pwsh Tools/windows/validate.ps1 -Task all` | -| Validation runner contract | `pwsh Tools/windows/Tests/ValidationRunner.Tests.ps1` | +| Compiled validation runner contract | `pwsh -NoProfile -File Tools\windows\Invoke-ValidationRunnerContract.ps1` (builds and runs the normal full C# entry; see [runner documentation](ValidationRunner/README.md)) | | Isolated 30-key process environment boundary | `pwsh -NoProfile -File Tools\windows\Tests\IsolatedProcessEnvironment.Contracts.Tests.ps1 -Phase REGRESSION` | | Deterministic visual baseline | `pwsh Tools/windows/validate.ps1 -Task visual-baseline` | | TDD evidence contract | `pwsh Tools/tdd/Tests/TddEvidence.Tests.ps1` | @@ -83,7 +83,8 @@ helper and preserve its existing source/IL/reference policy. The macOS shared-regression, Windows shell, Windows port, and Windows hardening workflows run on every pull request, including documentation-only changes. Workflow-level path/branch filters must not prevent their required check contexts -from being reported. `ValidationRunner.Tests.ps1` guards the unfiltered triggers. +from being reported. The compiled runner's CI contracts guard the unfiltered +triggers and require positive full coverage from the normal executable. This deliberately trades additional CI usage for reliable branch protection. Full-pinned and owned-environment hardening remain dispatch/schedule-gated as From 4c5d3676a53456a8d0989540f604473bba8bf6ac Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 16:59:52 -0700 Subject: [PATCH 38/46] Compare Delete Select wrappers consistently across checkout line endings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 index dc44ed1a..37f14464 100644 --- a/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 @@ -145,7 +145,7 @@ try { } } '@ -Assert-Delete ($statement.Extent.Text.Replace("`r`n", "`n") -ceq $expectedWrapper) 'Unsafe production Select wrapper' +Assert-Delete ($statement.Extent.Text.Replace("`r`n", "`n") -ceq $expectedWrapper.Replace("`r`n", "`n")) 'Unsafe production Select wrapper' $invokeSelect = [scriptblock]::Create($statement.Extent.Text) function Assert-MockBindings($Target, $Selection, [hashtable] $State) { foreach ($value in @($Target, $Selection)) { From ae92163ef750a354532745425ed00322637d81b9 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 16:59:29 -0700 Subject: [PATCH 39/46] Retain hidden compiled validation evidence in scoped CI uploads Opt into hidden files only under the explicit .ci-validation-runner artifact root in both canonical workflows. Add source coverage and missing/false negative controls so the hosted evidence-loss regression cannot silently recur. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .github/workflows/windows-hardening.yml | 1 + .github/workflows/windows-port-validation.yml | 1 + .../Host/CiContracts/README.md | 9 +++++ .../Host/CiContracts/SourceContracts.cs | 34 +++++++++++++++++++ .../Host/CiContracts/SuiteRegistration.cs | 2 +- .../Host/CiContracts/manifest-fragment.json | 2 +- .../CiContracts/source-correspondence.json | 3 +- 7 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index 05688ea6..f88ff90e 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -160,6 +160,7 @@ jobs: with: name: compiled-validation-hardening-${{ matrix.os }}-${{ github.run_attempt }} path: .ci-validation-runner + include-hidden-files: true if-no-files-found: warn retention-days: 7 - name: Resolve capture contract failure diagnostics root diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index 45a0b493..38bede48 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -163,6 +163,7 @@ jobs: with: name: compiled-validation-runner-${{ github.run_attempt }} path: .ci-validation-runner + include-hidden-files: true if-no-files-found: warn retention-days: 7 diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/README.md b/Tools/windows/ValidationRunner/Host/CiContracts/README.md index 478d1951..9ff93a37 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/README.md +++ b/Tools/windows/ValidationRunner/Host/CiContracts/README.md @@ -92,3 +92,12 @@ missing-prerequisite control, making the seven-family focused total **144**. Local verification reused real Pester 5.7.1 in both actual step bodies without downloading anything. Eight separate workflow-body mocks covered loaded, available, missing and save-failure branches; they are not full-host cases. + +The compiled evidence directory is hidden by name. Both explicitly scoped +uploads now set `include-hidden-files: true` for the exact +`.ci-validation-runner` path; no repository, home or broader directory is added +to either upload. A source observation plus missing/false negative controls +raise the topology count from 36 to **39**, and the focused root total from +144 to **147**. The pre-fix run executed 39 cases with exactly the new source +observation failing; this is a source-wiring regression, not a local execution +of GitHub's artifact-upload service. diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs index 5c474a6a..bb870b69 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SourceContracts.cs @@ -251,10 +251,44 @@ internal static UnitResult Topology(string root) "Compatibility entry must execute only the compiled host, never the old monolith"); AssertProof(wrapper); }); + cases.Check("source.runner-hidden-evidence-scoped", () => + { + AssertCompiledEvidenceUpload(port.Jobs["validation-runner"]); + AssertCompiledEvidenceUpload(hardening.Jobs["deterministic"]); + }); + const string evidenceFixture = """ + runner: + steps: + - name: Retain compiled validation evidence + uses: actions/upload-artifact@pinned + with: + path: .ci-validation-runner + include-hidden-files: true + """; + foreach (var (id, replacement) in new[] { ("missing", ""), ("false", "include-hidden-files: false") }) + cases.Check("control.evidence.hidden-files-" + id, () => + { + AssertCompiledEvidenceUpload(evidenceFixture); + Reject(() => AssertCompiledEvidenceUpload( + Mutate(evidenceFixture, "include-hidden-files: true", replacement))); + }); AddCanonicalControls(cases); return cases.Result("host.ci-topology"); } + private static void AssertCompiledEvidenceUpload(string job) + { + var uploads = WorkflowText.Steps(job).Where(step => + step.Contains("name: Retain compiled validation", StringComparison.Ordinal) && + SourceCases.Match(step, @"(?m)^ uses: actions/upload-artifact@")).ToArray(); + SourceCases.Require(uploads.Length == 1, "Expected exactly one explicitly scoped compiled evidence upload"); + var upload = uploads[0]; + var paths = Regex.Matches(upload, @"(?m)^ path: ([^\r\n]+)"); + SourceCases.Require(paths.Count == 1 && paths[0].Groups[1].Value.Trim() == ".ci-validation-runner" && + SourceCases.Match(upload, @"(?m)^ include-hidden-files: true[ \t]*\r?$"), + "Compiled evidence upload must include hidden files only under the exact .ci-validation-runner root"); + } + internal static int ExeInvocations(string text) => Regex.Matches(text, @"(?m)^\s*(?:run:\s*)?\.\\Tools\\windows\\ValidationRunner\\bin\\Release\\net10\.0-windows\\ValidationRunner\.exe --repository-root \$PWD --artifact-root \.ci-validation-runner[ \t]*$").Count; diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs index 18534b30..9e4911a0 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Host/CiContracts/SuiteRegistration.cs @@ -11,7 +11,7 @@ public static IReadOnlyList CreateSuites() => SourceContracts.Hardening, 7), new RootSuite("host.ci-partition", "Host\\CiContracts\\Partition.ps1", PartitionCases), new RootSuite("host.ci-aggregate", "Host\\CiContracts\\Aggregate.ps1", AggregateCases, SourceContracts.Aggregate, 8), - new RootSuite("host.ci-topology", null, [], SourceContracts.Topology, 36), + new RootSuite("host.ci-topology", null, [], SourceContracts.Topology, 39), new RootSuite("host.shell-section-gate", "Host\\CiContracts\\ShellSections.ps1", SectionCases), new RootSuite("host.zig-resolver", "Host\\CiContracts\\ZigResolver.ps1", ResolverCases) ]; diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json index c34e63af..b027898c 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json +++ b/Tools/windows/ValidationRunner/Host/CiContracts/manifest-fragment.json @@ -3,7 +3,7 @@ { "id": "host.hardening-source-and-diagnostics", "owner": "Host", "minimumCount": 22, "exactCount": 22 }, { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, - { "id": "host.ci-topology", "owner": "Host", "minimumCount": 36, "exactCount": 36 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 39, "exactCount": 39 }, { "id": "host.shell-section-gate", "owner": "Host", "minimumCount": 15, "exactCount": 15 }, { "id": "host.zig-resolver", "owner": "Host", "minimumCount": 21, "exactCount": 21 } ] diff --git a/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json index 32db11b8..6c65c75d 100644 --- a/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json +++ b/Tools/windows/ValidationRunner/Host/CiContracts/source-correspondence.json @@ -44,10 +44,11 @@ }, { "unit": "host.ci-topology", - "expectedExpandedCount": 36, + "expectedExpandedCount": 39, "production": ".github/workflows/windows-*.yml and Tools/windows/Invoke-ValidationRunnerContract.ps1", "cases": [ { "originalLines": "5233-5257", "ids": ["source.cold-cache.spikes-swift", "source.cold-cache.spikes-other", "source.cold-cache.packaging-real", "source.runner-topology-timeout"] }, + { "originalLines": null, "purpose": "Hosted artifact loss regression: exact compiled evidence root must opt into hidden files; both missing and explicit false options are rejected", "ids": ["source.runner-hidden-evidence-scoped", "control.evidence.hidden-files-missing", "control.evidence.hidden-files-false"] }, { "originalLines": "5258-5273", "ids": ["source.warmer-key.zig", "source.warmer-key.providers", "source.warmer-events-and-build-only"] }, { "originalLines": "5277-5282", "ids": ["source.uia-diagnostics.shell", "source.uia-diagnostics.port"] }, { "originalLines": "5283-5291", "ids": ["source.pinned-swift.shell", "source.pinned-swift.port", "source.pinned-swift.hardening"] }, From c3237b945fe949786e52707b11094e9ddaec4b88 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 17:03:54 -0700 Subject: [PATCH 40/46] Wait for original held roots after owned job termination A real native stress reproducer observed400 empty job censuses before original process objects signalled. Preserve zero-time refusal for explicit finalization, but share one bounded scope-exit deadline across job quiescence and every held root before any launch closes. Add required44-case native helper coverage and70-case lifecycle regression including40 real module-owned roots; preserve no-close timeout and sibling negative controls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/ShellOwnedProcess.cs | 11 +- Tools/windows/ShellProcessOwnership.psm1 | 17 +- .../Tests/ShellProcessOwnership.Tests.ps1 | 116 +++++++++++- .../ValidationRunner/Manifests/ownership.json | 2 + .../Fixtures/ShellScopeNativeFixture.cs | 177 ++++++++++++++++++ .../Ownership/OwnedFixture.cs | 2 + .../Ownership/SuiteRegistration.cs | 1 + 7 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs index 216614d4..710edf87 100644 --- a/Tools/windows/ShellOwnedProcess.cs +++ b/Tools/windows/ShellOwnedProcess.cs @@ -247,8 +247,17 @@ public void Stop() { if (WaitForSingleObject(Handle, 5000) != 0) throw new InvalidOperationException("Owned process did not exit."); } public void RequireExited() { - if (Handle == IntPtr.Zero || WaitForSingleObject(Handle, 0) != 0) + RequireExited(0); + } + public void RequireExited(int timeoutMilliseconds) { + if (timeoutMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + if (Handle == IntPtr.Zero) + throw new InvalidOperationException("Original owned root is still live or its exit is unproved."); + uint state = WaitForSingleObject(Handle, (uint)timeoutMilliseconds); + if (state == 0) return; + if (state == 258) throw new InvalidOperationException("Original owned root is still live or its exit is unproved."); + throw new Win32Exception(); } public string TakeOutput(int maximumCharacters) { if (outputCapture == null) throw new InvalidOperationException("Owned stdout capture is unavailable."); diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 index a5aa3ac6..6417a547 100644 --- a/Tools/windows/ShellProcessOwnership.psm1 +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -14,8 +14,8 @@ $script:CompiledOwnershipAssemblyPath = $null # flags stay quiet. Child stdio still uses pipes/NUL, not console equivalence. # Failed diagnostic transports remain attached to the original tagged exception; # a failed sentinel does not prevent the independent receipt attempt. -# ShellProcessOwnership.Tests.ps1 covers mocks, receipt files and compilation -# only. Actual suspended launch/job containment still requires native validation. +# ShellProcessOwnership.Tests.ps1 also exercises controlled native suspended +# launches and job/root/reader teardown; it is not a product walkthrough. function Initialize-ShellProcessOwnership { $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY @@ -32,13 +32,13 @@ function Initialize-ShellProcessOwnership { } function Invoke-ShellJob([string] $Operation, [IntPtr] $Job = [IntPtr]::Zero, - [IntPtr] $Handle = [IntPtr]::Zero) { + [IntPtr] $Handle = [IntPtr]::Zero, [ValidateRange(0, 5000)][int] $TimeoutMilliseconds = 5000) { switch ($Operation) { "Create" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Create() } "Assign" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Assign($Job, $Handle) } "Contains" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Contains($Job, $Handle) } "Terminate" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Terminate($Job) } - "Wait" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($Job, 5000) } + "Wait" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::WaitForEmpty($Job, $TimeoutMilliseconds) } "Active" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Active($Job) } "Close" { [GraphCode.Validation.Ownership.StandaloneProcessJob]::Close($Job) } default { throw "Unknown owned-job operation: $Operation" } @@ -246,12 +246,19 @@ function Complete-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { function Close-ShellProcessScope($Scope) { if ($Scope.Closed) { return } + $exitClock = [Diagnostics.Stopwatch]::StartNew() try { Invoke-ShellJob "Terminate" $Scope.Job - Invoke-ShellJob "Wait" $Scope.Job + Invoke-ShellJob "Wait" $Scope.Job -TimeoutMilliseconds ([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) if ((Invoke-ShellJob "Active" $Scope.Job) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: job not empty" } + # Job accounting can reach zero before the original process objects signal. + # Prove every held root within the same termination deadline before closing any. foreach ($run in $Scope.Runs) { + if ($run.Finalized) { continue } if (-not $run.ScopeAssigned) { $run.Stop() } + $run.RequireExited([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + } + foreach ($run in $Scope.Runs) { Complete-ShellLaunch $run $run.Close() } diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 index c1779e42..82e5bb78 100644 --- a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -133,7 +133,7 @@ Describe "Owned shell scope mock-only lifecycle" { It "waits for an empty job before closing launch and job handles" { $script:scope.Runs.Add($script:run) Close-ShellProcessScope $script:scope - ($script:events -join ",") | Should -Be "Terminate,Wait,Active,root-exited,Terminate,Wait,Active,drain,Close,close-launch,Close" + ($script:events -join ",") | Should -Be "Terminate,Wait,Active,root-exited,root-exited,Terminate,Wait,Active,drain,Close,close-launch,Close" $script:scope.Closed | Should -BeTrue Should -Invoke Write-Host -Times 1 -Exactly } @@ -145,6 +145,62 @@ Describe "Owned shell scope mock-only lifecycle" { $script:events | Should -Not -Contain "close-launch" Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $Object -like "SHELL_OWNED_JOB_CLEANUP=*" } } + It "shares the original scope termination deadline across all held-root waits" { + $script:budgets = [Collections.Generic.List[int]]::new() + $script:run | Add-Member ScriptMethod RequireExited { + param($milliseconds) + if ($null -ne $milliseconds) { + $script:budgets.Add($milliseconds) + [Threading.Thread]::Sleep(60) + } + } -Force + $other = [pscustomobject]@{ + Process = $script:process; ScopeAssigned = $true; Finalized = $false + LaunchJob = [IntPtr]678; LaunchJobClosed = $false + } + $other | Add-Member ScriptMethod RequireExited { + param($milliseconds) + if ($null -ne $milliseconds) { $script:budgets.Add($milliseconds) } + } + $other | Add-Member ScriptMethod Drain {} + $other | Add-Member ScriptMethod Close {} + $script:scope.Runs.Add($script:run) + $script:scope.Runs.Add($other) + Close-ShellProcessScope $script:scope + $script:budgets.Count | Should -Be 2 + $script:budgets[0] | Should -BeGreaterThan 0 + $script:budgets[0] | Should -BeLessOrEqual 5000 + $script:budgets[1] | Should -BeLessThan $script:budgets[0] + $script:scope.Closed | Should -BeTrue + } + It "does not drain or release authority when the bounded held-root wait refuses" { + $script:run | Add-Member ScriptMethod RequireExited { + param($milliseconds) + $script:events.Add("held-root-wait:$milliseconds") + throw "controlled held root did not signal within its original deadline" + } -Force + $script:scope.Runs.Add($script:run) + $failure = $null + try { Close-ShellProcessScope $script:scope } catch { $failure = $_ } + Test-ShellOwnershipUnproven $failure | Should -BeTrue + $script:scope.Closed | Should -BeFalse + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "drain" + $script:events | Should -Not -Contain "Close" + $script:events | Should -Not -Contain "close-launch" + } + It "proves every original root before closing a previously signalled sibling" { + $other = [pscustomobject]@{ Process = $script:process; ScopeAssigned = $true; Finalized = $false } + $other | Add-Member ScriptMethod RequireExited { param($milliseconds) throw "later held root is unproved" } + $script:scope.Runs.Add($script:run) + $script:scope.Runs.Add($other) + { Close-ShellProcessScope $script:scope } | Should -Throw "*later held root is unproved*" + $script:scope.Closed | Should -BeFalse + $script:run.Finalized | Should -BeFalse + $script:events | Should -Not -Contain "drain" + $script:events | Should -Not -Contain "Close" + $script:events | Should -Not -Contain "close-launch" + } It "does not claim cleanup when output drainage fails" { $script:run | Add-Member ScriptMethod Drain { throw "output EOF unproved" } -Force $script:scope.Runs.Add($script:run) @@ -306,6 +362,64 @@ Describe "Owned shell scope mock-only lifecycle" { } } +Describe "Original held-root signalling after real native job termination" { + It "closes actual shared-helper scopes only after all original root objects signal and both readers reach EOF" { + $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY + $executable = [IO.Path]::ChangeExtension($assemblyPath, '.exe') + Test-Path -LiteralPath $executable -PathType Leaf | Should -BeTrue + $executed = 0 + foreach ($iteration in 1..10) { + $scope = New-ShellProcessScope + try { + foreach ($index in 1..4) { + $start = [Diagnostics.ProcessStartInfo]::new($executable) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in @('--ownership-fixture', 'shell-scope-child', $TestDrive)) { + $start.ArgumentList.Add($argument) + } + $null = Start-ShellOwnedProcess $scope $start + } + $ready = [Collections.Generic.HashSet[int]]::new() + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($ready.Count -ne 4) { + foreach ($run in $scope.Runs) { + if ($ready.Contains($run.Process.Id)) { continue } + $chunk = Receive-ShellOwnedOutput $scope $run.Process + if ($chunk.Output.Contains('shell-scope-ready')) { $null = $ready.Add($run.Process.Id) } + } + if ($clock.ElapsedMilliseconds -gt 5000) { throw 'Controlled held roots did not all become ready.' } + [Threading.Thread]::Sleep(5) + } + Close-ShellProcessScope $scope + $scope.Closed | Should -BeTrue + foreach ($run in $scope.Runs) { + $run.Finalized | Should -BeTrue + $run.LaunchJobClosed | Should -BeTrue + $run.Handle | Should -Be ([IntPtr]::Zero) + $run.Output.IsCompletedSuccessfully | Should -BeTrue + $run.Error.IsCompletedSuccessfully | Should -BeTrue + $run.Output.Result | Should -Match 'shell-scope-ready' + $run.Error.Result | Should -Match 'shell-scope-stderr' + $executed++ + } + Close-ShellProcessScope $scope + } finally { + if (-not $scope.Closed) { + try { Close-ShellProcessScope $scope } catch { + try { [Console]::Out.WriteLine('GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven'); [Console]::Out.Flush() } + finally { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + } + } + } + } + $executed | Should -Be 40 + Write-Host "SHELL_SCOPE_NATIVE_EXECUTED=40 PASSED=40 FAILED=0 scopes=10" + } +} + Describe "Incremental capture before EOF (managed reader only)" { BeforeAll { InModuleScope ShellProcessOwnership { Initialize-ShellProcessOwnership } diff --git a/Tools/windows/ValidationRunner/Manifests/ownership.json b/Tools/windows/ValidationRunner/Manifests/ownership.json index 7def145f..be161750 100644 --- a/Tools/windows/ValidationRunner/Manifests/ownership.json +++ b/Tools/windows/ValidationRunner/Manifests/ownership.json @@ -1,5 +1,7 @@ { "units": [ + { "id": "ownership.shell-scope-native", "owner": "Ownership", "minimumCount": 44, "exactCount": 44, + "requiredGroups": ["controlled-native", "held-root-exit", "job-empty-not-process-signal"] }, { "id": "ownership.managed.quote-empty", "owner": "Ownership", diff --git a/Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs b/Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs new file mode 100644 index 00000000..448dbe27 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ownership/Fixtures/ShellScopeNativeFixture.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphCode.Validation.Ownership; + +public static class ShellScopeNativeFixture +{ + public const int ExpectedCases = 44; + + public static async Task TryRunAsync(string mode, string root) + { + if (mode is "shell-scope-child" or "shell-scope-exit") + { + for (int index = 0; index < 16; index++) + new Thread(() => Thread.Sleep(30000)) { IsBackground = true }.Start(); + Console.Out.WriteLine("shell-scope-ready"); + Console.Error.WriteLine("shell-scope-stderr"); + if (mode == "shell-scope-child") await Task.Delay(30000).ConfigureAwait(false); + else + { + var limit = new Deadline(TimeSpan.FromSeconds(5)); + while (!File.Exists(Path.Combine(root, "release"))) + { + if (limit.Expired) throw new TimeoutException("Controlled natural exit release was not published."); + await Task.Delay(5).ConfigureAwait(false); + } + } + return 7; + } + if (mode != "shell-scope-regression") return null; + int executed = 0, pendingAtZero = 0; + var natural = ShellOwnedLaunch.Suspended(OwnedFixture.Command("shell-scope-exit", root)); + try + { + IntPtr original = natural.Handle; + Refuse(() => natural.RequireExited()); + executed++; + Refuse(() => natural.RequireExited(20)); + executed++; + bool rejected = false; + try { natural.RequireExited(-1); } + catch (ArgumentOutOfRangeException) { rejected = true; } + Require(rejected && natural.Handle == original && !natural.Process.HasExited, + "A live-root refusal released or replaced the original held authority."); + executed++; + natural.Resume(); + File.WriteAllText(Path.Combine(root, "release"), "released"); + natural.RequireExited(5000); + natural.Drain(); + Require(natural.Process.ExitCode == 7 && natural.Handle == original && + natural.Output.Result.Contains("shell-scope-ready", StringComparison.Ordinal) && + natural.Error.Result.Contains("shell-scope-stderr", StringComparison.Ordinal), + "Natural held-root exit lost identity, code or independent stream evidence."); + executed++; + } + finally { ReleaseNatural(natural); } + + for (int iteration = 0; iteration < 10; iteration++) + { + IntPtr job = StandaloneProcessJob.Create(); + var runs = new List(); + try + { + for (int index = 0; index < 4; index++) + { + var run = ShellOwnedLaunch.Suspended(OwnedFixture.Command("shell-scope-child", root)); + runs.Add(run); + StandaloneProcessJob.Assign(job, run.Handle); + run.Resume(); + } + var ready = new HashSet(); + var startup = new Deadline(TimeSpan.FromSeconds(5)); + while (ready.Count != runs.Count) + { + foreach (var run in runs) + if (!ready.Contains(run) && run.TakeOutput(65536).Contains("shell-scope-ready", StringComparison.Ordinal)) + ready.Add(run); + if (startup.Expired) throw new TimeoutException("Controlled scope roots did not all become ready."); + await Task.Delay(5).ConfigureAwait(false); + } + var exit = new Deadline(TimeSpan.FromSeconds(5)); + StandaloneProcessJob.Terminate(job); + StandaloneProcessJob.WaitForEmpty(job, exit.RemainingMilliseconds); + foreach (var run in runs) + { + try { run.RequireExited(); } + catch (InvalidOperationException) { pendingAtZero++; } + run.RequireExited(exit.RemainingMilliseconds); + Require(StandaloneProcessJob.Active(job) == 0, "Owned job became active after termination."); + run.Drain(); + Require(run.Output.Result.Contains("shell-scope-ready", StringComparison.Ordinal) && + run.Error.Result.Contains("shell-scope-stderr", StringComparison.Ordinal), + "Terminated root lost independent EOF/output evidence."); + executed++; + } + } + finally { ReleaseJob(job, runs); } + } + Require(executed == ExpectedCases, "Native scope fixture did not execute its exact case inventory."); + Console.WriteLine("SHELL_NATIVE_RESULT=" + JsonSerializer.Serialize(new { executed, passed = executed, failed = 0, pendingAtZero })); + return 0; + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private static void Refuse(Action action) + { + bool rejected = false; + try { action(); } + catch (InvalidOperationException) { rejected = true; } + Require(rejected, "A still-live held root was incorrectly accepted as exited."); + } + + private static void ReleaseNatural(ShellOwnedLaunch run) + { + try { run.Stop(); run.RequireExited(); run.Close(); } + catch (Exception failure) { Retain(failure); } + } + + private static void ReleaseJob(IntPtr job, IReadOnlyList runs) + { + try + { + var deadline = new Deadline(TimeSpan.FromSeconds(5)); + StandaloneProcessJob.Terminate(job); + StandaloneProcessJob.WaitForEmpty(job, deadline.RemainingMilliseconds); + foreach (var run in runs) run.RequireExited(deadline.RemainingMilliseconds); + foreach (var run in runs) run.Close(); + StandaloneProcessJob.Close(job); + } + catch (Exception failure) { Retain(failure); } + } + + private static void Retain(Exception failure) + { + // Fixture failures must not turn process exit into kill-on-close recovery. + failure.Data[OwnershipRetention.RequiredKey] = true; + try { Console.Out.WriteLine(OwnershipRetention.Sentinel); Console.Out.Flush(); } + finally { Thread.Sleep(Timeout.Infinite); } + } +} + +public sealed class ShellScopeNativeSuite : IValidationSuite +{ + public string Id => "ownership.shell-scope-native"; + public IReadOnlyList UnitIds => [Id]; + + public async Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + string root = Path.Combine(context.ArtifactRoot, "shell-scope-native-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var result = await OwnedChildSupervisor.RunAsync(OwnedFixture.Command("shell-scope-regression", root), + TimeSpan.FromSeconds(30), root, cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0 || !result.Cleanup.ResourcesClosed || !string.IsNullOrWhiteSpace(result.StandardError)) + throw new InvalidOperationException("Controlled shared-helper regression failed: " + result.StandardError); + const string prefix = "SHELL_NATIVE_RESULT="; + string[] reports = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith(prefix, StringComparison.Ordinal)).ToArray(); + if (reports.Length != 1) throw new InvalidDataException("Expected one native scope fixture result."); + using var report = JsonDocument.Parse(reports[0][prefix.Length..]); + if (report.RootElement.GetProperty("executed").GetInt32() != ShellScopeNativeFixture.ExpectedCases || + report.RootElement.GetProperty("passed").GetInt32() != ShellScopeNativeFixture.ExpectedCases || + report.RootElement.GetProperty("failed").GetInt32() != 0) + throw new InvalidDataException("Native shared-helper executed counts differ."); + return [new UnitResult(Id, UnitStatus.Passed, ShellScopeNativeFixture.ExpectedCases, + reports[0][prefix.Length..], ["controlled-native", "held-root-exit", "job-empty-not-process-signal"])]; + } +} diff --git a/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs index b39d3a6a..60ec7aa7 100644 --- a/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs +++ b/Tools/windows/ValidationRunner/Ownership/OwnedFixture.cs @@ -39,6 +39,8 @@ public static ProcessStartInfo Command(string mode, string root, params string[] if (args.Length < 3) throw new ArgumentException("Owned fixture requires a mode and existing owned root."); string mode = args[1], root = Path.GetFullPath(args[2]); if (!Directory.Exists(root)) throw new DirectoryNotFoundException(root); + if (await ShellScopeNativeFixture.TryRunAsync(mode, root).ConfigureAwait(false) is int scopeExit) + return scopeExit; if (mode.StartsWith("bytes-", StringComparison.Ordinal)) { byte[] bytes = BytesForMode(mode); diff --git a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs index 91e51f7a..56089107 100644 --- a/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ownership/SuiteRegistration.cs @@ -24,6 +24,7 @@ public static class SuiteRegistration public static IReadOnlyList CreateSuites() => [ new OwnershipSuite(), + new ShellScopeNativeSuite(), new Adapters.StandaloneSuites.CaseScriptSuite("ownership.receipts", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\Receipt.Contracts.ps1", "OWNERSHIP_CASE_RESULT=", ["ownership.legacy.ownership-receipts"]), new Adapters.StandaloneSuites.CaseScriptSuite("ownership.tree-provider", "Tools\\windows\\ValidationRunner\\Ownership\\Adapters\\TreeProvider.Contracts.ps1", From 855311279b8e3fb693e59ddd5f9ed51bd95267c4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 18:41:10 -0700 Subject: [PATCH 41/46] Preserve native validation failure and lifecycle semantics Keep validation scoped to the main product baseline, preserve native window and smoke-exit behavior, finalize endpoint captures in the correct order, and observe daemon lifetime before job teardown. Add bounded native smoke controls and non-activating owned pointer fixtures without weakening recipient checks. Classify failing suites without printing private exception payloads. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/DaemonHandoff.Live.Tests.ps1 | 4 +- .../windows/Tests/OwnedLiveAdapter.Tests.ps1 | 33 +++++ .../Tests/ShellProcessOwnership.Tests.ps1 | 1 + .../Tests/ShellSmokeExit.Live.Tests.ps1 | 139 ++++++++++++++++++ Tools/windows/Tests/WindowsShell.Tests.ps1 | 24 +-- Tools/windows/Tests/WireTargeting.Tests.ps1 | 53 ++++++- .../Adapters/StandaloneSuites.cs | 2 +- .../ValidationRunner/Host/ConsoleReport.cs | 6 +- .../ValidationRunner/Host/HostSelfTests.cs | 15 ++ .../ValidationRunner/Manifests/host.json | 10 +- Tools/windows/ValidationRunner/Program.cs | 1 + Tools/windows/validate.ps1 | 7 + Tools/windows/validation-matrix.md | 14 ++ Tools/windows/windows-shell.ps1 | 5 +- graphcode-windows/src/App.zig | 24 ++- 15 files changed, 305 insertions(+), 33 deletions(-) create mode 100644 Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 diff --git a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 index 143a7e23..c4687c23 100644 --- a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 @@ -121,7 +121,6 @@ function Stop-ShellNormally([Diagnostics.Process] $process, [string] $role) { if (-not $process.WaitForExit(7000)) { throw "$role shell did not exit normally" } - Complete-ShellOwnedProcess $processScope $process return } Start-Sleep -Milliseconds 100 @@ -145,6 +144,7 @@ function Assert-EndpointReachable { if (-not $process.WaitForExit(7000)) { throw "daemon endpoint was not reachable through graphcode.exe" } + Complete-ShellOwnedProcess $processScope $process $stderr = Read-ShellOwnedOutput $processScope $process -StandardError if ($process.ExitCode -ne 0) { throw "daemon endpoint rejected graphcode.exe: $stderr" @@ -307,11 +307,13 @@ try { if ($daemonProcess.HasExited) { throw "non-owning shell exit stopped the externally owned daemon" } + Complete-ShellOwnedProcess $processScope $contender Stop-ShellNormally $owner "owning" if (-not $daemonProcess.WaitForExit(7000)) { throw "owning shell exit did not stop its graphcoded child" } + Complete-ShellOwnedProcess $processScope $owner } catch { $primaryFailure = $_ throw diff --git a/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 b/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 index ecf2e95b..bec6a6ab 100644 --- a/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 +++ b/Tools/windows/Tests/OwnedLiveAdapter.Tests.ps1 @@ -23,6 +23,7 @@ Describe "Owned live callers and shell adapter with mocked native operations" { . (Join-Path $moduleRoot "OwnedLiveProcess.ps1") foreach ($item in @( @{ File = "ScrubbedShellStartup.Live.Tests.ps1"; Name = "Invoke-ScrubbedCli" }, + @{ File = "DaemonHandoff.Live.Tests.ps1"; Name = "Assert-EndpointReachable" }, @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Name = "Start-OwnedDaemon" }, @{ File = "DaemonRoundTrip.Live.Tests.ps1"; Name = "Stop-OwnedDaemon" })) { $errors = $null @@ -163,6 +164,38 @@ Describe "Owned live callers and shell adapter with mocked native operations" { $script:scope.Closed | Should -BeTrue } + It "finalizes the handoff endpoint probe before reading or disposing it" { + $run = New-ControlledLaunch "endpoint" + $run.Process.ExitCode = 0 + $script:cliExecutable = "C:\mock\graphcode.exe" + $script:Executable = "C:\mock\graphcode-windows.exe" + $script:supportDirectory = "C:\mock\support" + $script:daemonPipe = "\\.\pipe\controlled-endpoint" + Assert-EndpointReachable + $run.Finalized | Should -BeTrue + ($script:events -join ",") | Should -BeExactly ( + "suspended,Assign:200,Create:,Assign:300,resume:endpoint,wait-root:endpoint," + + "require-exited:endpoint,Terminate:300,Wait:300,Active:300,drain:endpoint,Close:300,dispose:endpoint") + $handoffPath = Join-Path $moduleRoot 'Tests\DaemonHandoff.Live.Tests.ps1' + $handoff = [Management.Automation.Language.Parser]::ParseFile($handoffPath, [ref]$null, [ref]$null) + $normalStop = $handoff.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Stop-ShellNormally' + }, $true) + @($normalStop.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Complete-ShellOwnedProcess' + }, $true)).Count | Should -Be 0 + $source = [IO.File]::ReadAllText($handoffPath) + $ownerObservation = $source.IndexOf('$daemonProcess.WaitForExit(7000)', [StringComparison]::Ordinal) + $ownerFinalization = $source.IndexOf('Complete-ShellOwnedProcess $processScope $owner', [StringComparison]::Ordinal) + $ownerObservation | Should -BeGreaterThan 0 + $ownerFinalization | Should -BeGreaterThan $ownerObservation + Close-ShellProcessScope $script:scope + $script:scope.Closed | Should -BeTrue + } + It "does not finalize a live sibling when the command completes" { $daemon = New-ControlledLaunch "daemon" $command = New-ControlledLaunch "command" diff --git a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 index 82e5bb78..86f6352e 100644 --- a/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 +++ b/Tools/windows/Tests/ShellProcessOwnership.Tests.ps1 @@ -607,6 +607,7 @@ Describe "Ownership source wiring (not native execution)" { -CaptureOutput:$case.output -CaptureError:$case.error $script:observedStart.RedirectStandardOutput | Should -Be $case.output $script:observedStart.RedirectStandardError | Should -Be $case.error + $script:observedStart.WindowStyle | Should -Be ([Diagnostics.ProcessWindowStyle]::Normal) } $text = Get-Content -LiteralPath $shellPath -Raw $text | Should -Match '\$versionProcess = Start-SmokeProcess \$app @\("--version"\) -CaptureOutput -CaptureError' diff --git a/Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 b/Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 new file mode 100644 index 00000000..1c8eab5a --- /dev/null +++ b/Tools/windows/Tests/ShellSmokeExit.Live.Tests.ps1 @@ -0,0 +1,139 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $AppPath, + [Parameter(Mandatory)][string] $ArtifactRoot, + [Parameter(Mandatory)][string] $WinghosttyRoot, + [Parameter(Mandatory)][string] $ZmxRoot, + [Parameter(Mandatory)][string] $Zig0152, + [Parameter(Mandatory)][string] $Zig0160, + [ValidateSet('legacy-busy', 'owned-busy', 'legacy-success', 'owned-success', 'owned-unactivated', 'owned-no-focus')] + [string] $Worker +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +Import-Module (Join-Path $repoRoot 'Tools\windows\ShellProcessOwnership.psm1') + +function New-FixtureStart([string] $File, [string[]] $Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new($File) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + $start +} + +$null = New-Item -ItemType Directory -Force -Path $ArtifactRoot +if ($Worker) { + $env:GRAPHCODE_SUPPORT_DIR = Join-Path $ArtifactRoot 'support' + $env:LOCALAPPDATA = Join-Path $ArtifactRoot 'local' + $env:GRAPHCODE_WORKSPACE_LAYOUT = Join-Path $ArtifactRoot 'workspace.json' + $env:TEMP = Join-Path $ArtifactRoot 'temp' + $env:TMP = $env:TEMP + $null = New-Item -ItemType Directory -Force -Path $env:GRAPHCODE_SUPPORT_DIR,$env:LOCALAPPDATA,$env:TEMP + [IO.File]::WriteAllText((Join-Path $env:GRAPHCODE_SUPPORT_DIR 'onboarding-seen'), '') + Remove-Item Env:\GRAPHCODE_SHELL_WORKSPACE_ACTIONS -ErrorAction SilentlyContinue + if ($Worker -eq 'owned-no-focus') { + $failure = $null + try { + & (Join-Path $repoRoot 'Tools\windows\windows-shell.ps1') ` + -WinghosttyRoot $WinghosttyRoot -ZmxRoot $ZmxRoot -Zig0152 $Zig0152 -Zig0160 $Zig0160 ` + -SkipBuild -UseStubDaemon -StubResponseDelayMilliseconds 150 -SkipTrayLive -Stress ` + -WorkspaceActions 'create,split,select,close,restart' + } catch { $failure = $_ } + if ($null -ne $failure -and (Test-ShellOwnershipUnproven $failure)) { throw $failure } + if ($null -eq $failure -or + $failure.Exception.Message -notmatch '(?s)exited with code 1: .*Smoke contract failed: scripted=true .*focus=false') { + throw "Omitted-focus scenario did not refuse for its actual missing focus effect: $failure" + } + [IO.File]::WriteAllText((Join-Path $ArtifactRoot 'stderr.txt'), ($failure | Out-String)) + [ordered]@{ id = $Worker; exitCode = 1; stderrLength = $failure.Exception.Message.Length } | + ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $ArtifactRoot 'result.json') + exit 0 + } + $pipe = 'graphcode-smoke-exit-' + [guid]::NewGuid().ToString('N') + $result = Join-Path $ArtifactRoot 'daemon.json' + $errorPath = Join-Path $ArtifactRoot 'stderr.txt' + $scope = New-ShellProcessScope + try { + $stubStart = New-FixtureStart (Join-Path $PSHOME 'pwsh.exe') @( + '-NoProfile', '-File', (Join-Path $repoRoot 'Tools\windows\Stub-Daemon.ps1'), + '-PipeName', $pipe, '-ResultPath', $result, '-NonReading' + ) + $null = Start-ShellOwnedProcess $scope $stubStart + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$pipe" + $busy = $Worker.EndsWith('-busy', [StringComparison]::Ordinal) -or $Worker -eq 'owned-unactivated' + if ($busy) { + $env:GRAPHCODE_SHELL_REQUIRE_DAEMON = '1' + $env:GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR = '1' + } else { + Remove-Item Env:\GRAPHCODE_SHELL_REQUIRE_DAEMON -ErrorAction SilentlyContinue + Remove-Item Env:\GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR -ErrorAction SilentlyContinue + } + if ($Worker -eq 'owned-unactivated') { $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = '1' } + if ($Worker.StartsWith('legacy-', [StringComparison]::Ordinal)) { + # The worker is already suspended-assigned to the caller's held job. + $app = Start-Process -FilePath $AppPath -ArgumentList @('--smoke') -PassThru ` + -RedirectStandardError $errorPath + [void] $app.Handle + if (-not $app.WaitForExit(8000)) { throw "$Worker exceeded the original smoke deadline" } + $app.WaitForExit() + $app.Refresh() + $stderr = [IO.File]::ReadAllText($errorPath) + } else { + $app = Start-ShellOwnedProcess $scope (New-FixtureStart $AppPath @('--smoke')) + if (-not $app.WaitForExit(8000)) { throw "$Worker exceeded the original smoke deadline" } + Complete-ShellOwnedProcess $scope $app + $stderr = Read-ShellOwnedOutput $scope $app -StandardError + [IO.File]::WriteAllText($errorPath, $stderr) + } + $exitCode = $app.ExitCode + $daemon = Get-Content -LiteralPath $result -Raw | ConvertFrom-Json + if (-not $daemon.busyObserved) { throw "$Worker did not exercise a nonreading daemon" } + if ($busy) { + if ($exitCode -eq 0 -or $stderr -notmatch '(?i)Smoke daemon status: .*daemon') { + throw "$Worker failed smoke-exit contract: exit=$exitCode stderrLength=$($stderr.Length)" + } + if ($Worker -eq 'owned-unactivated' -and $stderr -notmatch 'Smoke contract failed: scripted=false') { + throw 'Unactivated workspace scenario did not prove its requested actions remained unexecuted' + } + } elseif ($exitCode -ne 0) { + throw "$Worker changed successful requested-exit behavior: exit=$exitCode" + } + [ordered]@{ id = $Worker; exitCode = $exitCode; stderrLength = $stderr.Length } | + ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $ArtifactRoot 'result.json') + } finally { + Close-ShellProcessScope $scope + } + exit 0 +} + +$executed = 0 +foreach ($case in @('legacy-busy', 'owned-busy', 'legacy-success', 'owned-success', 'owned-unactivated', 'owned-no-focus')) { + $caseRoot = Join-Path $ArtifactRoot $case + $null = New-Item -ItemType Directory -Force -Path $caseRoot + $scope = New-ShellProcessScope + try { + $start = New-FixtureStart (Join-Path $PSHOME 'pwsh.exe') @( + '-NoProfile', '-File', $PSCommandPath, '-AppPath', $AppPath, + '-ArtifactRoot', $caseRoot, '-Worker', $case, '-WinghosttyRoot', $WinghosttyRoot, + '-ZmxRoot', $ZmxRoot, '-Zig0152', $Zig0152, '-Zig0160', $Zig0160 + ) + $process = Start-ShellOwnedProcess $scope $start + $workerDeadline = if ($case -eq 'owned-no-focus') { 45000 } else { 15000 } + if (-not $process.WaitForExit($workerDeadline)) { throw "$case worker exceeded its owned deadline" } + Complete-ShellOwnedProcess $scope $process + [IO.File]::WriteAllText((Join-Path $caseRoot 'worker-stderr.txt'), + (Read-ShellOwnedOutput $scope $process -StandardError)) + if ($process.ExitCode -ne 0) { throw "$case worker failed: exit=$($process.ExitCode); evidence=$caseRoot" } + $row = Get-Content -LiteralPath (Join-Path $caseRoot 'result.json') -Raw | ConvertFrom-Json + if ($row.id -cne $case) { throw "$case worker returned mismatched evidence" } + $executed++ + Write-Host "SHELL_SMOKE_EXIT_CASE=$case EXIT=$($row.exitCode)" + } finally { + Close-ShellProcessScope $scope + } +} +if ($executed -ne 6) { throw "Smoke exit fixture did not execute all six controls: $executed" } +Write-Host "SHELL_SMOKE_EXIT_CASES=$executed" diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 1d9fd647..9d0f9040 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -520,27 +520,15 @@ function Resolve-TestZig { Invoke-Native "Shell process ownership mock contracts" { . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") $ownershipResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "ShellProcessOwnership.Tests.ps1") -PassThru - if ($ownershipResults.TotalCount -ne 66 -or + if ($ownershipResults.TotalCount -ne 70 -or $ownershipResults.PassedCount -ne $ownershipResults.TotalCount -or $ownershipResults.FailedCount -ne 0 -or $ownershipResults.SkippedCount -ne 0) { - throw "Shell process ownership mock contracts did not pass all 66 unskipped cases" + throw "Shell process ownership mock contracts did not pass all 70 unskipped cases" } Write-Host "SHELL_PROCESS_OWNERSHIP_MOCK_CASES=$($ownershipResults.TotalCount)" $global:LASTEXITCODE = 0 } -Invoke-Native "Stable canvas card context menu mock contracts" { - . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") - $cardMenuResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "CanvasCardContextMenu.Tests.ps1") -PassThru - if ($cardMenuResults.TotalCount -ne 16 -or $cardMenuResults.PassedCount -ne 16 -or - $cardMenuResults.FailedCount -ne 0 -or $cardMenuResults.SkippedCount -ne 0 -or - $cardMenuResults.NotRunCount -ne 0) { - throw "Stable canvas card context menu mock contracts did not pass all 16 unskipped cases" - } - Write-Host "CANVAS_CARD_CONTEXT_MENU_MOCK_CASES=$($cardMenuResults.TotalCount)" - $global:LASTEXITCODE = 0 -} - foreach ($path in @( "build.zig", "build.zig.zon", @@ -817,10 +805,6 @@ Invoke-Native "Sketch promotion executable tests" { Push-Location $shellRoot try { & $zig test src\SketchPromotion.zig --test-filter 'sketch promotion' } finally { Pop-Location } } -Invoke-Native "Card transfer data-only executable tests" { - Push-Location $shellRoot - try { & $zig test src\CardTransfer.zig --test-filter 'card transfer ' } finally { Pop-Location } -} Invoke-Native "Context menu and gate fixture message executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") @@ -867,10 +851,10 @@ Invoke-Native "Owned live process cleanup mock contracts" { Invoke-Native "Owned live caller and adapter mock integration" { . (Join-Path $PSScriptRoot "..\Import-PinnedPester.ps1") $adapterResults = Invoke-Pester -Path (Join-Path $PSScriptRoot "OwnedLiveAdapter.Tests.ps1") -PassThru - if ($adapterResults.TotalCount -ne 12 -or $adapterResults.PassedCount -ne 12 -or + if ($adapterResults.TotalCount -ne 13 -or $adapterResults.PassedCount -ne 13 -or $adapterResults.FailedCount -ne 0 -or $adapterResults.SkippedCount -ne 0 -or $adapterResults.NotRunCount -ne 0) { - throw "Owned live caller and adapter mock integration did not pass all 12 cases" + throw "Owned live caller and adapter mock integration did not pass all 13 cases" } Write-Host "OWNED_LIVE_ADAPTER_MOCK_CASES=$($adapterResults.PassedCount)" $global:LASTEXITCODE = 0 diff --git a/Tools/windows/Tests/WireTargeting.Tests.ps1 b/Tools/windows/Tests/WireTargeting.Tests.ps1 index 5b49abe1..6071fc12 100644 --- a/Tools/windows/Tests/WireTargeting.Tests.ps1 +++ b/Tools/windows/Tests/WireTargeting.Tests.ps1 @@ -56,8 +56,24 @@ public static class WireFixture { [DllImport("user32.dll")] public static extern bool DestroyWindow(IntPtr window); [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); [DllImport("user32.dll")] public static extern IntPtr GetThreadDpiAwarenessContext(); + [StructLayout(LayoutKind.Sequential)] struct Point { public int X,Y; } + [StructLayout(LayoutKind.Sequential)] struct Message { + public IntPtr Window; public uint Id; public UIntPtr WParam; public IntPtr LParam; + public uint Time; public Point Point; public uint Private; + } + [DllImport("user32.dll")] static extern IntPtr WindowFromPoint(Point point); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window,out uint processId); + [DllImport("user32.dll")] static extern bool PeekMessageW(out Message message,IntPtr window,uint first,uint last,uint remove); + public static IntPtr AtPoint(int x,int y) { return WindowFromPoint(new Point { X=x,Y=y }); } + public static bool HasPointerMessage(IntPtr window) { + Message message; return PeekMessageW(out message,window,0x0200,0x0202,0); + } + public static uint OwnerProcess(IntPtr window) { + uint processId; GetWindowThreadProcessId(window,out processId); return processId; + } public static IntPtr Create(IntPtr parent, bool child, string name) { - IntPtr result=CreateWindowExW(0x08000000,child ? "WireTargetFixtureContent" : "WireTargetFixture", + // Isolate only owned top-level fixtures without taking keyboard focus. + IntPtr result=CreateWindowExW(child ? 0x08000000u : 0x08000008u,child ? "WireTargetFixtureContent" : "WireTargetFixture", name,child ? 0x50000000u : 0x90000000u, 20,20,250,96,parent,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); if(result==IntPtr.Zero) throw new Exception("Fixture CreateWindow failed."); @@ -229,8 +245,41 @@ try { } Reject-WireCase "reject sheet $fault" {Resolve-CapOwnedSheet $rows 1 $PID} $message } + Test-WireCase 'native unrelated topmost fixture refuses a pointer before posting' { + $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds + $overlay=[IntPtr]::Zero + $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-4)) + Assert-WireCase ($previous -ne [IntPtr]::Zero) 'Overlay PMv2 setup failed.' + try { + $overlay=[WireFixture]::Create([IntPtr]::Zero,$false,'wire fixture unrelated overlay') + Assert-WireCase ([WireFixture]::AtPoint(($b[0]+10),($b[1]+10)) -eq $overlay -and + [WireFixture]::OwnerProcess($overlay) -eq $PID) 'Controlled unrelated overlay is not the actual recipient.' + Assert-WireCase (-not [WireFixture]::HasPointerMessage($hostWindow) -and + -not [WireFixture]::HasPointerMessage($overlay)) 'Pointer message was already queued before the refusal control.' + $failure=$null + try {[CapNative]::PostPointerClickScreen($hostWindow,($b[0]+10),($b[1]+10))|Out-Null} + catch {$failure=$_} + Assert-WireCase ($null -ne $failure -and + $failure.Exception.Message.Contains('Pointer recipient differs from intended owned HWND')) ` + 'Unrelated same-process HWND was not refused by the actual recipient guard.' + Assert-WireCase (-not [WireFixture]::HasPointerMessage($hostWindow) -and + -not [WireFixture]::HasPointerMessage($overlay)) 'Recipient refusal posted a pointer message.' + } finally { + try { + if ($overlay -ne [IntPtr]::Zero -and -not [WireFixture]::DestroyWindow($overlay)) { + throw 'Controlled overlay destruction failed.' + } + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($previous)} + } + } Test-WireCase 'native PMv2 conversion dispatch persists coordinates and restores caller awareness' { $b=@($nativeRows|Where-Object Handle -EQ $hostWindow.ToInt64())[0].Bounds + $preflight=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-4)) + Assert-WireCase ($preflight -ne [IntPtr]::Zero) 'Owned pointer fixture PMv2 preflight failed.' + try { + Assert-WireCase ([WireFixture]::AtPoint(($b[0]+10),($b[1]+10)) -eq $hostWindow -and + [WireFixture]::OwnerProcess($hostWindow) -eq $PID) 'Owned pointer fixture recipient preflight refused.' + } finally {$null=[WireFixture]::SetThreadDpiAwarenessContext($preflight)} $previous=[WireFixture]::SetThreadDpiAwarenessContext([IntPtr](-1)) try { $before=[WireFixture]::GetThreadDpiAwarenessContext() @@ -301,4 +350,4 @@ $report|ConvertTo-Json -Depth 30 | Set-Content -LiteralPath (Join-Path $ScratchR [pscustomobject]$report|Select-Object suite,executed,passed,failed,skipped|ConvertTo-Json -Compress foreach ($result in @($results|Where-Object passed -EQ $false)) {$result|ConvertTo-Json -Compress|Write-Host} if ($failed -gt 0) {throw "$failed behavioral wire targeting contracts failed."} -if ($results.Count -ne $(if ($LegacyBehavior) {2} else {56})) {throw 'Wire targeting executed count differs.'} +if ($results.Count -ne $(if ($LegacyBehavior) {2} else {57})) {throw 'Wire targeting executed count differs.'} diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index 5cdb3c61..65868251 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -24,7 +24,7 @@ public static IReadOnlyList Create(IReadOnlyList new ScriptSuite("ps.action-topology", "ActionTopologyImport.Tests.ps1", output => EvidenceParser.Structured("ps.action-topology", "ActionTopologyImport", output, 16, 16)), new ScriptSuite("ps.wire-targeting", "WireTargeting.Tests.ps1", - output => EvidenceParser.Structured("ps.wire-targeting", "WireTargeting", output, 56, 56)), + output => EvidenceParser.Structured("ps.wire-targeting", "WireTargeting", output, 57, 57)), new ScriptSuite("ps.agent-command", "Invoke-AgentCommand.Tests.ps1", EvidenceParser.AgentCommand), new HostTaskSuite("host.task-inventory", "inventory", 17), new HostTaskSuite("host.task-dry-run", "dry-run", 7), diff --git a/Tools/windows/ValidationRunner/Host/ConsoleReport.cs b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs index f591bfb8..193f0fa5 100644 --- a/Tools/windows/ValidationRunner/Host/ConsoleReport.cs +++ b/Tools/windows/ValidationRunner/Host/ConsoleReport.cs @@ -5,10 +5,14 @@ namespace GraphCode.Validation.Host; public static class ConsoleReport { public static bool ValidId(string? id) => - id is not null && Regex.IsMatch(id, "^[a-z0-9][a-z0-9.-]{0,160}$", RegexOptions.CultureInvariant); + id is not null && Regex.IsMatch(id, "^[a-z0-9][a-z0-9.-]{0,160}\\z", RegexOptions.CultureInvariant); public static string Unit(UnitResult result) => $"{result.Status}: {(ValidId(result.Id) ? result.Id : "[invalid-id]")} executed={result.ExecutedCount}"; public static string Error(int index) => $"ERROR {index}: details retained in coverage.json"; + + public static string SuiteFailure(string suiteId, string exceptionType) => + $"SUITE_FAILURE id={(ValidId(suiteId) ? suiteId : "[invalid-id]")} " + + $"exception={(Regex.IsMatch(exceptionType, "^[A-Za-z_][A-Za-z0-9_.+`]{0,255}\\z", RegexOptions.CultureInvariant) ? exceptionType : "[invalid-type]")}; details retained in coverage.json"; } diff --git a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs index 737f0919..0e3e23b4 100644 --- a/Tools/windows/ValidationRunner/Host/HostSelfTests.cs +++ b/Tools/windows/ValidationRunner/Host/HostSelfTests.cs @@ -119,6 +119,21 @@ void RejectSummary(string value) Check("console-invalid-id-isolation", () => Assert(!ConsoleReport.Unit(passed with { Id = "\nVALIDATION_RUNNER_EXECUTED=1" }).Contains('\n'), "invalid case ID injected a root line")); + Check("console-safe-failure-classification", () => + { + var safe = ConsoleReport.SuiteFailure("test.suite", "System.InvalidOperationException"); + Assert(safe.Contains("id=test.suite exception=System.InvalidOperationException", StringComparison.Ordinal), + "safe suite/type classification was lost"); + var injected = ConsoleReport.SuiteFailure("test\nPRIVATE_CANARY", "type\nVALIDATION_RUNNER_EXECUTED=1"); + Assert(!injected.Contains('\n') && !injected.Contains("PRIVATE_CANARY", StringComparison.Ordinal) && + !injected.Contains("VALIDATION_RUNNER_EXECUTED", StringComparison.Ordinal), "failure classification leaked raw payload"); + foreach (var newline in new[] { "\n", "\r", "\r\n" }) + { + var terminal = ConsoleReport.SuiteFailure("test.suite" + newline, "System.Exception" + newline); + Assert(!terminal.Contains('\n') && !terminal.Contains('\r') && + !ConsoleReport.ValidId("test.suite" + newline), "terminal newline bypassed failure classification"); + } + }); Check("terminal-known-exit", () => Assert(TerminalSafety.ExitBarrier(0, false).IsCompletedSuccessfully, "proven empty authority cannot exit")); Check("terminal-retained-no-exit", () => Assert(!TerminalSafety.ExitBarrier(1, false).IsCompleted, diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 9f0d5253..8901e1ca 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -1,9 +1,9 @@ { "units": [ { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, - { "id": "host.admission-guards", "owner": "Host", "minimumCount": 64, "exactCount": 64 }, - { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 66, "exactCount": 66 }, - { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 12, "exactCount": 12 }, + { "id": "host.admission-guards", "owner": "Host", "minimumCount": 65, "exactCount": 65 }, + { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 70, "exactCount": 70 }, + { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 13, "exactCount": 13 }, { "id": "host.legacy-ownership.live-cleanup", "owner": "Host", "minimumCount": 84, "exactCount": 84 }, { "id": "host.provenance.current-source", "owner": "Host", "minimumCount": 1 }, { "id": "host.provenance.source-changed", "owner": "Host", "minimumCount": 1 }, @@ -17,7 +17,7 @@ { "id": "host.shell-prerequisite", "owner": "Host", "minimumCount": 4, "exactCount": 4 }, { "id": "host.ci-partition", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, { "id": "host.ci-aggregate", "owner": "Host", "minimumCount": 17, "exactCount": 17 }, - { "id": "host.ci-topology", "owner": "Host", "minimumCount": 36, "exactCount": 36 }, + { "id": "host.ci-topology", "owner": "Host", "minimumCount": 39, "exactCount": 39 }, { "id": "host.provider-task-boundary", "owner": "Host", "minimumCount": 5, "exactCount": 5 }, { "id": "ui.root.source.line-5346", "owner": "Host", "minimumCount": 2, "exactCount": 2 }, { "id": "host.provider-cache-seeding", "owner": "Host", "minimumCount": 1 }, @@ -51,7 +51,7 @@ { "id": "ps.action-worker", "owner": "Host", "minimumCount": 18, "exactCount": 18 }, { "id": "ps.action-identity", "owner": "Host", "minimumCount": 62, "exactCount": 62 }, { "id": "ps.action-topology", "owner": "Host", "minimumCount": 16, "exactCount": 16 }, - { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 56, "exactCount": 56 }, + { "id": "ps.wire-targeting", "owner": "Host", "minimumCount": 57, "exactCount": 57 }, { "id": "ps.agent-command", "owner": "Host", "minimumCount": 6, "exactCount": 6 } ] } diff --git a/Tools/windows/ValidationRunner/Program.cs b/Tools/windows/ValidationRunner/Program.cs index 846cdbaf..774fb885 100644 --- a/Tools/windows/ValidationRunner/Program.cs +++ b/Tools/windows/ValidationRunner/Program.cs @@ -86,6 +86,7 @@ static async Task RunAsync(string[] args) catch (Exception error) { errors.Add($"{suite.Id}: {error}"); + Console.Error.WriteLine(ConsoleReport.SuiteFailure(suite.Id, error.GetType().FullName ?? error.GetType().Name)); break; } } diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index 1a1db9cb..e5645fa8 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -1297,6 +1297,13 @@ function Invoke-Task([string] $name) { -SkipTrayLive:$SkipTrayLive ` -Stress } + Invoke-Native "Native shell smoke exit regression" { + & pwsh -NoProfile -File ` + (Join-Path $repoRoot "Tools\windows\Tests\ShellSmokeExit.Live.Tests.ps1") ` + -AppPath (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") ` + -WinghosttyRoot $winghosttyRoot -ZmxRoot $zmxRoot -Zig0152 $zig0152 -Zig0160 $zig0160 ` + -ArtifactRoot (Join-Path $env:TEMP "shell-smoke-exit") + } Invoke-Native "Scrubbed production shell startup" { & pwsh -NoProfile -File ` (Join-Path $repoRoot "Tools\windows\Tests\ScrubbedShellStartup.Live.Tests.ps1") ` diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index ba0fb8a2..509aba0c 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -138,6 +138,20 @@ part. | `windows-spikes` | remaining tasks | `validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E -SkipTask ,windows-shell,packaging,hardening` | | `Deterministic hardening (...)` | hardening | `validate.ps1 -Task hardening` | +The integration part also requires six native smoke-exit controls: failing +nonreading-daemon smoke must emit its transport diagnostic and exit nonzero +through both the original stderr-file producer and owned capture; successful +requested smoke exits must remain zero through both producers. Requested +workspace actions must refuse when activation or the focus effect is missing. +All controls run +inside assigned owned jobs and retain evidence under the validation scratch root. + +Run local GUI, keyboard, and pointer gates serially on one desktop; CI runs the +shards and integration job on separate VM desktops. Use a short, exclusively +owned `-ShellValidationRoot` for UIA lease paths. Keep Zig caches in stable owned +directories outside per-invocation `LOCALAPPDATA`, which isolation removes after +a successful run. + `windows-spikes` no longer re-runs `windows-shell`, `packaging` or `hardening`. Those tasks already run in the `windows-shell` and `Deterministic hardening` required checks on every Windows-relevant pull request. diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 index fdc4b883..576ce901 100644 --- a/Tools/windows/windows-shell.ps1 +++ b/Tools/windows/windows-shell.ps1 @@ -11,6 +11,8 @@ param( [switch] $SkipTrayLive, [switch] $Stress, [switch] $UseStubDaemon, + [ValidateNotNullOrEmpty()] + [string] $WorkspaceActions = "1", [ValidateRange(0, 1000)] [int] $StubResponseDelayMilliseconds = 0, [string] $Version @@ -84,7 +86,6 @@ function Start-SmokeProcess([string] $file, [string[]] $arguments, [string] $dir $start.FileName = (Get-Command $file -ErrorAction Stop).Source $start.UseShellExecute = $false $start.CreateNoWindow = $true - $start.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden $start.WorkingDirectory = if ($directory) { $directory } else { [string]$repoRoot } $start.RedirectStandardOutput = $CaptureOutput $start.RedirectStandardError = $CaptureError @@ -539,7 +540,7 @@ try { } $env:GRAPHCODE_ZMX = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" $env:GRAPHCODE_GATE_CWD = $repoRoot - $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = "1" + $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = $WorkspaceActions New-SmokeZmxRoot $smokeZmxDir $env:ZMX_DIR = $smokeZmxDir $env:GRAPHCODE_WORKSPACE_LAYOUT = $workspaceLayoutBase diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a7d479e0..1378aea3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2066,7 +2066,21 @@ pub const App = struct { self.client.connect(); } try self.window.messageLoop(); - if (self.exit_requested) std.process.exit(0); + if (self.smoke_failure or + (self.smoke and self.require_smoke_contract and self.smoke_workspace_actions.len > 0)) + { + std.debug.print("Smoke contract {s}: scripted={} failed={} create={} split={} select={} focus={} close={} restart={}\n", .{ + if (self.smoke_failure) "failed" else "passed", + self.smoke_workspace_actions_ran, + self.smoke_workspace_action_failed, + self.smoke_workspace_create_observed, + self.smoke_workspace_split_observed, + self.smoke_workspace_select_observed, + self.smoke_workspace_focus_observed, + self.smoke_workspace_close_observed, + self.smoke_workspace_restart_observed, + }); + } if (self.smoke_failure) { if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR")) |value| { defer self.allocator.free(value); @@ -2074,8 +2088,10 @@ pub const App = struct { std.debug.print("Smoke daemon status: {s}\n", .{self.client.statusText()}); } } else |_| {} + if (self.exit_requested) std.process.exit(1); return error.SmokeContractFailed; } + if (self.exit_requested) std.process.exit(0); } pub fn configureArgs(self: *App, args: []const []const u8) void { @@ -26687,7 +26703,12 @@ test "Worktrees reclaim action returns before owned provider removal completes" fn runSmokeWorkspaceActions(self: *App) void { const script = self.smoke_workspace_actions; if (script.len == 0) return; + if (self.workspace) |workspace| { + if (workspace.collapsed) self.openSelectedNode(); + } const workspace = if (self.workspace) |value| value else return; + if (workspace.collapsed or + (self.surface != .workspace and !self.workspace_controls.panel_visible)) return; if (workspace.firstLiveSurface() == null) return; const default_script = "create,split,select,focus,close,restart"; const actions = if (std.mem.eql(u8, script, "1")) default_script else script; @@ -26776,6 +26797,7 @@ fn workspacePaneCount(workspace: anytype) usize { fn smokeContractPassed(self: *const App) bool { const scripted_actions = self.smoke_workspace_actions_ran; + if (self.smoke_workspace_actions.len > 0 and !scripted_actions) return false; if (!scripted_actions and self.client.connectionState() != .connected) return false; if (scripted_actions) { return !self.smoke_workspace_action_failed and From cfbe45af1a38c6319b316f6d31a7ad7a2c7eacc0 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 19:45:51 -0700 Subject: [PATCH 42/46] Restore the visible canvas after Delete selection Use the existing owned Show in Graph route after selection opens the loop workspace, reacquire the same card before Fit Canvas, and preserve the original pointer and selection guards. Clear the completed PowerShell stress job reference only after successful removal so cleanup cannot remove it twice. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/uia-live-gate.ps1 | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 8db246f2..80a7fef0 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -709,6 +709,52 @@ function ConvertTo-PopupMenuEvidence($items) { }) } +function Restore-DeleteLoopCanvas( + $process, + $root, + [IntPtr] $ownerWindow, + [string] $automationId, + $walker +) { + Require (Ensure-ShellForeground $ownerWindow "Delete Loop Show in Graph") ` + "Delete Loop could not acquire its owned foreground before Show in Graph" + $workspace = Wait-ForGraphChildren $root $walker ` + { $_.Current.AutomationId -match '^workspace-show-graph-' } ` + { param($items) @($items).Count -eq 1 } + $showGraph = @($workspace.Items | Where-Object { + $_.Current.AutomationId -match '^workspace-show-graph-' -and + $_.Current.Name -ceq "Show in Graph" -and + $_.Current.ProcessId -eq $process.Id -and -not $_.Current.IsOffscreen + }) + Require ($showGraph.Count -eq 1) "Delete Loop omitted its visible owned Show in Graph action" + $showGraph[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $root = Wait-ForRootReconnect $process @($walker) + $card = Find-FragmentByIdWithRetry $root $automationId $walker + Require ($null -ne $card -and $card.Current.AutomationId -ceq $automationId -and + $card.Current.ProcessId -eq $process.Id) "Delete Loop lost its same owned card after Show in Graph" + $fit = Find-FragmentByIdWithRetry $root "fit-canvas" $walker + Require ($null -ne $fit -and $fit.Current.AutomationId -ceq "fit-canvas" -and + $fit.Current.Name -ceq "Fit canvas" -and $fit.Current.ProcessId -eq $process.Id) ` + "Delete Loop graph reentry omitted the exact owned Fit Canvas action" + $fit.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $visible = Wait-ForGraphChildren $root $walker ` + { $_.Current.AutomationId -ceq $automationId -or $_.Current.AutomationId -match '^workspace-show-graph-' } ` + { param($items) + @($items | Where-Object { $_.Current.AutomationId -match '^workspace-show-graph-' }).Count -eq 0 -and + @($items | Where-Object { $_.Current.AutomationId -ceq $automationId -and + $_.Current.ProcessId -eq $process.Id -and -not $_.Current.IsOffscreen }).Count -eq 1 } + $visibleCard = @($visible.Items | Where-Object { $_.Current.AutomationId -ceq $automationId }) + Require ($null -ne $visible.Graph -and $visible.Graph.Current.ProcessId -eq $process.Id -and + $visibleCard.Count -eq 1 -and $visibleCard[0].Current.AutomationId -ceq $automationId -and + $visibleCard[0].Current.ProcessId -eq $process.Id -and -not $visibleCard[0].Current.IsOffscreen -and + @($visible.Items | Where-Object { $_.Current.AutomationId -match '^workspace-show-graph-' }).Count -eq 0) ` + "Delete Loop same card did not return to its visible canvas after Show in Graph and Fit Canvas" + Require ($visibleCard[0].GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` + "Delete Loop Show in Graph changed the selected card identity" + return (Wait-ForRootReconnect $process @($walker)) +} + function Open-CanvasCardContextMenu( $process, $root, @@ -7697,6 +7743,7 @@ try { Require ($concurrencyStressEvidence.completedReads -eq $concurrencyStressEvidence.requestedReads) ` "UIA read stress completed $($concurrencyStressEvidence.completedReads) of $($concurrencyStressEvidence.requestedReads) reads during fixture reorder/removal" Remove-Job -Job $stressJob -Force + $stressJob = $null $remainingRows = @(Get-DirectChildren $worktrees $rawWalker) $remainingRowIds = @($remainingRows | ForEach-Object { $_.Current.AutomationId }) Assert-Ids $remainingRowIds @($unsafeRow.Current.AutomationId) "removed Worktrees" @@ -7862,6 +7909,7 @@ try { "Delete Loop could not select the fixture loop before invoking the selection-dependent command" Write-Host ("UIA_DELETE_LOOP_TARGET automationId='$($deleteTarget.Current.AutomationId)' " + "name='$($deleteTarget.Current.Name)' selected=$($deleteTargetSelection.Current.IsSelected)") + $root = Restore-DeleteLoopCanvas $process $root $shellWindow $renameGraphIdentity $rawWalker $deleteLoopPopup = Open-CanvasCardContextMenu $process $root $shellWindow ` $renameGraphIdentity $rawWalker "Delete Loop" $deleteLoopItems = @(Get-PopupMenuItems $deleteLoopPopup) From 374c22cd9ee110ef20e45f46a71ed50afefaee08 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 19:48:17 -0700 Subject: [PATCH 43/46] Require owned Delete canvas restoration and successful stress-job release Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ui.json | 20 ++ .../ValidationRunner/Ui/UiAdapterCases.cs | 22 +- .../ValidationRunner/Ui/UiDeleteContracts.ps1 | 199 ++++++++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index ebe16fe7..aa2bdbbc 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,5 +1,25 @@ { "units": [ + { "id": "ui.delete.restore.source-order", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.owned-navigation", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreground-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.missing-route", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-route", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.offscreen-route", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.missing-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.wrong-card-id", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.wrong-fit-id", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-fit", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.retained-workspace", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.wrong-final-id", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-final-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.offscreen-final-card", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.foreign-final-graph", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.restore.lost-selection", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.cleanup.source-order", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.cleanup.success-clears", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.delete.cleanup.failed-removal-retains", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.activation.foreground-refused", "owner": "Ui", diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs index ae8804ec..cb706f1b 100644 --- a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -64,7 +64,27 @@ internal static class UiAdapterCases "ui.delete.select.invalid-boolean", "ui.delete.select.nonfinite-bounds", "ui.delete.select.exception-chain", - "ui.delete.select.foreign-parent" + "ui.delete.select.foreign-parent", + "ui.delete.restore.source-order", + "ui.delete.restore.owned-navigation", + "ui.delete.restore.foreground-refused", + "ui.delete.restore.missing-route", + "ui.delete.restore.foreign-route", + "ui.delete.restore.offscreen-route", + "ui.delete.restore.missing-card", + "ui.delete.restore.wrong-card-id", + "ui.delete.restore.foreign-card", + "ui.delete.restore.wrong-fit-id", + "ui.delete.restore.foreign-fit", + "ui.delete.restore.retained-workspace", + "ui.delete.restore.wrong-final-id", + "ui.delete.restore.foreign-final-card", + "ui.delete.restore.offscreen-final-card", + "ui.delete.restore.foreign-final-graph", + "ui.delete.restore.lost-selection", + "ui.delete.cleanup.source-order", + "ui.delete.cleanup.success-clears", + "ui.delete.cleanup.failed-removal-retains" }; internal static readonly string[] RootSourceIds = { diff --git a/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 index 37f14464..63b12ebb 100644 --- a/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiDeleteContracts.ps1 @@ -284,6 +284,205 @@ foreach ($case in $selectCases) { } } Assert-Delete ($visibilityCases.Count -eq 9 -and $selectCases.Count -eq 21 -and $results.Count -eq 30) 'Wrong positive case count' +Invoke-DeleteCase 'ui.delete.restore.source-order' { + $restoreDefinitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Restore-DeleteLoopCanvas' + }, $true)) + Assert-Delete ($restoreDefinitions.Count -eq 1) 'Expected one actual Delete canvas restoration helper' + $selection = $source.IndexOf('$deleteTargetSelection.Select()', [StringComparison]::Ordinal) + $selected = $source.IndexOf('Require $deleteTargetSelection.Current.IsSelected', $selection, [StringComparison]::Ordinal) + $restore = $source.IndexOf('Restore-DeleteLoopCanvas $process $root $shellWindow', $selected, [StringComparison]::Ordinal) + $menu = $source.IndexOf('$deleteLoopPopup = Open-CanvasCardContextMenu', $selected, [StringComparison]::Ordinal) + Assert-Delete ($selection -ge 0 -and $selected -gt $selection -and $restore -gt $selected -and $menu -gt $restore) ` + 'Delete must restore the canvas after the unchanged Select assertion and before the owned pointer route' + $calls = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Restore-DeleteLoopCanvas' + }, $true)) + Assert-Delete ($calls.Count -eq 1 -and $calls[0].Extent.Text -match '\$renameGraphIdentity \$rawWalker') ` + 'Delete restoration must bind the same renamed card identity and walker exactly once' +} +$restoreCases = @( + @{ name = 'owned-navigation'; failure = $null; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card,selected,reconnect' }, + @{ name = 'foreground-refused'; failure = 'Delete Loop could not acquire its owned foreground before Show in Graph'; order = 'foreground' }, + @{ name = 'missing-route'; failure = 'controlled route publication missing'; order = 'foreground,wait-route' }, + @{ name = 'foreign-route'; failure = 'Delete Loop omitted its visible owned Show in Graph action'; order = 'foreground,wait-route' }, + @{ name = 'offscreen-route'; failure = 'Delete Loop omitted its visible owned Show in Graph action'; order = 'foreground,wait-route' }, + @{ name = 'missing-card'; failure = 'Delete Loop lost its same owned card after Show in Graph'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned' }, + @{ name = 'wrong-card-id'; failure = 'Delete Loop lost its same owned card after Show in Graph'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned' }, + @{ name = 'foreign-card'; failure = 'Delete Loop lost its same owned card after Show in Graph'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned' }, + @{ name = 'wrong-fit-id'; failure = 'Delete Loop graph reentry omitted the exact owned Fit Canvas action'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas' }, + @{ name = 'foreign-fit'; failure = 'Delete Loop graph reentry omitted the exact owned Fit Canvas action'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas' }, + @{ name = 'retained-workspace'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'wrong-final-id'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'foreign-final-card'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'offscreen-final-card'; failure = 'controlled restored card publication missing'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'foreign-final-graph'; failure = 'Delete Loop same card did not return to its visible canvas after Show in Graph and Fit Canvas'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card' }, + @{ name = 'lost-selection'; failure = 'Delete Loop Show in Graph changed the selected card identity'; order = 'foreground,wait-route,show,reconnect,lookup:canvas-card-owned,lookup:fit-canvas,fit,wait-card,selected' } +) +foreach ($test in $restoreCases) { + Invoke-DeleteCase "ui.delete.restore.$($test.name)" { + $definition = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Restore-DeleteLoopCanvas' + }, $true)) + Assert-Delete ($definition.Count -eq 1) 'Expected one actual Delete canvas restoration helper' + & (Join-Path $RepositoryRoot 'Tools\windows\Import-CompiledValidationAssembly.ps1') ` + -AssemblyPath $AssemblyPath -RequiredType 'GraphCodeUiaGateState' | Out-Null + Assert-Delete ($definition[0].Extent.Text -notmatch '\[GraphCode[^\]]*\]::|SendInput|PostMouse|Start-Process|Add-Type') ` + 'Restoration fixture must exercise the production route without direct native effects' + . ([scriptblock]::Create($definition[0].Extent.Text)) + $state = @{ name = $test.name; order = [Collections.Generic.List[string]]::new(); waits = 0; reconnects = 0 } + $process = [pscustomobject]@{ Id = 4242 } + $initialRoot = [pscustomobject]@{ label = 'workspace-root' } + $restoredRoot = [pscustomobject]@{ label = 'restored-root' } + $finalRoot = [pscustomobject]@{ label = 'final-root' } + $ownedWalker = [pscustomobject]@{ fixture = 'owned-walker' } + function Require([bool] $condition, [string] $message) { if (-not $condition) { throw [InvalidOperationException]::new($message) } } + function New-RestoreElement([string] $Kind) { + $id = switch ($Kind) { route { 'workspace-show-graph-owned' } fit { 'fit-canvas' } graph { 'graph' } default { 'canvas-card-owned' } } + if (($Kind -ceq 'card' -and $state.name -ceq 'wrong-card-id') -or + ($Kind -ceq 'final-card' -and $state.name -ceq 'wrong-final-id')) { $id = 'canvas-card-foreign' } + if ($Kind -ceq 'fit' -and $state.name -ceq 'wrong-fit-id') { $id = 'fit-foreign' } + $owner = if (($Kind -ceq 'route' -and $state.name -ceq 'foreign-route') -or + ($Kind -ceq 'card' -and $state.name -ceq 'foreign-card') -or + ($Kind -ceq 'fit' -and $state.name -ceq 'foreign-fit') -or + ($Kind -ceq 'final-card' -and $state.name -ceq 'foreign-final-card') -or + ($Kind -ceq 'graph' -and $state.name -ceq 'foreign-final-graph')) { 7777 } else { 4242 } + $label = switch ($Kind) { route { 'Show in Graph' } fit { 'Fit canvas' } default { 'fixture' } } + $element = [pscustomobject]@{ + Kind = $Kind; State = $state + Current = [pscustomobject]@{ + AutomationId = $id; Name = $label; ProcessId = $owner + IsOffscreen = ($Kind -ceq 'route' -and $state.name -ceq 'offscreen-route') -or + ($Kind -ceq 'final-card' -and $state.name -ceq 'offscreen-final-card') + } + } + $element | Add-Member ScriptMethod GetCurrentPattern { + param($pattern) + if ($this.Kind -cin @('route','fit')) { + if ($pattern -ne [System.Windows.Automation.InvokePattern]::Pattern) { throw 'Unexpected route pattern' } + $invoke = [pscustomobject]@{ Kind = $this.Kind; State = $this.State } + $invoke | Add-Member ScriptMethod Invoke { + $this.State.order.Add($(if ($this.Kind -ceq 'route') { 'show' } else { 'fit' })) + } + return $invoke + } + if ($pattern -ne [System.Windows.Automation.SelectionItemPattern]::Pattern -or $this.Kind -cne 'final-card') { + throw 'Unexpected selection pattern' + } + $this.State.order.Add('selected') + return [pscustomobject]@{ Current = [pscustomobject]@{ IsSelected = $this.State.name -cne 'lost-selection' } } + } + return $element + } + function Ensure-ShellForeground($Owner, $Label) { + Assert-Delete ($Owner -eq [IntPtr]42 -and $Label -ceq 'Delete Loop Show in Graph') 'Wrong foreground authority' + $state.order.Add('foreground') + return $state.name -cne 'foreground-refused' + } + function Wait-ForRootReconnect($Process, $Walkers) { + Assert-Delete ($Process.Id -eq 4242 -and $Walkers.Count -eq 1 -and [object]::ReferenceEquals($Walkers[0],$ownedWalker)) 'Wrong reconnect authority' + $state.order.Add('reconnect'); $state.reconnects++ + if ($state.reconnects -eq 1) { return $restoredRoot } + return $finalRoot + } + function Find-FragmentByIdWithRetry($Root, [string] $Id, $Walker) { + Assert-Delete ([object]::ReferenceEquals($Root,$restoredRoot) -and [object]::ReferenceEquals($Walker,$ownedWalker)) 'Lookup used a stale workspace root' + $state.order.Add("lookup:$Id") + if ($Id -ceq 'canvas-card-owned') { + if ($state.name -ceq 'missing-card') { return $null } + return New-RestoreElement card + } + Assert-Delete ($Id -ceq 'fit-canvas') 'Lookup changed the selected card identity' + return New-RestoreElement fit + } + function Wait-ForGraphChildren($Root, $Walker, [scriptblock] $Filter, [scriptblock] $Ready) { + Assert-Delete ([object]::ReferenceEquals($Walker,$ownedWalker)) 'Graph wait changed walker' + $state.waits++ + if ($state.waits -eq 1) { + Assert-Delete ([object]::ReferenceEquals($Root,$initialRoot)) 'Route wait did not use selected workspace root' + $state.order.Add('wait-route') + $children = if ($state.name -ceq 'missing-route') { @() } else { @(New-RestoreElement route) } + $items = @($children | Where-Object $Filter) + if (-not (& $Ready $items)) { throw [InvalidOperationException]::new('controlled route publication missing') } + } else { + Assert-Delete ($state.waits -eq 2 -and [object]::ReferenceEquals($Root,$restoredRoot)) 'Card wait did not use reconnected graph root' + $state.order.Add('wait-card') + $children = @(New-RestoreElement final-card) + if ($state.name -ceq 'retained-workspace') { $children += New-RestoreElement route } + $items = @($children | Where-Object $Filter) + if (-not (& $Ready $items)) { throw [InvalidOperationException]::new('controlled restored card publication missing') } + } + return [pscustomobject]@{ Graph = (New-RestoreElement graph); Items = $items; Children = $children } + } + $caught = $null + $actualRoot = $null + try { $actualRoot = Restore-DeleteLoopCanvas $process $initialRoot ([IntPtr]42) 'canvas-card-owned' $ownedWalker } + catch { $caught = $_ } + Assert-Delete (($state.order -join ',') -ceq $test.order) "Restoration observation/action order differs: $($state.order -join ',')" + if ($null -eq $test.failure) { + Assert-Delete ($null -eq $caught -and [object]::ReferenceEquals($actualRoot,$finalRoot)) 'Restoration did not return the final freshly reconnected root' + } else { + Assert-Delete ($caught -and $caught.Exception.Message -ceq $test.failure -and $null -eq $actualRoot) ` + "Restoration refusal changed: $($caught.Exception.Message)" + } + } +} +$stressRemoval = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Remove-Job' -and + @($node.CommandElements | Where-Object { $_.Extent.Text -ceq '$stressJob' }).Count -eq 1 +}, $true) | Sort-Object { $_.Extent.StartOffset }) +function Get-StressReleaseStatements { + Assert-Delete ($stressRemoval.Count -eq 2) 'Expected primary and final owned stress-job removal' + $remove = $stressRemoval[0].Parent + while ($remove.Parent -isnot [Management.Automation.Language.StatementBlockAst]) { $remove = $remove.Parent } + $siblings = @($remove.Parent.Statements) + $position = [Array]::IndexOf($siblings, $remove) + Assert-Delete ($position -ge 0 -and $position + 1 -lt $siblings.Count) 'Primary stress removal has no following ownership update' + $clear = $siblings[$position + 1] + Assert-Delete ($clear -is [Management.Automation.Language.AssignmentStatementAst] -and + $clear.Left.Extent.Text -ceq '$stressJob' -and $clear.Right.Extent.Text -ceq '$null') ` + 'Successful stress removal must immediately clear its retained job reference' + return @($remove, $clear) +} +Invoke-DeleteCase 'ui.delete.cleanup.source-order' { + $statements = @(Get-StressReleaseStatements) + $guard = $stressRemoval[1].Parent + while ($null -ne $guard -and $guard -isnot [Management.Automation.Language.IfStatementAst]) { $guard = $guard.Parent } + Assert-Delete ($stressRemoval[1].Extent.Text -match '-ErrorAction Stop' -and + $null -ne $guard -and $guard.Clauses.Count -eq 1 -and + $guard.Clauses[0].Item1.Extent.Text -ceq '$stressJob') 'Final cleanup must retain its live-reference guard and terminating removal' + Assert-Delete ($statements.Count -eq 2) 'Stress release source statement count changed' +} +foreach ($removalFails in @($false, $true)) { + $id = if ($removalFails) { 'ui.delete.cleanup.failed-removal-retains' } else { 'ui.delete.cleanup.success-clears' } + Invoke-DeleteCase $id { + $statements = @(Get-StressReleaseStatements) + $state = @{ calls = 0; fail = $removalFails; original = [IO.IOException]::new('controlled stress removal failure') } + $ownedJob = [pscustomobject]@{ fixture = 'owned-stress-job' } + $stressJob = $ownedJob + function Remove-Job { + param($Job, [switch] $Force) + Assert-Delete ([object]::ReferenceEquals($Job, $ownedJob) -and $Force) 'Removal did not target the retained owned job' + $state.calls++ + if ($state.fail) { throw $state.original } + } + $caught = $null + try { . ([scriptblock]::Create(($statements | ForEach-Object { $_.Extent.Text }) -join "`n")) } catch { $caught = $_ } + Assert-Delete ($state.calls -eq 1) 'Stress removal replayed or was skipped' + if ($removalFails) { + Assert-Delete ($caught -and [object]::ReferenceEquals($caught.Exception, $state.original) -and + [object]::ReferenceEquals($stressJob, $ownedJob)) 'Failed stress removal cleared authority or replaced its original failure' + } else { + Assert-Delete ($null -eq $caught -and $null -eq $stressJob) 'Successful stress removal left a stale reference' + } + } +} +Assert-Delete ($restoreCases.Count -eq 16 -and $results.Count -eq 50) ` + "Delete restoration and cleanup require twenty additional cases without replacing the original thirty: scenarios=$($restoreCases.Count) results=$($results.Count)" [IO.File]::WriteAllText((Join-Path $ArtifactRoot 'ui-delete-results.json'), ($results | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) foreach ($result in $results) { Write-Output ('UI_CASE_RESULT=' + ($result | ConvertTo-Json -Compress -Depth 8)) } if (@($results | Where-Object status -cne 'Passed').Count -ne 0) { exit 1 } From 0bf9e393e4defa427aa8d0c678e48e28d7412482 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 20:28:21 -0700 Subject: [PATCH 44/46] Use exact control root authority while preserving immediate-parent hit resolution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Manifests/ui.json | 5 + .../Ui/ControlClickTopologySuite.cs | 108 +++++++++++++++++ .../Fixtures/ControlClickTopologyFixture.cs | 113 ++++++++++++++++++ .../Ui/Production/GraphCodeUiaGateState.cs | 35 +++++- .../ValidationRunner/Ui/SuiteRegistration.cs | 2 +- 5 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs create mode 100644 Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index aa2bdbbc..1586a752 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,5 +1,10 @@ { "units": [ + { "id": "ui.control-click.flat-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.nested-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.same-pid-unrelated-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.mismatched-parent", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.control-click.foreign-root", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.delete.restore.source-order", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.delete.restore.owned-navigation", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.delete.restore.foreground-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs b/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs new file mode 100644 index 00000000..c93b0319 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using GraphCode.Validation.Ui.Fixtures; +using GraphCode.Windows; + +namespace GraphCode.Validation.Ui; + +public sealed class ControlClickTopologySuite : IValidationSuite +{ + public string Id => "ui.control-click-topology"; + public IReadOnlyList UnitIds { get; } = new[] + { + "ui.control-click.flat-root", "ui.control-click.nested-root", + "ui.control-click.same-pid-unrelated-root", "ui.control-click.mismatched-parent", + "ui.control-click.foreign-root" + }; + + public Task> RunAsync(ValidationContext context, CancellationToken cancellationToken) + { + var results = new List(); + MethodInfo rootMethod = typeof(GraphCodeUiaGateState).GetMethod("RequireControlClickRoot", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Actual production control root validation missing"); + MethodInfo bridgeMethod = typeof(GraphCodeUiaGateState).GetMethod("ResolvesToControl", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Actual production control hit bridge missing"); + string cls = "GraphCodeControlTopology" + Guid.NewGuid().ToString("N"); + PromptLookupFixture.Register(cls); + var roots = new List(); + try + { + GraphCodeUiaGateState.RetainProcess(Process.GetCurrentProcess()); + IntPtr root = PromptLookupFixture.Create(cls, IntPtr.Zero, 0); + roots.Add(root); + IntPtr unrelated = PromptLookupFixture.Create(cls, IntPtr.Zero, 0); + roots.Add(unrelated); + IntPtr flat = PromptLookupFixture.Create("BUTTON", root, 101); + IntPtr viewport = PromptLookupFixture.Create("STATIC", root, 102); + IntPtr parent = PromptLookupFixture.Create("STATIC", viewport, 103); + IntPtr nested = PromptLookupFixture.Create("BUTTON", parent, 104); + IntPtr sibling = PromptLookupFixture.Create("STATIC", viewport, 105); + IntPtr ResolveRoot(IntPtr target, IntPtr owner) => (IntPtr)rootMethod.Invoke(null, new object[] { target, owner })!; + void Check(string id, Action assertion) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + assertion(); + results.Add(new UnitResult(id, UnitStatus.Passed, 1, + "Real hidden Win32 flat/nested topology; actual production root/bridge; no activation, cursor movement or injected input")); + } + catch (Exception failure) { results.Add(new UnitResult(id, UnitStatus.Failed, 1, failure.ToString())); } + } + Check(UnitIds[0], () => ActivationCases.Require(ResolveRoot(flat, root) == root && + !PromptLookupFixture.IsWindowVisible(root), "Flat control did not retain its exact hidden root")); + Check(UnitIds[1], () => ActivationCases.Require(parent != root && ResolveRoot(nested, parent) == root && + !PromptLookupFixture.IsWindowVisible(root), "Nested content child was mistaken for the exact root")); + void Refuse(IntPtr owner) + { + bool refused = false; + try { ResolveRoot(nested, owner); } + catch (TargetInvocationException failure) when (failure.InnerException is InvalidOperationException error && + error.Message == "Control click target lost its exact parent/root ownership; no input was injected") { refused = true; } + var point = new UiaInputSafety.ScreenPoint { X = 5, Y = 5 }; + bool hit = (bool)bridgeMethod.Invoke(null, new object[] { nested, owner, point })!; + ActivationCases.Require(refused && !hit, "Invalid owner reached the actual control input boundary"); + } + Check(UnitIds[2], () => Refuse(unrelated)); + Check(UnitIds[3], () => Refuse(sibling)); + Check(UnitIds[4], () => Refuse(ControlClickTopologyFixture.GetDesktopWindow())); + } + finally + { + foreach (IntPtr root in roots) + if (!PromptLookupFixture.DestroyWindow(root)) throw new InvalidOperationException("Owned control topology fixture cleanup failed"); + } + return Task.FromResult>(results); + } + + public static void RunPhysicalFixtures() + { + Console.WriteLine($"CONTROL_CLICK_FIXTURE_PID={Environment.ProcessId}"); + foreach (bool nested in new[] { false, true }) + { + using var fixture = new ControlClickTopologyFixture(nested); + fixture.Foreground(); + if (nested) + { + var bridge = typeof(GraphCodeUiaGateState).GetMethod("ResolvesToControl", BindingFlags.NonPublic | BindingFlags.Static)!; + var point = new UiaInputSafety.ScreenPoint { X = (fixture.Bounds[0] + fixture.Bounds[2]) / 2, + Y = (fixture.Bounds[1] + fixture.Bounds[3]) / 2 }; + bool Resolve(IntPtr owner) => fixture.Run(() => (bool)bridge.Invoke(null, new object[] { fixture.Target, owner, point })!); + ActivationCases.Require(Resolve(fixture.Parent) && Resolve(fixture.Root), + "Measured parent and verified exact root must resolve through the same immediate parent"); + ActivationCases.Require(!Resolve(fixture.Unrelated) && !Resolve(fixture.Sibling) && + !Resolve(ControlClickTopologyFixture.GetDesktopWindow()) && fixture.ClickCount == 0, + "Unrelated root or mismatched parent was accepted before any input"); + } + var result = fixture.Run(() => GraphCodeUiaGateState.ClickScreenPoint(fixture.Target)); + ActivationCases.Require(result is not null && result.HitTarget && result.SameTopLevel, + "Actual control click did not preserve the owned target/root hit"); + fixture.ExpectClick(); + Console.WriteLine($"CONTROL_CLICK_ACTUAL_TOPOLOGY={(nested ? "nested" : "flat")} TARGET_CLICKS=1"); + } + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs new file mode 100644 index 00000000..6305ebe2 --- /dev/null +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs @@ -0,0 +1,113 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; + +namespace GraphCode.Validation.Ui.Fixtures; + +internal sealed class ControlClickTopologyFixture : IDisposable +{ + private readonly Thread thread; + private readonly ManualResetEventSlim ready = new(false); + private Exception? startupFailure; + private Form? window; + private Form? unrelated; + private Panel? content; + private Panel? sibling; + private Button? target; + private readonly ManualResetEventSlim clicked = new(false); + internal IntPtr Root { get; private set; } + internal IntPtr Parent { get; private set; } + internal IntPtr Target { get; private set; } + internal IntPtr Sibling { get; private set; } + internal IntPtr Unrelated { get; private set; } + internal int[] Bounds { get; private set; } = Array.Empty(); + internal int ClickCount; + + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr window); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] internal static extern IntPtr GetDesktopWindow(); + [DllImport("user32.dll")] private static extern bool GetCursorPos(out NativePoint point); + [DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y); + [StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X, Y; } + private NativePoint initialCursor; + private bool cursorSaved; + + internal ControlClickTopologyFixture(bool nested) + { + thread = new Thread(() => + { + try + { + window = new Form { Text = "Owned control click fixture", StartPosition = FormStartPosition.Manual, + Left = 120, Top = 120, Width = 480, Height = 360, ShowInTaskbar = false }; + unrelated = new Form { Text = "Unrelated owned root fixture", StartPosition = FormStartPosition.Manual, + Left = 650, Top = 120, Width = 160, Height = 120, ShowInTaskbar = false }; + content = new Panel { Left = 20, Top = 25, Width = 400, Height = 250 }; + sibling = new Panel { Left = 350, Top = 280, Width = 30, Height = 20 }; + target = new Button { Left = 40, Top = 45, Width = 160, Height = 60, Text = "Owned target" }; + if (nested) + { + var viewport = new Panel { Dock = DockStyle.Fill }; + window.Controls.Add(viewport); + viewport.Controls.Add(content); + content.Controls.Add(target); + } + else window.Controls.Add(target); + window.Controls.Add(sibling); + target.MouseDown += (_, _) => { Interlocked.Increment(ref ClickCount); clicked.Set(); }; + window.Shown += (_, _) => + { + Root = window.Handle; + Parent = target.Parent!.Handle; + Target = target.Handle; + Sibling = sibling.Handle; + Unrelated = unrelated.Handle; + var rectangle = target.RectangleToScreen(target.ClientRectangle); + Bounds = new[] { rectangle.Left, rectangle.Top, rectangle.Right, rectangle.Bottom }; + ready.Set(); + }; + Application.Run(window); + } + catch (Exception failure) { startupFailure = failure; ready.Set(); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!ready.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException("Owned control fixture did not create its windows"); + if (startupFailure is not null) throw new InvalidOperationException("Owned control fixture startup failed", startupFailure); + GraphCodeUiaGateState.RetainProcess(Process.GetCurrentProcess()); + } + + internal T Run(Func action) => (T)window!.Invoke(action); + + internal void Foreground() + { + cursorSaved = GetCursorPos(out initialCursor); + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < 2000) + { + Run(() => { window!.Activate(); return GraphCodeUiaGateState.ActivateWindow(Root); }); + if (GetForegroundWindow() == Root) return; + Thread.Sleep(25); + } + throw new InvalidOperationException($"Owned fixture could not acquire exact foreground; {GraphCodeUiaGateState.LastActivationDiagnostic}"); + } + + internal void ExpectClick() + { + if (!clicked.Wait(TimeSpan.FromSeconds(3)) || Volatile.Read(ref ClickCount) != 1) + throw new InvalidOperationException($"Owned fixture expected exactly one physical target click; actual={ClickCount}"); + } + + public void Dispose() + { + if (window is not null && !window.IsDisposed) + window.Invoke(() => { unrelated?.Dispose(); window.Close(); }); + if (!thread.Join(TimeSpan.FromSeconds(10))) throw new TimeoutException("Owned control fixture UI thread did not exit"); + if (cursorSaved && !SetCursorPos(initialCursor.X, initialCursor.Y)) + throw new InvalidOperationException("Could not restore the fixture's original cursor position"); + clicked.Dispose(); + ready.Dispose(); + } +} diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs index ee8952c9..ddc3a0c9 100644 --- a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs @@ -859,12 +859,34 @@ private static IntPtr ResolveChild(IntPtr dialog, ScreenPoint point) { return RealChildWindowFromPoint(dialog, clientPoint); } private static bool ResolvesToControl(IntPtr target, IntPtr dialog, ScreenPoint point) { + IntPtr parent = GetAncestor(target, 1); + IntPtr root = GetAncestor(target, 2); + uint processId = WindowProcessId(target); + RECT bounds; + if (target == IntPtr.Zero || parent == IntPtr.Zero || root == IntPtr.Zero || processId == 0 || + (dialog != parent && dialog != root) || GetAncestor(parent, 2) != root || + WindowProcessId(parent) != processId || WindowProcessId(root) != processId || + !IsRetainedProcessAlive(processId) || !GetWindowRect(target, out bounds) || + point.X < bounds.Left || point.X >= bounds.Right || point.Y < bounds.Top || point.Y >= bounds.Bottom) + return false; IntPtr atPoint = WindowFromPoint(point); - if (atPoint == IntPtr.Zero || GetAncestor(atPoint, 2) != GetAncestor(target, 2)) return false; + if (atPoint == IntPtr.Zero || GetAncestor(atPoint, 2) != root || WindowProcessId(atPoint) != processId) return false; + dialog = parent; var clientPoint = point; - if (!ScreenToClient(dialog, ref clientPoint)) return false; + if (!ScreenToClient(dialog, ref clientPoint) || GetAncestor(target, 1) != parent || + GetAncestor(target, 2) != root || WindowProcessId(target) != processId) return false; return RealChildWindowFromPoint(dialog, clientPoint) == target; } + private static IntPtr RequireControlClickRoot(IntPtr target, IntPtr parent) { + IntPtr root = GetAncestor(target, 2); + uint processId = WindowProcessId(target); + if (target == IntPtr.Zero || parent == IntPtr.Zero || root == IntPtr.Zero || + GetAncestor(target, 1) != parent || GetAncestor(parent, 2) != root || + processId == 0 || WindowProcessId(parent) != processId || WindowProcessId(root) != processId || + !IsRetainedProcessAlive(processId)) + throw new InvalidOperationException("Control click target lost its exact parent/root ownership; no input was injected"); + return root; + } private static string ClassOf(IntPtr window) { if (window == IntPtr.Zero) return ""; var name = new StringBuilder(128); @@ -1024,8 +1046,9 @@ public static ControlClick ClickScreenPoint(IntPtr target) { hit.Samples = samples; } if (!hit.HitTarget) return hit; - uint expectedProcessId = WindowProcessId(dialog); - RequireOwnedForeground(dialog, expectedProcessId); + IntPtr inputRoot = RequireControlClickRoot(target, dialog); + uint expectedProcessId = WindowProcessId(inputRoot); + RequireOwnedForeground(inputRoot, expectedProcessId); ScreenPoint before, at; if (!GetCursorPos(out before)) throw new InvalidOperationException(String.Format("GetCursorPos before control click failed: Win32Error={0}", Marshal.GetLastWin32Error())); @@ -1041,7 +1064,9 @@ public static ControlClick ClickScreenPoint(IntPtr target) { new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0002 } }, new Input { Type = 0, Mouse = new MouseInput { Flags = 0x0004 } } }; - RequireOwnedMouseInput(dialog, expectedProcessId, center, target); + if (RequireControlClickRoot(target, dialog) != inputRoot) + throw new InvalidOperationException("Control click target root changed; no input was injected"); + RequireOwnedMouseInput(inputRoot, expectedProcessId, center, target); uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); if (sent != inputs.Length) throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} control mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index 21ae6744..30ad437a 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -10,7 +10,7 @@ public static class SuiteRegistration { public static IReadOnlyList CreateSuites() => new IValidationSuite[] { - new ActivationSuite(), new ManagedUiSuite(), new NativeUiSuite() + new ActivationSuite(), new ManagedUiSuite(), new NativeUiSuite(), new ControlClickTopologySuite() }.Concat(UiAdapterCases.CreateSuites()).ToArray(); } From 5a7a3bd4c1a227a81eae3c330c462b99d24b9351 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 20:29:41 -0700 Subject: [PATCH 45/46] Limit the explicit control click fixture to one activation attempt Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Ui/ControlClickTopologySuite.cs | 2 ++ .../Fixtures/ControlClickTopologyFixture.cs | 22 +++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs b/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs index c93b0319..53297ca3 100644 --- a/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs +++ b/Tools/windows/ValidationRunner/Ui/ControlClickTopologySuite.cs @@ -85,6 +85,7 @@ public static void RunPhysicalFixtures() foreach (bool nested in new[] { false, true }) { using var fixture = new ControlClickTopologyFixture(nested); + Console.WriteLine($"CONTROL_CLICK_FIXTURE_HOST_TITLE={ControlClickTopologyFixture.HostTitle} ROOT={fixture.Root} TOPOLOGY={(nested ? "nested" : "flat")}"); fixture.Foreground(); if (nested) { @@ -98,6 +99,7 @@ public static void RunPhysicalFixtures() !Resolve(ControlClickTopologyFixture.GetDesktopWindow()) && fixture.ClickCount == 0, "Unrelated root or mismatched parent was accepted before any input"); } + fixture.BeginInput(); var result = fixture.Run(() => GraphCodeUiaGateState.ClickScreenPoint(fixture.Target)); ActivationCases.Require(result is not null && result.HitTarget && result.SameTopLevel, "Actual control click did not preserve the owned target/root hit"); diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs index 6305ebe2..d5a7bcae 100644 --- a/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/ControlClickTopologyFixture.cs @@ -8,6 +8,7 @@ namespace GraphCode.Validation.Ui.Fixtures; internal sealed class ControlClickTopologyFixture : IDisposable { + internal const string HostTitle = "Owned control click fixture"; private readonly Thread thread; private readonly ManualResetEventSlim ready = new(false); private Exception? startupFailure; @@ -25,7 +26,6 @@ internal sealed class ControlClickTopologyFixture : IDisposable internal int[] Bounds { get; private set; } = Array.Empty(); internal int ClickCount; - [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr window); [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] internal static extern IntPtr GetDesktopWindow(); [DllImport("user32.dll")] private static extern bool GetCursorPos(out NativePoint point); @@ -40,7 +40,7 @@ internal ControlClickTopologyFixture(bool nested) { try { - window = new Form { Text = "Owned control click fixture", StartPosition = FormStartPosition.Manual, + window = new Form { Text = HostTitle, StartPosition = FormStartPosition.Manual, Left = 120, Top = 120, Width = 480, Height = 360, ShowInTaskbar = false }; unrelated = new Form { Text = "Unrelated owned root fixture", StartPosition = FormStartPosition.Manual, Left = 650, Top = 120, Width = 160, Height = 120, ShowInTaskbar = false }; @@ -83,15 +83,15 @@ internal ControlClickTopologyFixture(bool nested) internal void Foreground() { - cursorSaved = GetCursorPos(out initialCursor); - var deadline = Stopwatch.StartNew(); - while (deadline.ElapsedMilliseconds < 2000) - { - Run(() => { window!.Activate(); return GraphCodeUiaGateState.ActivateWindow(Root); }); - if (GetForegroundWindow() == Root) return; - Thread.Sleep(25); - } - throw new InvalidOperationException($"Owned fixture could not acquire exact foreground; {GraphCodeUiaGateState.LastActivationDiagnostic}"); + Run(() => GraphCodeUiaGateState.ActivateWindow(Root)); + if (GetForegroundWindow() != Root) + throw new InvalidOperationException($"Owned fixture could not acquire exact foreground; {GraphCodeUiaGateState.LastActivationDiagnostic}"); + } + + internal void BeginInput() + { + if (!GetCursorPos(out initialCursor)) throw new InvalidOperationException("Could not retain the fixture's original cursor position"); + cursorSaved = true; } internal void ExpectClick() From 90479b38995aba1b338cfa48b86159b0b2d1e95d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 2 Nov 2026 22:43:13 -0700 Subject: [PATCH 46/46] Publish compiled assembly at the real Windows validation boundary Build and validate once for a genuinely missing assembly environment entry, preserve supplied and empty semantics, and restore only the process publication after the task. Exercise native child inheritance and failure identity, and keep the strict fixture loader unchanged. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CompiledValidationEnvironment.Tests.ps1 | 191 ++++++++++++++++++ .../Adapters/LegacyOwnership.Contracts.ps1 | 9 +- .../Adapters/StandaloneSuites.cs | 2 + .../ValidationRunner/Manifests/host.json | 1 + Tools/windows/validate.ps1 | 41 +++- Tools/windows/validation-matrix.md | 6 + 6 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 diff --git a/Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 b/Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 new file mode 100644 index 00000000..2c4d6a5d --- /dev/null +++ b/Tools/windows/Tests/CompiledValidationEnvironment.Tests.ps1 @@ -0,0 +1,191 @@ +[CmdletBinding()] +param([string] $Worker, [string] $WorkspaceRoot, [string] $ProducerPath) + +$ErrorActionPreference = 'Stop' +function Import-CompiledEnvironmentProducer([string] $Path) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw 'Compiled environment producer does not parse' } + $functions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-WithCompiledValidationAssembly' + }, $true)) + if ($functions.Count -ne 1) { throw 'Expected exactly one compiled environment boundary' } + [scriptblock]::Create($functions[0].Extent.Text) +} +if ($Worker -eq 'empty') { + . (Import-CompiledEnvironmentProducer $ProducerPath) + $before = [Environment]::GetEnvironmentVariables('Process') + if (-not $before.Contains('GRAPHCODE_VALIDATION_ASSEMBLY') -or + [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY') -ne '') { + throw 'Child did not receive an explicitly empty environment entry' + } + $state = @{ ran = $false } + $failure = $null + try { + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $WorkspaceRoot -Action { $state.ran = $true } + } catch { $failure = $_ } + if ($null -eq $failure -or $failure.Exception.Message -notmatch 'explicitly empty' -or $state.ran -or + -not [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') -or + [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY') -ne '') { + throw 'Explicit empty entry was replaced, removed, or admitted' + } + Write-Output 'COMPILED_ENV_EMPTY_PRESERVED=1' + exit 0 +} +. (Join-Path $PSScriptRoot '..\Import-PinnedPester.ps1') + +Describe 'Scoped compiled assembly publication at the actual Windows caller boundary' { + BeforeAll { + $repository = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + $producer = Join-Path $repository 'Tools\windows\validate.ps1' + $testScript = Join-Path $PSScriptRoot 'CompiledValidationEnvironment.Tests.ps1' + $testAst = [Management.Automation.Language.Parser]::ParseFile($testScript, [ref]$null, [ref]$null) + $loader = $testAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Import-CompiledEnvironmentProducer' + }, $true) + . ([scriptblock]::Create($loader.Extent.Text)) + . (Import-CompiledEnvironmentProducer $producer) + } + BeforeEach { + $present = [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') + $saved = [Environment]::GetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY') + if ($present) { Remove-Item -LiteralPath 'Env:\GRAPHCODE_VALIDATION_ASSEMBLY' -ErrorAction Stop } + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $scripts = Join-Path $root 'Tools\windows' + $null = New-Item -ItemType Directory -Force -Path $scripts + $assembly = Join-Path $root 'ValidationRunner.dll' + [IO.File]::WriteAllBytes($assembly, [byte[]]@()) + $events = Join-Path $root 'events.txt' + $settings = Join-Path $root 'settings.json' + @{ assembly = $assembly; events = $events; mode = 'normal' } | ConvertTo-Json | + Set-Content -LiteralPath $settings + @' +$s = Get-Content (Join-Path $PSScriptRoot '..\..\settings.json') -Raw | ConvertFrom-Json +Add-Content -LiteralPath $s.events -Value 'build' +if ($s.mode -eq 'build-fail') { throw [IO.IOException]::new('controlled compiler failure') } +$s.assembly +'@ | Set-Content -LiteralPath (Join-Path $scripts 'Build-CompiledValidationRunner.ps1') + @' +param([string] $AssemblyPath, [string] $RequiredType) +$s = Get-Content (Join-Path $PSScriptRoot '..\..\settings.json') -Raw | ConvertFrom-Json +Add-Content -LiteralPath $s.events -Value 'load' +if ($s.mode -eq 'load-fail') { throw [IO.IOException]::new('controlled provenance failure') } +if ($AssemblyPath -cne $s.assembly -or $RequiredType -cne 'ShellOwnedLaunch') { + throw 'Unexpected current assembly loader arguments' +} +'@ | Set-Content -LiteralPath (Join-Path $scripts 'Import-CompiledValidationAssembly.ps1') + } + AfterEach { + if ($present) { [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY', $saved, 'Process') } + elseif ([Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY')) { + Remove-Item -LiteralPath 'Env:\GRAPHCODE_VALIDATION_ASSEMBLY' -ErrorAction Stop + } + } + It 'builds and validates once before publishing a missing entry, then removes only its publication' { + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { + $env:GRAPHCODE_VALIDATION_ASSEMBLY | Should -BeExactly $assembly + $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in @('-NoProfile', '-Command', + '[Environment]::GetEnvironmentVariable("GRAPHCODE_VALIDATION_ASSEMBLY")')) { + $start.ArgumentList.Add($argument) + } + $child = [Diagnostics.Process]::Start($start) + try { + $child.WaitForExit(15000) | Should -BeTrue + $child.ExitCode | Should -Be 0 -Because ($child.StandardError.ReadToEnd()) + $child.StandardOutput.ReadToEnd().Trim() | Should -BeExactly $assembly + } finally { $child.Dispose() } + Add-Content -LiteralPath $events -Value 'action' + } + (Get-Content $events) -join ',' | Should -BeExactly 'build,load,action' + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'validates but does not replace or rebuild a supplied assembly entry' { + [Environment]::SetEnvironmentVariable('GRAPHCODE_VALIDATION_ASSEMBLY', $assembly, 'Process') + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { + $env:GRAPHCODE_VALIDATION_ASSEMBLY | Should -BeExactly $assembly + } + (Get-Content $events) -join ',' | Should -BeExactly 'load' + $env:GRAPHCODE_VALIDATION_ASSEMBLY | Should -BeExactly $assembly + } + It 'preserves and refuses an explicitly empty native child entry without compiling or running the action' { + $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Environment['GRAPHCODE_VALIDATION_ASSEMBLY'] = '' + foreach ($argument in @('-NoProfile', '-File', $testScript, '-Worker', 'empty', + '-WorkspaceRoot', $root, '-ProducerPath', $producer)) { $start.ArgumentList.Add($argument) } + $child = [Diagnostics.Process]::Start($start) + try { + $child.WaitForExit(15000) | Should -BeTrue + $stdout = $child.StandardOutput.ReadToEnd() + $stderr = $child.StandardError.ReadToEnd() + $child.ExitCode | Should -Be 0 -Because $stderr + $stdout.Trim() | Should -BeExactly 'COMPILED_ENV_EMPTY_PRESERVED=1' + Test-Path -LiteralPath $events | Should -BeFalse + } finally { $child.Dispose() } + } + It 'keeps compiler failure primary and does not publish or invoke the action' { + @{ assembly = $assembly; events = $events; mode = 'build-fail' } | ConvertTo-Json | Set-Content $settings + { Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { throw 'unexpected action' } } | + Should -Throw '*controlled compiler failure*' + (Get-Content $events) -join ',' | Should -BeExactly 'build' + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'refuses failed current-source provenance before publishing or running the action' { + @{ assembly = $assembly; events = $events; mode = 'load-fail' } | ConvertTo-Json | Set-Content $settings + { Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { throw 'unexpected action' } } | + Should -Throw '*controlled provenance failure*' + (Get-Content $events) -join ',' | Should -BeExactly 'build,load' + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'preserves action exception identity while restoring the originally missing entry' { + $expected = [IO.IOException]::new('controlled action failure') + $failure = $null + try { + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $root -Action { + Remove-Item -LiteralPath 'Env:\GRAPHCODE_VALIDATION_ASSEMBLY' -ErrorAction Stop + throw $expected + } + } + catch { $failure = $_ } + [object]::ReferenceEquals($failure.Exception, $expected) | Should -BeTrue + [Environment]::GetEnvironmentVariables('Process').Contains('GRAPHCODE_VALIDATION_ASSEMBLY') | Should -BeFalse + } + It 'wraps the actual Windows task exactly once inside profile isolation and only outside dry run' { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($producer, [ref]$tokens, [ref]$errors) + $calls = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-WithCompiledValidationAssembly' + }, $true)) + $calls.Count | Should -Be 1 + $blocks = @($calls[0].CommandElements | Where-Object { + $_ -is [Management.Automation.Language.ScriptBlockExpressionAst] + }) + $blocks.Count | Should -Be 1 + $blocks[0].Extent.Text | Should -Match 'Invoke-Task "windows-shell"' + $isolation = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-WindowsShellValidationIsolation' -and + $node.Extent.StartOffset -le $calls[0].Extent.StartOffset -and + $node.Extent.EndOffset -ge $calls[0].Extent.EndOffset + }, $true)) + $isolation.Count | Should -Be 1 + ([IO.File]::ReadAllText($producer)) | Should -Match '\$name -eq "windows-shell" -and -not \$DryRun' + } +} diff --git a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 index 6ed64712..a5ac2d5b 100644 --- a/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Adapters/LegacyOwnership.Contracts.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory)][string] $RepositoryRoot, [Parameter(Mandatory)][string] $ArtifactRoot, [Parameter(Mandatory)][string] $AssemblyPath, - [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup", "sdk-prerequisites")][string] $Contract + [Parameter(Mandatory)][ValidateSet("shell-scope", "live-adapter", "live-cleanup", "sdk-prerequisites", "compiled-environment")][string] $Contract ) $ErrorActionPreference = "Stop" $tests = Join-Path $RepositoryRoot "Tools\windows\Tests" @@ -22,6 +22,7 @@ try { . (Join-Path $RepositoryRoot "Tools\windows\Import-PinnedPester.ps1") $files = if ($Contract -ceq "shell-scope") { @("ShellProcessOwnership.Tests.ps1") } ` elseif ($Contract -ceq "sdk-prerequisites") { @("DotNetSdk.Tests.ps1", "Bootstrap.Tests.ps1") } ` + elseif ($Contract -ceq "compiled-environment") { @("CompiledValidationEnvironment.Tests.ps1") } ` else { @("OwnedLiveAdapter.Tests.ps1") } $paths = @($files | ForEach-Object { Join-Path $tests $_ }) $output = @(Invoke-Pester -Path $paths -PassThru -Output None 6>&1 3>&1 4>&1 5>&1) @@ -58,9 +59,13 @@ if ($Contract -ceq "live-cleanup") { } $count = $run.TotalCount } -$id = if ($Contract -ceq "sdk-prerequisites") { "host.sdk-prerequisites" } else { "host.legacy-ownership.$Contract" } +$id = if ($Contract -ceq "sdk-prerequisites") { "host.sdk-prerequisites" } ` + elseif ($Contract -ceq "compiled-environment") { "host.compiled-assembly-environment" } ` + else { "host.legacy-ownership.$Contract" } $description = if ($Contract -ceq "sdk-prerequisites") { "SDK prerequisites: actual installed-SDK probe plus controlled bootstrap/dependency cases" +} elseif ($Contract -ceq "compiled-environment") { + "Actual scoped caller publication, supplied/empty/missing state, failure identity and native child environment controls" } else { "Actual original $Contract mock-only suite" } $row = [ordered]@{ id = $id; status = "Passed"; executedCount = $count diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index 65868251..d2bfea75 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -43,6 +43,8 @@ public static IReadOnlyList Create(IReadOnlyList "HOST_CASE_RESULT=", ["host.legacy-ownership.shell-scope"], ["-Contract", "shell-scope"]), new CaseScriptSuite("host.legacy-ownership.live-adapter", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.live-adapter"], ["-Contract", "live-adapter"]), + new CaseScriptSuite("host.compiled-assembly-environment", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", + "HOST_CASE_RESULT=", ["host.compiled-assembly-environment"], ["-Contract", "compiled-environment"]), new CaseScriptSuite("host.legacy-ownership.live-cleanup", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.live-cleanup"], ["-Contract", "live-cleanup"]), new CaseScriptSuite("host.sdk-prerequisites", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 8901e1ca..ca4ccc8e 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -2,6 +2,7 @@ "units": [ { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, { "id": "host.admission-guards", "owner": "Host", "minimumCount": 65, "exactCount": 65 }, + { "id": "host.compiled-assembly-environment", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, { "id": "host.legacy-ownership.shell-scope", "owner": "Host", "minimumCount": 70, "exactCount": 70 }, { "id": "host.legacy-ownership.live-adapter", "owner": "Host", "minimumCount": 13, "exactCount": 13 }, { "id": "host.legacy-ownership.live-cleanup", "owner": "Host", "minimumCount": 84, "exactCount": 84 }, diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index e5645fa8..3a60818a 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -829,6 +829,38 @@ function Invoke-ProviderBuild([object] $roots) { } } +function Invoke-WithCompiledValidationAssembly( + [Parameter(Mandatory)][scriptblock] $Action, + [string] $WorkspaceRoot = $repoRoot +) { + $variable = "GRAPHCODE_VALIDATION_ASSEMBLY" + $present = [Environment]::GetEnvironmentVariables("Process").Contains($variable) + $published = $false + try { + $assembly = if ($present) { + [Environment]::GetEnvironmentVariable($variable, "Process") + } else { + & (Join-Path $WorkspaceRoot "Tools\windows\Build-CompiledValidationRunner.ps1") + } + if ($assembly -isnot [string] -or [string]::IsNullOrEmpty($assembly) -or + -not [IO.Path]::IsPathFullyQualified($assembly) -or + -not (Test-Path -LiteralPath $assembly -PathType Leaf)) { + throw "GRAPHCODE_VALIDATION_ASSEMBLY must identify an existing current-worktree compiled assembly; an explicitly empty value is not a bootstrap request" + } + & (Join-Path $WorkspaceRoot "Tools\windows\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assembly -RequiredType "ShellOwnedLaunch" | Out-Null + if (-not $present) { + [Environment]::SetEnvironmentVariable($variable, $assembly, "Process") + $published = $true + } + & $Action + } finally { + if ($published -and [Environment]::GetEnvironmentVariables("Process").Contains($variable)) { + Remove-Item -LiteralPath "Env:\$variable" -ErrorAction Stop + } + } +} + function Invoke-Task([string] $name) { if ($DryRun) { $detail = "" @@ -1195,7 +1227,10 @@ function Invoke-Task([string] $name) { Write-Host "==> Windows shell validation isolation contract" . (Join-Path $repoRoot "Tools\windows\Import-PinnedPester.ps1") $isolationResults = Invoke-Pester ` - -Path (Join-Path $repoRoot "Tools\windows\Tests\ValidationIsolation.Tests.ps1") ` + -Path @( + (Join-Path $repoRoot "Tools\windows\Tests\ValidationIsolation.Tests.ps1"), + (Join-Path $repoRoot "Tools\windows\Tests\CompiledValidationEnvironment.Tests.ps1") + ) ` -PassThru if ($isolationResults.TotalCount -le 0 -or $isolationResults.PassedCount -ne $isolationResults.TotalCount -or @@ -1415,7 +1450,9 @@ try { foreach ($name in $selected) { if ($name -eq "windows-shell" -and -not $DryRun) { Invoke-WindowsShellValidationIsolation -ValidationRootParent $ShellValidationRoot -Action { - Invoke-Task "windows-shell" + Invoke-WithCompiledValidationAssembly -WorkspaceRoot $repoRoot -Action { + Invoke-Task "windows-shell" + } } } else { Invoke-Task $name diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 509aba0c..6bbe5fb1 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -152,6 +152,12 @@ owned `-ShellValidationRoot` for UIA lease paths. Keep Zig caches in stable owne directories outside per-invocation `LOCALAPPDATA`, which isolation removes after a successful run. +The real `windows-shell` task builds and validates the current compiled helper +assembly once when `GRAPHCODE_VALIDATION_ASSEMBLY` is absent, publishes it to its +child callers, and removes that process-only publication on exit. A supplied +assembly remains subject to current-worktree provenance; an explicitly empty +entry is refused rather than treated as a bootstrap request. + `windows-spikes` no longer re-runs `windows-shell`, `packaging` or `hardening`. Those tasks already run in the `windows-shell` and `Deterministic hardening` required checks on every Windows-relevant pull request.