Phase 1: Add example infrastructure attributes and classes

- Added ExampleMetadataAttribute, ExampleCategoryAttribute, ExampleDemoKeyStrokesAttribute
- Added ExampleContext, ExecutionMode, ExampleInfo, ExampleResult, ExampleMetrics classes
- Added ExampleDiscovery and ExampleRunner static classes
- Updated FakeComponentFactory to support context injection via environment variable
- Built successfully with no errors

Co-authored-by: tig <585482+tig@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-01 21:54:57 +00:00
parent 175a6439bb
commit ea5eabf6e3
12 changed files with 800 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Terminal.Gui.Examples;
namespace Terminal.Gui.Drivers;
@@ -35,7 +36,130 @@ public class FakeComponentFactory : ComponentFactoryImpl<ConsoleKeyInfo>
/// <inheritdoc/>
public override IInput<ConsoleKeyInfo> CreateInput ()
{
return _input ?? new FakeInput ();
FakeInput fakeInput = _input ?? new FakeInput ();
// Check for test context in environment variable
string? contextJson = Environment.GetEnvironmentVariable (ExampleContext.EnvironmentVariableName);
if (!string.IsNullOrEmpty (contextJson))
{
ExampleContext? context = ExampleContext.FromJson (contextJson);
if (context is { })
{
foreach (string keyStr in context.KeysToInject)
{
if (Input.Key.TryParse (keyStr, out Input.Key? key) && key is { })
{
ConsoleKeyInfo consoleKeyInfo = ConvertKeyToConsoleKeyInfo (key);
fakeInput.AddInput (consoleKeyInfo);
}
}
}
}
return fakeInput;
}
private static ConsoleKeyInfo ConvertKeyToConsoleKeyInfo (Input.Key key)
{
ConsoleModifiers modifiers = 0;
if (key.IsShift)
{
modifiers |= ConsoleModifiers.Shift;
}
if (key.IsAlt)
{
modifiers |= ConsoleModifiers.Alt;
}
if (key.IsCtrl)
{
modifiers |= ConsoleModifiers.Control;
}
// Remove the modifier masks to get the base key code
KeyCode baseKeyCode = key.KeyCode & KeyCode.CharMask;
// Map KeyCode to ConsoleKey
ConsoleKey consoleKey = baseKeyCode switch
{
KeyCode.A => ConsoleKey.A,
KeyCode.B => ConsoleKey.B,
KeyCode.C => ConsoleKey.C,
KeyCode.D => ConsoleKey.D,
KeyCode.E => ConsoleKey.E,
KeyCode.F => ConsoleKey.F,
KeyCode.G => ConsoleKey.G,
KeyCode.H => ConsoleKey.H,
KeyCode.I => ConsoleKey.I,
KeyCode.J => ConsoleKey.J,
KeyCode.K => ConsoleKey.K,
KeyCode.L => ConsoleKey.L,
KeyCode.M => ConsoleKey.M,
KeyCode.N => ConsoleKey.N,
KeyCode.O => ConsoleKey.O,
KeyCode.P => ConsoleKey.P,
KeyCode.Q => ConsoleKey.Q,
KeyCode.R => ConsoleKey.R,
KeyCode.S => ConsoleKey.S,
KeyCode.T => ConsoleKey.T,
KeyCode.U => ConsoleKey.U,
KeyCode.V => ConsoleKey.V,
KeyCode.W => ConsoleKey.W,
KeyCode.X => ConsoleKey.X,
KeyCode.Y => ConsoleKey.Y,
KeyCode.Z => ConsoleKey.Z,
KeyCode.D0 => ConsoleKey.D0,
KeyCode.D1 => ConsoleKey.D1,
KeyCode.D2 => ConsoleKey.D2,
KeyCode.D3 => ConsoleKey.D3,
KeyCode.D4 => ConsoleKey.D4,
KeyCode.D5 => ConsoleKey.D5,
KeyCode.D6 => ConsoleKey.D6,
KeyCode.D7 => ConsoleKey.D7,
KeyCode.D8 => ConsoleKey.D8,
KeyCode.D9 => ConsoleKey.D9,
KeyCode.Enter => ConsoleKey.Enter,
KeyCode.Esc => ConsoleKey.Escape,
KeyCode.Space => ConsoleKey.Spacebar,
KeyCode.Tab => ConsoleKey.Tab,
KeyCode.Backspace => ConsoleKey.Backspace,
KeyCode.Delete => ConsoleKey.Delete,
KeyCode.Home => ConsoleKey.Home,
KeyCode.End => ConsoleKey.End,
KeyCode.PageUp => ConsoleKey.PageUp,
KeyCode.PageDown => ConsoleKey.PageDown,
KeyCode.CursorUp => ConsoleKey.UpArrow,
KeyCode.CursorDown => ConsoleKey.DownArrow,
KeyCode.CursorLeft => ConsoleKey.LeftArrow,
KeyCode.CursorRight => ConsoleKey.RightArrow,
KeyCode.F1 => ConsoleKey.F1,
KeyCode.F2 => ConsoleKey.F2,
KeyCode.F3 => ConsoleKey.F3,
KeyCode.F4 => ConsoleKey.F4,
KeyCode.F5 => ConsoleKey.F5,
KeyCode.F6 => ConsoleKey.F6,
KeyCode.F7 => ConsoleKey.F7,
KeyCode.F8 => ConsoleKey.F8,
KeyCode.F9 => ConsoleKey.F9,
KeyCode.F10 => ConsoleKey.F10,
KeyCode.F11 => ConsoleKey.F11,
KeyCode.F12 => ConsoleKey.F12,
_ => (ConsoleKey)0
};
var keyChar = '\0';
Rune rune = key.AsRune;
if (Rune.IsValid (rune.Value))
{
keyChar = (char)rune.Value;
}
return new (keyChar, consoleKey, key.IsShift, key.IsAlt, key.IsCtrl);
}
/// <inheritdoc/>

View File

@@ -0,0 +1,22 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Represents a sequence of keystrokes to inject during example demonstration or testing.
/// </summary>
public class DemoKeyStrokeSequence
{
/// <summary>
/// Gets or sets the array of keystroke names to inject.
/// </summary>
public string [] KeyStrokes { get; set; } = [];
/// <summary>
/// Gets or sets the delay in milliseconds before injecting these keystrokes.
/// </summary>
public int DelayMs { get; set; } = 0;
/// <summary>
/// Gets or sets the order in which this sequence should be executed.
/// </summary>
public int Order { get; set; } = 0;
}

View File

@@ -0,0 +1,35 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Defines a category for an example application.
/// Apply this attribute to an assembly to associate it with one or more categories for organization and filtering.
/// </summary>
/// <remarks>
/// <para>
/// Multiple instances of this attribute can be applied to a single assembly to associate the example
/// with multiple categories.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [assembly: ExampleCategory("Text and Formatting")]
/// [assembly: ExampleCategory("Controls")]
/// </code>
/// </example>
[AttributeUsage (AttributeTargets.Assembly, AllowMultiple = true)]
public class ExampleCategoryAttribute : System.Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ExampleCategoryAttribute"/> class.
/// </summary>
/// <param name="category">The category name.</param>
public ExampleCategoryAttribute (string category)
{
Category = category;
}
/// <summary>
/// Gets or sets the category name.
/// </summary>
public string Category { get; set; }
}

View File

@@ -0,0 +1,76 @@
using System.Text.Json;
namespace Terminal.Gui.Examples;
/// <summary>
/// Defines the execution context for running an example application.
/// This context is used to configure how an example should be executed, including driver selection,
/// keystroke injection, timeouts, and metrics collection.
/// </summary>
public class ExampleContext
{
/// <summary>
/// Gets or sets the name of the driver to use (e.g., "FakeDriver", "DotnetDriver").
/// If <see langword="null"/>, the default driver for the platform is used.
/// </summary>
public string? DriverName { get; set; } = null;
/// <summary>
/// Gets or sets the list of key names to inject into the example during execution.
/// Each string should be a valid key name that can be parsed by <see cref="Input.Key.TryParse"/>.
/// </summary>
public List<string> KeysToInject { get; set; } = new ();
/// <summary>
/// Gets or sets the maximum time in milliseconds to allow the example to run before forcibly terminating it.
/// </summary>
public int TimeoutMs { get; set; } = 30000;
/// <summary>
/// Gets or sets the maximum number of iterations to allow before stopping the example.
/// If set to -1, no iteration limit is enforced.
/// </summary>
public int MaxIterations { get; set; } = -1;
/// <summary>
/// Gets or sets a value indicating whether to collect and report performance metrics during execution.
/// </summary>
public bool CollectMetrics { get; set; } = false;
/// <summary>
/// Gets or sets the execution mode for the example.
/// </summary>
public ExecutionMode Mode { get; set; } = ExecutionMode.OutOfProcess;
/// <summary>
/// The name of the environment variable used to pass the serialized <see cref="ExampleContext"/>
/// to example applications.
/// </summary>
public const string EnvironmentVariableName = "TERMGUI_TEST_CONTEXT";
/// <summary>
/// Serializes this context to a JSON string for passing via environment variables.
/// </summary>
/// <returns>A JSON string representation of this context.</returns>
public string ToJson ()
{
return JsonSerializer.Serialize (this);
}
/// <summary>
/// Deserializes a <see cref="ExampleContext"/> from a JSON string.
/// </summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized context, or <see langword="null"/> if deserialization fails.</returns>
public static ExampleContext? FromJson (string json)
{
try
{
return JsonSerializer.Deserialize<ExampleContext> (json);
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,50 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Defines keystrokes to be automatically injected when the example is run in demo or test mode.
/// Apply this attribute to an assembly to specify automated input sequences for demonstration or testing purposes.
/// </summary>
/// <remarks>
/// <para>
/// Multiple instances of this attribute can be applied to a single assembly to define a sequence
/// of keystroke injections. The <see cref="Order"/> property controls the execution sequence.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [assembly: ExampleDemoKeyStrokes(RepeatKey = "CursorDown", RepeatCount = 5, Order = 1, DelayMs = 100)]
/// [assembly: ExampleDemoKeyStrokes(KeyStrokes = new[] { "Enter" }, Order = 2, DelayMs = 200)]
/// </code>
/// </example>
[AttributeUsage (AttributeTargets.Assembly, AllowMultiple = true)]
public class ExampleDemoKeyStrokesAttribute : System.Attribute
{
/// <summary>
/// Gets or sets an array of keystroke names to inject.
/// Each string should be a valid key name that can be parsed by <see cref="Input.Key.TryParse"/>.
/// </summary>
public string []? KeyStrokes { get; set; }
/// <summary>
/// Gets or sets the name of a single key to repeat multiple times.
/// This is a convenience for repeating the same keystroke.
/// </summary>
public string? RepeatKey { get; set; }
/// <summary>
/// Gets or sets the number of times to repeat <see cref="RepeatKey"/>.
/// Only used when <see cref="RepeatKey"/> is specified.
/// </summary>
public int RepeatCount { get; set; } = 1;
/// <summary>
/// Gets or sets the delay in milliseconds before injecting these keystrokes.
/// </summary>
public int DelayMs { get; set; } = 0;
/// <summary>
/// Gets or sets the order in which this keystroke sequence should be executed
/// relative to other <see cref="ExampleDemoKeyStrokesAttribute"/> instances.
/// </summary>
public int Order { get; set; } = 0;
}

View File

@@ -0,0 +1,121 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace Terminal.Gui.Examples;
/// <summary>
/// Provides methods for discovering example applications by scanning assemblies for example metadata attributes.
/// </summary>
public static class ExampleDiscovery
{
/// <summary>
/// Discovers examples from the specified assembly file paths.
/// </summary>
/// <param name="assemblyPaths">The paths to assembly files to scan for examples.</param>
/// <returns>An enumerable of <see cref="ExampleInfo"/> objects for each discovered example.</returns>
[RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")]
[RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")]
public static IEnumerable<ExampleInfo> DiscoverFromFiles (params string [] assemblyPaths)
{
foreach (string path in assemblyPaths)
{
if (!File.Exists (path))
{
continue;
}
Assembly? asm = null;
try
{
asm = Assembly.LoadFrom (path);
}
catch
{
// Skip assemblies that can't be loaded
continue;
}
ExampleMetadataAttribute? metadata = asm.GetCustomAttribute<ExampleMetadataAttribute> ();
if (metadata is null)
{
continue;
}
ExampleInfo info = new ()
{
Name = metadata.Name,
Description = metadata.Description,
AssemblyPath = path,
Categories = asm.GetCustomAttributes<ExampleCategoryAttribute> ()
.Select (c => c.Category)
.ToList (),
DemoKeyStrokes = ParseDemoKeyStrokes (asm)
};
yield return info;
}
}
/// <summary>
/// Discovers examples from assemblies in the specified directory.
/// </summary>
/// <param name="directory">The directory to search for assembly files.</param>
/// <param name="searchPattern">The search pattern for assembly files (default is "*.dll").</param>
/// <param name="searchOption">The search option for traversing subdirectories.</param>
/// <returns>An enumerable of <see cref="ExampleInfo"/> objects for each discovered example.</returns>
[RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")]
[RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")]
public static IEnumerable<ExampleInfo> DiscoverFromDirectory (
string directory,
string searchPattern = "*.dll",
SearchOption searchOption = SearchOption.AllDirectories
)
{
if (!Directory.Exists (directory))
{
return [];
}
string [] assemblyPaths = Directory.GetFiles (directory, searchPattern, searchOption);
return DiscoverFromFiles (assemblyPaths);
}
private static List<DemoKeyStrokeSequence> ParseDemoKeyStrokes (Assembly assembly)
{
List<DemoKeyStrokeSequence> sequences = new ();
foreach (ExampleDemoKeyStrokesAttribute attr in assembly.GetCustomAttributes<ExampleDemoKeyStrokesAttribute> ())
{
List<string> keys = new ();
if (attr.KeyStrokes is { Length: > 0 })
{
keys.AddRange (attr.KeyStrokes);
}
if (!string.IsNullOrEmpty (attr.RepeatKey))
{
for (var i = 0; i < attr.RepeatCount; i++)
{
keys.Add (attr.RepeatKey);
}
}
if (keys.Count > 0)
{
sequences.Add (
new ()
{
KeyStrokes = keys.ToArray (),
DelayMs = attr.DelayMs,
Order = attr.Order
});
}
}
return sequences.OrderBy (s => s.Order).ToList ();
}
}

View File

@@ -0,0 +1,41 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Contains information about a discovered example application.
/// </summary>
public class ExampleInfo
{
/// <summary>
/// Gets or sets the display name of the example.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a description of what the example demonstrates.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the full path to the example's assembly file.
/// </summary>
public string AssemblyPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the list of categories this example belongs to.
/// </summary>
public List<string> Categories { get; set; } = new ();
/// <summary>
/// Gets or sets the demo keystroke sequences defined for this example.
/// </summary>
public List<DemoKeyStrokeSequence> DemoKeyStrokes { get; set; } = new ();
/// <summary>
/// Returns a string representation of this example info.
/// </summary>
/// <returns>A string containing the name and description.</returns>
public override string ToString ()
{
return $"{Name}: {Description}";
}
}

View File

@@ -0,0 +1,41 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Defines metadata (Name and Description) for an example application.
/// Apply this attribute to an assembly to mark it as an example that can be discovered and run.
/// </summary>
/// <remarks>
/// <para>
/// This attribute is used by the example discovery system to identify and describe standalone example programs.
/// Each example should have exactly one <see cref="ExampleMetadataAttribute"/> applied to its assembly.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [assembly: ExampleMetadata("Character Map", "Unicode character viewer and selector")]
/// </code>
/// </example>
[AttributeUsage (AttributeTargets.Assembly)]
public class ExampleMetadataAttribute : System.Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ExampleMetadataAttribute"/> class.
/// </summary>
/// <param name="name">The display name of the example.</param>
/// <param name="description">A brief description of what the example demonstrates.</param>
public ExampleMetadataAttribute (string name, string description)
{
Name = name;
Description = description;
}
/// <summary>
/// Gets or sets the display name of the example.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets a brief description of what the example demonstrates.
/// </summary>
public string Description { get; set; }
}

View File

@@ -0,0 +1,52 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Contains performance and execution metrics collected during an example's execution.
/// </summary>
public class ExampleMetrics
{
/// <summary>
/// Gets or sets the time when the example started.
/// </summary>
public DateTime StartTime { get; set; }
/// <summary>
/// Gets or sets the time when initialization completed.
/// </summary>
public DateTime? InitializedAt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether initialization completed successfully.
/// </summary>
public bool InitializedSuccessfully { get; set; }
/// <summary>
/// Gets or sets the number of iterations executed.
/// </summary>
public int IterationCount { get; set; }
/// <summary>
/// Gets or sets the time when shutdown began.
/// </summary>
public DateTime? ShutdownAt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether shutdown completed gracefully.
/// </summary>
public bool ShutdownGracefully { get; set; }
/// <summary>
/// Gets or sets the number of times the screen was cleared.
/// </summary>
public int ClearedContentCount { get; set; }
/// <summary>
/// Gets or sets the number of times views were drawn.
/// </summary>
public int DrawCompleteCount { get; set; }
/// <summary>
/// Gets or sets the number of times views were laid out.
/// </summary>
public int LaidOutCount { get; set; }
}

View File

@@ -0,0 +1,42 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Contains the result of running an example application.
/// </summary>
public class ExampleResult
{
/// <summary>
/// Gets or sets a value indicating whether the example completed successfully.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Gets or sets the exit code of the example process (for out-of-process execution).
/// </summary>
public int? ExitCode { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the example timed out.
/// </summary>
public bool TimedOut { get; set; }
/// <summary>
/// Gets or sets any error message that occurred during execution.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Gets or sets the performance metrics collected during execution.
/// </summary>
public ExampleMetrics? Metrics { get; set; }
/// <summary>
/// Gets or sets the standard output captured during execution.
/// </summary>
public string? StandardOutput { get; set; }
/// <summary>
/// Gets or sets the standard error captured during execution.
/// </summary>
public string? StandardError { get; set; }
}

View File

@@ -0,0 +1,176 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Terminal.Gui.Examples;
/// <summary>
/// Provides methods for running example applications in various execution modes.
/// </summary>
public static class ExampleRunner
{
/// <summary>
/// Runs an example with the specified context.
/// </summary>
/// <param name="example">The example information.</param>
/// <param name="context">The execution context.</param>
/// <returns>The result of running the example.</returns>
[RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")]
[RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")]
public static ExampleResult Run (ExampleInfo example, ExampleContext context)
{
return context.Mode == ExecutionMode.InProcess
? RunInProcess (example, context)
: RunOutOfProcess (example, context);
}
[RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")]
[RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")]
private static ExampleResult RunInProcess (ExampleInfo example, ExampleContext context)
{
Environment.SetEnvironmentVariable (
ExampleContext.EnvironmentVariableName,
context.ToJson ());
try
{
Assembly asm = Assembly.LoadFrom (example.AssemblyPath);
MethodInfo? entryPoint = asm.EntryPoint;
if (entryPoint is null)
{
return new ()
{
Success = false,
ErrorMessage = "Assembly does not have an entry point"
};
}
ParameterInfo [] parameters = entryPoint.GetParameters ();
object? result = null;
if (parameters.Length == 0)
{
result = entryPoint.Invoke (null, null);
}
else if (parameters.Length == 1 && parameters [0].ParameterType == typeof (string []))
{
result = entryPoint.Invoke (null, new object [] { Array.Empty<string> () });
}
else
{
return new ()
{
Success = false,
ErrorMessage = "Entry point has unsupported signature"
};
}
// If entry point returns Task, wait for it
if (result is Task task)
{
task.Wait ();
}
return new ()
{
Success = true
};
}
catch (Exception ex)
{
return new ()
{
Success = false,
ErrorMessage = ex.ToString ()
};
}
finally
{
Environment.SetEnvironmentVariable (ExampleContext.EnvironmentVariableName, null);
}
}
private static ExampleResult RunOutOfProcess (ExampleInfo example, ExampleContext context)
{
ProcessStartInfo psi = new ()
{
FileName = "dotnet",
Arguments = $"\"{example.AssemblyPath}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
psi.Environment [ExampleContext.EnvironmentVariableName] = context.ToJson ();
using Process? process = Process.Start (psi);
if (process is null)
{
return new ()
{
Success = false,
ErrorMessage = "Failed to start process"
};
}
bool exited = process.WaitForExit (context.TimeoutMs);
string stdout = process.StandardOutput.ReadToEnd ();
string stderr = process.StandardError.ReadToEnd ();
if (!exited)
{
try
{
process.Kill (true);
}
catch
{
// Ignore errors killing the process
}
return new ()
{
Success = false,
TimedOut = true,
StandardOutput = stdout,
StandardError = stderr
};
}
ExampleMetrics? metrics = ExtractMetricsFromOutput (stdout);
return new ()
{
Success = process.ExitCode == 0,
ExitCode = process.ExitCode,
StandardOutput = stdout,
StandardError = stderr,
Metrics = metrics
};
}
private static ExampleMetrics? ExtractMetricsFromOutput (string output)
{
// Look for the metrics marker in the output
Match match = Regex.Match (output, @"###TERMGUI_METRICS:(.+?)###");
if (!match.Success)
{
return null;
}
try
{
return JsonSerializer.Deserialize<ExampleMetrics> (match.Groups [1].Value);
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Defines the execution mode for running an example application.
/// </summary>
public enum ExecutionMode
{
/// <summary>
/// Run the example in a separate process.
/// This provides full isolation but makes debugging more difficult.
/// </summary>
OutOfProcess,
/// <summary>
/// Run the example in the same process by loading its assembly and invoking its entry point.
/// This allows for easier debugging but may have side effects from shared process state.
/// </summary>
InProcess
}