using System.Diagnostics; using System.IO; using System.Reflection; using System.Text; using System.Text.Json; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using XFEToolBox.Core.Tools; using XFEToolBox.Tools.ProgramDebugger; namespace ProgramDebugger.Validation; internal static class Program { private static readonly string Workspace = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "EditorWorkspaces", "ProgramDebugger"); private static readonly string Artifacts = Path.Combine(AppContext.BaseDirectory, "test-artifacts"); private static readonly List Results = []; private static int exitCode; private static string Executable => Environment.ProcessPath!; [STAThread] private static int Main(string[] args) { if (args.FirstOrDefault() == "--fixture") return FixtureAsync(args.Skip(1).ToArray()).GetAwaiter().GetResult(); Directory.CreateDirectory(Artifacts); var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/XFEToolBox.WpfCore;component/Resources/Style/ToolThemeResources.xaml") }); app.Startup += async (_, _) => { try { await TestAsync(args.Contains("--register"), args.Contains("--check-package")); } catch (Exception e) { Console.Error.WriteLine(e); exitCode = 1; } finally { File.WriteAllText(Path.Combine(Artifacts, "results.json"), JsonSerializer.Serialize(new { time = DateTimeOffset.Now, passed = exitCode == 0, checks = Results }, new JsonSerializerOptions { WriteIndented = true })); app.Shutdown(); } }; app.Run(); return exitCode; } private static void Check(bool value, string name) { if (!value) throw new InvalidOperationException("FAIL: " + name); Results.Add(name); Console.WriteLine("PASS: " + name); } private static LaunchOptions Options(string mode, string extra = "") => new(Executable, "--fixture " + mode + " " + extra, Artifacts, "UTF-8"); private static async Task<(RunResult Run, byte[] Output, byte[] Error)> RunFixtureAsync(string mode, string extra = "") { using var session = new ProcessSession(); var result = await session.RunAsync(Options(mode, extra)).WaitAsync(TimeSpan.FromSeconds(15)); return (result, session.Output.Snapshot(), session.Error.Snapshot()); } private static async Task WaitUntilAsync(Func condition) { var timeout = Stopwatch.StartNew(); while (!condition()) { if (timeout.Elapsed > TimeSpan.FromSeconds(8)) throw new TimeoutException(); await Task.Delay(15); } } private static async Task TestAsync(bool register, bool checkPackage) { MakeIcon(); var simple = await RunFixtureAsync("json", "\"中文 参数\" \"C:\\path with space\\sample.txt\""); Check(simple.Run.ExitCode == 0 && simple.Run.ProcessId != Environment.ProcessId, "real child PID and normal exit"); using (var json = JsonDocument.Parse(simple.Output)) { Check(json.RootElement.GetProperty("args")[0].GetString() == "中文 参数", "quoted Unicode arguments retained"); Check(json.RootElement.GetProperty("args")[1].GetString() == @"C:\path with space\sample.txt", "quoted file path retained"); Check(json.RootElement.GetProperty("cwd").GetString() == Artifacts, "working directory applied"); } Check(Encoding.UTF8.GetString(simple.Error) == "warning-only", "stderr isolated without trailing newline"); Check(ResultParser.Parse(simple.Output, Encoding.UTF8).Format.StartsWith("JSON"), "pretty multiline JSON parsed"); foreach (var entry in new[] { ("ndjson", "JSON Lines"), ("xml", "XML"), ("csv", "CSV"), ("tsv", "TSV"), ("kv", "键值对"), ("text", "文本"), ("binary", "二进制"), ("bom", "JSON") }) { var result = await RunFixtureAsync(entry.Item1); var parsed = ResultParser.Parse(result.Output, Encoding.UTF8); Check(parsed.Format.StartsWith(entry.Item2), $"real output: {entry.Item1} -> {parsed.Format}"); if (entry.Item1 == "binary") Check(result.Output.SequenceEqual(new byte[] { 0, 1, 2, 0xFF, 0xFE, 0x7F, 65, 0 }), "binary output byte-for-byte fidelity"); if (entry.Item1 == "csv") Check(parsed.Content.Contains("line1\r\nline2") && parsed.Content.Contains("a,\"b\""), "CSV quoted comma, quote escape and multiline cell"); } foreach (string scalar in new[] { "true", "null", "123456789012345678901234567890", "\"字符串\"", "[1,false,null]" }) Check(ResultParser.Parse(Encoding.UTF8.GetBytes(scalar), Encoding.UTF8).Format.StartsWith("JSON"), "JSON scalar/array: " + scalar); Check(ResultParser.Parse("{broken"u8.ToArray(), Encoding.UTF8, "JSON").Format.Contains("失败"), "invalid JSON preserved as raw text"); Check(ResultParser.Parse("]>&secret;"u8.ToArray(), Encoding.UTF8, "XML").Format.Contains("失败"), "XML external entity rejected"); Check(ResultParser.Parse(Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("", 80)) + string.Concat(Enumerable.Repeat("", 80))), Encoding.UTF8, "XML").Format.Contains("失败"), "excessively nested XML rejected"); Check(ResultParser.Parse("key=a=b\nkey=c"u8.ToArray(), Encoding.UTF8).Content.Contains("a=b"), "key-value duplicate keys and embedded equals retained"); Check(ResultParser.Parse([], Encoding.UTF8).Format == "空输出", "empty output handled"); Check(ResultParser.Parse(new byte[ResultParser.ParseLimit + 1], Encoding.UTF8).Format.Contains("大体积"), "large parse bounded"); Check(ResultParser.Parse("{}"u8.ToArray(), Encoding.UTF8, truncated: true).Format.Contains("截断"), "truncated data not reported as valid JSON"); var legacy = await RunFixtureAsync("gbk"); Check(ResultParser.Parse(legacy.Output, ProcessSession.ResolveEncoding("GB18030 / GBK")).Content.Contains("中文输出"), "GB18030 / GBK decoded"); var unicode = await RunFixtureAsync("chunked"); Check(Encoding.UTF8.GetString(unicode.Output) == "分块😀输出没有换行", "multibyte Unicode split across writes retained"); var nonzero = await RunFixtureAsync("failure"); Check(nonzero.Run.ExitCode == 23 && Encoding.UTF8.GetString(nonzero.Error) == "failed", "nonzero exit code and error payload"); using (var session = new ProcessSession()) { var task = session.RunAsync(Options("stdin")); await WaitUntilAsync(() => session.ProcessId > 0); await session.SendInputAsync("交互 输入", true); await session.CloseInputAsync(); await task.WaitAsync(TimeSpan.FromSeconds(8)); Check(Encoding.UTF8.GetString(session.Output.Snapshot()) == "交互 输入" + Environment.NewLine, "stdin text and EOF round trip"); } using (var session = new ProcessSession(65536)) { var result = await session.RunAsync(Options("flood")).WaitAsync(TimeSpan.FromSeconds(15)); Check(result.ExitCode == 0, "simultaneous stdout/stderr flood finishes without deadlock"); Check(session.Output.TotalBytes == 8 * 1024 * 1024 && session.Error.TotalBytes == 8 * 1024 * 1024, "both channels drained fully beyond capture cap"); Check(session.Output.Truncated && session.Error.Truncated && session.Output.Snapshot().Length == 65536, "capture limit enforced while continuing to drain"); } using (var session = new ProcessSession()) { var task = session.RunAsync(Options("sleep")); await WaitUntilAsync(() => session.Output.TotalBytes > 0); int id = session.ProcessId; await session.StopAsync(); var result = await task.WaitAsync(TimeSpan.FromSeconds(8)); Check(result.Stopped && !IsAlive(id), "stop terminates only owned test process"); } using (var session = new ProcessSession()) { var task = session.RunAsync(Options("tree")); await WaitUntilAsync(() => session.Output.TotalBytes > 0); int childId = int.Parse(Encoding.UTF8.GetString(session.Output.Snapshot())); await session.StopAsync(); await task.WaitAsync(TimeSpan.FromSeconds(8)); Check(!IsAlive(childId), "stop terminates owned descendant process"); } using (var session = new ProcessSession()) { var result = await session.RunAsync(Options("inherit")).WaitAsync(TimeSpan.FromSeconds(8)); Check(result.PipesTimedOut, "inherited open pipe bounded after parent exit"); } using (var session = new ProcessSession()) { bool caught = false; try { await session.RunAsync(Options("text") with { Program = Path.Combine(Artifacts, "not-existing.exe") }); } catch (FileNotFoundException) { caught = true; } Check(caught, "missing executable reported clearly"); } using (var session = new ProcessSession()) { bool caught = false; try { await session.RunAsync(Options("text") with { Directory = Path.Combine(Artifacts, "not-existing-directory") }); } catch (DirectoryNotFoundException) { caught = true; } Check(caught, "missing working directory reported clearly"); } await TestArgumentModesAsync(); await TestViewAsync(); await ValidateHostAsync(register); if (checkPackage) await ValidatePackageAsync(); Console.WriteLine($"ALL {Results.Count} CHECKS PASSED. Artifacts: {Artifacts}"); } private static bool IsAlive(int id) { try { using var p = Process.GetProcessById(id); return !p.HasExited; } catch (ArgumentException) { return false; } } private static async Task TestArgumentModesAsync() { var vm = new MainPageViewModel(); Check(vm.UseArgumentList && !vm.IsCommandLineMode && vm.ArgumentItems.Count == 0, "new tool defaults to list mode with zero arguments"); Check(vm.CreateLaunchOptions().ArgumentValues.Length == 0, "empty list does not send a spurious empty argument"); string[] values = ["--fixture", "json", "--output", @"C:\path with space\", "中文 参数😀", "", "say \"hello\"", " padded ", "a\tb", @"a\\\""b", "a\nb"]; foreach (string value in values) { vm.AddArgumentCommand.Execute(null); vm.ArgumentItems[^1].Value = value; } vm.AddArgumentCommand.Execute(null); var removed = vm.ArgumentItems[^1]; vm.RemoveArgumentCommand.Execute(removed); Check(vm.ArgumentItems.Select(item => item.Value).SequenceEqual(values) && !vm.RemoveArgumentCommand.CanExecute(removed), "add/remove commands retain argument order and reject stale rows"); Check(vm.ArgumentItems.Select(item => item.Label).SequenceEqual(Enumerable.Range(1, values.Length).Select(i => $"参数 {i}")), "argument row numbering updates"); vm.Arguments = "--fixture failure"; vm.IsCommandLineMode = true; Check(!vm.UseArgumentList && vm.ArgumentItems.Count == values.Length, "switch to raw mode retains list draft"); vm.UseArgumentList = true; Check(vm.Arguments == "--fixture failure" && !vm.IsCommandLineMode, "switch to list mode retains raw draft"); var snapshot = vm.CreateLaunchOptions(); vm.ArgumentItems[2].Value = "changed"; Check(snapshot.ArgumentValues[2] == "--output", "launch argument snapshot is independent of subsequent edits"); vm.RestoreLaunchOptions(JsonSerializer.Deserialize(JsonSerializer.Serialize(snapshot))!); Check(vm.UseArgumentList && vm.ArgumentItems.Select(x => x.Value).SequenceEqual(values) && vm.Arguments == "--fixture failure", "configuration round trip preserves both drafts and selected mode"); vm.ProgramPath = Executable; vm.WorkingDirectory = Artifacts; await vm.RunCommand.ExecuteAsync(null).WaitAsync(TimeSpan.FromSeconds(10)); using (var json = JsonDocument.Parse(vm.OutputText)) Check(json.RootElement.GetProperty("args").EnumerateArray().Select(x => x.GetString()).SequenceEqual(values.Skip(2)), "real child receives exact list arguments: Unicode, spaces, empty, quotes, slash, tabs and newlines"); vm.IsCommandLineMode = true; await vm.RunCommand.ExecuteAsync(null); Check(vm.ExitCodeText.StartsWith("23 "), "raw mode uses command text and ignores inactive list"); var old = JsonSerializer.Deserialize("{\"Program\":\"x.exe\",\"Arguments\":\"--name \\\"a b\\\"\",\"Directory\":\"\",\"EncodingName\":\"UTF-8\"}")!; vm.RestoreLaunchOptions(old); Check(vm.IsCommandLineMode && vm.Arguments == "--name \"a b\"", "legacy nonempty command preserved without reinterpretation"); vm.RestoreLaunchOptions(old with { Arguments = "" }); Check(vm.UseArgumentList, "empty legacy configuration defaults to list mode"); await vm.ShutdownAsync(); } private static async Task TestViewAsync() { var bindingTrace = new BindingTrace(); PresentationTraceSources.DataBindingSource.Listeners.Add(bindingTrace); PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Error; var page = new MainPage(); var vm = (MainPageViewModel)page.DataContext; var window = new Window { Content = page, Width = 1180, Height = 850, Left = -30000, Top = -30000, ShowInTaskbar = false, WindowStyle = WindowStyle.None }; window.Show(); vm.ProgramPath = Executable; vm.WorkingDirectory = Artifacts; foreach (string value in new[] { "--fixture", "json", "--output", @"C:\测试目录\包含空格的 文件.txt" }) { vm.AddArgumentCommand.Execute(null); vm.ArgumentItems[^1].Value = value; } await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); Check(((ScrollViewer)page.FindName("ArgumentsScroll")).VerticalOffset > 0, "adding argument scrolls newly added row into view"); var listRun = vm.RunCommand.ExecuteAsync(null); Check(!vm.AddArgumentCommand.CanExecute(null) && !vm.RemoveArgumentCommand.CanExecute(vm.ArgumentItems[0]), "list editing commands disabled while process is running"); await listRun.WaitAsync(TimeSpan.FromSeconds(10)); FindVisual(page)!.SelectedIndex = 2; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); SaveView(page, 1180, 850, "program-debugger-arguments.png"); window.Width = 928; window.Height = 770; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); SaveView(page, 928, 770, "program-debugger-arguments-minimum.png"); Check(page.ActualWidth <= 928 && page.ActualHeight <= 770, "list-mode minimum window layout"); Check(FindVisual(page, box => box.IsReadOnly && box.Text == vm.ParsedText)!.ActualHeight >= 65, "minimum list-mode layout preserves readable parsed output area"); window.Width = 1180; window.Height = 850; vm.UseArgumentList = false; int ticks = 0; TimeSpan maximumGap = TimeSpan.Zero; var clock = Stopwatch.StartNew(); TimeSpan last = clock.Elapsed; var heartbeat = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(20) }; heartbeat.Tick += (_, _) => { var now = clock.Elapsed; var gap = now - last; if (gap > maximumGap) maximumGap = gap; last = now; ticks++; }; heartbeat.Start(); vm.ProgramPath = Executable; vm.Arguments = "--fixture slow-json"; vm.WorkingDirectory = Artifacts; var running = vm.RunCommand.ExecuteAsync(null); await WaitUntilAsync(() => vm.OutputText.Contains("message")); Check(vm.IsRunning && !vm.RunCommand.CanExecute(null) && vm.StopCommand.CanExecute(null), "view receives output live and gates run/stop commands"); await running.WaitAsync(TimeSpan.FromSeconds(10)); heartbeat.Stop(); Check(ticks >= 10 && maximumGap.TotalSeconds < 1, $"WPF remains responsive: {ticks} ticks, max gap {maximumGap.TotalMilliseconds:0}ms"); Check(vm.FormatText.StartsWith("JSON") && vm.ParsedText.Contains("实时结果"), "actual view command auto-parses completed JSON"); Check(vm.ProgressValue == 1 && !vm.IsRunning && vm.RunCommand.CanExecute(null), "completion progress and restart availability"); FindVisual(page)!.SelectedIndex = 2; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); SaveView(page, 1180, 820, "program-debugger.png"); window.Width = 928; window.Height = 700; await Dispatcher.Yield(DispatcherPriority.ApplicationIdle); SaveView(page, 928, 700, "program-debugger-minimum.png"); Check(FindVisual