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 @@
-
+

- # 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 @@
-
-
-
-
-

-
- # 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 @@
-
-
+
-
+
-
+
-
+
+
+
+
+

+
+ # 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.");
+ }
}
}