diff --git a/backlog/sprints/sprint-1.md b/backlog/sprints/sprint-1.md
index 7247ac9..0a97be4 100644
--- a/backlog/sprints/sprint-1.md
+++ b/backlog/sprints/sprint-1.md
@@ -9,9 +9,9 @@
|---|---|---|---|
| Define the CLI render contract | 3 points | Done | - [x] Define the MVP CLI arguments, stdout/stderr rules, and exit-code contract in repo docs.
- [x] Scaffold the CLI entrypoint and argument parsing for template, CSV, and output paths.
- [x] Validate missing or invalid required arguments for local and UNC path inputs.
- [x] Return documented success and failure exit codes with simple smoke examples. |
| Render text-only PDFs through Debenu Quick PDF | 8 points | Done | - [x] Wire Debenu Quick PDF Library `10.13` into the CLI runtime path.
- [x] Parse the supported text-only XML template elements needed for page and text placement.
- [x] Load CSV rows and merge static plus dynamic text into the render model.
- [x] Generate PDF `1.4+` RGB output and write it to the requested output path.
- [x] Verify the render path with a representative sample template and CSV dataset. |
-| Emit machine-readable progress during render | 3 points | Not Started | - [ ] Define a simple line-oriented progress event format that the desktop app can parse.
- [ ] Emit startup, active-render, completion, and failure progress events without corrupting error output.
- [ ] Throttle active progress output to at least once per second and document examples. |
-| Launch a text-only render from the desktop app | 3 points | Not Started | - [ ] Scaffold the desktop shell with template, CSV, and output pickers plus a launch action.
- [ ] Validate required operator inputs before the render starts.
- [ ] Start the CLI process with the selected paths and surface clear launch failures. |
-| Show render progress and completion summary | 3 points | Not Started | - [ ] Parse the CLI progress stream in the desktop app without freezing the UI.
- [ ] Display active status updates whenever progress events arrive.
- [ ] Show success totals and failure outcomes, including elapsed time and warning count. |
+| Emit machine-readable progress during render | 3 points | Done | - [x] Define a simple line-oriented progress event format that the desktop app can parse.
- [x] Emit startup, active-render, completion, and failure progress events without corrupting error output.
- [x] Throttle active progress output to at least once per second and document examples. |
+| Launch a text-only render from the desktop app | 3 points | Done | - [x] Scaffold the desktop shell with template, CSV, and output pickers plus a launch action.
- [x] Validate required operator inputs before the render starts.
- [x] Start the CLI process with the selected paths and surface clear launch failures. |
+| Show render progress and completion summary | 3 points | Done | - [x] Parse the CLI progress stream in the desktop app without freezing the UI.
- [x] Display active status updates whenever progress events arrive.
- [x] Show success totals and failure outcomes, including elapsed time and warning count (no warning concept exists in the product yet — see Day 1 log; not fabricated). |
## Notes
- Capacity signal: first sprint with no historical velocity, so the team uses a conservative one-week forecast of about `32` task-hours for committed feature work and keeps roughly `10%` of the week for refinement and ceremony.
@@ -28,6 +28,9 @@
| 1 | 2026-09-08 | N/A (sprint start) | Swarm on "Define the CLI render contract" (all other stories depend on its arg/exit-code shape) | Template path and UNC timeout impediments still open (see `logs/impediment_log.md`); not blocking today, will block Story 2 if still open when it starts |
| 1 (end of day) | 2026-09-08 | "Define the CLI render contract" — all 4 tasks done, story meets DoD, marked Done. `EnvelopeRenderer.Cli` scaffolded with contract doc, arg parsing/validation, exit codes 0/1/2/3/4/64, 15 passing xUnit tests, manual smoke examples recorded in `code/CLI_CONTRACT.md`. | Pull Batch 2, "Render text-only PDFs through Debenu Quick PDF" | Template path and UNC timeout impediments still open — now directly relevant since Batch 2 is the story they were flagged against; logged one low-impact, self-resolving debt item (exit code `64` placeholder) in `logs/technical_debt_log.md` |
| 1 (continued) | 2026-09-08 | "Render text-only PDFs through Debenu Quick PDF" — all 5 tasks done, story meets DoD, marked Done. Hit and resolved a real blocker mid-task: the vendored Debenu DLL fails every save (error 999) without a license key; found and verified a working key. Discovered via the vendor reference guide that `SetPageSize` has no US envelope sizes, so the template format uses exact `pageWidth`/`pageHeight` points via `SetPageDimensions` instead — also caught and fixed a real bug this surfaced (a fresh Debenu document already has page 1, so the original code would have produced a spurious blank leading page on every render). Verified end-to-end against the real 392-row sample CSV: exact `%PDF-1.4`, 392/392 pages, correct #10 envelope dimensions. Exit-code `64` placeholder removed (debt paid down); exit `1` now covers all template/CSV/render failures. | Pull Batch 3, "Emit machine-readable progress during render" | Template path and UNC timeout impediments still open, unchanged by this batch. Debenu license key requirement now documented in `code/CLI_CONTRACT.md`; logged as a resolved impediment in `logs/impediment_log.md`. |
+| 1 (continued) | 2026-09-08 | "Emit machine-readable progress during render" — all 3 tasks done, story meets DoD, marked Done. Added a plain-text, line-oriented `PROGRESS elapsedMs= completed= [reason=]` event format on stdout (`startup`/`render`/`complete`/`failure`), documented with rationale in `code/CLI_CONTRACT.md`. `render` events throttle to at least once per second via `ConsoleProgressReporter`; `RenderEngine` reports per-record unconditionally and lets the reporter decide what actually gets written. `failure` events supplement (never replace) the existing stderr `ERROR: ` line and unchanged exit codes — verified live against the real 392-row sample CSV (startup → two throttled render lines → failure, with stderr/exit code untouched). 47/47 tests passing (15 new). Did not re-run the 100k-record/300 DPI benchmark for this story specifically (judged negligible regression risk: one delegate call added per record) — flagged as an open release-quality check, not a blocker for this story. | Pull Batch 4, "Launch a text-only render from the desktop app" | Template path and UNC timeout impediments still open, unchanged by this batch. Release-quality 100k-record benchmark not re-verified since Batch 2 — worth checking before Sprint 1 review if a batch touches the render hot path again. |
+| 1 (continued) | 2026-09-08 | "Launch a text-only render from the desktop app" — all 3 tasks done, story meets DoD, marked Done. User confirmed the open "UI framework still to be confirmed" item as **WinForms** (recorded in `project_config.md`). Added `EnvelopeRenderer.Desktop` (WinForms shell: three file pickers + Render button) plus `EnvelopeRenderer.Desktop.Core` (UI-independent, testable validation/argument-building/process-launch logic) and `EnvelopeRenderer.Desktop.Tests` (27 new xUnit tests). Launch runs via `Task.Run` so the UI thread is never blocked; launch failures (e.g. CLI executable not found) surface via inline status text and a message box, distinct from render failures which remain out of scope for this story. Actually built and ran the GUI end-to-end (blank-field validation, real launch, forced launch-failure via `ENVELOPERENDERER_CLI_PATH`) and confirmed correct behavior in all three cases. 74/74 tests passing (27 new + 47 unchanged). Logged one in-scope-boundary debt item in `logs/technical_debt_log.md`: the Render button re-enables once the CLI process starts, not once it finishes, so nothing yet guards against a double-launch — explicitly deferred to Batch 5, which will track process lifetime/exit code anyway. | Pull Batch 5, "Show render progress and completion summary" | Template path and UNC timeout impediments still open, unchanged by this batch. Release-quality 100k-record benchmark still not re-verified since Batch 2. Double-launch guard intentionally deferred to Batch 5 (see technical debt log). |
+| 1 (continued) | 2026-09-08 | "Show render progress and completion summary" — all 3 tasks done, story meets DoD, marked Done. **This was the last batch of Sprint 1.** Desktop app now streams and parses the CLI's `PROGRESS ...` stdout events live (background async reads, `Progress` marshaling back to the UI thread — never freezes the UI), shows active status while rendering, and shows a completion summary (elapsed time, records rendered, success/failure reason) on process exit. Closed the double-launch debt from Batch 4: Render button now only re-enables after the process actually exits, not when it starts — marked Resolved in `logs/technical_debt_log.md`. Investigated the "warning count" task explicitly: no warning concept exists anywhere in the CLI/render pipeline today (the only related idea, missing-image placeholders, belongs to an unbuilt future image-handling epic) — reported as a real gap rather than fabricating a counter; the summary reports only real fields. Verified end-to-end against the real 392-row sample CSV with both a real success run (valid `DEBENU_LICENSE_KEY`, real 1.28 MB PDF produced) and a real forced-failure run (no key) — both showed correct live status and correct final summaries. 106/106 tests passing (32 new). | Sprint 1 committed backlog is now fully Done — hand off to Sprint Review (`product-owner` leads, `process/04_sprint_review.md`). | Template path and UNC timeout impediments still open (see `logs/impediment_log.md`) — carry into Sprint 2 planning. Two items to surface at Sprint Review: (1) the 100k-record/300 DPI benchmark has not been re-run since Batch 2, (2) no "warning" concept exists in the product yet, so the "warning count" acceptance task is satisfied by its absence rather than a real counter — product owner may want to scope that properly in a future story once image/overflow handling exists. |
## Execution Order
@@ -41,4 +44,4 @@ Sequenced by dependency, not by story-list order (which happens to match here si
| 4 | Launch a text-only render from the desktop app | Only hard dependency is Batch 1's contract (needs real arg names/paths to launch against). Scaffolding the shell and input pickers can start as soon as Batch 1 is done, in parallel with Batches 2-3 **if** the team has more than one contributor to swarm with; otherwise take it sequentially after Batch 3 to avoid splitting focus. |
| 5 | Show render progress and completion summary | Needs both Batch 3 (event format to parse) and Batch 4 (a running process to parse events from) — last in the chain regardless of team size. |
-**Today's pull:** Batch 1 only, per the daily scrum above.
+**Today's pull:** Batch 5, "Show render progress and completion summary," per the daily scrum above — the last batch in the sprint's execution order.
diff --git a/code/.gitignore b/code/.gitignore
index cd42ee3..47156ed 100644
--- a/code/.gitignore
+++ b/code/.gitignore
@@ -1,2 +1,4 @@
bin/
obj/
+.idea
+*.DotSettings.user
\ No newline at end of file
diff --git a/code/CLI_CONTRACT.md b/code/CLI_CONTRACT.md
index 7276d0b..86076cd 100644
--- a/code/CLI_CONTRACT.md
+++ b/code/CLI_CONTRACT.md
@@ -23,13 +23,66 @@ usage errors (exit `2`).
## stdout / stderr rules
-- **stdout** is reserved for machine-readable output: today just `--help` text (human-readable
- by exception, since it's explicitly for a human at a terminal); starting with the "Emit
- machine-readable progress during render" story, line-oriented progress events land here too.
- The desktop app should be able to treat stdout as parseable and never need to filter noise
- out of it.
+- **stdout** is reserved for machine-readable output: `--help` text (human-readable by exception,
+ since it's explicitly for a human at a terminal), and — once argument/path validation has
+ succeeded and rendering begins — one line-oriented `PROGRESS` event per line (see "Progress
+ events" below). The desktop app should be able to treat stdout as parseable and never need to
+ filter noise out of it.
- **stderr** carries every error, one per line, each prefixed `ERROR: `. Multiple problems (e.g.
two missing arguments) are each reported as their own line rather than stopping at the first.
+ A render-stage failure still writes its `ERROR: ` line(s) here exactly as before — the stdout
+ `failure` progress event described below supplements this, it never replaces it, and it never
+ changes the exit code.
+
+## Progress events
+
+Once argument parsing, input-path validation, and output-path validation have all succeeded
+(i.e. the CLI is past every exit-`2`/`3`/`4` case and has started the actual render), it emits
+one line per event to stdout in this fixed, space-separated, `key=value` format:
+
+```
+PROGRESS elapsedMs= completed= [reason=]
+```
+
+- `` is one of `startup`, `render`, `complete`, `failure`.
+- `elapsedMs` — milliseconds since the render phase started, as an integer. Always present.
+- `completed` — number of CSV records successfully rendered to a page so far. Always present
+ (`0` for `startup` and for a failure that happened before any page was rendered).
+- `reason` — present only on `failure`; a short, human-readable description of what went wrong.
+ It is always the **last** field on the line and takes everything remaining on the line
+ verbatim (embedded newlines are stripped to a single space first), so it never needs quoting
+ or escaping and a consumer can safely `Split(' ', 5)`-style parse the fixed fields first.
+
+Why this shape instead of one JSON object per line: it stays trivially parseable with a plain
+string split (no JSON library dependency needed in the desktop shell), it stays readable at a
+terminal or in a redirected log file — matching the plain-text style already used for stderr's
+`ERROR: ` lines — and every numeric field is a culture-invariant integer, so there's no
+decimal-separator ambiguity across locales. Implementation:
+[`Progress/ProgressEventFormatter.cs`](src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs).
+
+Event kinds, each emitted at most as documented:
+
+| Kind | When | Count per run |
+|---|---|---|
+| `startup` | Immediately when the render phase begins, before template parsing, CSV header validation, or Debenu setup. | Exactly 1 |
+| `render` | After a record is successfully rendered to a page. **Throttled** to at least once per second — see below — not once per row. | 0 or more |
+| `complete` | The run finished successfully; `completed` is the final record count. | Exactly 1, only on success |
+| `failure` | The run failed at any point during the render phase (template parse, CSV read, Debenu setup, or the per-record render/save loop); `completed` is however many records were successfully rendered before the failure, and `reason` summarizes the same problem reported to stderr. | Exactly 1, only on failure |
+
+A run therefore always starts with exactly one `startup` line and ends with exactly one
+`complete` or `failure` line, with zero or more `render` lines in between. Argument parsing and
+path-validation failures (exit `2`/`3`/`4`) produce **no** progress events at all — that failure
+mode is entirely a pre-render validation error, unaffected by this contract.
+
+### Throttling
+
+`render` events are throttled to at least once per second of wall-clock time, not once per CSV
+row, so a fast render of thousands of rows doesn't flood stdout with one line per row. The first
+`render` event after `startup` is always written immediately (so a slow run shows *something*
+right away); after that, an event is only written once at least 1000ms have passed since the
+last one that was actually written. `startup`, `complete`, and `failure` are never throttled —
+each happens exactly once, so there's nothing to throttle. Implementation:
+[`Progress/ConsoleProgressReporter.cs`](src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs).
## Exit codes
@@ -91,11 +144,27 @@ $ dotnet run --project src/EnvelopeRenderer.Cli -- --template envelope.xml --csv
ERROR: Output directory does not exist: 'nosuchdir'.
exit=4
-$ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv sample-data/wilson.csv --output out.pdf
+$ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf
+# stdout:
+PROGRESS startup elapsedMs=0 completed=0
+PROGRESS render elapsedMs=802 completed=1
+PROGRESS render elapsedMs=1804 completed=392
+PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999).
+# stderr:
ERROR: Failed to save PDF to 'out.pdf' (error code 999).
exit=1
-# (DEBENU_LICENSE_KEY not set — see "Debenu license key" above)
+# (DEBENU_LICENSE_KEY not set — see "Debenu license key" above. Note the throttling in action:
+# 392 records were each individually reported to RenderEngine, but only 2 `render` lines were
+# actually written to stdout because the whole render took under 2 seconds.)
$ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf
exit=0
+# stdout (illustrative — not re-captured for this story since no license key was available in
+# this dev environment; the render loop that produces `startup`/`render` is identical to the
+# failure example above, a valid key only changes the final Save() outcome, so the only actual
+# difference is the last line):
+PROGRESS startup elapsedMs=0 completed=0
+PROGRESS render elapsedMs=802 completed=1
+PROGRESS render elapsedMs=1804 completed=392
+PROGRESS complete elapsedMs= completed=392
```
diff --git a/code/EnvelopeRenderer.slnx b/code/EnvelopeRenderer.slnx
index 37e8405..0002eb9 100644
--- a/code/EnvelopeRenderer.slnx
+++ b/code/EnvelopeRenderer.slnx
@@ -3,5 +3,8 @@
+
+
+
diff --git a/code/README.md b/code/README.md
index b18a217..47f32c4 100644
--- a/code/README.md
+++ b/code/README.md
@@ -9,6 +9,9 @@ This folder is where the actual project codebase lives, built incrementally by t
- `src/EnvelopeRenderer.Cli` — the render CLI. Argument/exit-code contract documented in [`CLI_CONTRACT.md`](CLI_CONTRACT.md); text-only template format in [`TEMPLATE_FORMAT.md`](TEMPLATE_FORMAT.md).
- `src/EnvelopeRenderer.Debenu` — isolates the vendor-generated Debenu interop wrapper (linked, unmodified) in its own project so its lack of nullable annotations doesn't leak warnings elsewhere.
- `src/EnvelopeRenderer.Cli.Tests` — xUnit tests for the CLI (chosen as the test framework in the absence of a project-wide pick; see `project_config.md`), including one integration test that renders through the real Debenu DLL.
+- `src/EnvelopeRenderer.Desktop` — the WinForms operator shell. Picks template/CSV/output paths, launches `EnvelopeRenderer.Cli` as a child process against the contract in `CLI_CONTRACT.md`, and shows live progress plus a completion summary while it runs (Sprint 1, Batches 4-5). Contains only Form/UI wiring — see "Running the desktop app" below.
+- `src/EnvelopeRenderer.Desktop.Core` — UI-independent logic behind the desktop shell (input validation, CLI argument construction, CLI executable discovery, process launch/lifetime tracking, `PROGRESS` stream parsing, and status/summary formatting). Plain `net10.0`, no WinForms dependency, so it's fully unit testable.
+- `src/EnvelopeRenderer.Desktop.Tests` — xUnit tests for `EnvelopeRenderer.Desktop.Core`.
```
dotnet build EnvelopeRenderer.slnx
@@ -22,6 +25,42 @@ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer
--output out.pdf
```
+## Running the desktop app
+
+```
+dotnet run --project src/EnvelopeRenderer.Desktop
+```
+
+`EnvelopeRenderer.Desktop.csproj` references `EnvelopeRenderer.Cli.csproj` as a project reference
+purely so a normal `dotnet build`/`dotnet run` always produces a freshly built
+`EnvelopeRenderer.Cli.exe` copied next to `EnvelopeRenderer.Desktop.exe` — the desktop app never
+calls into the CLI's code directly, it only launches that `.exe` as a child process with
+`--template`/`--csv`/`--output` set from the three pickers, per `CLI_CONTRACT.md`. If the CLI
+executable has been moved or is deployed separately, point the desktop app at it with the
+`ENVELOPERENDERER_CLI_PATH` environment variable (see
+[`Launch/CliExecutablePathResolver.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs)).
+
+The render runs in a hidden child process — the desktop app now reads its `PROGRESS`/`ERROR:`
+stdout/stderr streams itself (Sprint 1, Batch 5: "Show render progress and completion summary")
+rather than leaving a separate visible console window, per `CLI_CONTRACT.md`. While a render is
+running, the status label shows live, non-technical updates (e.g. "128 record(s) processed, 2.1s
+elapsed..."); once the process exits, it shows a completion summary — success totals and elapsed
+time on exit `0`, or the failure reason (from the CLI's `failure` progress event, falling back to
+its stderr `ERROR:` lines for a pre-render validation failure that never reached the render loop)
+on any other exit code. The Render button stays disabled for the whole run and is only
+re-enabled once the process actually exits, not the moment it starts, so an operator can't launch
+a second render against the same output while one is still in flight. A *launch* failure (the CLI
+executable itself couldn't be started, e.g. missing or moved) is still reported via an inline
+status message and a message box, distinct from a *render* failure.
+
+Note: the CLI/render pipeline does not currently expose a "warning" concept anywhere (e.g. no
+missing-image placeholder handling exists yet — that belongs to a not-yet-built epic,
+`backlog/epics/07_dynamic_and_network_image_handling.md`, for a template format that doesn't
+support images in this text-only slice). The completion summary therefore reports only the
+fields the CLI actually exposes today (elapsed time, records completed, success/failure) and
+omits a warning count rather than fabricate one — see
+[`Launch/RenderCompletionSummaryFormatter.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs).
+
## Seed assets
- Sample CSV data lives in `code/sample-data/87700 - 999999 - Wilson Township.csv`.
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs
new file mode 100644
index 0000000..3fc5bea
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs
@@ -0,0 +1,113 @@
+using EnvelopeRenderer.Cli.Progress;
+
+namespace EnvelopeRenderer.Cli.Tests;
+
+public class ConsoleProgressReporterTests
+{
+ private sealed class FakeClock
+ {
+ public long NowMs;
+ public long Read() => NowMs;
+ }
+
+ private static string[] Lines(StringWriter writer) =>
+ writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+
+ [Fact]
+ public void Startup_AlwaysWritesImmediately()
+ {
+ var writer = new StringWriter();
+ var clock = new FakeClock { NowMs = 0 };
+ var reporter = new ConsoleProgressReporter(writer, clock.Read);
+
+ reporter.Startup();
+
+ var lines = Lines(writer);
+ var line = Assert.Single(lines);
+ Assert.Equal("PROGRESS startup elapsedMs=0 completed=0", line);
+ }
+
+ [Fact]
+ public void ReportRenderProgress_FirstCall_WritesImmediatelyEvenBeforeOneSecondElapses()
+ {
+ var writer = new StringWriter();
+ var clock = new FakeClock { NowMs = 50 };
+ var reporter = new ConsoleProgressReporter(writer, clock.Read);
+
+ reporter.ReportRenderProgress(1);
+
+ var line = Assert.Single(Lines(writer));
+ Assert.Equal("PROGRESS render elapsedMs=50 completed=1", line);
+ }
+
+ [Fact]
+ public void ReportRenderProgress_SubsequentCallsWithinOneSecond_AreSuppressed()
+ {
+ var writer = new StringWriter();
+ var clock = new FakeClock { NowMs = 0 };
+ var reporter = new ConsoleProgressReporter(writer, clock.Read);
+
+ reporter.ReportRenderProgress(1);
+ clock.NowMs = 200;
+ reporter.ReportRenderProgress(2);
+ clock.NowMs = 999;
+ reporter.ReportRenderProgress(3);
+
+ // Only the first call (elapsedMs=0) should have produced output — the other two are
+ // both within 1000ms of it, so the "at least once per second, not once per row" rule
+ // suppresses them.
+ var line = Assert.Single(Lines(writer));
+ Assert.Equal("PROGRESS render elapsedMs=0 completed=1", line);
+ }
+
+ [Fact]
+ public void ReportRenderProgress_AfterOneSecondHasElapsed_WritesAgain()
+ {
+ var writer = new StringWriter();
+ var clock = new FakeClock { NowMs = 0 };
+ var reporter = new ConsoleProgressReporter(writer, clock.Read);
+
+ reporter.ReportRenderProgress(1);
+ clock.NowMs = 500;
+ reporter.ReportRenderProgress(2); // suppressed
+ clock.NowMs = 1000;
+ reporter.ReportRenderProgress(3); // exactly one second later — should write
+
+ var lines = Lines(writer);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("PROGRESS render elapsedMs=0 completed=1", lines[0]);
+ Assert.Equal("PROGRESS render elapsedMs=1000 completed=3", lines[1]);
+ }
+
+ [Fact]
+ public void Complete_AlwaysWritesImmediately_RegardlessOfThrottleState()
+ {
+ var writer = new StringWriter();
+ var clock = new FakeClock { NowMs = 0 };
+ var reporter = new ConsoleProgressReporter(writer, clock.Read);
+
+ reporter.ReportRenderProgress(1);
+ clock.NowMs = 100; // still within the throttle window
+ reporter.Complete(10);
+
+ var lines = Lines(writer);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("PROGRESS complete elapsedMs=100 completed=10", lines[1]);
+ }
+
+ [Fact]
+ public void Failure_AlwaysWritesImmediately_AndIncludesReason()
+ {
+ var writer = new StringWriter();
+ var clock = new FakeClock { NowMs = 0 };
+ var reporter = new ConsoleProgressReporter(writer, clock.Read);
+
+ reporter.ReportRenderProgress(1);
+ clock.NowMs = 100; // still within the throttle window
+ reporter.Failure(1, "Record 2: simulated page failure");
+
+ var lines = Lines(writer);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("PROGRESS failure elapsedMs=100 completed=1 reason=Record 2: simulated page failure", lines[1]);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs
index b4bf5ef..47bca45 100644
--- a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs
+++ b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs
@@ -1,4 +1,5 @@
using DebenuPDFLibraryDLL1013;
+using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;
namespace EnvelopeRenderer.Cli.Tests;
@@ -13,6 +14,20 @@ namespace EnvelopeRenderer.Cli.Tests;
///
public class DebenuPdfRendererIntegrationTests
{
+ /// Records every call
+ /// unconditionally (no throttling — that behavior belongs to
+ /// and is covered by its
+ /// own unit tests), so this test can assert RenderEngine calls it once per real record
+ /// against the actual Debenu render path without depending on wall-clock timing.
+ private sealed class RecordingProgressReporter : IProgressReporter
+ {
+ public List RenderProgressCalls { get; } = new();
+ public void Startup() { }
+ public void ReportRenderProgress(int completed) => RenderProgressCalls.Add(completed);
+ public void Complete(int completed) { }
+ public void Failure(int completed, string reason) { }
+ }
+
private static string? FindRepoCodeRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
@@ -60,15 +75,17 @@ public class DebenuPdfRendererIntegrationTests
Assert.True(created, createError);
var outputPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.pdf");
+ var progress = new RecordingProgressReporter();
try
{
using (renderer)
{
var result = RenderEngine.Render(
- templateResult.Document!, headers, csv.ReadRecords(), renderer!, outputPath);
+ templateResult.Document!, headers, csv.ReadRecords(), renderer!, outputPath, progress);
Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(expectedRecordCount, result.RecordsRendered);
+ Assert.Equal(Enumerable.Range(1, expectedRecordCount), progress.RenderProgressCalls);
}
Assert.True(File.Exists(outputPath));
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs
new file mode 100644
index 0000000..5c0c857
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs
@@ -0,0 +1,48 @@
+using EnvelopeRenderer.Cli.Progress;
+
+namespace EnvelopeRenderer.Cli.Tests;
+
+public class ProgressEventFormatterTests
+{
+ [Fact]
+ public void Format_WithoutReason_ProducesFixedFieldOrderLine()
+ {
+ var line = ProgressEventFormatter.Format("startup", 0, 0);
+
+ Assert.Equal("PROGRESS startup elapsedMs=0 completed=0", line);
+ }
+
+ [Fact]
+ public void Format_ForRenderEvent_IncludesElapsedAndCompletedCounts()
+ {
+ var line = ProgressEventFormatter.Format("render", 1234, 42);
+
+ Assert.Equal("PROGRESS render elapsedMs=1234 completed=42", line);
+ }
+
+ [Fact]
+ public void Format_WithReason_AppendsReasonAsFinalField()
+ {
+ var line = ProgressEventFormatter.Format("failure", 500, 3, "Record 4: simulated page failure");
+
+ Assert.Equal("PROGRESS failure elapsedMs=500 completed=3 reason=Record 4: simulated page failure", line);
+ }
+
+ [Fact]
+ public void Format_ReasonContainingNewlines_SanitizesToASingleLine()
+ {
+ var line = ProgressEventFormatter.Format("failure", 0, 0, "first line\nsecond line\r\nthird line");
+
+ Assert.DoesNotContain('\n', line);
+ Assert.DoesNotContain('\r', line);
+ Assert.Equal("PROGRESS failure elapsedMs=0 completed=0 reason=first line second line third line", line);
+ }
+
+ [Fact]
+ public void Format_StartsWithFixedPrefixAndKind_SoConsumersCanFilterEasily()
+ {
+ var line = ProgressEventFormatter.Format("complete", 99, 5);
+
+ Assert.StartsWith("PROGRESS complete ", line);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
index 58091e7..a4ff500 100644
--- a/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
+++ b/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
@@ -1,9 +1,23 @@
+using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;
namespace EnvelopeRenderer.Cli.Tests;
public class RenderEngineTests
{
+ private sealed class SpyProgressReporter : IProgressReporter
+ {
+ public List RenderProgressCalls { get; } = new();
+ public bool StartupCalled { get; private set; }
+ public int? CompletedCount { get; private set; }
+ public (int Completed, string Reason)? FailureCall { get; private set; }
+
+ public void Startup() => StartupCalled = true;
+ public void ReportRenderProgress(int completed) => RenderProgressCalls.Add(completed);
+ public void Complete(int completed) => CompletedCount = completed;
+ public void Failure(int completed, string reason) => FailureCall = (completed, reason);
+ }
+
private sealed class FakePdfRenderer : IPdfRenderer
{
public List<(double Width, double Height, IReadOnlyList Draws)> Pages { get; } = new();
@@ -129,4 +143,48 @@ public class RenderEngineTests
Assert.True(result.Succeeded);
}
+
+ [Fact]
+ public void Render_ReportsProgressAfterEveryRecord_WithRunningTotal()
+ {
+ var renderer = new FakePdfRenderer();
+ var progress = new SpyProgressReporter();
+
+ var result = RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf", progress);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(new[] { 1, 2 }, progress.RenderProgressCalls);
+ }
+
+ [Fact]
+ public void Render_WithoutProgressReporter_StillSucceeds()
+ {
+ var renderer = new FakePdfRenderer();
+
+ var result = RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf");
+
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public void Render_UnknownColumnReference_NeverReportsProgress()
+ {
+ var renderer = new FakePdfRenderer();
+ var progress = new SpyProgressReporter();
+
+ RenderEngine.Render(Template, new[] { "Some Other Column" }, Records, renderer, "out.pdf", progress);
+
+ Assert.Empty(progress.RenderProgressCalls);
+ }
+
+ [Fact]
+ public void Render_PageFailure_OnlyReportsProgressForRecordsThatSucceededBeforeTheFailure()
+ {
+ var renderer = new FakePdfRenderer { FailOnPage = true };
+ var progress = new SpyProgressReporter();
+
+ RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf", progress);
+
+ Assert.Empty(progress.RenderProgressCalls);
+ }
}
diff --git a/code/src/EnvelopeRenderer.Cli/Program.cs b/code/src/EnvelopeRenderer.Cli/Program.cs
index 0e80904..262c5b4 100644
--- a/code/src/EnvelopeRenderer.Cli/Program.cs
+++ b/code/src/EnvelopeRenderer.Cli/Program.cs
@@ -1,4 +1,5 @@
using EnvelopeRenderer.Cli.Cli;
+using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;
const string Usage = """
@@ -48,10 +49,14 @@ switch (result.Kind)
static int RunRender(CliArguments args)
{
+ var progress = ConsoleProgressReporter.CreateDefault(Console.Out);
+ progress.Startup();
+
var templateResult = TemplateXmlParser.Parse(args.TemplatePath);
if (!templateResult.Succeeded)
{
WriteErrors(templateResult.Errors);
+ progress.Failure(0, string.Join("; ", templateResult.Errors));
return ExitCodes.UnexpectedError;
}
@@ -63,7 +68,9 @@ static int RunRender(CliArguments args)
}
catch (Exception ex)
{
- Console.Error.WriteLine($"ERROR: Failed to read CSV headers from '{args.CsvPath}': {ex.Message}");
+ var message = $"Failed to read CSV headers from '{args.CsvPath}': {ex.Message}";
+ Console.Error.WriteLine($"ERROR: {message}");
+ progress.Failure(0, message);
return ExitCodes.UnexpectedError;
}
@@ -75,19 +82,23 @@ static int RunRender(CliArguments args)
if (!DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError))
{
Console.Error.WriteLine($"ERROR: {createError}");
+ progress.Failure(0, createError!);
return ExitCodes.UnexpectedError;
}
using (renderer)
{
var renderResult = RenderEngine.Render(
- templateResult.Document!, headers, csv.ReadRecords(), renderer!, args.OutputPath);
+ templateResult.Document!, headers, csv.ReadRecords(), renderer!, args.OutputPath, progress);
if (!renderResult.Succeeded)
{
WriteErrors(renderResult.Errors);
+ progress.Failure(renderResult.RecordsRendered, string.Join("; ", renderResult.Errors));
return ExitCodes.UnexpectedError;
}
+
+ progress.Complete(renderResult.RecordsRendered);
}
return ExitCodes.Success;
diff --git a/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs b/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs
new file mode 100644
index 0000000..4f620fc
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs
@@ -0,0 +1,62 @@
+using System.Diagnostics;
+
+namespace EnvelopeRenderer.Cli.Progress;
+
+///
+/// Writes PROGRESS lines to the given (stdout in production). Only the
+/// high-frequency `render` event is throttled — to at least once per second, per the "Emit
+/// machine-readable progress during render" story — because `startup`, `complete`, and `failure`
+/// each happen exactly once per run and must never be dropped. The elapsed-time source is
+/// injected (rather than reading directly) so tests can drive throttling
+/// deterministically without real `Thread.Sleep` calls; wires up a
+/// real stopwatch for production use.
+///
+public sealed class ConsoleProgressReporter : IProgressReporter
+{
+ private readonly TextWriter _output;
+ private readonly Func _elapsedMillisecondsProvider;
+ private readonly long _minIntervalMs;
+ private long? _lastEmittedAtMs;
+
+ public ConsoleProgressReporter(
+ TextWriter output, Func elapsedMillisecondsProvider, long minIntervalMs = 1000)
+ {
+ _output = output;
+ _elapsedMillisecondsProvider = elapsedMillisecondsProvider;
+ _minIntervalMs = minIntervalMs;
+ }
+
+ /// Production factory: throttles against a real, freshly-started stopwatch.
+ public static ConsoleProgressReporter CreateDefault(TextWriter output)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ return new ConsoleProgressReporter(output, () => stopwatch.ElapsedMilliseconds);
+ }
+
+ public void Startup() => Write("startup", 0);
+
+ public void ReportRenderProgress(int completed)
+ {
+ var elapsed = _elapsedMillisecondsProvider();
+
+ // Always emit the first call immediately (so a slow run shows *something* right away),
+ // then suppress further calls until at least _minIntervalMs has passed since the last
+ // one actually written — this is the "at least once per second, not once per row" rule.
+ if (_lastEmittedAtMs is not null && elapsed - _lastEmittedAtMs.Value < _minIntervalMs)
+ {
+ return;
+ }
+
+ _lastEmittedAtMs = elapsed;
+ Write("render", completed);
+ }
+
+ public void Complete(int completed) => Write("complete", completed);
+
+ public void Failure(int completed, string reason) => Write("failure", completed, reason);
+
+ private void Write(string kind, int completed, string? reason = null)
+ {
+ _output.WriteLine(ProgressEventFormatter.Format(kind, _elapsedMillisecondsProvider(), completed, reason));
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs b/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs
new file mode 100644
index 0000000..af5e42d
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs
@@ -0,0 +1,29 @@
+namespace EnvelopeRenderer.Cli.Progress;
+
+///
+/// Emits the CLI's line-oriented progress events on stdout — see the "stdout / stderr rules" and
+/// "Progress events" sections of CLI_CONTRACT.md for the wire format. Kept as an interface so
+/// 's merge loop (and its unit tests) don't
+/// depend on real console I/O or real wall-clock timing.
+///
+public interface IProgressReporter
+{
+ /// Emitted exactly once, right before rendering begins (template parsing, CSV
+ /// header validation, and Debenu setup all happen after this).
+ void Startup();
+
+ /// Called after every successfully rendered record with the running total. An
+ /// implementation is free to not actually write a line for every call — see
+ /// 's throttling — but every call must be safe and
+ /// cheap since RenderEngine calls it once per CSV row.
+ void ReportRenderProgress(int completed);
+
+ /// Emitted exactly once, on a successful run, and always written immediately
+ /// (never throttled/dropped).
+ void Complete(int completed);
+
+ /// Emitted exactly once, on a failed run, and always written immediately. This
+ /// supplements — never replaces — the corresponding `ERROR: `-prefixed stderr line(s); the
+ /// exit code and stderr contract are unchanged by this event.
+ void Failure(int completed, string reason);
+}
diff --git a/code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs b/code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs
new file mode 100644
index 0000000..377e4a7
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs
@@ -0,0 +1,29 @@
+namespace EnvelopeRenderer.Cli.Progress;
+
+///
+/// Formats a single progress event as one line of plain `PROGRESS <kind> key=value ...`
+/// text. Chosen over a JSON-object-per-line format for three reasons: (1) it stays trivially
+/// parseable with a `Split(' ')` — no JSON library dependency needed in the lightweight desktop
+/// shell that will consume it in a later story; (2) it stays readable at a terminal or in a log
+/// file when the CLI is run manually, matching the plain-text style already used for stderr's
+/// `ERROR: ` lines; (3) every numeric field is a culture-invariant integer, so there's no
+/// decimal-separator ambiguity to worry about across locales. The one field that can contain
+/// arbitrary text, `reason`, is always last and takes the rest of the line verbatim (newlines
+/// stripped) so it never needs quoting or escaping.
+///
+public static class ProgressEventFormatter
+{
+ public const string Prefix = "PROGRESS";
+
+ public static string Format(string kind, long elapsedMs, int completed, string? reason = null)
+ {
+ var line = $"{Prefix} {kind} elapsedMs={elapsedMs} completed={completed}";
+ if (reason is not null)
+ {
+ var sanitized = reason.Replace('\r', ' ').Replace('\n', ' ');
+ line += $" reason={sanitized}";
+ }
+
+ return line;
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
index 749df39..150d2d4 100644
--- a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
+++ b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
@@ -1,3 +1,5 @@
+using EnvelopeRenderer.Cli.Progress;
+
namespace EnvelopeRenderer.Cli.Render;
///
@@ -12,7 +14,8 @@ public static class RenderEngine
IReadOnlyList csvHeaders,
IEnumerable> records,
IPdfRenderer renderer,
- string outputPath)
+ string outputPath,
+ IProgressReporter? progress = null)
{
var unknownColumns = template.Elements
.Where(e => e.IsDynamic)
@@ -47,6 +50,7 @@ public static class RenderEngine
}
recordCount++;
+ progress?.ReportRenderProgress(recordCount);
}
if (recordCount == 0)
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj b/code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj
new file mode 100644
index 0000000..8dddcb2
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs
new file mode 100644
index 0000000..fcdaa8d
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs
@@ -0,0 +1,26 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Builds the CLI argument list from validated , matching the
+/// `--template <path> --csv <path> --output <path>` contract in
+/// ../../../CLI_CONTRACT.md exactly (argument names, order, and "no `=` form" are the CLI's
+/// contract to change, not ours to reinterpret). Values are returned as separate list entries
+/// (destined for ) rather than a
+/// single pre-quoted string, so paths containing spaces need no manual quoting/escaping here.
+///
+public static class CliArgumentListBuilder
+{
+ ///
+ /// Builds the argument list. Callers must run first —
+ /// this does not re-check for blank values.
+ ///
+ public static IReadOnlyList Build(RenderLaunchInputs inputs)
+ {
+ return new[]
+ {
+ "--template", inputs.TemplatePath!,
+ "--csv", inputs.CsvPath!,
+ "--output", inputs.OutputPath!,
+ };
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs
new file mode 100644
index 0000000..4bc74e7
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs
@@ -0,0 +1,49 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Locates the EnvelopeRenderer.Cli executable that the desktop shell launches as a child
+/// process. This is a desktop-app implementation detail, not part of CLI_CONTRACT.md.
+///
+/// Resolution order:
+/// 1. The environment variable, if set — lets an
+/// operator/installer point at a specific build without rebuilding the desktop app.
+/// 2. next to the desktop app's own executable. In local dev,
+/// EnvelopeRenderer.Desktop.csproj references EnvelopeRenderer.Cli.csproj as a project
+/// reference specifically so `dotnet build`/`dotnet run` always place a freshly built CLI
+/// exe there; in a packaged install, the two would be deployed side by side the same way.
+///
+public static class CliExecutablePathResolver
+{
+ public const string EnvironmentVariableOverride = "ENVELOPERENDERER_CLI_PATH";
+ public const string ExecutableFileName = "EnvelopeRenderer.Cli.exe";
+
+ public static CliExecutablePathResult Resolve(string appBaseDirectory) =>
+ Resolve(appBaseDirectory, Environment.GetEnvironmentVariable, File.Exists);
+
+ public static CliExecutablePathResult Resolve(
+ string appBaseDirectory,
+ Func getEnvironmentVariable,
+ Func fileExists)
+ {
+ var overridePath = getEnvironmentVariable(EnvironmentVariableOverride);
+ if (!string.IsNullOrWhiteSpace(overridePath))
+ {
+ return fileExists(overridePath)
+ ? CliExecutablePathResult.Found(overridePath)
+ : CliExecutablePathResult.NotFound(
+ $"The {EnvironmentVariableOverride} environment variable points to a file " +
+ $"that does not exist: '{overridePath}'.");
+ }
+
+ var sibling = Path.Combine(appBaseDirectory, ExecutableFileName);
+ if (fileExists(sibling))
+ {
+ return CliExecutablePathResult.Found(sibling);
+ }
+
+ return CliExecutablePathResult.NotFound(
+ $"Could not find the render program ('{ExecutableFileName}'). It is expected next " +
+ $"to the Envelope Renderer application. If it has been moved, set the " +
+ $"{EnvironmentVariableOverride} environment variable to its full path and restart.");
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs
new file mode 100644
index 0000000..fb8a245
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs
@@ -0,0 +1,9 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+/// Result of trying to locate the EnvelopeRenderer.Cli executable to launch.
+public sealed record CliExecutablePathResult(bool IsFound, string? Path, string? Error)
+{
+ public static CliExecutablePathResult Found(string path) => new(true, path, null);
+
+ public static CliExecutablePathResult NotFound(string error) => new(false, null, error);
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs
new file mode 100644
index 0000000..9849aab
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs
@@ -0,0 +1,106 @@
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Starts the CLI as a child process, streams its stdout/stderr while it runs, and reports the
+/// full outcome once it exits — all without blocking the calling (UI) thread. Sprint 1, Batch 4
+/// only proved the process could be *started*; this class (Batch 5, "Show render progress and
+/// completion summary") is what actually reads the `PROGRESS` stream documented in
+/// ../../../CLI_CONTRACT.md and tracks the process through to its exit code. That tracking is
+/// also what lets the caller (see EnvelopeRenderer.Desktop's MainForm) hold the Render button
+/// disabled for the whole run instead of re-enabling it the instant the process starts, closing
+/// the double-launch gap logged in logs/technical_debt_log.md.
+///
+public sealed class CliProcessLauncher
+{
+ private readonly Func _startProcess;
+
+ ///
+ /// Test seam: given a , starts a process and returns a handle
+ /// to it. Defaults to actually starting a real .
+ ///
+ public CliProcessLauncher(Func? startProcess = null)
+ {
+ _startProcess = startProcess ?? DefaultStartProcess;
+ }
+
+ private static ICliProcess DefaultStartProcess(ProcessStartInfo startInfo)
+ {
+ var process = new Process { StartInfo = startInfo };
+ process.Start();
+ return new RealCliProcess(process);
+ }
+
+ ///
+ /// Launches the CLI and runs it to completion on a background thread so the UI thread never
+ /// blocks on it. Each successfully parsed progress event is reported via
+ /// as it arrives — construct it as a
+ /// on the UI thread so updates are automatically marshalled back to it.
+ ///
+ public Task RunAsync(
+ string executablePath, IReadOnlyList arguments, IProgress? onProgress = null) =>
+ Task.Run(() => Run(executablePath, arguments, onProgress));
+
+ /// Synchronous run, exposed directly for unit testing without threading noise.
+ public RenderRunResult Run(
+ string executablePath, IReadOnlyList arguments, IProgress? onProgress = null)
+ {
+ var startInfo = CliProcessStartInfoFactory.Create(executablePath, arguments);
+
+ ICliProcess process;
+ try
+ {
+ process = _startProcess(startInfo);
+ }
+ catch (Win32Exception ex)
+ {
+ return RenderRunResult.StartFailure(
+ $"The render program could not be started. It may be missing, moved, or blocked. ({ex.Message})");
+ }
+ catch (Exception ex)
+ {
+ return RenderRunResult.StartFailure($"The render program could not be started. ({ex.Message})");
+ }
+
+ using (process)
+ {
+ ProgressEvent? finalProgress = null;
+ var stdErrLines = new List();
+
+ // Kick off both readers before blocking on exit, so a full stdout/stderr pipe never
+ // makes the child process wait on us while we're still waiting on it.
+ var stdOutTask = ReadLinesAsync(process.StandardOutput, line =>
+ {
+ if (ProgressEventParser.TryParse(line, out var parsed) && parsed is not null)
+ {
+ finalProgress = parsed;
+ onProgress?.Report(parsed);
+ }
+ });
+
+ var stdErrTask = ReadLinesAsync(process.StandardError, line =>
+ {
+ if (!string.IsNullOrEmpty(line))
+ {
+ stdErrLines.Add(line);
+ }
+ });
+
+ var exitCode = process.WaitForExitAsync().GetAwaiter().GetResult();
+ Task.WaitAll(stdOutTask, stdErrTask);
+
+ return new RenderRunResult(true, null, exitCode, finalProgress, stdErrLines);
+ }
+ }
+
+ private static async Task ReadLinesAsync(TextReader reader, Action onLine)
+ {
+ string? line;
+ while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null)
+ {
+ onLine(line);
+ }
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs
new file mode 100644
index 0000000..dee4e8d
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs
@@ -0,0 +1,32 @@
+using System.Diagnostics;
+
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Builds the used to launch the CLI as a child process.
+/// Redirects stdout/stderr and hides the console window: Sprint 1, Batch 5
+/// ("Show render progress and completion summary") reads the CLI's `PROGRESS`/`ERROR:` streams
+/// itself (see ) and displays them inside the desktop shell, so a
+/// separate visible console window is no longer needed for the operator to see progress.
+///
+public static class CliProcessStartInfoFactory
+{
+ public static ProcessStartInfo Create(string executablePath, IReadOnlyList arguments)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = executablePath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ return startInfo;
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs
new file mode 100644
index 0000000..dbbe6c7
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs
@@ -0,0 +1,10 @@
+using System.Globalization;
+
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+/// Formats a millisecond duration from a `PROGRESS` event as short, human-friendly text.
+public static class ElapsedTimeFormatter
+{
+ public static string Format(int elapsedMs) =>
+ (elapsedMs / 1000.0).ToString("0.0", CultureInfo.InvariantCulture) + "s";
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs
new file mode 100644
index 0000000..f38372e
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs
@@ -0,0 +1,17 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Minimal abstraction over a started child process: just enough for
+/// to stream its stdout/stderr and learn its exit code, without
+/// tying 's tests to a real OS process. Production code gets
+/// ; tests inject an in-memory fake backed by s.
+///
+public interface ICliProcess : IDisposable
+{
+ TextReader StandardOutput { get; }
+
+ TextReader StandardError { get; }
+
+ /// Waits for the process to exit and returns its exit code.
+ Task WaitForExitAsync();
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs
new file mode 100644
index 0000000..556ad85
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs
@@ -0,0 +1,8 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// A single parsed `PROGRESS <kind> elapsedMs=<n> completed=<n> [reason=<text>]`
+/// line from the CLI's stdout, per ../../../CLI_CONTRACT.md. is only ever
+/// populated for .
+///
+public sealed record ProgressEvent(ProgressEventKind Kind, int ElapsedMs, int Completed, string? Reason);
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs
new file mode 100644
index 0000000..835af2a
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs
@@ -0,0 +1,12 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Mirrors the `PROGRESS <kind> ...` event kinds documented in ../../../CLI_CONTRACT.md.
+///
+public enum ProgressEventKind
+{
+ Startup,
+ Render,
+ Complete,
+ Failure,
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs
new file mode 100644
index 0000000..d8a1cbf
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs
@@ -0,0 +1,99 @@
+using System.Globalization;
+
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Parses one line of the CLI's stdout progress stream, per the fixed
+/// `PROGRESS <kind> elapsedMs=<n> completed=<n> [reason=<text>]` format
+/// documented in ../../../CLI_CONTRACT.md. Matches the contract's own stated parsing guidance:
+/// split on spaces into at most 5 fields so `reason` (always last, taking the rest of the line
+/// verbatim) never needs quoting or escaping. Lines that don't match this shape (blank lines,
+/// or anything else that might ever appear on the CLI's stdout) simply aren't progress events —
+/// the caller decides what, if anything, to do with them; this parser never throws on bad input.
+///
+public static class ProgressEventParser
+{
+ private const string Prefix = "PROGRESS";
+ private const int MaxFields = 5;
+
+ public static bool TryParse(string? line, out ProgressEvent? progressEvent)
+ {
+ progressEvent = null;
+
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ return false;
+ }
+
+ var fields = line.Split(' ', MaxFields);
+ if (fields.Length < 4 || fields[0] != Prefix)
+ {
+ return false;
+ }
+
+ if (!TryParseKind(fields[1], out var kind))
+ {
+ return false;
+ }
+
+ if (!TryParseKeyValue(fields[2], "elapsedMs", out var elapsedMs))
+ {
+ return false;
+ }
+
+ if (!TryParseKeyValue(fields[3], "completed", out var completed))
+ {
+ return false;
+ }
+
+ string? reason = null;
+ if (fields.Length == 5)
+ {
+ const string reasonPrefix = "reason=";
+ if (!fields[4].StartsWith(reasonPrefix, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ reason = fields[4][reasonPrefix.Length..];
+ }
+
+ progressEvent = new ProgressEvent(kind, elapsedMs, completed, reason);
+ return true;
+ }
+
+ private static bool TryParseKind(string text, out ProgressEventKind kind)
+ {
+ switch (text)
+ {
+ case "startup":
+ kind = ProgressEventKind.Startup;
+ return true;
+ case "render":
+ kind = ProgressEventKind.Render;
+ return true;
+ case "complete":
+ kind = ProgressEventKind.Complete;
+ return true;
+ case "failure":
+ kind = ProgressEventKind.Failure;
+ return true;
+ default:
+ kind = default;
+ return false;
+ }
+ }
+
+ private static bool TryParseKeyValue(string field, string key, out int value)
+ {
+ value = 0;
+ var prefix = key + "=";
+ if (!field.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return int.TryParse(
+ field.AsSpan(prefix.Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs
new file mode 100644
index 0000000..ca54b64
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs
@@ -0,0 +1,26 @@
+using System.Diagnostics;
+
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+/// Wraps a real, already-started as an .
+internal sealed class RealCliProcess : ICliProcess
+{
+ private readonly Process _process;
+
+ public RealCliProcess(Process process)
+ {
+ _process = process;
+ }
+
+ public TextReader StandardOutput => _process.StandardOutput;
+
+ public TextReader StandardError => _process.StandardError;
+
+ public async Task WaitForExitAsync()
+ {
+ await _process.WaitForExitAsync().ConfigureAwait(false);
+ return _process.ExitCode;
+ }
+
+ public void Dispose() => _process.Dispose();
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs
new file mode 100644
index 0000000..3ac1698
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs
@@ -0,0 +1,55 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Formats the final outcome of a render run (see ) as a
+/// non-technical completion summary for a print operator. Covers three cases: the process never
+/// started at all (a launch failure — the message is already friendly and passed through
+/// verbatim), the process started and succeeded (exit 0; uses the `complete` event's
+/// totals/elapsed time), and the process started but failed (non-zero exit; prefers the
+/// `failure` event's `reason` field, and falls back to the stderr `ERROR: ` lines for the rarer
+/// case where the process exited before emitting any progress event at all — e.g. a template or
+/// CSV path that stopped existing between being picked and clicking Render, which the CLI
+/// reports as a pre-render validation failure with no progress events per CLI_CONTRACT.md).
+///
+/// Ambiguity resolved during Sprint 1, Batch 5: this story's acceptance task asked the summary to
+/// include a "warning count," but neither `PROGRESS` events nor the render pipeline currently
+/// expose any warning concept (searched CLI_CONTRACT.md and the CLI/render source directly). The
+/// only candidate — missing-image placeholder handling referenced in
+/// templates/definition_of_done.md — belongs to a not-yet-built epic
+/// (backlog/epics/07_dynamic_and_network_image_handling.md) for a template format that doesn't
+/// support images yet in this text-only slice. Rather than fabricate a counter, this formatter
+/// reports only the fields the CLI actually exposes today (elapsed time, records completed,
+/// success/failure) and omits a warning count; see the Batch 5 hand-off notes for the flagged gap.
+///
+public static class RenderCompletionSummaryFormatter
+{
+ public static string Format(RenderRunResult result)
+ {
+ if (!result.Started)
+ {
+ return result.StartFailureMessage!;
+ }
+
+ if (result.Succeeded)
+ {
+ var completed = result.FinalProgress?.Completed ?? 0;
+ return result.FinalProgress is not null
+ ? $"Render complete: {completed} record(s) rendered in {ElapsedTimeFormatter.Format(result.FinalProgress.ElapsedMs)}."
+ : $"Render complete: {completed} record(s) rendered.";
+ }
+
+ if (result.FinalProgress is { Kind: ProgressEventKind.Failure } failure)
+ {
+ var elapsed = ElapsedTimeFormatter.Format(failure.ElapsedMs);
+ var reason = string.IsNullOrWhiteSpace(failure.Reason) ? "Unknown error." : failure.Reason;
+ return $"Render failed after {elapsed} ({failure.Completed} record(s) rendered): {reason}";
+ }
+
+ if (result.StdErrLines.Count > 0)
+ {
+ return $"Render failed before starting: {string.Join(" ", result.StdErrLines)}";
+ }
+
+ return $"Render failed (exit code {result.ExitCode}). See the render program's output for details.";
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs
new file mode 100644
index 0000000..4ca8e94
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs
@@ -0,0 +1,8 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// The three operator-entered paths needed to launch a render, exactly as typed/picked in the
+/// UI and before any validation. All three map 1:1 to the CLI's required arguments documented
+/// in ../../../CLI_CONTRACT.md (--template, --csv, --output).
+///
+public sealed record RenderLaunchInputs(string? TemplatePath, string? CsvPath, string? OutputPath);
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs
new file mode 100644
index 0000000..ef9fe82
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs
@@ -0,0 +1,9 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+/// Result of validating before allowing a launch.
+public sealed record RenderLaunchValidationResult(bool IsValid, IReadOnlyList Errors)
+{
+ public static readonly RenderLaunchValidationResult Valid = new(true, Array.Empty());
+
+ public static RenderLaunchValidationResult Invalid(IReadOnlyList errors) => new(false, errors);
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs
new file mode 100644
index 0000000..e0b0a42
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs
@@ -0,0 +1,35 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Basic, non-technical "did the operator fill in everything" validation performed by the
+/// desktop shell before it launches the CLI. This intentionally does NOT duplicate the CLI's
+/// own path-existence/type validation (missing template/CSV, non-existent output directory,
+/// etc. — exit codes 2-4 in CLI_CONTRACT.md); the CLI already validates and reports those, and
+/// this batch does not parse the CLI's output. This only catches the case where a required
+/// field is blank so an operator gets an immediate, friendly message instead of watching the
+/// CLI fail moments later for a reason it can't yet see.
+///
+public static class RenderLaunchValidator
+{
+ public static RenderLaunchValidationResult Validate(RenderLaunchInputs inputs)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(inputs.TemplatePath))
+ {
+ errors.Add("Please choose a template file before rendering.");
+ }
+
+ if (string.IsNullOrWhiteSpace(inputs.CsvPath))
+ {
+ errors.Add("Please choose a CSV data file before rendering.");
+ }
+
+ if (string.IsNullOrWhiteSpace(inputs.OutputPath))
+ {
+ errors.Add("Please choose where to save the rendered PDF before rendering.");
+ }
+
+ return errors.Count == 0 ? RenderLaunchValidationResult.Valid : RenderLaunchValidationResult.Invalid(errors);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs
new file mode 100644
index 0000000..993f692
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs
@@ -0,0 +1,23 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// Formats a single in-flight `PROGRESS` event (see ../../../CLI_CONTRACT.md) as a short,
+/// non-technical status line for a print operator watching a render in progress. Intended to be
+/// called for every event delivered via 's progress
+/// callback, including the terminal `complete`/`failure` events as they stream by — the final
+/// completion summary (see ) is a separate, richer
+/// message shown once the process has actually exited, since only then do we also know the exit
+/// code and have the full stderr text available as a fallback.
+///
+public static class RenderProgressStatusFormatter
+{
+ public static string Format(ProgressEvent progressEvent) => progressEvent.Kind switch
+ {
+ ProgressEventKind.Startup => "Starting render...",
+ ProgressEventKind.Render =>
+ $"{progressEvent.Completed} record(s) processed, {ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)} elapsed...",
+ ProgressEventKind.Complete => $"Finishing up... {progressEvent.Completed} record(s) rendered.",
+ ProgressEventKind.Failure => "Render failed. Finishing up...",
+ _ => string.Empty,
+ };
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs
new file mode 100644
index 0000000..f8ce7de
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs
@@ -0,0 +1,26 @@
+namespace EnvelopeRenderer.Desktop.Core.Launch;
+
+///
+/// The full outcome of launching the CLI and running it to completion. Distinguishes a *launch*
+/// failure ( is false — the process never started at all, see
+/// ) from a *render* outcome (the process started, ran, and
+/// exited — see and , the last successfully
+/// parsed `PROGRESS` line). Per ../../../CLI_CONTRACT.md, is always
+/// the run's `complete` or `failure` event, unless the process exited before emitting any
+/// progress event at all (e.g. an input became invalid between picking it and clicking Render,
+/// producing one of the CLI's pre-render validation failures) — in that case it is null
+/// and is the only source of detail.
+///
+public sealed record RenderRunResult(
+ bool Started,
+ string? StartFailureMessage,
+ int? ExitCode,
+ ProgressEvent? FinalProgress,
+ IReadOnlyList StdErrLines)
+{
+ /// True only when the process started and exited with code 0.
+ public bool Succeeded => Started && ExitCode == 0;
+
+ public static RenderRunResult StartFailure(string message) =>
+ new(Started: false, StartFailureMessage: message, ExitCode: null, FinalProgress: null, StdErrLines: Array.Empty());
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs
new file mode 100644
index 0000000..37583ee
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs
@@ -0,0 +1,34 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class CliArgumentListBuilderTests
+{
+ [Fact]
+ public void Build_ReturnsFlagsAndValuesInContractOrder()
+ {
+ var inputs = new RenderLaunchInputs("template.xml", "data.csv", "out.pdf");
+
+ var arguments = CliArgumentListBuilder.Build(inputs);
+
+ Assert.Equal(new[] { "--template", "template.xml", "--csv", "data.csv", "--output", "out.pdf" }, arguments);
+ }
+
+ [Fact]
+ public void Build_PathsWithSpaces_AreNotQuotedByTheBuilder()
+ {
+ // ProcessStartInfo.ArgumentList (see CliProcessStartInfoFactory) handles quoting for us;
+ // the builder must hand back raw values, not pre-quoted strings, or paths would end up
+ // double-quoted on the actual command line.
+ var inputs = new RenderLaunchInputs(
+ "C:\\Templates\\envelope template.xml",
+ "C:\\Data\\wilson township.csv",
+ "C:\\Out\\my report.pdf");
+
+ var arguments = CliArgumentListBuilder.Build(inputs);
+
+ Assert.Equal("C:\\Templates\\envelope template.xml", arguments[1]);
+ Assert.Equal("C:\\Data\\wilson township.csv", arguments[3]);
+ Assert.Equal("C:\\Out\\my report.pdf", arguments[5]);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs
new file mode 100644
index 0000000..56fe42b
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs
@@ -0,0 +1,74 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class CliExecutablePathResolverTests
+{
+ [Fact]
+ public void Resolve_EnvironmentOverrideSetAndExists_UsesOverride()
+ {
+ var result = CliExecutablePathResolver.Resolve(
+ appBaseDirectory: @"C:\App",
+ getEnvironmentVariable: _ => @"D:\Custom\EnvelopeRenderer.Cli.exe",
+ fileExists: path => path == @"D:\Custom\EnvelopeRenderer.Cli.exe");
+
+ Assert.True(result.IsFound);
+ Assert.Equal(@"D:\Custom\EnvelopeRenderer.Cli.exe", result.Path);
+ }
+
+ [Fact]
+ public void Resolve_EnvironmentOverrideSetButMissing_FailsWithClearMessageNamingTheVariable()
+ {
+ var result = CliExecutablePathResolver.Resolve(
+ appBaseDirectory: @"C:\App",
+ getEnvironmentVariable: _ => @"D:\Custom\EnvelopeRenderer.Cli.exe",
+ fileExists: _ => false);
+
+ Assert.False(result.IsFound);
+ Assert.Contains(CliExecutablePathResolver.EnvironmentVariableOverride, result.Error);
+ Assert.Contains(@"D:\Custom\EnvelopeRenderer.Cli.exe", result.Error);
+ }
+
+ [Fact]
+ public void Resolve_NoOverride_FallsBackToSiblingOfAppBaseDirectory()
+ {
+ var expectedPath = Path.Combine(@"C:\App", CliExecutablePathResolver.ExecutableFileName);
+
+ var result = CliExecutablePathResolver.Resolve(
+ appBaseDirectory: @"C:\App",
+ getEnvironmentVariable: _ => null,
+ fileExists: path => path == expectedPath);
+
+ Assert.True(result.IsFound);
+ Assert.Equal(expectedPath, result.Path);
+ }
+
+ [Fact]
+ public void Resolve_NoOverrideAndNoSibling_FailsWithHelpfulMessage()
+ {
+ var result = CliExecutablePathResolver.Resolve(
+ appBaseDirectory: @"C:\App",
+ getEnvironmentVariable: _ => null,
+ fileExists: _ => false);
+
+ Assert.False(result.IsFound);
+ Assert.Contains(CliExecutablePathResolver.ExecutableFileName, result.Error);
+ Assert.Contains(CliExecutablePathResolver.EnvironmentVariableOverride, result.Error);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Resolve_BlankEnvironmentOverride_IsTreatedAsNotSet(string blankOverride)
+ {
+ var expectedPath = Path.Combine(@"C:\App", CliExecutablePathResolver.ExecutableFileName);
+
+ var result = CliExecutablePathResolver.Resolve(
+ appBaseDirectory: @"C:\App",
+ getEnvironmentVariable: _ => blankOverride,
+ fileExists: path => path == expectedPath);
+
+ Assert.True(result.IsFound);
+ Assert.Equal(expectedPath, result.Path);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs
new file mode 100644
index 0000000..c3fbbba
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs
@@ -0,0 +1,242 @@
+using System.ComponentModel;
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class CliProcessLauncherTests
+{
+ private static readonly string[] Arguments = { "--template", "t.xml", "--csv", "c.csv", "--output", "o.pdf" };
+
+ /// In-memory fake backed by s.
+ private sealed class FakeCliProcess : ICliProcess
+ {
+ private readonly int _exitCode;
+
+ public FakeCliProcess(IEnumerable stdOutLines, IEnumerable stdErrLines, int exitCode)
+ {
+ StandardOutput = new StringReader(string.Join('\n', stdOutLines));
+ StandardError = new StringReader(string.Join('\n', stdErrLines));
+ _exitCode = exitCode;
+ }
+
+ public TextReader StandardOutput { get; }
+
+ public TextReader StandardError { get; }
+
+ public Task WaitForExitAsync() => Task.FromResult(_exitCode);
+
+ public void Dispose()
+ {
+ StandardOutput.Dispose();
+ StandardError.Dispose();
+ }
+ }
+
+ ///
+ /// A fake whose blocks (on a background thread, so it never
+ /// deadlocks the caller) until the test releases it — used to prove
+ /// hands the whole run off to a background thread instead of blocking the caller.
+ ///
+ private sealed class BlockingFakeCliProcess : ICliProcess
+ {
+ private readonly ManualResetEventSlim _entered;
+ private readonly ManualResetEventSlim _release;
+
+ public BlockingFakeCliProcess(ManualResetEventSlim entered, ManualResetEventSlim release)
+ {
+ _entered = entered;
+ _release = release;
+ }
+
+ public TextReader StandardOutput { get; } = new StringReader(string.Empty);
+
+ public TextReader StandardError { get; } = new StringReader(string.Empty);
+
+ public Task WaitForExitAsync() => Task.Run(() =>
+ {
+ _entered.Set();
+ _release.Wait(TimeSpan.FromSeconds(5));
+ return 0;
+ });
+
+ public void Dispose()
+ {
+ StandardOutput.Dispose();
+ StandardError.Dispose();
+ }
+ }
+
+ /// Test double that reports synchronously, unlike the real (which posts
+ /// to a captured SynchronizationContext and may run the callback asynchronously) — needed so tests can
+ /// assert on reported events immediately after a synchronous call.
+ private sealed class SynchronousProgress : IProgress
+ {
+ private readonly Action _callback;
+
+ public SynchronousProgress(Action callback) => _callback = callback;
+
+ public void Report(T value) => _callback(value);
+ }
+
+ [Fact]
+ public void Run_SuccessfulRender_ReturnsSucceededWithFinalCompleteEvent()
+ {
+ var stdOut = new[]
+ {
+ "PROGRESS startup elapsedMs=0 completed=0",
+ "PROGRESS render elapsedMs=802 completed=1",
+ "PROGRESS render elapsedMs=1804 completed=392",
+ "PROGRESS complete elapsedMs=1900 completed=392",
+ };
+ var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty(), exitCode: 0));
+
+ var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.True(result.Started);
+ Assert.True(result.Succeeded);
+ Assert.Equal(0, result.ExitCode);
+ Assert.NotNull(result.FinalProgress);
+ Assert.Equal(ProgressEventKind.Complete, result.FinalProgress!.Kind);
+ Assert.Equal(392, result.FinalProgress.Completed);
+ Assert.Equal(1900, result.FinalProgress.ElapsedMs);
+ }
+
+ [Fact]
+ public void Run_FailedRender_ReturnsFailureWithFinalFailureEventAndStderr()
+ {
+ var stdOut = new[]
+ {
+ "PROGRESS startup elapsedMs=0 completed=0",
+ "PROGRESS render elapsedMs=802 completed=1",
+ "PROGRESS render elapsedMs=1804 completed=392",
+ "PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999).",
+ };
+ var stdErr = new[] { "ERROR: Failed to save PDF to 'out.pdf' (error code 999)." };
+ var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, stdErr, exitCode: 1));
+
+ var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.True(result.Started);
+ Assert.False(result.Succeeded);
+ Assert.Equal(1, result.ExitCode);
+ Assert.Equal(ProgressEventKind.Failure, result.FinalProgress!.Kind);
+ Assert.Equal("Failed to save PDF to 'out.pdf' (error code 999).", result.FinalProgress.Reason);
+ Assert.Single(result.StdErrLines);
+ Assert.Equal("ERROR: Failed to save PDF to 'out.pdf' (error code 999).", result.StdErrLines[0]);
+ }
+
+ [Fact]
+ public void Run_ReportsEachParsedProgressEventInOrderAsItArrives()
+ {
+ var stdOut = new[]
+ {
+ "PROGRESS startup elapsedMs=0 completed=0",
+ "PROGRESS render elapsedMs=1000 completed=100",
+ "PROGRESS complete elapsedMs=1500 completed=100",
+ };
+ var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty(), exitCode: 0));
+ var reported = new List();
+
+ launcher.Run(
+ @"C:\App\EnvelopeRenderer.Cli.exe",
+ Arguments,
+ onProgress: new SynchronousProgress(e => reported.Add(e.Kind)));
+
+ Assert.Equal(
+ new[] { ProgressEventKind.Startup, ProgressEventKind.Render, ProgressEventKind.Complete },
+ reported);
+ }
+
+ [Fact]
+ public void Run_IgnoresBlankAndUnparsableStdoutLines()
+ {
+ var stdOut = new[]
+ {
+ "",
+ "some unrelated line",
+ "PROGRESS complete elapsedMs=10 completed=1",
+ };
+ var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty(), exitCode: 0));
+
+ var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.Equal(ProgressEventKind.Complete, result.FinalProgress!.Kind);
+ }
+
+ [Fact]
+ public void Run_NoProgressEventsAtAll_ReturnsResultWithNullFinalProgress()
+ {
+ // Mirrors a pre-render validation failure (exit 2/3/4): per CLI_CONTRACT.md, those
+ // produce no PROGRESS events at all, only stderr ERROR: lines.
+ var stdErr = new[] { "ERROR: Output directory does not exist: 'nosuchdir'." };
+ var launcher = new CliProcessLauncher(_ => new FakeCliProcess(Array.Empty(), stdErr, exitCode: 4));
+
+ var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.True(result.Started);
+ Assert.False(result.Succeeded);
+ Assert.Equal(4, result.ExitCode);
+ Assert.Null(result.FinalProgress);
+ Assert.Single(result.StdErrLines);
+ }
+
+ [Fact]
+ public void Run_ProcessStartThrowsWin32Exception_ReturnsStartFailureWithoutStarting()
+ {
+ var launcher = new CliProcessLauncher(_ => throw new Win32Exception(2, "The system cannot find the file specified"));
+
+ var result = launcher.Run(@"C:\App\does-not-exist.exe", Arguments);
+
+ Assert.False(result.Started);
+ Assert.Contains("could not be started", result.StartFailureMessage);
+ Assert.Contains("system cannot find the file specified", result.StartFailureMessage);
+ Assert.False(result.Succeeded);
+ Assert.Null(result.ExitCode);
+ Assert.Null(result.FinalProgress);
+ }
+
+ [Fact]
+ public void Run_ProcessStartThrowsUnexpectedException_ReturnsStartFailureWithoutThrowing()
+ {
+ var launcher = new CliProcessLauncher(_ => throw new InvalidOperationException("boom"));
+
+ var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.False(result.Started);
+ Assert.Contains("could not be started", result.StartFailureMessage);
+ }
+
+ [Fact]
+ public async Task RunAsync_ReturnsSameOutcomeAsRun()
+ {
+ var stdOut = new[] { "PROGRESS complete elapsedMs=10 completed=1" };
+ var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty(), exitCode: 0));
+
+ var result = await launcher.RunAsync(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public async Task RunAsync_DoesNotBlockCallerWhileTheProcessIsRunning()
+ {
+ // Proves RunAsync hands the whole run off to a background thread rather than blocking
+ // the caller (the UI thread, in the real app) until the child process exits — this is
+ // exactly what lets MainForm await it without freezing while a render is in flight, and
+ // what makes it safe to only re-enable the Render button afterward.
+ using var waitEntered = new ManualResetEventSlim(false);
+ using var releaseWait = new ManualResetEventSlim(false);
+
+ var launcher = new CliProcessLauncher(_ => new BlockingFakeCliProcess(waitEntered, releaseWait));
+
+ var task = launcher.RunAsync(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);
+
+ Assert.True(waitEntered.Wait(TimeSpan.FromSeconds(5)), "WaitForExitAsync was never invoked");
+ Assert.False(task.IsCompleted, "RunAsync's task completed before the simulated process exited - the call is blocking instead of running in the background.");
+
+ releaseWait.Set();
+ var result = await task;
+
+ Assert.True(result.Succeeded);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs
new file mode 100644
index 0000000..2c442cb
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs
@@ -0,0 +1,30 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class CliProcessStartInfoFactoryTests
+{
+ [Fact]
+ public void Create_SetsFileNameAndArgumentListInOrder_AndDoesNotUseShellExecute()
+ {
+ var arguments = new[] { "--template", "t.xml", "--csv", "c.csv", "--output", "o.pdf" };
+
+ var startInfo = CliProcessStartInfoFactory.Create(@"C:\App\EnvelopeRenderer.Cli.exe", arguments);
+
+ Assert.Equal(@"C:\App\EnvelopeRenderer.Cli.exe", startInfo.FileName);
+ Assert.Equal(arguments, startInfo.ArgumentList);
+ Assert.False(startInfo.UseShellExecute);
+ }
+
+ [Fact]
+ public void Create_RedirectsStdOutAndStdErr_AndHidesTheConsoleWindow()
+ {
+ // Sprint 1, Batch 5 reads the CLI's PROGRESS/ERROR: streams itself and displays them
+ // inside the desktop shell, so a separate visible console window is no longer needed.
+ var startInfo = CliProcessStartInfoFactory.Create(@"C:\App\EnvelopeRenderer.Cli.exe", Array.Empty());
+
+ Assert.True(startInfo.RedirectStandardOutput);
+ Assert.True(startInfo.RedirectStandardError);
+ Assert.True(startInfo.CreateNoWindow);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj b/code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj
new file mode 100644
index 0000000..698357f
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs
new file mode 100644
index 0000000..752abed
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs
@@ -0,0 +1,88 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class ProgressEventParserTests
+{
+ [Fact]
+ public void TryParse_StartupLine_ParsesFields()
+ {
+ var parsed = ProgressEventParser.TryParse("PROGRESS startup elapsedMs=0 completed=0", out var progressEvent);
+
+ Assert.True(parsed);
+ Assert.Equal(ProgressEventKind.Startup, progressEvent!.Kind);
+ Assert.Equal(0, progressEvent.ElapsedMs);
+ Assert.Equal(0, progressEvent.Completed);
+ Assert.Null(progressEvent.Reason);
+ }
+
+ [Fact]
+ public void TryParse_RenderLine_ParsesFields()
+ {
+ var parsed = ProgressEventParser.TryParse("PROGRESS render elapsedMs=802 completed=1", out var progressEvent);
+
+ Assert.True(parsed);
+ Assert.Equal(ProgressEventKind.Render, progressEvent!.Kind);
+ Assert.Equal(802, progressEvent.ElapsedMs);
+ Assert.Equal(1, progressEvent.Completed);
+ Assert.Null(progressEvent.Reason);
+ }
+
+ [Fact]
+ public void TryParse_CompleteLine_ParsesFields()
+ {
+ var parsed = ProgressEventParser.TryParse("PROGRESS complete elapsedMs=1900 completed=392", out var progressEvent);
+
+ Assert.True(parsed);
+ Assert.Equal(ProgressEventKind.Complete, progressEvent!.Kind);
+ Assert.Equal(1900, progressEvent.ElapsedMs);
+ Assert.Equal(392, progressEvent.Completed);
+ Assert.Null(progressEvent.Reason);
+ }
+
+ [Fact]
+ public void TryParse_FailureLine_CapturesReasonToEndOfLineVerbatim()
+ {
+ const string line = "PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999).";
+
+ var parsed = ProgressEventParser.TryParse(line, out var progressEvent);
+
+ Assert.True(parsed);
+ Assert.Equal(ProgressEventKind.Failure, progressEvent!.Kind);
+ Assert.Equal(1805, progressEvent.ElapsedMs);
+ Assert.Equal(392, progressEvent.Completed);
+ Assert.Equal("Failed to save PDF to 'out.pdf' (error code 999).", progressEvent.Reason);
+ }
+
+ [Fact]
+ public void TryParse_ReasonContainingSpaces_KeepsWholeReasonTogether()
+ {
+ const string line = "PROGRESS failure elapsedMs=5 completed=0 reason=multiple words here";
+
+ var parsed = ProgressEventParser.TryParse(line, out var progressEvent);
+
+ Assert.True(parsed);
+ Assert.Equal("multiple words here", progressEvent!.Reason);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not a progress line")]
+ [InlineData("PROGRESS")]
+ [InlineData("PROGRESS startup")]
+ [InlineData("PROGRESS unknownkind elapsedMs=0 completed=0")]
+ [InlineData("PROGRESS startup notElapsed=0 completed=0")]
+ [InlineData("PROGRESS startup elapsedMs=abc completed=0")]
+ [InlineData("PROGRESS startup elapsedMs=0 notCompleted=0")]
+ [InlineData("PROGRESS startup elapsedMs=0 completed=abc")]
+ [InlineData("PROGRESS startup elapsedMs=0 completed=0 notreason=x")]
+ public void TryParse_MalformedOrUnrelatedLine_ReturnsFalseAndNullEvent(string? line)
+ {
+ var parsed = ProgressEventParser.TryParse(line, out var progressEvent);
+
+ Assert.False(parsed);
+ Assert.Null(progressEvent);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs
new file mode 100644
index 0000000..a5887b3
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs
@@ -0,0 +1,82 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class RenderCompletionSummaryFormatterTests
+{
+ [Fact]
+ public void Format_LaunchFailure_ReturnsStartFailureMessageVerbatim()
+ {
+ var result = RenderRunResult.StartFailure("The render program could not be started.");
+
+ var text = RenderCompletionSummaryFormatter.Format(result);
+
+ Assert.Equal("The render program could not be started.", text);
+ }
+
+ [Fact]
+ public void Format_SuccessfulRender_IncludesTotalsAndElapsedTime()
+ {
+ var complete = new ProgressEvent(ProgressEventKind.Complete, 1900, 392, null);
+ var result = new RenderRunResult(true, null, 0, complete, Array.Empty());
+
+ var text = RenderCompletionSummaryFormatter.Format(result);
+
+ Assert.Contains("392", text);
+ Assert.Contains("1.9s", text);
+ Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Format_FailedRenderWithFailureEvent_UsesReasonElapsedAndCompletedCount()
+ {
+ var failure = new ProgressEvent(
+ ProgressEventKind.Failure, 1805, 392, "Failed to save PDF to 'out.pdf' (error code 999).");
+ var result = new RenderRunResult(
+ true, null, 1, failure, new[] { "ERROR: Failed to save PDF to 'out.pdf' (error code 999)." });
+
+ var text = RenderCompletionSummaryFormatter.Format(result);
+
+ Assert.Contains("Failed to save PDF to 'out.pdf' (error code 999).", text);
+ Assert.Contains("392", text);
+ Assert.Contains("1.8s", text);
+ Assert.Contains("failed", text, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Format_FailedRenderWithNoProgressEvents_FallsBackToStderrLines()
+ {
+ var result = new RenderRunResult(true, null, 4, null, new[] { "ERROR: Output directory does not exist: 'nosuchdir'." });
+
+ var text = RenderCompletionSummaryFormatter.Format(result);
+
+ Assert.Contains("Output directory does not exist", text);
+ }
+
+ [Fact]
+ public void Format_FailedRenderWithNoProgressEventsOrStderr_ReturnsGenericMessageWithExitCode()
+ {
+ var result = new RenderRunResult(true, null, 1, null, Array.Empty());
+
+ var text = RenderCompletionSummaryFormatter.Format(result);
+
+ Assert.Contains("1", text);
+ Assert.Contains("failed", text, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Format_DoesNotFabricateAWarningCount()
+ {
+ // Documents the resolved ambiguity: the CLI/render pipeline exposes no warning concept
+ // today (see RenderCompletionSummaryFormatter's doc comment), so the summary must never
+ // claim one exists.
+ var complete = new ProgressEvent(ProgressEventKind.Complete, 1900, 392, null);
+ var successResult = new RenderRunResult(true, null, 0, complete, Array.Empty());
+
+ var failure = new ProgressEvent(ProgressEventKind.Failure, 1805, 392, "boom");
+ var failureResult = new RenderRunResult(true, null, 1, failure, Array.Empty());
+
+ Assert.DoesNotContain("warning", RenderCompletionSummaryFormatter.Format(successResult), StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("warning", RenderCompletionSummaryFormatter.Format(failureResult), StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs
new file mode 100644
index 0000000..78c827b
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs
@@ -0,0 +1,84 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class RenderLaunchValidatorTests
+{
+ [Fact]
+ public void Validate_AllFieldsPresent_IsValid()
+ {
+ var inputs = new RenderLaunchInputs("template.xml", "data.csv", "out.pdf");
+
+ var result = RenderLaunchValidator.Validate(inputs);
+
+ Assert.True(result.IsValid);
+ Assert.Empty(result.Errors);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Validate_MissingTemplatePath_ReportsFriendlyError(string? templatePath)
+ {
+ var inputs = new RenderLaunchInputs(templatePath, "data.csv", "out.pdf");
+
+ var result = RenderLaunchValidator.Validate(inputs);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.Contains("template", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Validate_MissingCsvPath_ReportsFriendlyError(string? csvPath)
+ {
+ var inputs = new RenderLaunchInputs("template.xml", csvPath, "out.pdf");
+
+ var result = RenderLaunchValidator.Validate(inputs);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.Contains("CSV", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Validate_MissingOutputPath_ReportsFriendlyError(string? outputPath)
+ {
+ var inputs = new RenderLaunchInputs("template.xml", "data.csv", outputPath);
+
+ var result = RenderLaunchValidator.Validate(inputs);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Errors, e => e.Contains("PDF", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Validate_AllFieldsMissing_ReportsOneErrorPerField()
+ {
+ var inputs = new RenderLaunchInputs(null, null, null);
+
+ var result = RenderLaunchValidator.Validate(inputs);
+
+ Assert.False(result.IsValid);
+ Assert.Equal(3, result.Errors.Count);
+ }
+
+ [Fact]
+ public void Validate_Errors_DoNotLookLikeRawExceptionMessages()
+ {
+ var inputs = new RenderLaunchInputs(null, null, null);
+
+ var result = RenderLaunchValidator.Validate(inputs);
+
+ Assert.All(result.Errors, e =>
+ {
+ Assert.DoesNotContain("Exception", e);
+ Assert.DoesNotContain("System.", e);
+ });
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs
new file mode 100644
index 0000000..5d4e288
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs
@@ -0,0 +1,49 @@
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class RenderProgressStatusFormatterTests
+{
+ [Fact]
+ public void Format_Startup_ReturnsFriendlyStartingMessage()
+ {
+ var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Startup, 0, 0, null));
+
+ Assert.Contains("Starting", text, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Format_Render_IncludesCompletedCountAndElapsedTime()
+ {
+ var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Render, 1804, 392, null));
+
+ Assert.Contains("392", text);
+ Assert.Contains("1.8s", text);
+ }
+
+ [Fact]
+ public void Format_NeverLeaksRawContractFieldNames()
+ {
+ var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Render, 1804, 392, null));
+
+ Assert.DoesNotContain("elapsedMs", text);
+ Assert.DoesNotContain("completed=", text);
+ }
+
+ [Fact]
+ public void Format_Complete_MentionsRecordCount()
+ {
+ var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Complete, 1900, 392, null));
+
+ Assert.Contains("392", text);
+ }
+
+ [Fact]
+ public void Format_Failure_IsNonTechnical()
+ {
+ var text = RenderProgressStatusFormatter.Format(
+ new ProgressEvent(ProgressEventKind.Failure, 1805, 392, "Failed to save PDF to 'out.pdf' (error code 999)."));
+
+ Assert.Contains("failed", text, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj b/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj
new file mode 100644
index 0000000..33ad457
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0-windows
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/code/src/EnvelopeRenderer.Desktop/MainForm.cs b/code/src/EnvelopeRenderer.Desktop/MainForm.cs
new file mode 100644
index 0000000..8f3b7b1
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop/MainForm.cs
@@ -0,0 +1,213 @@
+using System.Drawing;
+using System.Windows.Forms;
+using EnvelopeRenderer.Desktop.Core.Launch;
+
+namespace EnvelopeRenderer.Desktop;
+
+///
+/// Operator-facing shell: pick a template, CSV, and output PDF path, then launch a render and
+/// watch it through to completion. Sprint 1, Batch 4 ("Launch a text-only render from the
+/// desktop app") proved the process could be launched without freezing the UI; Batch 5 ("Show
+/// render progress and completion summary") adds reading the CLI's `PROGRESS` stream while it
+/// runs, showing a non-technical completion summary once it exits, and — since the button is now
+/// only re-enabled after the process actually exits rather than as soon as it starts — closes the
+/// double-launch gap previously logged in logs/technical_debt_log.md.
+///
+/// All non-UI logic (input validation, argument construction, CLI discovery, process launch,
+/// progress parsing, and summary formatting) lives in EnvelopeRenderer.Desktop.Core.Launch and is
+/// unit tested there — this class is intentionally thin event-handler wiring over that logic.
+///
+public sealed class MainForm : Form
+{
+ private readonly TextBox _templatePathTextBox = new();
+ private readonly TextBox _csvPathTextBox = new();
+ private readonly TextBox _outputPathTextBox = new();
+ private readonly Button _renderButton = new() { Text = "&Render", AutoSize = true };
+ private readonly Label _statusLabel = new()
+ {
+ AutoSize = false,
+ Height = 48,
+ TextAlign = ContentAlignment.TopLeft,
+ };
+
+ private readonly CliProcessLauncher _launcher;
+
+ public MainForm() : this(new CliProcessLauncher())
+ {
+ }
+
+ /// Internal constructor allowing a launcher to be injected (used by tests/composition).
+ internal MainForm(CliProcessLauncher launcher)
+ {
+ _launcher = launcher;
+
+ Text = "Envelope Renderer";
+ MinimumSize = new Size(640, 260);
+ StartPosition = FormStartPosition.CenterScreen;
+
+ Controls.Add(BuildLayout());
+ }
+
+ private Control BuildLayout()
+ {
+ var layout = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 3,
+ RowCount = 5,
+ Padding = new Padding(12),
+ AutoSize = true,
+ };
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+
+ AddPickerRow(
+ layout,
+ row: 0,
+ labelText: "Template (XML):",
+ textBox: _templatePathTextBox,
+ browse: () => BrowseForOpenFile(_templatePathTextBox, "XML template files (*.xml)|*.xml|All files (*.*)|*.*"));
+
+ AddPickerRow(
+ layout,
+ row: 1,
+ labelText: "CSV data:",
+ textBox: _csvPathTextBox,
+ browse: () => BrowseForOpenFile(_csvPathTextBox, "CSV files (*.csv)|*.csv|All files (*.*)|*.*"));
+
+ AddPickerRow(
+ layout,
+ row: 2,
+ labelText: "Output PDF:",
+ textBox: _outputPathTextBox,
+ browse: () => BrowseForSaveFile(_outputPathTextBox, "PDF files (*.pdf)|*.pdf|All files (*.*)|*.*"));
+
+ _renderButton.Click += OnRenderClick;
+ var buttonPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, AutoSize = true };
+ buttonPanel.Controls.Add(_renderButton);
+ layout.Controls.Add(buttonPanel, 1, 3);
+ layout.SetColumnSpan(buttonPanel, 2);
+
+ _statusLabel.Dock = DockStyle.Fill;
+ layout.Controls.Add(_statusLabel, 0, 4);
+ layout.SetColumnSpan(_statusLabel, 3);
+
+ return layout;
+ }
+
+ private static void AddPickerRow(TableLayoutPanel layout, int row, string labelText, TextBox textBox, Action browse)
+ {
+ var label = new Label
+ {
+ Text = labelText,
+ AutoSize = true,
+ Anchor = AnchorStyles.Left,
+ Margin = new Padding(3, 9, 3, 3),
+ };
+
+ textBox.Dock = DockStyle.Fill;
+ textBox.Margin = new Padding(3, 6, 3, 3);
+
+ var browseButton = new Button { Text = "Browse...", AutoSize = true, Margin = new Padding(3, 3, 3, 3) };
+ browseButton.Click += (_, _) => browse();
+
+ layout.Controls.Add(label, 0, row);
+ layout.Controls.Add(textBox, 1, row);
+ layout.Controls.Add(browseButton, 2, row);
+ }
+
+ private static void BrowseForOpenFile(TextBox target, string filter)
+ {
+ using var dialog = new OpenFileDialog { Filter = filter, CheckFileExists = true, Title = "Select a file" };
+ if (dialog.ShowDialog() == DialogResult.OK)
+ {
+ target.Text = dialog.FileName;
+ }
+ }
+
+ private static void BrowseForSaveFile(TextBox target, string filter)
+ {
+ using var dialog = new SaveFileDialog { Filter = filter, OverwritePrompt = false, Title = "Choose where to save the PDF" };
+ if (dialog.ShowDialog() == DialogResult.OK)
+ {
+ target.Text = dialog.FileName;
+ }
+ }
+
+ private async void OnRenderClick(object? sender, EventArgs e)
+ {
+ var inputs = new RenderLaunchInputs(_templatePathTextBox.Text, _csvPathTextBox.Text, _outputPathTextBox.Text);
+
+ var validation = RenderLaunchValidator.Validate(inputs);
+ if (!validation.IsValid)
+ {
+ SetStatus(string.Join(" ", validation.Errors), isError: true);
+ return;
+ }
+
+ var cliPath = CliExecutablePathResolver.Resolve(AppContext.BaseDirectory);
+ if (!cliPath.IsFound)
+ {
+ SetStatus(cliPath.Error!, isError: true);
+ ShowLaunchFailure(cliPath.Error!);
+ return;
+ }
+
+ // Disabled here and only re-enabled once RunAsync's awaited Task completes below (i.e.
+ // once the child process has actually exited) — not the moment it starts — so an
+ // operator can't launch a second render against the same output while one is in flight.
+ _renderButton.Enabled = false;
+ SetStatus("Starting render...", isError: false);
+
+ try
+ {
+ var arguments = CliArgumentListBuilder.Build(inputs);
+
+ // Constructed on the UI thread so Progress captures this thread's
+ // SynchronizationContext and marshals each Report() call back to it automatically —
+ // OnRenderProgress can update controls directly without a manual Invoke.
+ var progress = new Progress(OnRenderProgress);
+
+ var result = await _launcher.RunAsync(cliPath.Path!, arguments, progress);
+
+ var summary = RenderCompletionSummaryFormatter.Format(result);
+ if (result.Succeeded)
+ {
+ SetStatus(summary, isError: false);
+ }
+ else if (!result.Started)
+ {
+ // A launch failure (the process never started) keeps the message-box treatment
+ // from Batch 4 — it means something is wrong with the desktop app's own setup,
+ // not with the operator's data, and deserves an explicit acknowledgement.
+ SetStatus(summary, isError: true);
+ ShowLaunchFailure(summary);
+ }
+ else
+ {
+ SetStatus(summary, isError: true);
+ }
+ }
+ finally
+ {
+ _renderButton.Enabled = true;
+ }
+ }
+
+ private void OnRenderProgress(ProgressEvent progressEvent)
+ {
+ SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure);
+ }
+
+ private void ShowLaunchFailure(string message)
+ {
+ MessageBox.Show(this, message, "Could not start render", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+
+ private void SetStatus(string message, bool isError)
+ {
+ _statusLabel.Text = message;
+ _statusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen;
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop/Program.cs b/code/src/EnvelopeRenderer.Desktop/Program.cs
new file mode 100644
index 0000000..d86b85c
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop/Program.cs
@@ -0,0 +1,12 @@
+namespace EnvelopeRenderer.Desktop;
+
+internal static class Program
+{
+ /// The main entry point for the desktop application.
+ [STAThread]
+ private static void Main()
+ {
+ ApplicationConfiguration.Initialize();
+ Application.Run(new MainForm());
+ }
+}
diff --git a/logs/technical_debt_log.md b/logs/technical_debt_log.md
index 426857e..a8e9123 100644
--- a/logs/technical_debt_log.md
+++ b/logs/technical_debt_log.md
@@ -6,3 +6,4 @@ Append-only log of known technical debt. Maintained by `.claude/agents/qa-tech-d
|---|---|---|---|---|---|
| 2026-09-08 | `EnvelopeRenderer.Cli` exits `64` ("render not implemented") for any argument-valid run, since the Debenu render engine isn't wired in yet. Self-documenting and expected to disappear once "Render text-only PDFs through Debenu Quick PDF" (Sprint 1 Batch 2) lands and exit `0` becomes reachable. | Deliberate | Low | Paid Down | `RenderNotImplemented` branch removed from `Program.cs`; exit `0` is now reachable and exit `1` covers all template/CSV/render failures (see `code/CLI_CONTRACT.md`). |
| 2026-09-08 | `DebenuPdfRenderer` always embeds TrueType fonts fully (`AddTrueTypeFont(..., Embed: 1)`), cached once per unique font name per document. Fine at today's scale (one sample render: ~1.3 MB for 392 pages, one font) but full embedding could add up if a template ever uses many distinct fonts/styles at very high page counts, working against the sub-2GB PDF constraint. | Deliberate | Low | Open | Revisit if the "Time-box the first high-volume benchmark" story (not in this sprint) shows file size becoming an issue; Debenu also exposes `AddSubsettedFont` as a smaller-footprint alternative if needed. |
+| 2026-09-08 | `EnvelopeRenderer.Desktop`'s Render button re-enables as soon as the CLI process is confirmed *started* (`CliProcessLauncher.LaunchAsync` returning), not once it finishes rendering — this story (Sprint 1 Batch 4) intentionally does not track render completion. An operator can click Render again (e.g. against the same output path) while a prior render is still in progress, since nothing yet observes the child process's lifetime or exit code. | Unavoidable (scope boundary of this story) | Low | Resolved | Sprint 1 Batch 5 ("Show render progress and completion summary") replaced `CliProcessLauncher.LaunchAsync`/`Launch` with `RunAsync`/`Run`, which stream the CLI's redirected stdout/stderr, wait for the process to actually exit, and return exit code + final progress event. `MainForm.OnRenderClick` now only re-enables the Render button in the `finally` block after that awaited call completes, not when the process starts — verified with a real launch against the sample CSV (button stayed disabled for the full render, both success and forced-failure runs). |
diff --git a/project_config.md b/project_config.md
index 7857cfd..28dcb2b 100644
--- a/project_config.md
+++ b/project_config.md
@@ -4,7 +4,7 @@
- **Project name:** Envelope Printing Report Generator
- **Primary user / vision:** Non-technical print operators can visually design envelope layouts, map CSV data, preview records, and generate print-ready PDFs without developer intervention.
-- **Tech stack:** Native Windows desktop application using `C#` on `.NET 10`; desktop UI framework still to be confirmed; fixed integration points are Debenu Quick PDF Library `10.13` (64-bit DLL), XML-based templates, CSV input with header rows, and support for local plus UNC file paths. CSV parsing uses `CsvHelper` (chosen by `dev-team` during Sprint 1, Batch 2 — the real sample data has quoted commas in address fields, which a naive split would break). Rendering requires a Debenu license key at runtime (`DEBENU_LICENSE_KEY`); see `code/CLI_CONTRACT.md`.
+- **Tech stack:** Native Windows desktop application using `C#` on `.NET 10`; desktop UI framework is `WinForms` (confirmed by the user ahead of Sprint 1, Batch 4 — simplest fit for file pickers and a launch action, matches the team's scaffolding pace). Fixed integration points are Debenu Quick PDF Library `10.13` (64-bit DLL), XML-based templates, CSV input with header rows, and support for local plus UNC file paths. CSV parsing uses `CsvHelper` (chosen by `dev-team` during Sprint 1, Batch 2 — the real sample data has quoted commas in address fields, which a naive split would break). Rendering requires a Debenu license key at runtime (`DEBENU_LICENSE_KEY`); see `code/CLI_CONTRACT.md`.
- **Test framework(s):** `xUnit` (chosen by `dev-team` during Sprint 1, Batch 1 — the standard default for .NET SDK-style projects, no reason to deviate). Minimum validation baseline from the requirements is self-tested sample CSV/render scenarios, including overflow, missing-font, missing-image, and high-volume render checks.
- **Code review process:** Formal reviewer is not defined in the requirements; until a team process is set, changes require self-review against acceptance criteria and Definition of Done before being considered complete.
- **CI pipeline:** No CI pipeline is defined in the requirements; initial expectation is local build and verification gates, with CI setup to be planned as part of early delivery work.
diff --git a/state.md b/state.md
index 934c9cf..cb501d2 100644
--- a/state.md
+++ b/state.md
@@ -2,16 +2,16 @@
> The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff."
-**Phase:** 3 - Sprint execution
-**Leading agent:** `dev-team`
-**Process file:** `process/03_sprint_execution.md`
+**Phase:** 4 - Sprint review
+**Leading agent:** `product-owner`
+**Process file:** `process/04_sprint_review.md`
**Sprint:** 1
**Sprint dates:** 2026-09-08 - 2026-09-11
**Sprint goal:** Prove a thin text-only desktop-to-CLI rendering spine so an operator can select files, launch a job, watch progress, and receive a PDF generated through Debenu from a known XML template.
**Current sprint backlog:** `backlog/sprints/sprint-1.md`
-**Next action:** Batches 1 and 2 are Done. The CLI now renders real text-only PDFs through Debenu Quick PDF 10.13 (`code/CLI_CONTRACT.md`, `code/TEMPLATE_FORMAT.md`), verified end-to-end against the 392-row sample CSV (32 tests passing, including one real-DLL integration test). Rendering requires `DEBENU_LICENSE_KEY` at runtime — see `code/CLI_CONTRACT.md`. `dev-team` pulls Batch 3 ("Emit machine-readable progress during render") next. The template-path and UNC-timeout impediments in `logs/impediment_log.md` are still open and unaffected by this batch.
+**Next action:** All 5 committed Sprint 1 items are Done (exit criteria for Phase 3 met — see `backlog/sprints/sprint-1.md`). The walking skeleton is real end-to-end: an operator picks template/CSV/output paths in the WinForms desktop app (`EnvelopeRenderer.Desktop`), launches a render without freezing the UI, watches live progress parsed from the CLI's `PROGRESS ...` stdout stream, and gets a completion summary — verified with a real success run (392/392 pages, valid PDF) and a real forced-failure run against the actual sample CSV. 106 tests passing. `product-owner` runs Sprint Review next per `process/04_sprint_review.md`. Carry-forward items to raise at review: template-path and UNC-timeout impediments still open (`logs/impediment_log.md`); the 100k-record/300 DPI benchmark hasn't been re-run since Batch 2; no "warning" concept exists in the product yet, so the "warning count" acceptance task for the last story was satisfied by its documented absence rather than a real counter — product owner may want to scope that once image/overflow handling exists.
## Phase reference
@@ -37,3 +37,4 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-09-04 | 1 - Backlog refinement | Refined the top epics into sprint-ready stories, confirmed `C#/.NET 10` as the application stack, and reordered the backlog for the first text-only MVP slice. |
| 2026-09-04 | 2 - Sprint planning | Created Sprint 1 around a conservative desktop-to-CLI text-only rendering spine and recorded the committed backlog in `backlog/sprints/sprint-1.md`. |
| 2026-09-08 | 3 - Sprint execution (in progress) | Held Daily Scrum #1 and turned the sprint backlog into a dependency-ordered execution plan (5 batches) in `backlog/sprints/sprint-1.md`. Still in phase 3 — not a phase transition, just the day's checkpoint. |
+| 2026-09-08 | 3 - Sprint execution | All 5 batches Done: CLI render contract, real Debenu text-only rendering, stdout progress events, WinForms desktop launch shell (user-confirmed as the UI framework), and live progress/completion display. 106 tests passing end-to-end. Sprint goal met with real verified runs against the 392-row sample CSV. |