diff --git a/docs/adr/ADR0001V01R01-SplitConsolePlusAndPromptPlus.md b/docs/adr/ADR0001V01R02-SplitConsolePlusAndPromptPlus.md similarity index 75% rename from docs/adr/ADR0001V01R01-SplitConsolePlusAndPromptPlus.md rename to docs/adr/ADR0001V01R02-SplitConsolePlusAndPromptPlus.md index 6816a7c..4635ac7 100644 --- a/docs/adr/ADR0001V01R01-SplitConsolePlusAndPromptPlus.md +++ b/docs/adr/ADR0001V01R02-SplitConsolePlusAndPromptPlus.md @@ -1,20 +1,20 @@ - +
ConsolePlus - # ADR0001V01R01 + # ADR0001V01R02
[ADR Index](README.md) • **Next:** [ADR0002 →](ADR0002V01R02-ImmutableCapabilityProfile.md) @@ -23,11 +23,12 @@ -# ADR0001V01R01 — Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) +# ADR0001V01R02 — Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) - **Status:** Accepted -- **Version:** V01 / Revision R01 +- **Version:** V01 / Revision R02 - **Created:** 2026-07-24 +- **Changed:** 2026-07-25 ## Context @@ -60,6 +61,12 @@ ConsolePlus is the base layer and has **no dependency on PromptPlus**. PromptPlu depends on ConsolePlus (one-directional) and reuses the same console driver instance (`PromptPlus.Console` is the same as `ConsolePlus.Driver`). +**R02 — 2026-07-25:** this record absorbs the former ADR0011 ("Separation of +ConsolePlus and PromptPlus"), which restated the same split decision from the +ongoing-boundary angle without adding a distinct decision of its own. The +boundary must be kept clean going forward: neither project should leak +implementation details across the dependency direction established above. + ## Consequences - **Positive:** ConsolePlus can be consumed standalone for pure rendering; the @@ -67,10 +74,9 @@ instance (`PromptPlus.Console` is the same as `ConsolePlus.Driver`). rendering/interaction boundary is explicit and independently testable. - **Negative / trade-off:** two repositories to coordinate when the shared driver contract changes; PromptPlus consumers upgrading from 5.x must take on the - ConsolePlus dependency. + ConsolePlus dependency; the boundary must be actively kept clean to avoid + leakage in either direction. ## Related -- [ADR0011](ADR0011V01R01-SeparationFromPromptPlus.md) details the ongoing - boundary and one-directional dependency between the two projects. - PromptPlus ADR0001 records the same decision from the interaction side. diff --git a/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md b/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md index e5f7153..f948997 100644 --- a/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md +++ b/docs/adr/ADR0002V01R02-ImmutableCapabilityProfile.md @@ -17,7 +17,7 @@ # ADR0002V01R02 -[← ADR0001](ADR0001V01R01-SplitConsolePlusAndPromptPlus.md) • [ADR Index](README.md) • **Next:** [ADR0003 →](ADR0003V01R01-AutomaticInitializationOnFirstAccess.md) +[← ADR0001](ADR0001V01R02-SplitConsolePlusAndPromptPlus.md) • [ADR Index](README.md) • **Next:** [ADR0003 →](ADR0003V01R01-AutomaticInitializationOnFirstAccess.md) --- diff --git a/docs/adr/ADR0010V01R01-MarkupLanguage.md b/docs/adr/ADR0010V01R01-MarkupLanguage.md index a03c484..c1169c0 100644 --- a/docs/adr/ADR0010V01R01-MarkupLanguage.md +++ b/docs/adr/ADR0010V01R01-MarkupLanguage.md @@ -17,7 +17,7 @@ # ADR0010V01R01 -[← ADR0009](ADR0009V01R01-StyleModelColorOnly.md) • [ADR Index](README.md) • **Next:** [ADR0011 →](ADR0011V01R01-SeparationFromPromptPlus.md) +[← ADR0009](ADR0009V01R01-StyleModelColorOnly.md) • [ADR Index](README.md) • **Next:** [ADR0011 →](ADR0011V01R01-ShutdownStateRestoration.md) --- diff --git a/docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md b/docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md deleted file mode 100644 index f8c5682..0000000 --- a/docs/adr/ADR0011V01R01-SeparationFromPromptPlus.md +++ /dev/null @@ -1,51 +0,0 @@ - - - -
- ConsolePlus - - # ADR0011V01R01 -
- -[← ADR0010](ADR0010V01R01-MarkupLanguage.md) • [ADR Index](README.md) • **Next:** [ADR0012 →](ADR0012V01R01-ShutdownStateRestoration.md) - ---- - - - -# ADR0011V01R01 — Separation of ConsolePlus and PromptPlus - -- **Status:** Accepted -- **Version:** V01 / Revision R01 -- **Created:** 2026-07-24 - -## Context - -Rendering (writing styled output, managing the screen) and interaction -(prompts, controls) are distinct concerns. Combining them in one library would -force interaction dependencies on users who only need rendering. - -## Decision - -Keep two separate products in two repositories: **ConsolePlus is the rendering -foundation** ("how you render") and **PromptPlus is the interactive toolkit** -("how you interact") layered on top. PromptPlus reuses the same console driver — -`PromptPlus.Console` is the same instance as `ConsolePlus.Driver`. ConsolePlus -has no dependency on PromptPlus. - -## Consequences - -- **Positive:** rendering can be used standalone; clear one-directional - dependency; each product versions independently. -- **Negative / trade-off:** cross-repo coordination is needed when the shared - driver contract changes; the boundary must be kept clean to avoid leakage. diff --git a/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md b/docs/adr/ADR0011V01R01-ShutdownStateRestoration.md similarity index 78% rename from docs/adr/ADR0012V01R01-ShutdownStateRestoration.md rename to docs/adr/ADR0011V01R01-ShutdownStateRestoration.md index de49264..b26608c 100644 --- a/docs/adr/ADR0012V01R01-ShutdownStateRestoration.md +++ b/docs/adr/ADR0011V01R01-ShutdownStateRestoration.md @@ -1,8 +1,8 @@ - - + - + - + - + + + +
+ ConsolePlus + + # ADR0015V01R01 +
+ +[← ADR0014](ADR0014V01R02-GeneratedApiDocsOffLimits.md) • [ADR Index](README.md) + +--- + +# ADR0015V01R01 — Redirected/headless console I/O: fail-safe writes, fail-loud reads + +- **Status:** Accepted +- **Version:** V01 / Revision R01 +- **Created:** 2026-07-28 + +## Context + +A process whose console I/O is redirected (a file, a pipe, a CI runner, or a test host such as +`dotnet test` itself) has no live OS console handle. Two classes of member are affected differently, +and two real bugs (found 2026-07-28 while auditing whether ConsolePlus's public API runs error-free +under redirection) showed the driver was inconsistent about which failure mode applies where: + +1. **Cursor/screen writes** — `Clear()`, `SetCursorPosition()`, the `CursorLeft`/`CursorTop` setters — + called the raw `Console.*` API unguarded in `NoAnsiConsoleAdapter`. Under a redirected/headless + process this threw `IOException: "The handle is invalid."`, even though `HideCursor()`/ + `ShowCursor()` and `EnvironmentUtil.GetSafeWidth`/`GetSafeHeight`/`GetSafeTopCursor` already had an + established "Safe" pattern (catch `IOException`/`PlatformNotSupportedException`, degrade quietly) + for exactly this situation. +2. **Key reads** — `KeyAvailable`, `ReadKey`, `ReadKeyAsync` — only guarded on `_profile.Interactive`, + which reflects a **hardcoded list of known CI providers** (see + [Environment Detection](../environment-detection.md)), not the real `Console.IsInputRedirected`. + Any redirected process outside that list (including this project's own local `dotnet test` run) + hit the raw `Console.KeyAvailable`/`ReadKey` call, which throws a raw, undocumented + `InvalidOperationException` for `ReadKey` or behaves unpredictably for `KeyAvailable`. + +`Read()`/`ReadLine()` were not part of this problem: they read from the input **text stream**, which +a redirected file/pipe legitimately provides — `Console.ReadLine()` works correctly against redirected +input. Only key-based reads need a **live interactive input buffer**, which redirection does not have. + +## Decision + +Establish one explicit, documented contract for every member that touches the console under +redirected/headless I/O, split by what the member does: + +- **Presentation-only writes** (`Clear`, `SetCursorPosition`, `CursorLeft`/`CursorTop` setters, + `HideCursor`/`ShowCursor`) — **fail safe**. Wrap the raw `Console.*` call in + `catch (Exception ex) when (ex is PlatformNotSupportedException or IOException)` and silently + no-op (or return `false` for the two members that already have a boolean success contract). These + calls only affect what is visually drawn; there is nothing meaningful to report when there is no + screen to draw on, and forcing every caller to guard every cursor/clear call would be unreasonable. +- **Blocking/consuming reads** (`Read`, `ReadLine`, `ReadKey`, `ReadKeyAsync`) — **fail loud, predictably**. + Throw `InvalidOperationException("Console is not interactive.")` — the same message and type for + all four — instead of letting a raw platform exception (or an inconsistent one) leak through. A + read that cannot happen is a real logic error the caller must handle, not something to swallow. +- **The non-blocking probe** `KeyAvailable` — **fails safe**, returning `false` instead of throwing. + It already has a non-throwing contract (`while (!KeyAvailable) { ... }` is the documented polling + idiom in [Reading Input](../reading-input.md)); making it throw would break every such loop. +- `ReadKey`/`ReadKeyAsync`/`KeyAvailable` check **both** `_profile.Interactive` **and** + `Console.IsInputRedirected` explicitly — `Interactive` alone only reflects the CI-provider + heuristic and does not catch plain redirection outside that list. `Read()`/`ReadLine()` keep + checking `Interactive` only, since redirected input is a valid source for them. + +## Consequences + +- **Positive:** one predictable rule instead of case-by-case behavior — "does this member draw, or + does it consume a real keystroke?" answers which failure mode applies. No raw platform exceptions + (`IOException`, undocumented `InvalidOperationException` text) leak to callers. Matches the + project's existing "Safe" pattern (`EnvironmentUtil.GetSafe*`) instead of adding a second one. +- **Negative / trade-off:** a caller polling `KeyAvailable` in a loop with no independent exit + condition (no `CancellationToken`, no timeout) will now loop forever under redirected input instead + of crashing, because `KeyAvailable` no longer throws — it simply never becomes `true`. This is + correct for `KeyAvailable`'s own non-throwing contract, but any code built on top of it (such as + PromptPlus's interactive controls) must add its **own** upfront `IsInputRedirected` check if it + wants to fail fast instead of hanging — see PromptPlus's + [ADR0023 — Guard interactive controls against redirected console input](https://github.com/FRACerqueira/PromptPlus/blob/develop/docs/adr/ADR0023V01R01-GuardInteractiveControlsAgainstRedirectedInput.md), + which does exactly that on top of this contract. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9513187..81aed65 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,7 +4,7 @@ # Architecture Decision Records (ADR) -[Back to Home](../../README.md) • **First:** [ADR0001 →](ADR0001V01R01-SplitConsolePlusAndPromptPlus.md) +[Back to Home](../../README.md) • **First:** [ADR0001 →](ADR0001V01R02-SplitConsolePlusAndPromptPlus.md) --- @@ -45,7 +45,7 @@ projects (ConsolePlus + PromptPlus). | ADR | Title | Version | Status | | --- | --- | --- | --- | -| [ADR0001V01R01](ADR0001V01R01-SplitConsolePlusAndPromptPlus.md) | Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) | V01 | Accepted | +| [ADR0001V01R02](ADR0001V01R02-SplitConsolePlusAndPromptPlus.md) | Split PromptPlus 5.x into two projects (ConsolePlus + PromptPlus) | V01 (R02) | Accepted | | [ADR0002V01R02](ADR0002V01R02-ImmutableCapabilityProfile.md) | Immutable capability profile | V01 (R02) | Accepted | | [ADR0003V01R01](ADR0003V01R01-AutomaticInitializationOnFirstAccess.md) | Automatic initialization on first access | V01 | Accepted | | [ADR0004V01R01](ADR0004V01R01-AnsiVsNonAnsiDriverSelection.md) | ANSI vs non-ANSI driver selection | V01 | Accepted | @@ -55,8 +55,8 @@ projects (ConsolePlus + PromptPlus). | [ADR0008V01R01](ADR0008V01R01-ColorDepthModel.md) | Color depth model (`ColorSystem`) | V01 | Accepted | | [ADR0009V01R01](ADR0009V01R01-StyleModelColorOnly.md) | Style model: color + overflow only | V01 | Accepted | | [ADR0010V01R01](ADR0010V01R01-MarkupLanguage.md) | Inline markup language for styling | V01 | Accepted | -| [ADR0011V01R01](ADR0011V01R01-SeparationFromPromptPlus.md) | Separation of ConsolePlus and PromptPlus | V01 | Accepted | -| [ADR0012V01R01](ADR0012V01R01-ShutdownStateRestoration.md) | Shutdown state restoration | V01 | Accepted | -| [ADR0013V01R01](ADR0013V01R01-EmojiShortcodeModel.md) | Emoji shortcode model with fallback | V01 | Accepted | -| [ADR0014V01R01](ADR0014V01R01-LowLevelAnsiAndAlternateScreen.md) | Low-level ANSI and alternate-screen API | V01 | Accepted | -| [ADR0015V01R02](ADR0015V01R02-GeneratedApiDocsOffLimits.md) | Generated API docs are off-limits for manual edits; regeneration gated on `ReleaseDoc` | V01 (R02) | Accepted | +| [ADR0011V01R01](ADR0011V01R01-ShutdownStateRestoration.md) | Shutdown state restoration | V01 | Accepted | +| [ADR0012V01R01](ADR0012V01R01-EmojiShortcodeModel.md) | Emoji shortcode model with fallback | V01 | Accepted | +| [ADR0013V01R01](ADR0013V01R01-LowLevelAnsiAndAlternateScreen.md) | Low-level ANSI and alternate-screen API | V01 | Accepted | +| [ADR0014V01R02](ADR0014V01R02-GeneratedApiDocsOffLimits.md) | Generated API docs are off-limits for manual edits; regeneration gated on `ReleaseDoc` | V01 (R02) | Accepted | +| [ADR0015V01R01](ADR0015V01R01-RedirectedConsoleIoContract.md) | Redirected/headless console I/O: fail-safe writes, fail-loud reads | V01 | Accepted | diff --git a/docs/adr/adr-config.adrplus b/docs/adr/adr-config.adrplus new file mode 100644 index 0000000..f09c9e6 --- /dev/null +++ b/docs/adr/adr-config.adrplus @@ -0,0 +1,31 @@ +{ + "folderadr": "docs/adr", + "migrationpattern": "", + "template": "", + "prefix": "ADR", + "lenseq": 4, + "lenversion": 2, + "lenrevision": 2, + "lenscope": 0, + "separator": "-", + "casetransform": "PascalCase", + "statusnew": "Proposed", + "statusacc": "Accepted", + "statusrej": "Rejected", + "statussup": "Superseded", + "scopes": "", + "folderbyscope": false, + "skipdomain": "", + "headerdisclaimer": "Do not remove this comment, lines and table", + "headertitlefile": "ADR", + "headerversion": "Version", + "headerrevision": "Revision", + "headerscope": "Scope", + "headerdomain": "Domain", + "headertitlestatuscreated": "Created", + "headertitlestatuschanged": "Changed", + "headertitlestatussuperseded": "Superseded", + "headertablefields": "Fields", + "headertablevalues": "Values", + "headermigrated": "Migrated" +} diff --git a/docs/cursor-and-screen.md b/docs/cursor-and-screen.md index b599236..f3fc692 100644 --- a/docs/cursor-and-screen.md +++ b/docs/cursor-and-screen.md @@ -82,6 +82,13 @@ ConsolePlus.Clear(ConsoleColor.Blue); // ConsoleColor implicitly converts to C `Clear(Color?)` fills the buffer and sets the background color in one step. See also [`ClearLine`](writing-output.md#blank-lines-and-clearing-lines) to clear a single row. +> ℹ️ **Redirected or headless console.** `Clear()`, `SetCursorPosition()`, the `CursorLeft`/`CursorTop` +> setters, and `HideCursor()`/`ShowCursor()` never throw when there is no real console attached (output +> redirected to a file/pipe, or a headless/CI process) — they silently do nothing (the two `bool`-returning +> members return `false`). These are presentation-only operations with nothing meaningful to report when +> there is no screen to draw on. See [ADR0015](adr/ADR0015V01R01-RedirectedConsoleIoContract.md) for the +> full redirected-I/O contract, including how key-based **reads** differ (they throw predictably instead). + --- ## Window size and resize events diff --git a/docs/reading-input.md b/docs/reading-input.md index 829b37c..674197f 100644 --- a/docs/reading-input.md +++ b/docs/reading-input.md @@ -258,10 +258,16 @@ while (!ConsolePlus.KeyAvailable) var key = ConsolePlus.ReadKey(intercept: true); ``` -> ⚠️ `ReadKey`/`ReadKeyAsync` throw `InvalidOperationException` ("Console is not interactive.") -> when `ConsolePlus.Profile.Interactive` is `false` — for example, when input is redirected. Check -> `IsInputRedirected` (or `Profile.Interactive`) before calling them in code that might run with -> redirected/piped input. +> ⚠️ **Redirected or non-interactive input.** `ReadKey`/`ReadKeyAsync` need a live keyboard input +> buffer, which a redirected input (file/pipe) does not have. They throw `InvalidOperationException` +> ("Console is not interactive.") when **either** `Profile.Interactive` is `false` **or** +> `IsInputRedirected` is `true` — check `IsInputRedirected` explicitly before calling them, since +> `Profile.Interactive` alone only reflects a hardcoded list of known CI providers (see +> [Environment Detection](environment-detection.md)) and misses plain redirection outside that list. +> `KeyAvailable` never throws under either condition — it simply returns `false`, so the polling loop +> above is always safe to use as written. `Read()`/`ReadLine()` are unaffected by `IsInputRedirected` +> (a redirected stream is a legitimate source for them); they only throw when `Profile.Interactive` +> is `false`. See [ADR0015](adr/ADR0015V01R01-RedirectedConsoleIoContract.md) for the full contract. --- diff --git a/src/ConsoleAbstractions/AnsiConsoleAdapter.cs b/src/ConsoleAbstractions/AnsiConsoleAdapter.cs index 1f73b54..e2cf902 100644 --- a/src/ConsoleAbstractions/AnsiConsoleAdapter.cs +++ b/src/ConsoleAbstractions/AnsiConsoleAdapter.cs @@ -1179,8 +1179,13 @@ public ConsoleKeyInfo ReadKey(bool intercept = false) public async Task ReadKeyAsync(bool intercept, CancellationToken cancellationToken) { ThrowIfDisposed(); - if (!_profile.Interactive) + if (!_profile.Interactive || Console.IsInputRedirected) { + // A real key press requires a live console input buffer; a redirected input + // (file/pipe/SetIn) has no such stream, so fail the same documented way Read()/ + // ReadLine() already do instead of letting Console.KeyAvailable/ReadKey's own + // InvalidOperationException leak through. _profile.Interactive alone only reflects + // known CI-provider detection, not real redirection — hence the explicit check. throw new InvalidOperationException("Console is not interactive."); } return await _lock.RunAsync(async () => @@ -1275,7 +1280,7 @@ public bool KeyAvailable { return _lock.Run(() => { - return _profile.Interactive && Console.KeyAvailable; + return _profile.Interactive && !Console.IsInputRedirected && Console.KeyAvailable; }); } } diff --git a/src/ConsoleAbstractions/NoAnsiConsoleAdapter.cs b/src/ConsoleAbstractions/NoAnsiConsoleAdapter.cs index 277a831..f6d73fc 100644 --- a/src/ConsoleAbstractions/NoAnsiConsoleAdapter.cs +++ b/src/ConsoleAbstractions/NoAnsiConsoleAdapter.cs @@ -1033,7 +1033,16 @@ public void Clear(Color? backcolor = null) } private static void Clear() { - Console.Clear(); + try + { + Console.Clear(); + } + catch (Exception ex) when (ex is PlatformNotSupportedException or IOException) + { + // Ignore if the platform doesn't support this operation, or there is no real + // console attached (e.g. headless/redirected process) — same "Safe" pattern + // used by EnvironmentUtil.GetSafeWidth/GetSafeHeight/GetSafeTopCursor/etc. + } } @@ -1043,7 +1052,16 @@ public void SetCursorPosition(int left, int top) _lock.Run(() => { ThrowIfDisposed(); - Console.SetCursorPosition(left, top); + try + { + Console.SetCursorPosition(left, top); + } + catch (Exception ex) when (ex is PlatformNotSupportedException or IOException) + { + // Ignore if the platform doesn't support this operation, or there is no real + // console attached (e.g. headless/redirected process) — same "Safe" pattern + // used by EnvironmentUtil.GetSafeWidth/GetSafeHeight/GetSafeTopCursor/etc. + } }); } @@ -1051,14 +1069,14 @@ public void SetCursorPosition(int left, int top) public int CursorLeft { get { return _lock.Run(() => EnvironmentUtil.GetSafeLeftCursor()); } - set { _lock.Run(() => Console.SetCursorPosition(value, CursorTop)); } + set { SetCursorPosition(value, CursorTop); } } /// public int CursorTop { get { return _lock.Run(() => EnvironmentUtil.GetSafeTopCursor()); } - set { _lock.Run(() => Console.SetCursorPosition(CursorLeft, value)); } + set { SetCursorPosition(CursorLeft, value); } } /// @@ -1170,8 +1188,13 @@ public ConsoleKeyInfo ReadKey(bool intercept = false) public async Task ReadKeyAsync(bool intercept, CancellationToken cancellationToken) { ThrowIfDisposed(); - if (!_profile.Interactive) + if (!_profile.Interactive || Console.IsInputRedirected) { + // A real key press requires a live console input buffer; a redirected input + // (file/pipe/SetIn) has no such stream, so fail the same documented way Read()/ + // ReadLine() already do instead of letting Console.KeyAvailable/ReadKey's own + // InvalidOperationException leak through. _profile.Interactive alone only reflects + // known CI-provider detection, not real redirection — hence the explicit check. throw new InvalidOperationException("Console is not interactive."); } return await _lock.RunAsync(async () => @@ -1264,7 +1287,7 @@ public bool KeyAvailable return _lock.Run(() => { ThrowIfDisposed(); - return _profile.Interactive && Console.KeyAvailable; + return _profile.Interactive && !Console.IsInputRedirected && Console.KeyAvailable; }); } } diff --git a/src/ConsolePlus.csproj b/src/ConsolePlus.csproj index 1b129bd..4764c15 100644 --- a/src/ConsolePlus.csproj +++ b/src/ConsolePlus.csproj @@ -24,7 +24,7 @@ ConsolePlus.net README.md https://github.com/FRACerqueira/ConsolePlus - 1.0.0-Beta1 + 1.0.0-Beta4 icon.png © 2026 - Fernando Cerqueira True diff --git a/tests/ConsolePlus.Tests/Unit/ConsolePlusExtendsTests.cs b/tests/ConsolePlus.Tests/Unit/ConsolePlusExtendsTests.cs index 631e0aa..17fac01 100644 --- a/tests/ConsolePlus.Tests/Unit/ConsolePlusExtendsTests.cs +++ b/tests/ConsolePlus.Tests/Unit/ConsolePlusExtendsTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Threading.Tasks; using ConsolePlusLibrary; using ConsolePlusLibrary.Testing; using FluentAssertions; @@ -49,5 +51,76 @@ public void ClearLine_targets_the_given_row_and_restores_the_cursor_style() vt.TextAt(2, 0, 5).Trim().Should().BeEmpty(); vt.CurrentStyle.Should().Be(styleBefore); } + + // Ground truth: unlike the tests above, these go through the REAL static ConsolePlus + // singleton (not VirtualTerminal, which is its own mock IConsole and never touches + // NoAnsiConsoleAdapter). The xUnit test host runs with Console.IsOutputRedirected/ + // IsInputRedirected == true, so the real singleton picks NoAnsiConsoleAdapter, the same + // adapter a headless/piped production process would get. Console.Clear() and + // Console.SetCursorPosition() call into the real console handle and used to throw + // IOException ("The handle is invalid") here uncaught — found via manual empirical + // verification (redirected stdio), not covered by any prior test. Fixed by wrapping both + // in the same catch-IOException "Safe" pattern already used by HideCursor/ShowCursor and + // EnvironmentUtil's GetSafeWidth/GetSafeHeight/GetSafeTopCursor/etc. + [Fact] + public void Clear_does_not_throw_when_console_io_is_redirected() + { + var act = () => ConsolePlusLibrary.ConsolePlus.Clear(); + + act.Should().NotThrow(); + } + + [Fact] + public void SetCursorPosition_does_not_throw_when_console_io_is_redirected() + { + var act = () => ConsolePlusLibrary.ConsolePlus.SetCursorPosition(0, 0); + + act.Should().NotThrow(); + } + + [Fact] + public void CursorLeft_and_CursorTop_setters_do_not_throw_when_console_io_is_redirected() + { + var act = () => + { + ConsolePlusLibrary.ConsolePlus.CursorLeft = 0; + ConsolePlusLibrary.ConsolePlus.CursorTop = 0; + }; + + act.Should().NotThrow(); + } + + [Fact] + public void ClearLine_via_real_singleton_does_not_throw_when_console_io_is_redirected() + { + var act = () => ConsolePlusLibrary.ConsolePlus.ClearLine(); + + act.Should().NotThrow(); + } + + // Ground truth: KeyAvailable/ReadKey(Async) call the raw Console.KeyAvailable/ReadKey APIs, + // which require a live console input buffer and throw InvalidOperationException when input + // is redirected. The pre-existing guard (`if (!_profile.Interactive) throw ...`) didn't catch + // this because _profile.Interactive defaults to true everywhere except a short list of known + // CI providers — a real redirected xUnit test host (confirmed empirically: + // Console.IsInputRedirected == true here) doesn't match any of them, so the raw call was + // reached and threw uncaught. Fixed by also checking Console.IsInputRedirected directly. + [Fact] + public void KeyAvailable_returns_false_instead_of_throwing_when_console_input_is_redirected() + { + var act = () => ConsolePlusLibrary.ConsolePlus.KeyAvailable; + + act.Should().NotThrow(); + ConsolePlusLibrary.ConsolePlus.KeyAvailable.Should().BeFalse(); + } + + [Fact] + public async Task ReadKeyAsync_throws_InvalidOperationException_instead_of_the_raw_console_exception_when_input_is_redirected() + { + Func act = () => ConsolePlusLibrary.ConsolePlus.ReadKeyAsync(intercept: true); + + await act.Should().ThrowAsync() + .WithMessage("Console is not interactive."); + } } }