feat(scheduler): ScriptRunner through the process spawn policy
Spec: on Windows the command goes through Arguments like the bash tool, since cmd.exe doesn't parse ArgumentList quoting; the workspace is created if missing, and a timeout vs cancellation outcome is spelled out.
This commit is contained in:
@@ -163,16 +163,20 @@ Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pi
|
||||
|
||||
- The shell matches the built-in `bash` tool, so a script the agent tested with `bash` behaves the same when it's
|
||||
scheduled: `/bin/sh -c <command>` on Unix and `cmd.exe /d /s /c` on Windows. It's overridable through
|
||||
`SchedulerOptions.Shell`. The command is passed through `ArgumentList`, not quoted by hand.
|
||||
- The working directory is the target's workspace. Stdin is closed.
|
||||
`SchedulerOptions.Shell`. On Unix the command is passed through `ArgumentList`, not quoted by hand. On Windows
|
||||
`cmd.exe` doesn't parse `ArgumentList`'s quoting, so the command goes through `Arguments` as
|
||||
`/d /s /c "<command>"`, exactly as the `bash` tool does. A configured `SchedulerOptions.Shell` always uses
|
||||
`ArgumentList`.
|
||||
- The working directory is the target's workspace (created if missing). Stdin is closed.
|
||||
- The command, cwd and environment come from `IProcessSpawnPolicy.Prepare` (DI singleton, registered by
|
||||
`AddAgenticCore`) with `Purpose = ScheduledScript`, `Scope = target`, `Origin = job id` and `TimeZoneId` = the
|
||||
target's zone. So the script sees the same filtered environment as `bash`, with `TZ` set to the chat's zone, and
|
||||
host hooks apply. There's no scheduler-specific scrub list any more (`SchedulerOptions.ScrubbedEnvironment` is
|
||||
removed). A policy that throws is `Failed(null, "process environment hook failed: …")`. Session-level
|
||||
`ProcessSpawnEvent` handlers don't run: a job fires without a live session.
|
||||
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A shell that fails to start is `Failed(null, "failed to start <shell>: …")`,
|
||||
never an exception.
|
||||
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A timeout is `Failed(null, stderrTail, timedOut: true)`;
|
||||
a cancellation throws `OperationCanceledException` after the kill. A shell that fails to start (or a workspace that
|
||||
can't be created) is `Failed(null, "failed to start <shell>: …")`, never an exception.
|
||||
- Output capture as in the reference, then `Trim()` on stdout.
|
||||
|
||||
### 4. `JobScheduler` (process-wide, the slice's entry type)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>One watcher run. <see cref="TimeZoneId"/> becomes the script's <c>TZ</c> (the target's zone).</summary>
|
||||
public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId);
|
||||
|
||||
/// <summary>What a watcher run means for the job.</summary>
|
||||
public abstract record ScriptOutcome
|
||||
{
|
||||
private ScriptOutcome() { }
|
||||
|
||||
/// <summary>Exit 0, empty stdout after trimming: the condition isn't met. Costs nothing.</summary>
|
||||
public sealed record Silent : ScriptOutcome;
|
||||
|
||||
/// <summary>Exit 0 with output: the condition is met; <see cref="Text"/> (trimmed) goes to the agent.</summary>
|
||||
public sealed record Output(string Text) : ScriptOutcome;
|
||||
|
||||
/// <summary>Non-zero exit, timeout, a hook that threw, or a shell that didn't start.</summary>
|
||||
public sealed record Failed(int? ExitCode, string StderrTail, bool TimedOut) : ScriptOutcome;
|
||||
}
|
||||
|
||||
/// <summary>Runs watcher scripts. Faked in scheduler tests.</summary>
|
||||
public interface IScriptRunner
|
||||
{
|
||||
/// <summary>Never throws for a script problem (that's <see cref="ScriptOutcome.Failed"/>); throws <see cref="OperationCanceledException"/> after killing the script when <paramref name="cancellationToken"/> is cancelled.</summary>
|
||||
Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a watcher with the <c>bash</c> tool's shell in the target's workspace, stdin closed, with the command, cwd and
|
||||
/// environment from the host's <see cref="IProcessSpawnPolicy"/> (<see cref="ProcessPurpose.ScheduledScript"/>): the same
|
||||
/// filtered environment as <c>bash</c>, <c>TZ</c> = the target's zone, host hooks applied. No session-level
|
||||
/// <see cref="ProcessSpawnEvent"/> runs: a job fires without a live session.
|
||||
/// </summary>
|
||||
public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy policy, ILogger<ScriptRunner> logger, TimeProvider? time = null) : IScriptRunner
|
||||
{
|
||||
public const int StderrTailLines = 20;
|
||||
private static readonly TimeSpan PipeGrace = TimeSpan.FromSeconds(5);
|
||||
private readonly TimeProvider _time = time ?? TimeProvider.System;
|
||||
|
||||
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
ProcessSpawnContext spawn;
|
||||
try
|
||||
{
|
||||
spawn = policy.Prepare(new ProcessSpawnRequest
|
||||
{
|
||||
Purpose = ProcessPurpose.ScheduledScript,
|
||||
Command = request.Command,
|
||||
Cwd = request.Cwd,
|
||||
Scope = request.Target,
|
||||
Origin = request.JobId,
|
||||
TimeZoneId = request.TimeZoneId,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ScriptOutcome.Failed(null, $"process environment hook failed: {ex.Message}", false);
|
||||
}
|
||||
|
||||
var psi = StartInfo(spawn.Command);
|
||||
spawn.ApplyTo(psi); // cwd and exactly the prepared environment
|
||||
using var process = new Process { StartInfo = psi };
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(spawn.Cwd);
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
|
||||
}
|
||||
process.StandardInput.Close();
|
||||
var stdout = new OutputCapture();
|
||||
var stderr = new OutputCapture();
|
||||
var pumps = Task.WhenAll(PumpAsync(process.StandardOutput, stdout), PumpAsync(process.StandardError, stderr));
|
||||
|
||||
using var timeout = new CancellationTokenSource(request.Timeout, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
var timedOut = false;
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Kill(process, request);
|
||||
timedOut = !cancellationToken.IsCancellationRequested;
|
||||
}
|
||||
await DrainAsync(pumps, request).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (timedOut) return new ScriptOutcome.Failed(null, Tail(stderr.ToString()), true);
|
||||
if (process.ExitCode != 0) return new ScriptOutcome.Failed(process.ExitCode, Tail(stderr.ToString()), false);
|
||||
var text = stdout.ToString().Trim();
|
||||
return text.Length == 0 ? new ScriptOutcome.Silent() : new ScriptOutcome.Output(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>bash</c> tool's shell: <c>/bin/sh -c <command></c> through <see cref="ProcessStartInfo.ArgumentList"/>. On
|
||||
/// Windows <c>cmd.exe</c> doesn't parse the MSVC quoting <c>ArgumentList</c> produces, so the command goes through
|
||||
/// <see cref="ProcessStartInfo.Arguments"/> as <c>/d /s /c "<command>"</c>, exactly as <c>bash</c> does.
|
||||
/// A configured <see cref="SchedulerOptions.Shell"/> always uses <c>ArgumentList</c>.
|
||||
/// </summary>
|
||||
internal ProcessStartInfo StartInfo(string command)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
if (options.Shell.Count > 0)
|
||||
{
|
||||
psi.FileName = options.Shell[0];
|
||||
foreach (var argument in options.Shell.Skip(1)) psi.ArgumentList.Add(argument);
|
||||
psi.ArgumentList.Add(command);
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
psi.FileName = "cmd.exe";
|
||||
psi.Arguments = $"/d /s /c \"{command}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
psi.FileName = "/bin/sh";
|
||||
psi.ArgumentList.Add("-c");
|
||||
psi.ArgumentList.Add(command);
|
||||
}
|
||||
return psi;
|
||||
}
|
||||
|
||||
private void Kill(Process process, ScriptRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.WaitForExit(PipeGrace);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException)
|
||||
{
|
||||
logger.LogDebug(ex, "Job {JobId}: killing the script failed (it may have exited)", request.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for both output pipes to close, at most <see cref="PipeGrace"/> (a surviving grandchild may hold them).</summary>
|
||||
private async Task DrainAsync(Task pumps, ScriptRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pumps.WaitAsync(PipeGrace, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
logger.LogWarning("Job {JobId}: the script's output pipes stayed open after it ended", request.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(StreamReader reader, OutputCapture capture)
|
||||
{
|
||||
var buffer = new char[4096];
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false)) > 0)
|
||||
capture.Append(buffer.AsSpan(0, read));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
// The pipe broke or the process was disposed: keep what was read.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The last <see cref="StderrTailLines"/> lines.</summary>
|
||||
internal static string Tail(string text)
|
||||
{
|
||||
var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n').Split('\n');
|
||||
return string.Join('\n', lines.TakeLast(StderrTailLines));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keeps the first <see cref="HeadChars"/> and the last <see cref="TailChars"/> characters of a stream.</summary>
|
||||
internal sealed class OutputCapture
|
||||
{
|
||||
public const int HeadChars = 48 * 1024;
|
||||
public const int TailChars = 16 * 1024;
|
||||
private readonly StringBuilder _head = new();
|
||||
private readonly char[] _tail = new char[TailChars];
|
||||
private readonly Lock _lock = new();
|
||||
private int _tailStart;
|
||||
private int _tailLength;
|
||||
private long _dropped;
|
||||
|
||||
public void Append(string text) => Append(text.AsSpan());
|
||||
|
||||
public void Append(ReadOnlySpan<char> text)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var toHead = Math.Min(text.Length, HeadChars - _head.Length);
|
||||
if (toHead > 0) _head.Append(text[..toHead]);
|
||||
foreach (var c in text[Math.Max(toHead, 0)..])
|
||||
{
|
||||
if (_tailLength == TailChars)
|
||||
{
|
||||
_tail[_tailStart] = c;
|
||||
_tailStart = (_tailStart + 1) % TailChars;
|
||||
_dropped++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_tail[(_tailStart + _tailLength) % TailChars] = c;
|
||||
_tailLength++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sb = new StringBuilder(_head.Length + _tailLength + 64).Append(_head);
|
||||
if (_dropped > 0) sb.Append(CultureInfo.InvariantCulture, $"\n[… {_dropped} characters omitted …]\n");
|
||||
for (var i = 0; i < _tailLength; i++) sb.Append(_tail[(_tailStart + i) % TailChars]);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Collections;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
/// <summary>Real processes (Unix only, like the other script tests) with a real ProcessSpawnPolicy over an injected environment.</summary>
|
||||
public sealed class ScriptRunnerTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("scriptrunner-").FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>The real environment (so the shell starts) plus host secrets the default deny-list must remove.</summary>
|
||||
private static Dictionary<string, string?> Snapshot()
|
||||
{
|
||||
var env = new Dictionary<string, string?>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
|
||||
foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value;
|
||||
env.Remove("TZ");
|
||||
env["TELEGRAM_BOT_TOKEN"] = "123:bot-token";
|
||||
env["OPENAI_API_KEY"] = "sk-test-key";
|
||||
return env;
|
||||
}
|
||||
|
||||
private ScriptRunner Runner(Action<ProcessEnvironmentOptions>? configure = null, SchedulerOptions? options = null)
|
||||
{
|
||||
var env = Snapshot();
|
||||
var processOptions = new ProcessEnvironmentOptions { HardenHostProcess = false };
|
||||
configure?.Invoke(processOptions);
|
||||
return new ScriptRunner(options ?? new SchedulerOptions(), new ProcessSpawnPolicy(processOptions, null, () => env), NullLogger<ScriptRunner>.Instance);
|
||||
}
|
||||
|
||||
private ScriptRequest Request(string command, TimeSpan? timeout = null, string? zone = null, string? cwd = null) =>
|
||||
new(command, cwd ?? Path.Combine(_root, "workspace"), timeout ?? TimeSpan.FromSeconds(30), "111", "w8r4t", zone);
|
||||
|
||||
[Fact]
|
||||
public async Task Output_is_trimmed_stdout()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(Request("printf ' price 48\\n\\n'"), CancellationToken.None);
|
||||
Assert.Equal(new ScriptOutcome.Output("price 48"), outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_output_is_silent()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("true"), CancellationToken.None));
|
||||
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("printf ' \\n'; echo noise >&2"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_zero_exit_is_a_failure_with_the_last_20_stderr_lines()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(Request("for i in $(seq 1 30); do echo line$i >&2; done; exit 3"), CancellationToken.None);
|
||||
|
||||
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
|
||||
Assert.Equal(3, failed.ExitCode);
|
||||
Assert.False(failed.TimedOut);
|
||||
Assert.Equal(Enumerable.Range(11, 20).Select(i => $"line{i}"), failed.StderrTail.Split('\n'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_kills_the_process_tree()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var pidFile = Path.Combine(_root, "child.pid");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var outcome = await Runner().RunAsync(Request($"sleep 30 & echo $! > '{pidFile}'; wait", TimeSpan.FromSeconds(1)), CancellationToken.None);
|
||||
|
||||
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
|
||||
Assert.True(failed.TimedOut);
|
||||
Assert.Null(failed.ExitCode);
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), $"took {stopwatch.Elapsed}");
|
||||
var child = int.Parse(File.ReadAllText(pidFile).Trim(), System.Globalization.CultureInfo.InvariantCulture);
|
||||
await Task.Delay(200);
|
||||
Assert.True(IsGone(child), "the background sleep must be killed with the tree");
|
||||
}
|
||||
|
||||
private static bool IsGone(int pid)
|
||||
{
|
||||
try { return Process.GetProcessById(pid).HasExited; }
|
||||
catch (ArgumentException) { return true; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_kills_the_script_and_throws()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Runner().RunAsync(Request("sleep 30"), cts.Token));
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_is_capped_to_head_and_tail()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(Request("head -c 200000 /dev/zero | tr '\\0' a; printf END"), CancellationToken.None);
|
||||
|
||||
var text = Assert.IsType<ScriptOutcome.Output>(outcome).Text;
|
||||
Assert.StartsWith(new string('a', 100), text);
|
||||
Assert.EndsWith("aEND", text);
|
||||
Assert.Contains("characters omitted", text);
|
||||
Assert.True(text.Length < OutputCapture.HeadChars + OutputCapture.TailChars + 200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_secrets_are_absent_and_TZ_is_the_target_zone()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(
|
||||
Request("printf '%s|%s|%s' \"${TELEGRAM_BOT_TOKEN-unset}\" \"${OPENAI_API_KEY-unset}\" \"$TZ\"", zone: "Asia/Tokyo"),
|
||||
CancellationToken.None);
|
||||
Assert.Equal(new ScriptOutcome.Output("unset|unset|Asia/Tokyo"), outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_host_hook_fails_without_starting_a_process()
|
||||
{
|
||||
var marker = Path.Combine(_root, "workspace", "started");
|
||||
var runner = Runner(o => o.Hooks.Add(_ => throw new InvalidOperationException("nope")));
|
||||
|
||||
var outcome = await runner.RunAsync(Request(OperatingSystem.IsWindows() ? $"type nul > \"{marker}\"" : $"touch '{marker}'"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(new ScriptOutcome.Failed(null, "process environment hook failed: nope", false), outcome);
|
||||
Assert.False(File.Exists(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cwd_is_the_workspace()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var cwd = Path.Combine(_root, "not-yet-created");
|
||||
var outcome = await Runner().RunAsync(Request("basename \"$(pwd)\"", cwd: cwd), CancellationToken.None);
|
||||
Assert.Equal(new ScriptOutcome.Output("not-yet-created"), outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_shell_that_fails_to_start_is_a_failure()
|
||||
{
|
||||
var shell = Path.Combine(_root, "no-such-shell");
|
||||
var outcome = await Runner(options: new SchedulerOptions { Shell = [shell, "-c"] }).RunAsync(Request("true"), CancellationToken.None);
|
||||
|
||||
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
|
||||
Assert.StartsWith($"failed to start {shell}: ", failed.StderrTail);
|
||||
Assert.Null(failed.ExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OutputCaptureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Keeps_the_head_and_tail_and_counts_the_rest()
|
||||
{
|
||||
var capture = new OutputCapture();
|
||||
capture.Append(new string('h', OutputCapture.HeadChars));
|
||||
capture.Append(new string('x', 1000));
|
||||
capture.Append(new string('t', OutputCapture.TailChars));
|
||||
|
||||
var text = capture.ToString();
|
||||
|
||||
Assert.StartsWith(new string('h', OutputCapture.HeadChars), text);
|
||||
Assert.EndsWith(new string('t', OutputCapture.TailChars), text);
|
||||
Assert.Contains("[… 1000 characters omitted …]", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_output_is_kept_whole()
|
||||
{
|
||||
var capture = new OutputCapture();
|
||||
capture.Append("one\n");
|
||||
capture.Append("two");
|
||||
Assert.Equal("one\ntwo", capture.ToString());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user