refactor(printing): put every Windows printing call behind a tested seam, changing nothing that reaches Windows (#42)

This commit was merged in pull request #42.
This commit is contained in:
james.maes committed 2026-11-11 02:08:15 +00:00
1 parent 077148ff41
commit e2ca086711
49 files changed
+5502 -442

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"Qqq.PrintAgent.Core": 100,
"Qqq.PrintAgent.Presentation": 100,
"Qqq.PrintAgent.Windows": 80,
"Qqq.PrintAgent.Windows": 86,
"qqq-print-agent": 100
}
+1 -1
View File
@@ -10,7 +10,7 @@ The remote-control adapter accepts only inventory refresh and cancellation of on
The native QQQ session adapter requires discovery before credentials and never treats a native control 404 as legacy permission. After support is negotiated, 404 does not remove restrictions. Authorization denial becomes authorization-required, without inferring that every 401/403 proves revocation. New claims wait for an authenticated, bound QQQ user and valid control state. Existing installations without a protected QQQ session wait for explicit sign-in; device proof alone cannot authorize normal service printing. The command line has no server transport: the `--legacy-compat` bearer diagnostics were removed in 2.0, and the code they used was deleted after them. No shipped code can send a device token outside a session. The printing API client takes no token and sets no Authorization header; it can be built only from a signed-in session, and every request it makes, a document download included, is sent through that session, which adds what authenticates it and refuses any address outside the printing extension's path. Inventory, job events and command receipts travel only over the authenticated realtime channel: with no channel they fail and are replayed after the reconnect, and nothing is sent over HTTP in their place. The command line keeps no credentials or journal of its own.
Printing has one path. A document reaches Windows only as a staged file whose length and SHA-256 were verified while it streamed: the worker hands the file's path to the print helper, which checks the file is a regular, absolute path and verifies its length and hash again before the spooler. The helper is the service's own executable started with `--printer-worker`, and it accepts three operations: discover printers, print a staged file by path, and cancel one spool job. The operation that carried a document inside the request, and the in-memory submission behind it, were deleted in 2.0, so no document travels in a message between the two processes.
Printing has one path. A document reaches Windows only as a staged file whose length and SHA-256 were verified while it streamed: the worker hands the file's path to the print helper, which checks the file is a regular, absolute path and verifies its length and hash again before the spooler. The helper is the service's own executable started with `--printer-worker`, and it accepts three operations: discover printers, print a staged file by path, and cancel one spool job. A request is one line of at most 1 MiB characters (it holds a job's description and a path, a few kilobytes); the helper counts while it reads and refuses a longer one before it is held in memory. The service bounds the helper's answer at the same size, after reading it. The operation that carried a document inside the request, and the in-memory submission behind it, were deleted in 2.0, so no document travels in a message between the two processes.
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. The companion holds a session only while a sign-in attempt runs: it discards its copy as soon as the service confirms that it took the session over, and also when a hand-over that was sent could not be confirmed, so the service is the only process that keeps one. CLI operations use the existing authenticated service pipe. The enrollment code reaches the CLI only through an environment variable or standard input, never as an argument, and the CLI prints it nowhere. Typed at a Windows console it is read without echo; redirected input is read as a line, and that includes a terminal that hands native programs a pipe instead of a console (the default Git Bash window, for example), where the terminal itself may echo what is typed and the variable should be used instead. An unattended caller with a console nobody types at must set the variable or redirect input, or the command waits. A command line the CLI refuses is never echoed, because it can carry a misplaced enrollment code or a sign-in callback address. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; when the server advertises `nonceValidation: REQUIRED` it also sends only that attempt's own authorization nonce as `expectedNonce` (never a callback-supplied or regenerated value) and refuses any other non-empty nonce policy; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// What a bitmap handed to GDI looks like: its width and height in pixels (a negative height means the first row is the
/// top one), the number of planes and the bits per pixel. These are the fields of BITMAPINFOHEADER the printing code
/// chooses; the rest of that structure (its own size, no compression) is filled in where the native call is made.
/// </summary>
internal sealed record GdiBitmapHeader(int Width, int Height, ushort Planes, ushort BitCount);
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows GDI calls that a printer device context needs: create one from a queue's settings, read what it reports
/// about its page, and print a document of bitmap pages on it. Each member is one Windows call and returns what Windows
/// returned; a call that fails leaves its Windows error as the thread's last error (<c>Marshal.GetLastWin32Error</c>),
/// exactly as the native call does. Which settings a context is built from, whether its page matches the selected media,
/// where and how large each page is drawn, how often it is repeated and when a document is aborted is decided by
/// <see cref="WindowsPrinters"/>.
/// </summary>
internal interface IGdiPrinter
{
/// <summary>
/// Creates a device context for a queue (<c>CreateDC</c>, no output override) from the given complete settings, or from
/// the queue's current defaults when the settings are null. It returns zero when Windows refused. The context must be
/// released with <see cref="DeleteDC"/>.
/// </summary>
IntPtr CreateDC(string driver, string device, byte[]? devMode);
/// <summary>Reads one value a device context reports about itself (<c>GetDeviceCaps</c>), such as 88 and 90 for resolution or 110 and 111 for the physical page.</summary>
int GetDeviceCaps(IntPtr dc, int index);
/// <summary>Releases a device context created by <see cref="CreateDC"/> (<c>DeleteDC</c>).</summary>
bool DeleteDC(IntPtr dc);
/// <summary>
/// Starts a document with the given name on a device context (<c>StartDoc</c>, no output file or data type) and returns
/// its spooler job number, or a value of zero or less when Windows refused it.
/// </summary>
int StartDoc(IntPtr dc, string documentName);
/// <summary>Starts a page in the open document (<c>StartPage</c>); a value of zero or less is a failure.</summary>
int StartPage(IntPtr dc);
/// <summary>Selects how a bitmap is resampled when it is stretched (<c>SetStretchBltMode</c>); zero is a failure.</summary>
int SetStretchBltMode(IntPtr dc, int mode);
/// <summary>
/// Draws the given part of a bitmap, described by its header, into the given rectangle of the page (<c>StretchDIBits</c>)
/// with the given colour usage and raster operation. Zero or -1 is a failure.
/// </summary>
int StretchDIBits(IntPtr dc, int x, int y, int width, int height, int sourceX, int sourceY, int sourceWidth, int sourceHeight, byte[] bits, GdiBitmapHeader header, uint usage, uint operation);
/// <summary>Ends the current page so the driver can emit it (<c>EndPage</c>); a value of zero or less is a failure.</summary>
int EndPage(IntPtr dc);
/// <summary>Ends the document and hands it to the spooler (<c>EndDoc</c>); a value of zero or less is a failure.</summary>
int EndDoc(IntPtr dc);
/// <summary>Cancels the open document (<c>AbortDoc</c>), so nothing partial is printed.</summary>
int AbortDoc(IntPtr dc);
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Ties the print helper's life to the service that started it. The helper starts one watch before it reads its request
/// and stops it when its one operation is done; what the helper does in between is decided by
/// <see cref="PrinterWorkerHost"/>.
/// </summary>
internal interface IParentWatch
{
/// <summary>
/// Watches the process with the given id until the token is cancelled. If that process exits first, or does not exist,
/// the helper must not go on running: the real watch ends this process at once. The returned task completes when the
/// watch has stopped.
/// </summary>
Task WatchAsync(int parentId, CancellationToken token);
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>A loaded PDF: how many pages it has and a way to take one of them out.</summary>
internal interface IPdfDocument
{
/// <summary>The number of pages in the document.</summary>
uint PageCount { get; }
/// <summary>Takes out the page at the given zero-based index. The caller disposes it when the page has been printed.</summary>
IPdfPage GetPage(uint index);
}
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// One page of a loaded PDF: its size as the document states it, in units of 1/96 inch, and a way to render it to a
/// bitmap of a size the caller chooses. The size comes from the document and is not trusted; the caller checks it.
/// </summary>
internal interface IPdfPage : IDisposable
{
/// <summary>The page width in units of 1/96 inch, exactly as the document states it.</summary>
double Width { get; }
/// <summary>The page height in units of 1/96 inch, exactly as the document states it.</summary>
double Height { get; }
/// <summary>Renders the page to a bitmap of the given size in pixels and returns its 32-bit BGRA pixels, first row at the top, with the size the renderer actually produced.</summary>
Task<PageBitmap> RenderAsync(uint width, uint height);
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Windows.Storage.Streams;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows PDF renderer as the printing rules use it: open a document, then hand out its pages one at a time. An
/// implementation only loads and renders; which pages are printed, how often, at what size and where on the paper is
/// decided by <see cref="WindowsPrinters"/>.
/// </summary>
internal interface IPdfRenderer
{
/// <summary>
/// Loads a PDF from the given stream. The document keeps reading from that stream while its pages are rendered, so
/// the caller keeps the stream open until it is done with the document.
/// </summary>
Task<IPdfDocument> LoadAsync(IRandomAccessStream stream, CancellationToken cancellationToken);
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows calls that read a queue's settings and let its driver reconcile a job's settings: <c>GetPrinter</c>,
/// <c>DeviceCapabilities</c> and <c>DocumentProperties</c>. Each member is one Windows call and reports what Windows
/// answered; a <c>GetPrinter</c> call that fails returns false and leaves its Windows error as the thread's last error
/// (<c>Marshal.GetLastWin32Error</c>), exactly as the native call does. Which settings are read, the bounds on every
/// answer, what counts as usable paper and resolution, and whether a driver's answer is accepted are decided by
/// <see cref="WindowsPrinters"/>.
/// </summary>
internal interface IPrinterSettings
{
/// <summary>
/// Asks how many bytes a queue's details need at the given level (<c>GetPrinter</c> with no buffer). Windows answers
/// this sizing call with false and a size.
/// </summary>
bool GetPrinterSize(IntPtr printer, uint level, out uint needed);
/// <summary>
/// Reads a queue's details at the given level into a buffer of the given size (<c>GetPrinter</c>) and reports the bytes
/// Windows wrote together with the address they were written at, so a pointer stored inside them can be turned into an
/// offset. The buffer is empty when the call failed.
/// </summary>
bool GetPrinter(IntPtr printer, uint level, uint size, out byte[] buffer, out long address);
/// <summary>
/// Asks the driver how many entries one capability has for the given settings (<c>DeviceCapabilities</c> with no output
/// buffer), such as 2 for paper identifiers or 3 for paper sizes. A driver that cannot answer returns -1.
/// </summary>
int DeviceCapabilities(string device, string port, ushort capability, byte[] devMode);
/// <summary>
/// Reads one capability's entries for the given settings into a buffer of the given number of bytes
/// (<c>DeviceCapabilities</c>) and reports the number of entries the driver says it wrote and the buffer's bytes.
/// </summary>
int DeviceCapabilities(string device, string port, ushort capability, int outputBytes, byte[] devMode, out byte[] output);
/// <summary>
/// Asks how many bytes the driver's complete document settings need (<c>DocumentProperties</c> with no buffers and no
/// mode flags). Nothing is shown and nothing is changed.
/// </summary>
int DocumentPropertiesSize(IntPtr printer, string device);
/// <summary>
/// Lets the driver merge the input settings into the output buffer (<c>DocumentProperties</c>) under the given mode
/// flags, with no owner window. The driver returns 1 when it accepted the settings.
/// </summary>
int DocumentProperties(IntPtr printer, string device, byte[] output, byte[] input, uint mode);
}
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows spooler calls that listing queues and sending one RAW document need. Each member is one Windows call and
/// reports what Windows answered; a call that fails returns false (or a job number of zero) and leaves its Windows error
/// as the thread's last error (<c>Marshal.GetLastWin32Error</c>), exactly as the native call does. Which queues may print
/// unattended, what a document is called, how its bytes are written and when it is aborted is decided by
/// <see cref="WindowsPrinters"/>.
/// </summary>
internal interface ISpooler
{
/// <summary>
/// Asks how many bytes the list of queues selected by the flags needs (<c>EnumPrinters</c> level 2 with no buffer).
/// Windows answers this sizing call with false, error 122 and a size when there are queues to list.
/// </summary>
bool EnumPrintersSize(uint flags, out uint needed);
/// <summary>
/// Lists the queues selected by the flags into a buffer of the given size (<c>EnumPrinters</c> level 2) and reports each
/// queue's name, driver, port and status as a plain record. The list is empty when the call failed.
/// </summary>
bool EnumPrinters(uint flags, int size, out IReadOnlyList<SpoolerQueue> queues);
/// <summary>Opens a queue by name with default access (<c>OpenPrinter</c>). The handle must be closed with <see cref="ClosePrinter"/>.</summary>
bool OpenPrinter(string name, out IntPtr printer);
/// <summary>
/// Starts one spool document with the given name and data type (<c>StartDocPrinter</c> level 1, no output file) and
/// returns its spooler job number, or zero when Windows refused it.
/// </summary>
uint StartDocPrinter(IntPtr printer, string documentName, string dataType);
/// <summary>Writes the first <paramref name="count"/> bytes into the open document (<c>WritePrinter</c>) and reports how many the spooler took, which can be fewer.</summary>
bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written);
/// <summary>Finishes the open document so the spooler may send it to the printer (<c>EndDocPrinter</c>).</summary>
bool EndDocPrinter(IntPtr printer);
/// <summary>Discards the open document (<c>AbortPrinter</c>), so nothing partial is printed.</summary>
bool AbortPrinter(IntPtr printer);
/// <summary>Releases a queue handle opened by <see cref="OpenPrinter"/> (<c>ClosePrinter</c>).</summary>
bool ClosePrinter(IntPtr printer);
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows spooler calls that cancelling one job needs: open a queue, read a job's document name, send the job a
/// command and close the queue. Each member is one Windows call and reports what Windows answered; a call that fails
/// returns false and leaves its Windows error as the thread's last error (<c>Marshal.GetLastWin32Error</c>), exactly as
/// the native call does. Whether a job is ours, whether it is already gone and whether it may be deleted is decided by
/// <see cref="WindowsSpoolerJobs"/>.
/// </summary>
internal interface ISpoolerJobs
{
/// <summary>Opens a queue by name with default access (<c>OpenPrinter</c>). The handle must be closed with <see cref="ClosePrinter"/>.</summary>
bool OpenPrinter(string name, out IntPtr printer);
/// <summary>
/// Asks how many bytes a job's level 1 details need (<c>GetJob</c> with no buffer). Windows answers this sizing call
/// with false and a size; a size of zero means the job could not be read at all, and the last error says why.
/// </summary>
bool GetJobSize(IntPtr printer, uint job, out uint needed);
/// <summary>
/// Reads a job's level 1 details into a buffer of the given size (<c>GetJob</c>) and reports its document name, which is
/// null when Windows stored none. The name is read only when the call succeeded.
/// </summary>
bool GetJobDocument(IntPtr printer, uint job, uint size, out string? document);
/// <summary>Sends one control command to a job (<c>SetJob</c> with no job details), such as 5 to delete it.</summary>
bool SetJob(IntPtr printer, uint job, uint command);
/// <summary>Releases a queue handle opened by <see cref="OpenPrinter"/> (<c>ClosePrinter</c>).</summary>
bool ClosePrinter(IntPtr printer);
}
+12
View File
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// One rendered page: its pixels as 32-bit BGRA with the first row at the top, and the width and height in pixels the
/// renderer reported for them.
/// </summary>
internal sealed record PageBitmap(byte[] Pixels, uint PixelWidth, uint PixelHeight);
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real watch behind <see cref="IParentWatch"/>: it waits for the parent process by its id and ends this process
/// when the parent is gone. A lookup or wait that fails for any reason other than the parent not running is not handled
/// here; see <see cref="WatchAsync"/>.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Waits for another process by its id and calls Environment.Exit when that process has exited or is not running. A test cannot run this to its end inside the test process, because it would end the test run, and the wait depends on a second real process. Nothing about printing is decided here; the watch itself has four outcomes. A wait cancelled by the helper means its operation finished; every test that runs the fixture helper to its end goes through it with real processes. A parent that exits during the wait means exit code 2: PrinterWorkerIsolationTests.WorkerExitsWhenItsParentDiesDuringANativeOperation. A parent that is already gone when the helper looks for it means exit code 2 before any request is read: PrinterWorkerIsolationTests.WorkerExitsAtOnceWhenItsParentIsAlreadyGone. A lookup or wait that fails in any other way is not caught here: the task fails, nothing ends the helper, the operation runs unwatched, and the failure comes out only when PrinterWorkerHost.RunAsync awaits the watch in its finally block; no test arranges such a failure in the real watch, and what RunAsync does with a watch that failed is shown by PrinterWorkerHostTests.WatchThatFailsLeavesTheOperationUnwatchedAndSurfacesWhenTheHelperEnds with a fake. When the watch is started and stopped, and everything the helper does in between, is decided in PrinterWorkerHost, which is tested with a fake of IParentWatch.")]
internal sealed class ParentProcessWatch : IParentWatch
{
/// <summary>
/// Waits for the parent service to exit and then terminates this helper immediately with exit code 2, so a blocked
/// driver call can never leave an orphan process running after the service is gone. A parent that is not running when
/// it is looked up ends the helper the same way, at once. A cancelled wait is the normal end: the helper's operation
/// finished. Any other failure of the lookup or the wait is not caught: the returned task fails and this process is not
/// ended, so the helper carries out its operation with nobody watching the parent, and the failure is seen only by
/// whoever awaits the task, which is <see cref="PrinterWorkerHost"/> once the operation is done.
/// </summary>
public async Task WatchAsync(int parentId, CancellationToken token)
{
try
{
using var parent = Process.GetProcessById(parentId);
await parent.WaitForExitAsync(token).ConfigureAwait(false);
Environment.Exit(2);
}
catch(OperationCanceledException) when(token.IsCancellationRequested)
{
// The requested operation completed normally.
}
catch(ArgumentException)
{
Environment.Exit(2);
}
}
}
+94 -56
View File
@@ -3,8 +3,8 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
@@ -14,10 +14,23 @@ namespace Qqq.PrintAgent.Windows;
/// <summary>One native printer operation in a disposable process. A document reaches it only as the path of a verified staged file, never as content.</summary>
public static class PrinterWorkerHost
{
/// <summary>
/// The environment variable in which the service hands a helper the service's own process id. The helper refuses to run
/// without it and has that process watched, so that it ends when the service is gone.
/// </summary>
internal const string ParentEnvironmentVariable = "QQQ_PRINT_WORKER_PARENT";
/// <summary>
/// The most characters a request may have. A request is one small JSON object (an operation, a job's description and the
/// path of its staged file), a few kilobytes at most, because documents travel as files; 1 MiB is the bound the service
/// puts on the helper's answer, so the two directions match.
/// </summary>
internal const int MaximumRequestCharacters = 1_048_576;
/// <summary>One operation sent to the helper as a single JSON line: discover, print a staged file, or cancel a spool job, with only the fields that operation needs. A staged file travels by path, never by content.</summary>
internal sealed record Request(string Operation, PrintJob? Job = null, string? PrinterName = null, string? JobId = null, string? SpoolerJobId = null, string? ContentPath = null);
@@ -28,93 +41,118 @@ public static class PrinterWorkerHost
/// <summary>Runs one operation against the Windows printers. The service's executable calls this, before it hosts anything, when the service started it with --printer-worker as its print helper.</summary>
public static Task<int> RunAsync() => RunAsync(new WindowsPrinters());
/// <summary>
/// Runs one operation against the Windows printers and spooler, reading the request from standard input and the parent's
/// process id from the environment. The service's executable calls this, before it hosts anything, when the service
/// started it with --printer-worker as its print helper. The Windows adapters are built here, not held in fields, so
/// nothing exists until a helper is actually run.
/// </summary>
public static Task<int> RunAsync()
=> RunAsync(new WindowsPrinters(), new WindowsSpoolerJobQueue(), new ParentProcessWatch(), Environment.GetEnvironmentVariable(ParentEnvironmentVariable), Console.In, Console.Out);
/// <summary>
/// Runs exactly one operation read from standard input and writes one JSON answer to standard output, with exit code 0 for
/// success and 1 for failure. It refuses to start without a parent process id, ends itself if that parent exits, bounds the
/// request size, and redacts error text before it leaves the process. A backend is injected so tests can run it without a printer.
/// Runs exactly one operation read from the input and writes one JSON answer to the output, with exit code 0 for
/// success and 1 for failure. It refuses to start without a parent process id, has that parent watched from before the
/// request is read until the operation is done, bounds the request size, and redacts error text before it leaves the
/// process. The printers, the spooler's job calls, the watch and the two streams are given, so tests run it without a
/// printer, a second process or the console.
/// </summary>
internal static async Task<int> RunAsync(IPrinterBackend printers)
internal static async Task<int> RunAsync(IPrinterBackend printers, ISpoolerJobs spoolerJobs, IParentWatch parentWatch, string? parent, TextReader input, TextWriter output)
{
using CancellationTokenSource lifetime = new();
Task? parentWatch = null;
Task? watch = null;
try
{
if(!int.TryParse(Environment.GetEnvironmentVariable(ParentEnvironmentVariable), NumberStyles.None, CultureInfo.InvariantCulture, out int parentId))
if(!int.TryParse(parent, NumberStyles.None, CultureInfo.InvariantCulture, out int parentId))
{
throw new InvalidOperationException("Printer workers must be launched by the print connector.");
}
parentWatch = WatchParentAsync(parentId, lifetime.Token);
string? input = await Console.In.ReadLineAsync().ConfigureAwait(false);
if(input is null || input.Length > 30_000_000)
{
throw new InvalidDataException("Invalid printer worker request size.");
}
Request request = JsonSerializer.Deserialize<Request>(input, PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Missing printer worker request.");
Response response;
switch(request.Operation)
{
case "discover":
response = new Response(true, printers.Discover());
break;
case "cancel":
WindowsSpoolerJobs.Cancel(request.PrinterName ?? "", request.JobId ?? "", request.SpoolerJobId ?? "");
response = new Response(true);
break;
case "print-file":
if(request.Job is null || request.ContentPath is null)
{
throw new InvalidDataException("The print worker requires a verified document file.");
}
////////////////////////////////////////////////////////////////////////
// no limit is passed on: the service that started this process holds //
// the submission limit and ends the process when it passes //
////////////////////////////////////////////////////////////////////////
response = new Response(true, SpoolerJobId: await printers.PrintFileAsync(request.Job, request.ContentPath, Timeout.InfiniteTimeSpan, lifetime.Token).ConfigureAwait(false));
break;
default:
throw new InvalidDataException("Unknown printer worker operation.");
}
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(response, PrintApiClient.JsonOptions)).ConfigureAwait(false);
watch = parentWatch.WatchAsync(parentId, lifetime.Token);
Request request = JsonSerializer.Deserialize<Request>(await ReadRequestAsync(input).ConfigureAwait(false), PrintApiClient.JsonOptions) ?? throw new InvalidDataException("Missing printer worker request.");
Response response = await ExecuteAsync(request, printers, spoolerJobs, lifetime.Token).ConfigureAwait(false);
await output.WriteLineAsync(JsonSerializer.Serialize(response, PrintApiClient.JsonOptions)).ConfigureAwait(false);
return 0;
}
catch(Exception exception)
{
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(new Response(false, Error: AgentActivityLog.Redact(exception.Message), ErrorCode: exception is PrinterMediaMismatchException ? "MEDIA_PROFILE_MISMATCH" : (exception as SpoolCancellationException)?.Outcome), PrintApiClient.JsonOptions)).ConfigureAwait(false);
await output.WriteLineAsync(JsonSerializer.Serialize(new Response(false, Error: AgentActivityLog.Redact(exception.Message), ErrorCode: exception is PrinterMediaMismatchException ? "MEDIA_PROFILE_MISMATCH" : (exception as SpoolCancellationException)?.Outcome), PrintApiClient.JsonOptions)).ConfigureAwait(false);
return 1;
}
finally
{
await lifetime.CancelAsync().ConfigureAwait(false);
if(parentWatch is not null)
if(watch is not null)
{
await parentWatch.ConfigureAwait(false);
await watch.ConfigureAwait(false);
}
}
}
/// <summary>Waits for the parent service to exit and then terminates this helper immediately, so a blocked driver call can never leave an orphan process running after the service is gone.</summary>
private static async Task WatchParentAsync(int parentId, CancellationToken token)
/// <summary>
/// Reads the one request line a piece at a time and stops with an error as soon as more characters than a request may
/// have arrived without a line end, so an oversized request is refused while it is read and never held in memory whole.
/// Input that ends before any character arrives is refused the same way. A line ends at a carriage return or a line
/// feed; nothing after it is used, because a helper carries out exactly one request.
/// </summary>
private static async Task<string> ReadRequestAsync(TextReader input)
{
try
StringBuilder line = new();
char[] piece = new char[4096];
bool any = false;
while(true)
{
using var parent = Process.GetProcessById(parentId);
await parent.WaitForExitAsync(token).ConfigureAwait(false);
Environment.Exit(2);
int read = await input.ReadAsync(piece, 0, piece.Length).ConfigureAwait(false);
if(read == 0)
{
return any ? line.ToString() : throw new InvalidDataException("Invalid printer worker request size.");
}
any = true;
int end = piece.AsSpan(0, read).IndexOfAny('\r', '\n');
int count = end < 0 ? read : end;
if(line.Length + count > MaximumRequestCharacters)
{
throw new InvalidDataException("Invalid printer worker request size.");
}
line.Append(piece, 0, count);
if(end >= 0)
{
return line.ToString();
}
}
catch(OperationCanceledException) when(token.IsCancellationRequested)
}
/// <summary>
/// Carries out the one operation a request names and builds its answer: the inventory for discover, nothing but success
/// for cancel (which reads the job back and proves it is ours before deleting it), and the spooler receipt for
/// print-file, which needs both a job and the path of its verified file. Any other operation is refused.
/// </summary>
private static async Task<Response> ExecuteAsync(Request request, IPrinterBackend printers, ISpoolerJobs spoolerJobs, CancellationToken token)
{
switch(request.Operation)
{
// The requested operation completed normally.
}
catch(ArgumentException)
{
Environment.Exit(2);
case "discover":
return new Response(true, printers.Discover());
case "cancel":
WindowsSpoolerJobs.Cancel(spoolerJobs, request.PrinterName ?? "", request.JobId ?? "", request.SpoolerJobId ?? "");
return new Response(true);
case "print-file":
if(request.Job is null || request.ContentPath is null)
{
throw new InvalidDataException("The print worker requires a verified document file.");
}
////////////////////////////////////////////////////////////////////////
// no limit is passed on: the service that started this process holds //
// the submission limit and ends the process when it passes //
////////////////////////////////////////////////////////////////////////
return new Response(true, SpoolerJobId: await printers.PrintFileAsync(request.Job, request.ContentPath, Timeout.InfiniteTimeSpan, token).ConfigureAwait(false));
default:
throw new InvalidDataException("Unknown printer worker operation.");
}
}
}
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// One installed queue as the Windows spooler lists it: its name, driver, port and status flags, copied out of the
/// spooler's answer so nothing that reads it holds native memory. A name Windows left out is null.
/// </summary>
internal sealed record SpoolerQueue(string? Name, string? Driver, string? Port, uint Status);
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows GDI behind <see cref="IGdiPrinter"/>: each member makes one call into gdi32.dll and returns what it
/// returned. It decides nothing about pages or documents.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Calls CreateDC, GetDeviceCaps, DeleteDC, StartDoc, StartPage, SetStretchBltMode, StretchDIBits, EndPage, EndDoc and AbortDoc in gdi32.dll on a printer device context. A test cannot run these without an installed Windows print queue and its driver, and StartDoc and the page calls send a document to that queue, which tests must never do. No rule lives here: which settings a context is created from, the media check before StartDoc, the document name, the position, size and scaling of each page, the stretch mode and raster operation, the copy and page loops, and when a document is ended or aborted are decided in WindowsPrinters, which is tested with a fake of IGdiPrinter. Fixed here, because each is the shape of one call and not a rule: CreateDC passes no output name; the DOCINFO for StartDoc carries its own size and the document name and nothing else (no output file, no data type, type 0); the bitmap header for StretchDIBits has size 40 and no compression, with zero in every field that is not handed in. Nothing here saves or puts back the thread's last error, because no member frees unmanaged memory or makes another native call after the one it stands for. Pinning the settings for CreateDC, null included, is done by WindowsPrinters.WithPinnedMode, which is covered.")]
internal sealed class WindowsGdiPrinter : IGdiPrinter
{
/// <summary>Calls CreateDC with no output override and the settings pinned for the length of the call; null settings are passed as a null pointer.</summary>
public IntPtr CreateDC(string driver, string device, byte[]? devMode) => WindowsPrinters.WithPinnedMode(devMode, pointer => Native.CreateDC(driver, device, null, pointer));
/// <summary>Calls GetDeviceCaps.</summary>
public int GetDeviceCaps(IntPtr dc, int index) => Native.GetDeviceCaps(dc, index);
/// <summary>Calls DeleteDC.</summary>
public bool DeleteDC(IntPtr dc) => Native.DeleteDC(dc);
/// <summary>Calls StartDoc with a DOCINFO that carries its own size and the document name and nothing else.</summary>
public int StartDoc(IntPtr dc, string documentName)
{
Native.DocInfo info = new() { Size = Marshal.SizeOf<Native.DocInfo>(), DocumentName = documentName };
return Native.StartDoc(dc, ref info);
}
/// <summary>Calls StartPage.</summary>
public int StartPage(IntPtr dc) => Native.StartPage(dc);
/// <summary>Calls SetStretchBltMode.</summary>
public int SetStretchBltMode(IntPtr dc, int mode) => Native.SetStretchBltMode(dc, mode);
/// <summary>Calls StretchDIBits with a 40-byte BITMAPINFOHEADER filled from the given header and no compression.</summary>
public int StretchDIBits(IntPtr dc, int x, int y, int width, int height, int sourceX, int sourceY, int sourceWidth, int sourceHeight, byte[] bits, GdiBitmapHeader header, uint usage, uint operation)
{
Native.BitmapInfo bitmap = new() { HeaderSize = 40, Width = header.Width, Height = header.Height, Planes = header.Planes, BitCount = header.BitCount };
return Native.StretchDIBits(dc, x, y, width, height, sourceX, sourceY, sourceWidth, sourceHeight, bits, ref bitmap, usage, operation);
}
/// <summary>Calls EndPage.</summary>
public int EndPage(IntPtr dc) => Native.EndPage(dc);
/// <summary>Calls EndDoc.</summary>
public int EndDoc(IntPtr dc) => Native.EndDoc(dc);
/// <summary>Calls AbortDoc.</summary>
public int AbortDoc(IntPtr dc) => Native.AbortDoc(dc);
/// <summary>
/// The Win32 GDI entry points this adapter calls. Every declaration mirrors the documented native signature exactly;
/// the layouts below are marshaled by the runtime, so field order and types must not change.
/// </summary>
private static class Native
{
/// <summary>DOCINFO for GDI StartDoc: the document name shown in the spooler for a rendered PDF.</summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct DocInfo
{
internal int Size;
[MarshalAs(UnmanagedType.LPWStr)] internal string DocumentName;
[MarshalAs(UnmanagedType.LPWStr)] internal string? OutputFile;
[MarshalAs(UnmanagedType.LPWStr)] internal string? DataType;
internal uint Type;
}
/// <summary>BITMAPINFOHEADER used with StretchDIBits to draw the rendered page bitmap.</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct BitmapInfo
{
internal uint HeaderSize;
internal int Width, Height;
internal ushort Planes, BitCount;
internal uint Compression, ImageSize;
internal int XPelsPerMeter, YPelsPerMeter;
internal uint ColorsUsed, ColorsImportant;
}
/// <summary>Creates a printer device context from the supplied settings so the real page size and resolution can be read and drawn on.</summary>
[DllImport("gdi32.dll", EntryPoint = "CreateDCW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern IntPtr CreateDC(string driver, string device, string? output, IntPtr devMode);
/// <summary>Starts a GDI document on a device context and returns its spooler job number.</summary>
[DllImport("gdi32.dll", EntryPoint = "StartDocW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern int StartDoc(IntPtr dc, ref DocInfo info);
/// <summary>Reads one capability of a device context, such as resolution, printable size or margins.</summary>
[DllImport("gdi32.dll")] internal static extern int GetDeviceCaps(IntPtr dc, int index);
/// <summary>Starts a page in the open GDI document.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int StartPage(IntPtr dc);
/// <summary>Ends the current page so the driver can emit it.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int EndPage(IntPtr dc);
/// <summary>Ends the GDI document and submits it to the spooler.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int EndDoc(IntPtr dc);
/// <summary>Cancels the GDI document after a failure, so nothing partial is printed.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int AbortDoc(IntPtr dc);
/// <summary>Deletes a device context created by CreateDC.</summary>
[DllImport("gdi32.dll")][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool DeleteDC(IntPtr dc);
/// <summary>Selects the stretch mode used when the page bitmap is scaled onto the driver page.</summary>
[DllImport("gdi32.dll")] internal static extern int SetStretchBltMode(IntPtr dc, int mode);
/// <summary>Draws the rendered page bitmap onto the page at the computed position and size.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int StretchDIBits(IntPtr dc, int x, int y, int width, int height, int sx, int sy, int sw, int sh, byte[] bits, ref BitmapInfo info, uint usage, uint operation);
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using Windows.Data.Pdf;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows PDF renderer behind <see cref="IPdfRenderer"/>: it loads a document with Windows.Data.Pdf, hands out
/// its pages, and renders a page to pixels through the Windows image decoder. It decides nothing about printing.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Calls the Windows Runtime PDF renderer (Windows.Data.Pdf) and image decoder (Windows.Graphics.Imaging). These are operating system components: what they return depends on the Windows edition, its installed features and the display scale of the session (a request for 600 by 900 pixels came back as 750 by 1125 on a desktop at 125 percent), so a test that ran them would not be hermetic. No rule lives here: the check of each page's size, the bound on the rendered bitmap, the size and position of the page on the paper, the scaling choice and the copy and page loops are decided in WindowsPrinters, which is tested with a fake of IPdfRenderer. The pixel format asked of the decoder (32-bit BGRA, alpha ignored, no orientation or colour management) is fixed here because it describes the bytes this class returns.")]
internal sealed class WindowsPdfRenderer : IPdfRenderer
{
/// <summary>Loads the document from the stream with the Windows PDF renderer; a file that is not a readable PDF fails here.</summary>
public async Task<IPdfDocument> LoadAsync(IRandomAccessStream stream, CancellationToken cancellationToken)
=> new Document(await PdfDocument.LoadFromStreamAsync(stream).AsTask(cancellationToken).ConfigureAwait(false));
/// <summary>A document loaded by the Windows PDF renderer.</summary>
private sealed class Document(PdfDocument document) : IPdfDocument
{
/// <summary>The page count the renderer reports.</summary>
public uint PageCount => document.PageCount;
/// <summary>Takes the page out of the renderer's document.</summary>
public IPdfPage GetPage(uint index) => new Page(document.GetPage(index));
}
/// <summary>A page of a document loaded by the Windows PDF renderer.</summary>
private sealed class Page(PdfPage page) : IPdfPage
{
/// <summary>The width the renderer reports for the page.</summary>
public double Width => page.Size.Width;
/// <summary>The height the renderer reports for the page.</summary>
public double Height => page.Size.Height;
/// <summary>Renders the page into an in-memory image of the given size and decodes it to 32-bit BGRA pixels.</summary>
public async Task<PageBitmap> RenderAsync(uint width, uint height)
{
using InMemoryRandomAccessStream rendered = new();
await page.RenderToStreamAsync(rendered, new PdfPageRenderOptions
{
DestinationWidth = width,
DestinationHeight = height,
});
rendered.Seek(0);
BitmapDecoder decoder = await BitmapDecoder.CreateAsync(rendered);
PixelDataProvider pixels = await decoder.GetPixelDataAsync(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore, new BitmapTransform(), ExifOrientationMode.IgnoreExifOrientation, ColorManagementMode.DoNotColorManage);
return new PageBitmap(pixels.DetachPixelData(), decoder.PixelWidth, decoder.PixelHeight);
}
/// <summary>Releases the renderer's page.</summary>
public void Dispose() => page.Dispose();
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows behind <see cref="IPrinterSettings"/>: each member makes one call into winspool.drv, with the byte
/// arrays it is given pinned or copied into unmanaged memory for the length of that call, and reports what the call
/// returned. It decides nothing about settings.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Calls GetPrinter, DeviceCapabilities and DocumentProperties in winspool.drv. A test cannot run these without an installed Windows print queue and its driver on this computer, and what they answer depends on that driver. No rule lives here: which level and capability is asked for, every bound on a size or a count, how the returned bytes are read, the mode flags of the merge and whether the driver's answer is accepted are decided in WindowsPrinters, which is tested with a fake of IPrinterSettings. Fixed here, because each is the shape of one call and not a rule: the GetPrinter sizing call passes no buffer and size 0; the DeviceCapabilities count call passes no output buffer; DocumentProperties is always called with no window, and its sizing call with no buffers and mode 0. The one branch here copies a GetPrinter buffer out only when Windows filled it. After that buffer is freed, the error GetPrinter left is put back as the thread's last error, so the caller reads the error of GetPrinter and not of the cleanup.")]
internal sealed class WindowsPrinterSettings : IPrinterSettings
{
/// <summary>Calls GetPrinter with no buffer, which only reports the size the details need.</summary>
public bool GetPrinterSize(IntPtr printer, uint level, out uint needed) => GetPrinter(printer, level, IntPtr.Zero, 0, out needed);
/// <summary>
/// Calls GetPrinter into an unmanaged buffer of the given size and copies the whole buffer out before it is freed,
/// together with the address it had. The error GetPrinter left is put back after the buffer is freed, so the caller
/// reads the error of GetPrinter and not of the cleanup.
/// </summary>
public bool GetPrinter(IntPtr printer, uint level, uint size, out byte[] buffer, out long address)
{
int error = 0;
IntPtr memory = Marshal.AllocHGlobal(checked((int)size));
try
{
bool read = GetPrinter(printer, level, memory, size, out _);
error = Marshal.GetLastPInvokeError();
address = memory.ToInt64();
buffer = read ? new byte[size] : [];
Marshal.Copy(memory, buffer, 0, buffer.Length);
return read;
}
finally
{
Marshal.FreeHGlobal(memory);
Marshal.SetLastPInvokeError(error);
}
}
/// <summary>Calls DeviceCapabilities with no output buffer and the settings pinned, which only reports the number of entries.</summary>
public int DeviceCapabilities(string device, string port, ushort capability, byte[] devMode)
{
var pinned = GCHandle.Alloc(devMode, GCHandleType.Pinned);
try
{ return DeviceCapabilities(device, port, capability, IntPtr.Zero, pinned.AddrOfPinnedObject()); }
finally { pinned.Free(); }
}
/// <summary>Calls DeviceCapabilities into an unmanaged buffer of the given size with the settings pinned, and copies the buffer out before it is freed.</summary>
public int DeviceCapabilities(string device, string port, ushort capability, int outputBytes, byte[] devMode, out byte[] output)
{
IntPtr memory = Marshal.AllocHGlobal(outputBytes);
var pinned = GCHandle.Alloc(devMode, GCHandleType.Pinned);
try
{
int entries = DeviceCapabilities(device, port, capability, memory, pinned.AddrOfPinnedObject());
output = new byte[outputBytes];
Marshal.Copy(memory, output, 0, outputBytes);
return entries;
}
finally
{
pinned.Free();
Marshal.FreeHGlobal(memory);
}
}
/// <summary>Calls DocumentProperties with no window, no buffers and mode 0, which only reports the size of the driver's settings.</summary>
public int DocumentPropertiesSize(IntPtr printer, string device) => DocumentProperties(IntPtr.Zero, printer, device, IntPtr.Zero, IntPtr.Zero, 0);
/// <summary>Calls DocumentProperties with no window and both arrays pinned for the length of the call.</summary>
public int DocumentProperties(IntPtr printer, string device, byte[] output, byte[] input, uint mode)
{
var source = GCHandle.Alloc(input, GCHandleType.Pinned);
var destination = GCHandle.Alloc(output, GCHandleType.Pinned);
try
{ return DocumentProperties(IntPtr.Zero, printer, device, destination.AddrOfPinnedObject(), source.AddrOfPinnedObject(), mode); }
finally
{
destination.Free();
source.Free();
}
}
/// <summary>
/// Reads printer information into a caller-sized buffer; level eight supplies machine-wide DEVMODE defaults.
/// </summary>
[DllImport("winspool.drv", EntryPoint = "GetPrinterW", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetPrinter(IntPtr printer, uint level, IntPtr buffer, uint size, out uint needed);
/// <summary>
/// Queries driver capabilities against the supplied DEVMODE so paper IDs and sizes refer to the same captured configuration.
/// </summary>
[DllImport("winspool.drv", EntryPoint = "DeviceCapabilitiesW", CharSet = CharSet.Unicode)]
private static extern int DeviceCapabilities(string device, string port, ushort capability, IntPtr output, IntPtr devMode);
/// <summary>
/// Queries required buffer size or merges job settings; callers deliberately omit prompt and persistent-update flags.
/// </summary>
[DllImport("winspool.drv", EntryPoint = "DocumentPropertiesW", CharSet = CharSet.Unicode)]
private static extern int DocumentProperties(IntPtr window, IntPtr printer, string device, IntPtr output, IntPtr input, uint mode);
}
@@ -7,7 +7,6 @@ using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using Qqq.PrintAgent.Core.Printing;
using Windows.Data.Pdf;
namespace Qqq.PrintAgent.Windows;
@@ -48,38 +47,41 @@ public sealed partial class WindowsPrinters
return PrintRawStream(printer, job, input, cancellationToken);
}
using global::Windows.Storage.Streams.IRandomAccessStream random = input.AsRandomAccessStream();
PdfDocument document = await PdfDocument.LoadFromStreamAsync(random).AsTask(cancellationToken).ConfigureAwait(false);
IPdfDocument document = await _pdf.LoadAsync(random, cancellationToken).ConfigureAwait(false);
return await PrintPdfDocumentAsync(printer, job, document, cancellationToken).ConfigureAwait(false);
}
/// <summary>Spools a staged file as one RAW document, rechecking the selected media just before it starts, and aborts the document if anything fails after it began so no partial label stays queued.</summary>
private static string PrintRawStream(PrinterInfo selectedPrinter, PrintJob job, Stream input, CancellationToken token)
private string PrintRawStream(PrinterInfo selectedPrinter, PrintJob job, Stream input, CancellationToken token)
{
Check(Native.OpenPrinter(selectedPrinter.Name, out IntPtr printer, IntPtr.Zero));
Check(_spooler.OpenPrinter(selectedPrinter.Name, out IntPtr printer));
try
{
_ = RequireCurrentMedia(selectedPrinter, job);
Native.DocInfo1 info = new() { DocumentName = $"QQQ {job.JobId}", DataType = "RAW" };
uint id = Native.StartDocPrinter(printer, 1, ref info);
uint id = _spooler.StartDocPrinter(printer, $"QQQ {job.JobId}", "RAW");
if(id == 0)
{ throw new Win32Exception(Marshal.GetLastWin32Error()); }
try
{
WriteRawCopies(printer, job.Options.Copies, input, token);
Check(Native.EndDocPrinter(printer));
Check(_spooler.EndDocPrinter(printer));
return id.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
catch { Native.AbortPrinter(printer); throw; }
catch
{
_ = _spooler.AbortPrinter(printer);
throw;
}
}
finally { Native.ClosePrinter(printer); }
finally { _ = _spooler.ClosePrinter(printer); }
}
/// <summary>Writes the staged bytes once per copy in 64 KiB pieces, continuing after partial spooler writes and refusing an impossible byte count, with cancellation checked between writes.</summary>
private static void WriteRawCopies(IntPtr printer, int copies, Stream input, CancellationToken token)
private void WriteRawCopies(IntPtr printer, int copies, Stream input, CancellationToken token)
{
byte[] buffer = new byte[65536];
for(int copy = 0; copy < copies; copy++)
@@ -93,7 +95,7 @@ public sealed partial class WindowsPrinters
{
token.ThrowIfCancellationRequested();
byte[] remaining = buffer.AsSpan(offset, count - offset).ToArray();
Check(Native.WritePrinter(printer, remaining, remaining.Length, out int written));
Check(_spooler.WritePrinter(printer, remaining, remaining.Length, out int written));
if(written <= 0 || written > remaining.Length)
{ throw new IOException("Printer returned an invalid byte count."); }
offset += written;
@@ -21,29 +21,23 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Keeps the complete driver bytes beside their resolved profile so validation and submission use the same captured defaults.
/// </summary>
private sealed record QueueDefaultMedia(byte[] DevMode, PrinterMediaProfile? Profile);
internal sealed record QueueDefaultMedia(byte[] DevMode, PrinterMediaProfile? Profile);
/// <summary>
/// Resolves bounded machine defaults into a selectable profile; unreadable queues remain discoverable without a media claim.
/// </summary>
private static QueueDefaultMedia? ReadQueueMedia(string id, string name, string driver, string port)
internal QueueDefaultMedia? ReadQueueMedia(string id, string name, string driver, string port)
{
try
{
byte[]? mode = ReadMachineDevMode(name);
if(mode is null)
{ return null; }
var pinned = GCHandle.Alloc(mode, GCHandleType.Pinned);
try
{
IntPtr pointer = pinned.AddrOfPinnedObject();
(int width, int height) = ResolvePaperDimensions(name, port, mode, pointer);
(int dpiX, int dpiY) = ResolveResolution(name, mode, pointer);
return new QueueDefaultMedia(mode, BuildMediaProfile(id, driver, mode, width, height, dpiX, dpiY));
}
finally { pinned.Free(); }
(int width, int height) = ResolvePaperDimensions(name, port, mode);
(int dpiX, int dpiY) = ResolveResolution(name, mode);
return new QueueDefaultMedia(mode, BuildMediaProfile(id, driver, mode, width, height, dpiX, dpiY));
}
catch(Win32Exception)
{
@@ -56,34 +50,29 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Copies level-eight machine defaults only after checking native pointer and length bounds, avoiding per-user preferences.
/// The answer's first field is the address of the settings inside the same answer, so it is turned into an offset and
/// checked against the answer's size before anything is read at it.
/// </summary>
private static byte[]? ReadMachineDevMode(string name)
internal byte[]? ReadMachineDevMode(string name)
{
Check(Native.OpenPrinter(name, out IntPtr printer, IntPtr.Zero));
Check(_spooler.OpenPrinter(name, out IntPtr printer));
try
{
_ = Native.GetPrinter(printer, 8, IntPtr.Zero, 0, out uint needed);
_ = _settings.GetPrinterSize(printer, 8, out uint needed);
if(needed < IntPtr.Size + 102 || needed > 1_048_576)
{ return null; }
IntPtr buffer = Marshal.AllocHGlobal(checked((int)needed));
try
{
Check(Native.GetPrinter(printer, 8, buffer, needed, out _));
IntPtr mode = Marshal.ReadIntPtr(buffer);
long offset = mode.ToInt64() - buffer.ToInt64();
if(mode == IntPtr.Zero || offset < IntPtr.Size || offset > needed - 102)
{ return null; }
int size = (ushort)Marshal.ReadInt16(mode, 68);
int extra = (ushort)Marshal.ReadInt16(mode, 70);
if(size < 102 || size + extra > needed - offset)
{ return null; }
byte[] bytes = new byte[size + extra];
Marshal.Copy(mode, bytes, 0, bytes.Length);
return bytes;
}
finally { Marshal.FreeHGlobal(buffer); }
Check(_settings.GetPrinter(printer, 8, needed, out byte[] buffer, out long address));
long mode = BinaryPrimitives.ReadIntPtrLittleEndian(buffer);
long offset = mode - address;
if(mode == 0 || offset < IntPtr.Size || offset > needed - 102)
{ return null; }
int size = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan((int)offset + 68));
int extra = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan((int)offset + 70));
if(size < 102 || size + extra > needed - offset)
{ return null; }
return buffer.AsSpan((int)offset, size + extra).ToArray();
}
finally { _ = Native.ClosePrinter(printer); }
finally { _ = _spooler.ClosePrinter(printer); }
}
@@ -91,7 +80,7 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Uses both explicit form dimensions or an unambiguous driver paper-ID mapping, because partial dimensions are not reliable stock data.
/// </summary>
private static (int Width, int Height) ResolvePaperDimensions(string name, string port, byte[] mode, IntPtr pointer)
private (int Width, int Height) ResolvePaperDimensions(string name, string port, byte[] mode)
{
uint fields = BinaryPrimitives.ReadUInt32LittleEndian(mode.AsSpan(72));
if((fields & 12) != 0)
@@ -101,40 +90,36 @@ public sealed partial class WindowsPrinters
}
if((fields & 2) == 0)
{ return (0, 0); }
return ReadDriverPaperSize(name, port, pointer, ReadModeShort(mode, 78));
return ReadDriverPaperSize(name, port, mode, ReadModeShort(mode, 78));
}
/// <summary>
/// Pairs the driver's paper identifiers and size arrays and accepts exactly one bounded match, rather than guessing a standard form.
/// Capability 2 lists the identifiers as 16-bit values and capability 3 the sizes as pairs of 32-bit tenths of a millimeter.
/// </summary>
private static (int Width, int Height) ReadDriverPaperSize(string name, string port, IntPtr mode, short paperId)
private (int Width, int Height) ReadDriverPaperSize(string name, string port, byte[] mode, short paperId)
{
int count = Native.DeviceCapabilities(name, port, 2, IntPtr.Zero, mode);
if(count is < 1 or > ushort.MaxValue || Native.DeviceCapabilities(name, port, 3, IntPtr.Zero, mode) != count)
int count = _settings.DeviceCapabilities(name, port, 2, mode);
if(count is < 1 or > ushort.MaxValue || _settings.DeviceCapabilities(name, port, 3, mode) != count)
{ return (0, 0); }
IntPtr identifiers = Marshal.AllocHGlobal(count * sizeof(short));
IntPtr dimensions = Marshal.AllocHGlobal(count * 2 * sizeof(int));
try
if(_settings.DeviceCapabilities(name, port, 2, count * sizeof(short), mode, out byte[] identifiers) != count
|| _settings.DeviceCapabilities(name, port, 3, count * 2 * sizeof(int), mode, out byte[] dimensions) != count)
{ return (0, 0); }
List<(int Width, int Height)> matches = [];
for(int index = 0; index < count; index++)
{
if(Native.DeviceCapabilities(name, port, 2, identifiers, mode) != count || Native.DeviceCapabilities(name, port, 3, dimensions, mode) != count)
{ return (0, 0); }
List<(int Width, int Height)> matches = [];
for(int index = 0; index < count; index++)
if(BinaryPrimitives.ReadInt16LittleEndian(identifiers.AsSpan(index * 2)) == paperId)
{
if(Marshal.ReadInt16(identifiers, index * 2) == paperId)
{
int width = Marshal.ReadInt32(dimensions, index * 8);
int height = Marshal.ReadInt32(dimensions, index * 8 + 4);
if(width is < 1 or > 20_000 || height is < 1 or > 20_000)
{ return (0, 0); }
matches.Add((width * 100, height * 100));
}
int width = BinaryPrimitives.ReadInt32LittleEndian(dimensions.AsSpan(index * 8));
int height = BinaryPrimitives.ReadInt32LittleEndian(dimensions.AsSpan(index * 8 + 4));
if(width is < 1 or > 20_000 || height is < 1 or > 20_000)
{ return (0, 0); }
matches.Add((width * 100, height * 100));
}
return matches.Count == 1 ? matches[0] : (0, 0);
}
finally { Marshal.FreeHGlobal(identifiers); Marshal.FreeHGlobal(dimensions); }
return matches.Count == 1 ? matches[0] : (0, 0);
}
@@ -142,18 +127,18 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Uses positive explicit DPI or a DC built from the captured defaults; symbolic quality values are not treated as resolution.
/// </summary>
private static (int X, int Y) ResolveResolution(string name, byte[] mode, IntPtr pointer)
private (int X, int Y) ResolveResolution(string name, byte[] mode)
{
uint fields = BinaryPrimitives.ReadUInt32LittleEndian(mode.AsSpan(72));
if((fields & 0x2400) == 0x2400 && ReadModeShort(mode, 90) > 0 && ReadModeShort(mode, 96) > 0)
{ return (ReadModeShort(mode, 90), ReadModeShort(mode, 96)); }
// Symbolic quality values are not DPI. Ask this driver with this exact machine DEVMODE.
IntPtr dc = Native.CreateDC("WINSPOOL", name, null, pointer);
IntPtr dc = _gdi.CreateDC("WINSPOOL", name, mode);
if(dc == IntPtr.Zero)
{ return (0, 0); }
try
{ return (Native.GetDeviceCaps(dc, 88), Native.GetDeviceCaps(dc, 90)); }
finally { _ = Native.DeleteDC(dc); }
{ return (_gdi.GetDeviceCaps(dc, 88), _gdi.GetDeviceCaps(dc, 90)); }
finally { _ = _gdi.DeleteDC(dc); }
}
@@ -203,7 +188,7 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Re-reads and matches selected defaults at the native submission boundary, while leaving legacy unselected jobs on their existing path.
/// </summary>
private static QueueDefaultMedia? RequireCurrentMedia(PrinterInfo printer, PrintJob job)
internal QueueDefaultMedia? RequireCurrentMedia(PrinterInfo printer, PrintJob job)
{
if(job.Options.MediaProfile is null)
{ return null; }
@@ -217,8 +202,8 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Passes the pinned captured or normalized job settings to GDI so selected jobs do not silently request current-user defaults.
/// </summary>
private static IntPtr CreateDocumentContext(string printerName, byte[]? mode)
=> WithPinnedMode(mode, pointer => Native.CreateDC("WINSPOOL", printerName, null, pointer));
private IntPtr CreateDocumentContext(string printerName, byte[]? mode)
=> _gdi.CreateDC("WINSPOOL", printerName, mode);
@@ -290,27 +275,4 @@ public sealed partial class WindowsPrinters
{ return create(pinned.AddrOfPinnedObject()); }
finally { pinned.Free(); }
}
/// <summary>
/// Contains read-only Winspool entry points used to obtain machine defaults and driver form capabilities.
/// </summary>
private static partial class Native
{
/// <summary>
/// Reads printer information into a caller-sized buffer; level eight supplies machine-wide DEVMODE defaults.
/// </summary>
[DllImport("winspool.drv", EntryPoint = "GetPrinterW", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GetPrinter(IntPtr printer, uint level, IntPtr buffer, uint size, out uint needed);
/// <summary>
/// Queries driver capabilities against the supplied DEVMODE so paper IDs and sizes refer to the same captured configuration.
/// </summary>
[DllImport("winspool.drv", EntryPoint = "DeviceCapabilitiesW", CharSet = CharSet.Unicode)]
internal static extern int DeviceCapabilities(string device, string port, ushort capability, IntPtr output, IntPtr devMode);
}
}
@@ -4,7 +4,6 @@
*/
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
@@ -17,18 +16,12 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Normalizes a selected PDF job and resolves the returned driver settings again, refusing any geometry or DPI change.
/// </summary>
private static byte[] PreparePdfMode(PrinterInfo printer, QueueDefaultMedia media)
internal byte[] PreparePdfMode(PrinterInfo printer, QueueDefaultMedia media)
=> NormalizePdfMode(media.DevMode, media.Profile!, mode => MergeDriverMode(printer.Name, mode), mode =>
{
var pinned = GCHandle.Alloc(mode, GCHandleType.Pinned);
try
{
IntPtr pointer = pinned.AddrOfPinnedObject();
(int width, int height) = ResolvePaperDimensions(printer.Name, printer.PortName, mode, pointer);
(int dpiX, int dpiY) = ResolveResolution(printer.Name, mode, pointer);
return BuildMediaProfile(printer.PrinterId, printer.DriverName, mode, width, height, dpiX, dpiY);
}
finally { pinned.Free(); }
(int width, int height) = ResolvePaperDimensions(printer.Name, printer.PortName, mode);
(int dpiX, int dpiY) = ResolveResolution(printer.Name, mode);
return BuildMediaProfile(printer.PrinterId, printer.DriverName, mode, width, height, dpiX, dpiY);
});
@@ -112,7 +105,7 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Reads the unchanged machine defaults for the queue and builds job-local explicit paper settings from them.
/// </summary>
private static byte[] PrepareCustomPdfMode(PrinterInfo printer, (int WidthMicrons, int HeightMicrons) paper)
internal byte[] PrepareCustomPdfMode(PrinterInfo printer, (int WidthMicrons, int HeightMicrons) paper)
{
byte[] captured = ReadMachineDevMode(printer.Name)
?? throw new PrinterMediaMismatchException("The queue has no readable machine defaults to apply job paper to.");
@@ -124,28 +117,22 @@ public sealed partial class WindowsPrinters
/// <summary>
/// Lets DocumentProperties reconcile private settings in bounded buffers using input/output flags only, with no prompt or queue update.
/// </summary>
private static byte[] MergeDriverMode(string printerName, byte[] input)
private byte[] MergeDriverMode(string printerName, byte[] input)
{
Check(Native.OpenPrinter(printerName, out IntPtr printer, IntPtr.Zero));
Check(_spooler.OpenPrinter(printerName, out IntPtr printer));
try
{
int required = Native.DocumentProperties(IntPtr.Zero, printer, printerName, IntPtr.Zero, IntPtr.Zero, 0);
int required = _settings.DocumentPropertiesSize(printer, printerName);
if(required is < 102 or > 1_048_576)
{
throw new PrinterMediaMismatchException("The driver did not provide a bounded document-settings buffer.");
}
byte[] output = new byte[required];
var source = GCHandle.Alloc(input, GCHandleType.Pinned);
var destination = GCHandle.Alloc(output, GCHandleType.Pinned);
try
// DM_IN_BUFFER | DM_OUT_BUFFER only: no prompt and no queue-default update.
if(_settings.DocumentProperties(printer, printerName, output, input, 10) != 1)
{
// DM_IN_BUFFER | DM_OUT_BUFFER only: no prompt and no queue-default update.
if(Native.DocumentProperties(IntPtr.Zero, printer, printerName, destination.AddrOfPinnedObject(), source.AddrOfPinnedObject(), 10) != 1)
{
throw new PrinterMediaMismatchException("The driver refused job-level document settings.");
}
throw new PrinterMediaMismatchException("The driver refused job-level document settings.");
}
finally { destination.Free(); source.Free(); }
int size = BinaryPrimitives.ReadUInt16LittleEndian(output.AsSpan(68));
int extra = BinaryPrimitives.ReadUInt16LittleEndian(output.AsSpan(70));
if(size < 102 || size + extra > output.Length)
@@ -154,20 +141,6 @@ public sealed partial class WindowsPrinters
}
return output[..(size + extra)];
}
finally { _ = Native.ClosePrinter(printer); }
}
/// <summary>
/// Contains the driver-normalization call used to create application-local DEVMODE settings.
/// </summary>
private static partial class Native
{
/// <summary>
/// Queries required buffer size or merges job settings; callers deliberately omit prompt and persistent-update flags.
/// </summary>
[DllImport("winspool.drv", EntryPoint = "DocumentPropertiesW", CharSet = CharSet.Unicode)]
internal static extern int DocumentProperties(IntPtr window, IntPtr printer, string device, IntPtr output, IntPtr input, uint mode);
finally { _ = _spooler.ClosePrinter(printer); }
}
}
+77 -197
View File
@@ -9,9 +9,6 @@ using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Qqq.PrintAgent.Core.Printing;
using Windows.Data.Pdf;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
[assembly: InternalsVisibleTo("Qqq.PrintAgent.Cli.Tests")]
@@ -20,6 +17,34 @@ namespace Qqq.PrintAgent.Windows;
/// <summary>Windows spooler discovery, RAW ZPL, and Windows PDF renderer to native GDI.</summary>
public sealed partial class WindowsPrinters : IPrinterBackend
{
private readonly ISpooler _spooler;
private readonly IPrinterSettings _settings;
private readonly IGdiPrinter _gdi;
private readonly IPdfRenderer _pdf;
/// <summary>Builds the adapter over the real Windows spooler, printer settings, GDI and PDF renderer. Nothing is asked of Windows until a member is called.</summary>
public WindowsPrinters() : this(new WindowsSpooler(), new WindowsPrinterSettings(), new WindowsGdiPrinter(), new WindowsPdfRenderer())
{
}
/// <summary>
/// Builds the adapter over the given Windows calls. Every rule in this class reaches Windows only through these, so
/// tests run the rules against fakes that record what was asked and never open a printer.
/// </summary>
internal WindowsPrinters(ISpooler spooler, IPrinterSettings settings, IGdiPrinter gdi, IPdfRenderer pdf)
{
_spooler = spooler;
_settings = settings;
_gdi = gdi;
_pdf = pdf;
}
/// <summary>
/// Enumerates the queues visible to this Windows identity (for the service, the machine-wide ones). A queue whose port
/// or driver needs a person at the keyboard (file output, print-to-PDF, XPS, fax, OneNote) is reported UNKNOWN with no
@@ -29,35 +54,33 @@ public sealed partial class WindowsPrinters : IPrinterBackend
/// </summary>
public IReadOnlyList<PrinterInfo> Discover()
{
bool probeSucceeded = Native.EnumPrinters(6, null, 2, IntPtr.Zero, 0, out uint needed, out _);
////////////////////////////////////////////////////////////////////
// 6 is PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS: the queues //
// installed here and the connections this identity has //
////////////////////////////////////////////////////////////////////
bool probeSucceeded = _spooler.EnumPrintersSize(6, out uint needed);
ValidateDiscoveryProbe(probeSucceeded, Marshal.GetLastWin32Error(), needed);
if(needed == 0)
{
return [];
}
IntPtr memory = Marshal.AllocHGlobal(checked((int)needed));
try
Check(_spooler.EnumPrinters(6, checked((int)needed), out IReadOnlyList<SpoolerQueue> queues));
List<PrinterInfo> printers = [];
foreach(SpoolerQueue queue in queues)
{
Check(Native.EnumPrinters(6, null, 2, memory, needed, out _, out uint count));
List<PrinterInfo> printers = [];
for(int i = 0; i < count; i++)
{
Native.PrinterInfo2 info = Marshal.PtrToStructure<Native.PrinterInfo2>(memory + i * Marshal.SizeOf<Native.PrinterInfo2>());
string name = Marshal.PtrToStringUni(info.PrinterName) ?? "";
string driver = Marshal.PtrToStringUni(info.DriverName) ?? "";
bool zebra = (name + driver).Contains("Zebra", StringComparison.OrdinalIgnoreCase) || driver.Contains("ZDesigner", StringComparison.OrdinalIgnoreCase);
string status = MapStatus(info.Status);
string port = Marshal.PtrToStringUni(info.PortName) ?? "";
bool interactive = RequiresInteractiveOutput(port, driver);
string id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name))).ToLowerInvariant()[..24];
QueueDefaultMedia? media = interactive ? null : ReadQueueMedia(id, name, driver, port);
printers.Add(new PrinterInfo(id, name, driver, port, interactive ? "UNKNOWN" : status, interactive ? [] : zebra ? ["PDF", "ZPL"] : ["PDF"],
MediaProfiles: media?.Profile is null ? [] : [media.Profile]));
}
return printers;
string name = queue.Name ?? "";
string driver = queue.Driver ?? "";
bool zebra = (name + driver).Contains("Zebra", StringComparison.OrdinalIgnoreCase) || driver.Contains("ZDesigner", StringComparison.OrdinalIgnoreCase);
string status = MapStatus(queue.Status);
string port = queue.Port ?? "";
bool interactive = RequiresInteractiveOutput(port, driver);
string id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name))).ToLowerInvariant()[..24];
QueueDefaultMedia? media = interactive ? null : ReadQueueMedia(id, name, driver, port);
printers.Add(new PrinterInfo(id, name, driver, port, interactive ? "UNKNOWN" : status, interactive ? [] : zebra ? ["PDF", "ZPL"] : ["PDF"],
MediaProfiles: media?.Profile is null ? [] : [media.Profile]));
}
finally { Marshal.FreeHGlobal(memory); }
return printers;
}
@@ -130,7 +153,7 @@ public sealed partial class WindowsPrinters : IPrinterBackend
/// the selection. Pages are rendered and printed copy by copy; any failure after the document starts aborts it, and the
/// context is always released.
/// </summary>
private static async Task<string> PrintPdfDocumentAsync(PrinterInfo printer, PrintJob job, PdfDocument document, CancellationToken cancellationToken)
private async Task<string> PrintPdfDocumentAsync(PrinterInfo printer, PrintJob job, IPdfDocument document, CancellationToken cancellationToken)
{
(int WidthMicrons, int HeightMicrons)? paper = job.Options.CustomPaperMicrons();
QueueDefaultMedia? media = RequireCurrentMedia(printer, job);
@@ -143,14 +166,13 @@ public sealed partial class WindowsPrinters : IPrinterBackend
try
{
Native.DocInfo info = new() { Size = Marshal.SizeOf<Native.DocInfo>(), DocumentName = $"QQQ {job.JobId}" };
// Explicit job paper is checked against the device context's physical size and dpi, so a driver that
// substitutes its 4 x 6 default fails before StartDoc instead of feeding the wrong length.
PrinterMediaProfile? expected = paper is { } requested
? new PrinterMediaProfile("job-paper", "job-paper", "Job paper", requested.WidthMicrons, requested.HeightMicrons, Native.GetDeviceCaps(dc, 88), Native.GetDeviceCaps(dc, 90))
? new PrinterMediaProfile("job-paper", "job-paper", "Job paper", requested.WidthMicrons, requested.HeightMicrons, _gdi.GetDeviceCaps(dc, 88), _gdi.GetDeviceCaps(dc, 90))
: media?.Profile;
int jobId = StartValidatedPdfDocument(documentMode, expected,
capability => Native.GetDeviceCaps(dc, capability), () => Native.StartDoc(dc, ref info));
capability => _gdi.GetDeviceCaps(dc, capability), () => _gdi.StartDoc(dc, $"QQQ {job.JobId}"));
if(jobId <= 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
@@ -163,12 +185,12 @@ public sealed partial class WindowsPrinters : IPrinterBackend
for(uint pageIndex = 0; pageIndex < document.PageCount; pageIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
using PdfPage page = document.GetPage(pageIndex);
using IPdfPage page = document.GetPage(pageIndex);
await PrintPageAsync(dc, page, job.Options.Scale).ConfigureAwait(false);
}
}
if(Native.EndDoc(dc) <= 0)
if(_gdi.EndDoc(dc) <= 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
@@ -178,11 +200,11 @@ public sealed partial class WindowsPrinters : IPrinterBackend
catch
{
// Cleanup is best effort; preserve the original submission error.
_ = Native.AbortDoc(dc);
_ = _gdi.AbortDoc(dc);
throw;
}
}
finally { Native.DeleteDC(dc); }
finally { _ = _gdi.DeleteDC(dc); }
}
@@ -192,43 +214,42 @@ public sealed partial class WindowsPrinters : IPrinterBackend
/// cannot exhaust memory) and draws it at its true physical size on the driver page, or scaled to fit and centered
/// when the job asks for FIT. Invalid page dimensions are refused before any rendering.
/// </summary>
private static async Task PrintPageAsync(IntPtr dc, PdfPage page, string scaleMode)
private async Task PrintPageAsync(IntPtr dc, IPdfPage page, string scaleMode)
{
if(!double.IsFinite(page.Size.Width) || !double.IsFinite(page.Size.Height) || page.Size.Width <= 0 || page.Size.Height <= 0)
if(!double.IsFinite(page.Width) || !double.IsFinite(page.Height) || page.Width <= 0 || page.Height <= 0)
{
throw new InvalidDataException("PDF page dimensions are invalid.");
}
double renderScale = Math.Min(150d / 96, 5000 / Math.Max(page.Size.Width, page.Size.Height));
using InMemoryRandomAccessStream rendered = new();
await page.RenderToStreamAsync(rendered, new PdfPageRenderOptions
{
DestinationWidth = (uint)Math.Max(1, page.Size.Width * renderScale),
DestinationHeight = (uint)Math.Max(1, page.Size.Height * renderScale),
});
rendered.Seek(0);
BitmapDecoder decoder = await BitmapDecoder.CreateAsync(rendered);
PixelDataProvider pixels = await decoder.GetPixelDataAsync(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore, new BitmapTransform(), ExifOrientationMode.IgnoreExifOrientation, ColorManagementMode.DoNotColorManage);
byte[] data = pixels.DetachPixelData();
int width = checked((int)(page.Size.Width / 96 * Native.GetDeviceCaps(dc, 88)));
int height = checked((int)(page.Size.Height / 96 * Native.GetDeviceCaps(dc, 90)));
int x = -Native.GetDeviceCaps(dc, 112);
int y = -Native.GetDeviceCaps(dc, 113);
double renderScale = Math.Min(150d / 96, 5000 / Math.Max(page.Width, page.Height));
PageBitmap bitmap = await page.RenderAsync((uint)Math.Max(1, page.Width * renderScale), (uint)Math.Max(1, page.Height * renderScale)).ConfigureAwait(false);
int width = checked((int)(page.Width / 96 * _gdi.GetDeviceCaps(dc, 88)));
int height = checked((int)(page.Height / 96 * _gdi.GetDeviceCaps(dc, 90)));
int x = -_gdi.GetDeviceCaps(dc, 112);
int y = -_gdi.GetDeviceCaps(dc, 113);
if(string.Equals(scaleMode, "FIT", StringComparison.Ordinal))
{
int availableWidth = Native.GetDeviceCaps(dc, 8);
int availableHeight = Native.GetDeviceCaps(dc, 10);
int availableWidth = _gdi.GetDeviceCaps(dc, 8);
int availableHeight = _gdi.GetDeviceCaps(dc, 10);
double factor = Math.Min((double)availableWidth / width, (double)availableHeight / height);
width = (int)(width * factor);
height = (int)(height * factor);
x = (availableWidth - width) / 2;
y = (availableHeight - height) / 2;
}
Native.BitmapInfo bitmap = new() { HeaderSize = 40, Width = (int)decoder.PixelWidth, Height = -(int)decoder.PixelHeight, Planes = 1, BitCount = 32 };
Check(Native.StartPage(dc) > 0);
Check(Native.SetStretchBltMode(dc, 4) != 0);
int result = Native.StretchDIBits(dc, x, y, width, height, 0, 0, (int)decoder.PixelWidth, (int)decoder.PixelHeight, data, ref bitmap, 0, 0x00CC0020);
///////////////////////////////////////////////////////////////
// a negative height tells GDI the first row is the top one; //
// one plane, 32 bits a pixel //
///////////////////////////////////////////////////////////////
GdiBitmapHeader header = new((int)bitmap.PixelWidth, -(int)bitmap.PixelHeight, 1, 32);
Check(_gdi.StartPage(dc) > 0);
///////////////////////////////////////////////////////////////////
// 4 is HALFTONE; 0 is DIB_RGB_COLORS and 0x00CC0020 is SRCCOPY: //
// the whole bitmap, copied as it is //
///////////////////////////////////////////////////////////////////
Check(_gdi.SetStretchBltMode(dc, 4) != 0);
int result = _gdi.StretchDIBits(dc, x, y, width, height, 0, 0, (int)bitmap.PixelWidth, (int)bitmap.PixelHeight, bitmap.Pixels, header, 0, 0x00CC0020);
Check(result is not (0 or -1));
Check(Native.EndPage(dc) > 0);
Check(_gdi.EndPage(dc) > 0);
}
@@ -241,145 +262,4 @@ public sealed partial class WindowsPrinters : IPrinterBackend
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
/// <summary>
/// The Win32 spooler and GDI entry points this adapter calls. Every declaration mirrors the documented native
/// signature exactly; the layouts below are marshaled by the runtime, so field order and types must not change.
/// </summary>
private static partial class Native
{
/// <summary>PRINTER_INFO_2 as returned by EnumPrinters level 2: queue, driver and port names, status and the machine default settings pointer.</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct PrinterInfo2
{
internal IntPtr ServerName, PrinterName, ShareName, PortName, DriverName, Comment, Location, DevMode, SepFile, PrintProcessor, DataType, Parameters, SecurityDescriptor;
internal uint Attributes, Priority, DefaultPriority, StartTime, UntilTime, Status, Jobs, AveragePpm;
}
/// <summary>DOC_INFO_1 for StartDocPrinter: the spool document's name and data type (RAW for ZPL).</summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct DocInfo1
{
[MarshalAs(UnmanagedType.LPWStr)] internal string DocumentName;
[MarshalAs(UnmanagedType.LPWStr)] internal string? OutputFile;
[MarshalAs(UnmanagedType.LPWStr)] internal string DataType;
}
/// <summary>DOCINFO for GDI StartDoc: the document name shown in the spooler for a rendered PDF.</summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct DocInfo
{
internal int Size;
[MarshalAs(UnmanagedType.LPWStr)] internal string DocumentName;
[MarshalAs(UnmanagedType.LPWStr)] internal string? OutputFile;
[MarshalAs(UnmanagedType.LPWStr)] internal string? DataType;
internal uint Type;
}
/// <summary>BITMAPINFOHEADER used with StretchDIBits to draw the rendered page bitmap.</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct BitmapInfo
{
internal uint HeaderSize;
internal int Width, Height;
internal ushort Planes, BitCount;
internal uint Compression, ImageSize;
internal int XPelsPerMeter, YPelsPerMeter;
internal uint ColorsUsed, ColorsImportant;
}
/// <summary>Lists the installed queues (level 2) so discovery can read their names, drivers, ports and status.</summary>
[DllImport("winspool.drv", EntryPoint = "EnumPrintersW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool EnumPrinters(uint flags, string? name, uint level, IntPtr buffer, uint size, out uint needed, out uint returned);
/// <summary>Opens a queue by name for a spool document or a settings read; the handle must be closed.</summary>
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool OpenPrinter(string name, out IntPtr handle, IntPtr defaults);
/// <summary>Starts one RAW spool document and returns its spooler job number, or zero on failure.</summary>
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern uint StartDocPrinter(IntPtr printer, int level, ref DocInfo1 info);
/// <summary>Writes bytes into the open spool document; the written count can be less than requested.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written);
/// <summary>Finishes the spool document so the spooler may send it to the printer.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool EndDocPrinter(IntPtr printer);
/// <summary>Releases a queue handle opened by OpenPrinter.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool ClosePrinter(IntPtr printer);
/// <summary>Discards a spool document that failed part way, so a partial label is not printed.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool AbortPrinter(IntPtr printer);
/// <summary>Creates a printer device context from the supplied settings so the real page size and resolution can be read and drawn on.</summary>
[DllImport("gdi32.dll", EntryPoint = "CreateDCW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern IntPtr CreateDC(string driver, string device, string? output, IntPtr devMode);
/// <summary>Starts a GDI document on a device context and returns its spooler job number.</summary>
[DllImport("gdi32.dll", EntryPoint = "StartDocW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern int StartDoc(IntPtr dc, ref DocInfo info);
/// <summary>Reads one capability of a device context, such as resolution, printable size or margins.</summary>
[DllImport("gdi32.dll")] internal static extern int GetDeviceCaps(IntPtr dc, int index);
/// <summary>Starts a page in the open GDI document.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int StartPage(IntPtr dc);
/// <summary>Ends the current page so the driver can emit it.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int EndPage(IntPtr dc);
/// <summary>Ends the GDI document and submits it to the spooler.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int EndDoc(IntPtr dc);
/// <summary>Cancels the GDI document after a failure, so nothing partial is printed.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int AbortDoc(IntPtr dc);
/// <summary>Deletes a device context created by CreateDC.</summary>
[DllImport("gdi32.dll")][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool DeleteDC(IntPtr dc);
/// <summary>Selects the stretch mode used when the page bitmap is scaled onto the driver page.</summary>
[DllImport("gdi32.dll")] internal static extern int SetStretchBltMode(IntPtr dc, int mode);
/// <summary>Draws the rendered page bitmap onto the page at the computed position and size.</summary>
[DllImport("gdi32.dll", SetLastError = true)] internal static extern int StretchDIBits(IntPtr dc, int x, int y, int width, int height, int sx, int sy, int sw, int sh, byte[] bits, ref BitmapInfo info, uint usage, uint operation);
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows spooler behind <see cref="ISpooler"/>: each member makes one call into winspool.drv and reports what
/// it returned. It decides nothing about queues or documents.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Calls EnumPrinters, OpenPrinter, StartDocPrinter, WritePrinter, EndDocPrinter, AbortPrinter and ClosePrinter in winspool.drv. A test cannot run these without listing this computer's print queues or sending a document to one of them, which tests must never do. No rule lives here: which queues are listed (the flags) and which of them may print unattended, the queue identifier, the document name and data type, how the bytes are split and repeated per copy, the check of each written count, and when a document is ended, aborted or its queue closed are decided in WindowsPrinters, which is tested with a fake of ISpooler. Fixed here, because each is the shape of one call and not a rule: EnumPrinters is always asked at level 2 with no server name, and its sizing call passes no buffer and size 0; OpenPrinter passes no defaults structure; StartDocPrinter is called at level 1 with no output file. The one branch here reads queue records only when Windows filled the buffer, because the pointers in an unfilled buffer are not valid. After that buffer is freed, the error EnumPrinters left is put back as the thread's last error, so the caller reads the error of EnumPrinters and not of the cleanup.")]
internal sealed class WindowsSpooler : ISpooler
{
/// <summary>Calls EnumPrinters at level 2 with no server name and no buffer, which only reports the size the list needs.</summary>
public bool EnumPrintersSize(uint flags, out uint needed) => Native.EnumPrinters(flags, null, 2, IntPtr.Zero, 0, out needed, out _);
/// <summary>
/// Calls EnumPrinters at level 2 into an unmanaged buffer of the given size and copies each queue's name, driver, port
/// and status out of it before the buffer is freed. A failed call leaves the buffer unfilled, so nothing is read from
/// it. The error EnumPrinters left is put back after the buffer is freed, so the caller reads the error of
/// EnumPrinters and not of the cleanup.
/// </summary>
public bool EnumPrinters(uint flags, int size, out IReadOnlyList<SpoolerQueue> queues)
{
int error = 0;
List<SpoolerQueue> listed = [];
queues = listed;
IntPtr memory = Marshal.AllocHGlobal(size);
try
{
bool read = Native.EnumPrinters(flags, null, 2, memory, (uint)size, out _, out uint count);
error = Marshal.GetLastPInvokeError();
for(int i = 0; read && i < count; i++)
{
Native.PrinterInfo2 info = Marshal.PtrToStructure<Native.PrinterInfo2>(memory + i * Marshal.SizeOf<Native.PrinterInfo2>());
listed.Add(new SpoolerQueue(Marshal.PtrToStringUni(info.PrinterName), Marshal.PtrToStringUni(info.DriverName), Marshal.PtrToStringUni(info.PortName), info.Status));
}
return read;
}
finally
{
Marshal.FreeHGlobal(memory);
Marshal.SetLastPInvokeError(error);
}
}
/// <summary>Opens the queue with no defaults structure, so the handle has the caller's default access.</summary>
public bool OpenPrinter(string name, out IntPtr printer) => Native.OpenPrinter(name, out printer, IntPtr.Zero);
/// <summary>Calls StartDocPrinter at level 1 with the given name and data type and no output file.</summary>
public uint StartDocPrinter(IntPtr printer, string documentName, string dataType)
{
Native.DocInfo1 info = new() { DocumentName = documentName, DataType = dataType };
return Native.StartDocPrinter(printer, 1, ref info);
}
/// <summary>Calls WritePrinter with the array and count as given.</summary>
public bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written) => Native.WritePrinter(printer, bytes, count, out written);
/// <summary>Calls EndDocPrinter.</summary>
public bool EndDocPrinter(IntPtr printer) => Native.EndDocPrinter(printer);
/// <summary>Calls AbortPrinter.</summary>
public bool AbortPrinter(IntPtr printer) => Native.AbortPrinter(printer);
/// <summary>Calls ClosePrinter.</summary>
public bool ClosePrinter(IntPtr printer) => Native.ClosePrinter(printer);
/// <summary>
/// The Win32 spooler entry points this adapter calls. Every declaration mirrors the documented native signature
/// exactly; the layouts below are marshaled by the runtime, so field order and types must not change.
/// </summary>
private static class Native
{
/// <summary>PRINTER_INFO_2 as returned by EnumPrinters level 2: queue, driver and port names, status and the machine default settings pointer.</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct PrinterInfo2
{
internal IntPtr ServerName, PrinterName, ShareName, PortName, DriverName, Comment, Location, DevMode, SepFile, PrintProcessor, DataType, Parameters, SecurityDescriptor;
internal uint Attributes, Priority, DefaultPriority, StartTime, UntilTime, Status, Jobs, AveragePpm;
}
/// <summary>DOC_INFO_1 for StartDocPrinter: the spool document's name and data type (RAW for ZPL).</summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct DocInfo1
{
[MarshalAs(UnmanagedType.LPWStr)] internal string DocumentName;
[MarshalAs(UnmanagedType.LPWStr)] internal string? OutputFile;
[MarshalAs(UnmanagedType.LPWStr)] internal string DataType;
}
/// <summary>Lists the installed queues (level 2) so discovery can read their names, drivers, ports and status.</summary>
[DllImport("winspool.drv", EntryPoint = "EnumPrintersW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool EnumPrinters(uint flags, string? name, uint level, IntPtr buffer, uint size, out uint needed, out uint returned);
/// <summary>Opens a queue by name for a spool document or a settings read; the handle must be closed.</summary>
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool OpenPrinter(string name, out IntPtr handle, IntPtr defaults);
/// <summary>Starts one RAW spool document and returns its spooler job number, or zero on failure.</summary>
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern uint StartDocPrinter(IntPtr printer, int level, ref DocInfo1 info);
/// <summary>Writes bytes into the open spool document; the written count can be less than requested.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written);
/// <summary>Finishes the spool document so the spooler may send it to the printer.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool EndDocPrinter(IntPtr printer);
/// <summary>Releases a queue handle opened by OpenPrinter.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool ClosePrinter(IntPtr printer);
/// <summary>Discards a spool document that failed part way, so a partial label is not printed.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] internal static extern bool AbortPrinter(IntPtr printer);
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows spooler behind <see cref="ISpoolerJobs"/>: each member makes one call into winspool.drv and reports
/// what it returned. It decides nothing about jobs.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Calls OpenPrinter, GetJob, SetJob and ClosePrinter in winspool.drv. A test cannot run these without opening a real Windows print queue on this computer and reading, or deleting, one of its jobs. No rule lives here: which job is read, what counts as this application's document, when a job is reported as already gone, the bound on the answer and whether the job is deleted are decided in WindowsSpoolerJobs, which is tested with a fake of ISpoolerJobs. Fixed here, because each is the shape of one call and not a rule: OpenPrinter passes no defaults structure; GetJob is always asked at level 1, and its sizing call passes no buffer and size 0; SetJob is called at level 0 with no job details, which sends the command alone. The one branch here reads the document name only when Windows filled the buffer, because the pointers in an unfilled buffer are not valid. After that buffer is freed, the error GetJob left is put back as the thread's last error, so the caller reads the error of GetJob and not of the cleanup.")]
internal sealed class WindowsSpoolerJobQueue : ISpoolerJobs
{
/// <summary>Opens the queue with no defaults structure, so the handle has the caller's default access.</summary>
public bool OpenPrinter(string name, out IntPtr printer) => OpenPrinter(name, out printer, IntPtr.Zero);
/// <summary>Calls GetJob at level 1 with no buffer, which only reports the size the details need.</summary>
public bool GetJobSize(IntPtr printer, uint job, out uint needed) => GetJob(printer, job, 1, IntPtr.Zero, 0, out needed);
/// <summary>
/// Calls GetJob at level 1 into an unmanaged buffer of the given size and copies the document name out of it before
/// the buffer is freed. A failed call leaves the buffer unfilled, so nothing is read from it. The error GetJob left is
/// put back after the buffer is freed, so the caller reads the error of GetJob and not of the cleanup.
/// </summary>
public bool GetJobDocument(IntPtr printer, uint job, uint size, out string? document)
{
int error = 0;
IntPtr buffer = Marshal.AllocHGlobal((int)size);
try
{
bool read = GetJob(printer, job, 1, buffer, size, out _);
error = Marshal.GetLastPInvokeError();
document = read ? Marshal.PtrToStringUni(Marshal.PtrToStructure<JobInfo1>(buffer).Document) : null;
return read;
}
finally
{
Marshal.FreeHGlobal(buffer);
Marshal.SetLastPInvokeError(error);
}
}
/// <summary>Calls SetJob at level 0 with no job details, which is how a command alone is sent.</summary>
public bool SetJob(IntPtr printer, uint job, uint command) => SetJob(printer, job, 0, IntPtr.Zero, command);
/// <summary>Closes the queue handle.</summary>
bool ISpoolerJobs.ClosePrinter(IntPtr printer) => ClosePrinter(printer);
/// <summary>JOB_INFO_1 as returned by GetJob level 1; only the document name is used, to prove ownership. The layout is marshaled and must not change.</summary>
[StructLayout(LayoutKind.Sequential)]
private struct JobInfo1
{
internal uint Id;
internal IntPtr Printer, Machine, User, Document, DataType, StatusText;
internal uint Status, Priority, Position, TotalPages, PagesPrinted;
internal ushort Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds;
}
/// <summary>Opens a queue by name so one of its jobs can be read and cancelled; the handle must be closed.</summary>
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool OpenPrinter(string name, out IntPtr printer, IntPtr defaults);
/// <summary>Reads one job's details; called first with no buffer to learn the size, then again to fill it.</summary>
[DllImport("winspool.drv", EntryPoint = "GetJobW", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetJob(IntPtr printer, uint job, uint level, IntPtr buffer, uint size, out uint needed);
/// <summary>Sends a control command, here delete, to one spooler job.</summary>
[DllImport("winspool.drv", EntryPoint = "SetJobW", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetJob(IntPtr printer, uint job, uint level, IntPtr buffer, uint command);
/// <summary>Releases a queue handle opened by OpenPrinter.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool ClosePrinter(IntPtr printer);
}
@@ -10,24 +10,30 @@ using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Windows;
/// <summary>Cancel only the exact QQQ document identified by the durable receipt.</summary>
public static class WindowsSpoolerJobs
internal static class WindowsSpoolerJobs
{
/// <summary>The spooler command that deletes a job (JOB_CONTROL_DELETE).</summary>
private const uint JobControlDelete = 5;
/// <summary>
/// Cancels one spooler job by its number, but only after reading the job back and confirming its document name is
/// "QQQ" plus this job's identifier. A number that has been reused by someone else's document is refused, a job that has
/// already left the queue is reported as absent, and the answer size is bounded. Pages already printed cannot be recalled.
/// The queue handle is closed on every path once it was opened.
/// </summary>
public static void Cancel(string printerName, string jobId, string spoolerJobId)
internal static void Cancel(ISpoolerJobs spooler, string printerName, string jobId, string spoolerJobId)
{
if(!uint.TryParse(spoolerJobId, NumberStyles.None, CultureInfo.InvariantCulture, out uint id))
{
throw new InvalidOperationException("This job has no valid Windows spooler receipt.");
}
if(!OpenPrinter(printerName, out IntPtr printer, IntPtr.Zero))
if(!spooler.OpenPrinter(printerName, out IntPtr printer))
{ throw new Win32Exception(Marshal.GetLastWin32Error()); }
try
{
_ = GetJob(printer, id, 1, IntPtr.Zero, 0, out uint needed);
_ = spooler.GetJobSize(printer, id, out uint needed);
if(needed == 0)
{
int error = Marshal.GetLastWin32Error();
@@ -39,54 +45,15 @@ public static class WindowsSpoolerJobs
}
if(needed > 1_048_576)
{ throw new InvalidOperationException("The Windows spooler response exceeded its bound."); }
IntPtr buffer = Marshal.AllocHGlobal((int)needed);
try
if(!spooler.GetJobDocument(printer, id, needed, out string? document))
{ throw new Win32Exception(Marshal.GetLastWin32Error()); }
if(!string.Equals(document, "QQQ " + jobId, StringComparison.Ordinal))
{
if(!GetJob(printer, id, 1, buffer, needed, out _))
{ throw new Win32Exception(Marshal.GetLastWin32Error()); }
JobInfo1 info = Marshal.PtrToStructure<JobInfo1>(buffer);
if(!string.Equals(Marshal.PtrToStringUni(info.Document), "QQQ " + jobId, StringComparison.Ordinal))
{
throw new SpoolCancellationException("OWNERSHIP_MISMATCH", "The spooler number now belongs to another document; cancellation was refused.");
}
const uint jobControlDelete = 5;
if(!SetJob(printer, id, 0, IntPtr.Zero, jobControlDelete))
{ throw new Win32Exception(Marshal.GetLastWin32Error()); }
throw new SpoolCancellationException("OWNERSHIP_MISMATCH", "The spooler number now belongs to another document; cancellation was refused.");
}
finally { Marshal.FreeHGlobal(buffer); }
if(!spooler.SetJob(printer, id, JobControlDelete))
{ throw new Win32Exception(Marshal.GetLastWin32Error()); }
}
finally { _ = ClosePrinter(printer); }
finally { _ = spooler.ClosePrinter(printer); }
}
/// <summary>JOB_INFO_1 as returned by GetJob level 1; only the document name is used, to prove ownership. The layout is marshaled and must not change.</summary>
[StructLayout(LayoutKind.Sequential)]
private struct JobInfo1
{
internal uint Id;
internal IntPtr Printer, Machine, User, Document, DataType, StatusText;
internal uint Status, Priority, Position, TotalPages, PagesPrinted;
internal ushort Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds;
}
/// <summary>Opens a queue by name so one of its jobs can be read and cancelled; the handle must be closed.</summary>
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool OpenPrinter(string name, out IntPtr printer, IntPtr defaults);
/// <summary>Reads one job's details; called first with no buffer to learn the size, then again to fill it.</summary>
[DllImport("winspool.drv", EntryPoint = "GetJobW", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetJob(IntPtr printer, uint job, uint level, IntPtr buffer, uint size, out uint needed);
/// <summary>Sends a control command, here delete, to one spooler job.</summary>
[DllImport("winspool.drv", EntryPoint = "SetJobW", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetJob(IntPtr printer, uint job, uint level, IntPtr buffer, uint command);
/// <summary>Releases a queue handle opened by OpenPrinter.</summary>
[DllImport("winspool.drv", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)] private static extern bool ClosePrinter(IntPtr printer);
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// One GDI document <see cref="FakeWindowsPrinting"/> was asked to start on a device context: the queue it went to, its
/// name and job number, how many pages were started and ended, the bitmaps drawn, and whether it was ended or aborted.
/// </summary>
internal sealed class FakeGdiDocument(string? queue, string name, int job)
{
/// <summary>The queue the document was started on.</summary>
internal string? Queue { get; } = queue;
/// <summary>The document name the spooler would show.</summary>
internal string Name { get; } = name;
/// <summary>The spooler job number the fake gave the document.</summary>
internal int Job { get; } = job;
/// <summary>How many pages were started.</summary>
internal int PagesStarted { get; set; }
/// <summary>How many pages were ended, which is when the driver would emit them.</summary>
internal int PagesEnded { get; set; }
/// <summary>The pixel data of every bitmap drawn, in order.</summary>
internal List<byte[]> Drawn { get; } = [];
/// <summary>True once the document was ended, which hands it to the spooler.</summary>
internal bool Ended { get; set; }
/// <summary>True once the document was cancelled.</summary>
internal bool Aborted { get; set; }
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A parent watch that watches nothing: it records which process id it was asked to watch and whether it was running
/// when a test looked, and it stops when the helper cancels it, as the real one does. It never ends a process.
/// </summary>
internal sealed class FakeParentWatch : IParentWatch
{
/// <summary>The process id the helper asked to have watched, or null when no watch was started.</summary>
internal int? ParentId { get; private set; }
/// <summary>True from the moment the watch was started until the helper cancelled it.</summary>
internal bool Watching { get; private set; }
/// <summary>True once the helper cancelled the watch and the watch's task completed.</summary>
internal bool Stopped { get; private set; }
/// <summary>Records the process id and returns a task that completes when the token is cancelled.</summary>
public Task WatchAsync(int parentId, CancellationToken token)
{
ParentId = parentId;
Watching = true;
TaskCompletionSource done = new(TaskCreationOptions.RunContinuationsAsynchronously);
_ = token.Register(() =>
{
Watching = false;
Stopped = true;
done.SetResult();
});
return done.Task;
}
}
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
using Windows.Storage.Streams;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A PDF renderer that exists only in memory. It does not interpret the stream it is given, but it reads it, from where
/// it stood when it was handed over to its end, and keeps those bytes, so a test can see what the renderer was given.
/// A "document" is the list of page sizes a test describes, and a rendered page is four marker bytes with the size that
/// was asked for. Every load, page taken, render and page release is written to the call list it shares with
/// <see cref="FakeWindowsPrinting"/>, so a test sees them in order with the Windows calls. Rendering finishes on another
/// thread, as the real renderer does.
/// </summary>
internal sealed class FakePdfRenderer(List<string> calls) : IPdfRenderer
{
/// <summary>The call list shared with the fake Windows, so the document and page classes below can write to it.</summary>
private List<string> Calls { get; } = calls;
/// <summary>The pages of the document, as width and height in units of 1/96 inch; one 4 by 6 inch page unless a test says otherwise.</summary>
internal List<(double Width, double Height)> Pages { get; } = [(384, 576)];
/// <summary>When set, loading the document fails with this exception, as a file that is not a PDF does.</summary>
internal Exception? LoadFailure { get; set; }
/// <summary>Runs when a page is taken out, with the number of pages taken so far including this one; a test uses it to cancel part way.</summary>
internal Action<int>? OnGetPage { get; set; }
/// <summary>When set, the size the renderer reports for its bitmap instead of the size that was asked for.</summary>
internal (uint Width, uint Height)? ReportedSize { get; set; }
/// <summary>How many pages were taken out.</summary>
internal int PagesTaken { get; private set; }
/// <summary>How many pages were released.</summary>
internal int PagesReleased { get; private set; }
/// <summary>The bytes of the stream the renderer was last given, from the position it had when it was handed over to its end; null until a load was asked for.</summary>
internal byte[]? Handed { get; private set; }
/// <summary>
/// Reads the stream from where it stands to its end and keeps the bytes, then hands out the described document or fails
/// the way a test asked. The stream is left open; it belongs to the caller.
/// </summary>
public async Task<IPdfDocument> LoadAsync(IRandomAccessStream stream, CancellationToken cancellationToken)
{
Calls.Add("LoadPdf");
using MemoryStream handed = new();
await stream.AsStreamForRead().CopyToAsync(handed, cancellationToken);
Handed = handed.ToArray();
if(LoadFailure is not null)
{
throw LoadFailure;
}
return new Document(this);
}
/// <summary>The described document.</summary>
private sealed class Document(FakePdfRenderer renderer) : IPdfDocument
{
/// <summary>The number of pages a test described.</summary>
public uint PageCount => (uint)renderer.Pages.Count;
/// <summary>Takes out one described page and tells the test's hook.</summary>
public IPdfPage GetPage(uint index)
{
renderer.Calls.Add($"GetPage({index})");
renderer.PagesTaken++;
renderer.OnGetPage?.Invoke(renderer.PagesTaken);
return new Page(renderer, index);
}
}
/// <summary>One described page.</summary>
private sealed class Page(FakePdfRenderer renderer, uint index) : IPdfPage
{
/// <summary>The width a test described.</summary>
public double Width => renderer.Pages[(int)index].Width;
/// <summary>The height a test described.</summary>
public double Height => renderer.Pages[(int)index].Height;
/// <summary>Returns four marker bytes (the first is the page index) as a bitmap of the asked size, after a thread switch.</summary>
public async Task<PageBitmap> RenderAsync(uint width, uint height)
{
renderer.Calls.Add($"RenderPage({index}, {width}, {height})");
await Task.Yield();
(uint reportedWidth, uint reportedHeight) = renderer.ReportedSize ?? (width, height);
return new PageBitmap([(byte)index, 0xb, 0xc, 0xd], reportedWidth, reportedHeight);
}
/// <summary>Records that the page was released.</summary>
public void Dispose()
{
renderer.Calls.Add($"DisposePage({index})");
renderer.PagesReleased++;
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// One print queue of <see cref="FakeWindowsPrinting"/>: what the spooler lists for it, the machine default settings it
/// hands out, what its driver answers about paper and document settings, and what a device context made for it reports.
/// A test describes the queue here and changes single answers to make the fake behave like a driver that misbehaves.
/// </summary>
internal sealed class FakeQueue(string? name, string? driver = "Synthetic label driver", string? port = "USB001")
{
/// <summary>The queue name the spooler lists; null stands for a name Windows left out.</summary>
internal string? Name { get; } = name;
/// <summary>The driver name the spooler lists.</summary>
internal string? Driver { get; set; } = driver;
/// <summary>The port name the spooler lists.</summary>
internal string? Port { get; set; } = port;
/// <summary>The spooler status flags; zero is an idle, ready queue.</summary>
internal uint Status { get; set; }
/// <summary>When set, opening this queue fails with this Windows error.</summary>
internal int? OpenError { get; set; }
/// <summary>The machine default settings (a complete DEVMODE with its private part), or null for a queue that has none.</summary>
internal byte[]? MachineDefaults { get; set; }
/// <summary>When set, the size the sizing call reports for the level 8 details instead of the real one.</summary>
internal uint? ReportedDefaultsSize { get; set; }
/// <summary>When set, reading the level 8 details fails with this Windows error.</summary>
internal int? GetPrinterError { get; set; }
/// <summary>Where the settings start inside the level 8 answer; the size of a pointer unless a test moves it.</summary>
internal long DevModeOffset { get; set; } = IntPtr.Size;
/// <summary>When true, the level 8 answer holds a null pointer where the settings should be.</summary>
internal bool NullDevModePointer { get; set; }
/// <summary>The paper identifiers the driver lists (capability 2).</summary>
internal short[] PaperIds { get; set; } = [];
/// <summary>The paper sizes the driver lists (capability 3), in tenths of a millimeter, in the same order as the identifiers.</summary>
internal (int Width, int Height)[] PaperSizes { get; set; } = [];
/// <summary>Answers to the counting call that replace the real number of entries, by capability.</summary>
internal Dictionary<ushort, int> CountAnswers { get; } = [];
/// <summary>Answers to the reading call that replace the real number of entries, by capability.</summary>
internal Dictionary<ushort, int> ReadAnswers { get; } = [];
/// <summary>When set, the size the driver reports for its document settings instead of the size of the machine defaults.</summary>
internal int? SettingsSize { get; set; }
/// <summary>What the driver returns from merging document settings; 1 means it accepted them.</summary>
internal int MergeResult { get; set; } = 1;
/// <summary>What the driver makes of the settings it is asked to merge; unchanged unless a test says otherwise.</summary>
internal Func<byte[], byte[]> Merge { get; set; } = input => input.ToArray();
/// <summary>When set, creating a device context for this queue fails with this Windows error.</summary>
internal int? CreateContextError { get; set; }
/// <summary>What a device context for this queue reports, by index; asking for an index that is not here fails the test.</summary>
internal Dictionary<int, int> DeviceCaps { get; } = [];
/// <summary>Describes a 300 dpi label device context: resolution, the physical page in pixels, no unprintable margin, and a printable area equal to the page.</summary>
internal FakeQueue WithContext(int widthPixels, int heightPixels, int dpiX = 300, int dpiY = 300)
{
DeviceCaps[88] = dpiX;
DeviceCaps[90] = dpiY;
DeviceCaps[110] = widthPixels;
DeviceCaps[111] = heightPixels;
DeviceCaps[112] = 0;
DeviceCaps[113] = 0;
DeviceCaps[8] = widthPixels;
DeviceCaps[10] = heightPixels;
return this;
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// One spool document <see cref="FakeWindowsPrinting"/> was asked to start: the queue it went to, its name, data type and
/// job number, the bytes the fake took, and whether it was ended or aborted. It is how a test sees what would have reached
/// a printer.
/// </summary>
internal sealed class FakeSpoolDocument(string? queue, string name, string dataType, uint job)
{
/// <summary>The queue the document was started on.</summary>
internal string? Queue { get; } = queue;
/// <summary>The document name the spooler would show.</summary>
internal string Name { get; } = name;
/// <summary>The spool data type, RAW for ZPL.</summary>
internal string DataType { get; } = dataType;
/// <summary>The spooler job number the fake gave the document.</summary>
internal uint Job { get; } = job;
/// <summary>The bytes the fake spooler took, in order.</summary>
internal MemoryStream Bytes { get; } = new();
/// <summary>True once the document was finished, which is when the spooler may send it to the printer.</summary>
internal bool Ended { get; set; }
/// <summary>True once the document was discarded.</summary>
internal bool Aborted { get; set; }
}
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Runtime.InteropServices;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A Windows spooler that exists only in memory, for the cancellation rules. It holds queues by name, each with jobs by
/// number and their document names, records every call it was sent with its arguments, and can be told to fail each call
/// the way the real one can. No print queue on this computer is opened.
/// <para>
/// The thread's last error is treated as Windows treats it. All five calls stand for imports declared with SetLastError
/// (OpenPrinter, GetJob twice, SetJob and ClosePrinter), so each first replaces the last error with
/// <see cref="StaleLastError.Code"/> (0x0BADC0DE) and sets the configured Windows error only when it then fails. A rule
/// sees the configured error only if it reads it straight after the failing call; read after the queue was closed, it
/// is the marker.
/// </para>
/// </summary>
internal sealed class FakeSpoolerJobs : ISpoolerJobs
{
private readonly Dictionary<IntPtr, string> _open = [];
private int _handles;
/// <summary>The queues, by name; each holds its jobs by spooler number with the document name Windows stored (null for none).</summary>
internal Dictionary<string, Dictionary<uint, string?>> Queues { get; } = new(StringComparer.Ordinal);
/// <summary>Every call sent, in order, written as the call and its arguments.</summary>
internal List<string> Calls { get; } = [];
/// <summary>The error a queue that does not exist is refused with (ERROR_INVALID_PRINTER_NAME).</summary>
internal int UnknownQueueError { get; set; } = 1801;
/// <summary>The error a job that is not in the queue is refused with; Windows answers 87 for an unknown job number.</summary>
internal int MissingJobError { get; set; } = 87;
/// <summary>When set, the size the sizing call reports instead of the size the job's details really need.</summary>
internal uint? ReportedSize { get; set; }
/// <summary>When set, reading a job's details fails with this error although the job exists.</summary>
internal int? ReadError { get; set; }
/// <summary>When set, a command to a job fails with this error and the job stays where it is.</summary>
internal int? CommandError { get; set; }
/// <summary>Queue handles that were opened and not yet closed.</summary>
internal int OpenHandles => _open.Count;
/// <summary>Adds a queue holding one job and returns this fake, so a test can set it up in one expression.</summary>
internal FakeSpoolerJobs WithJob(string queue, uint job, string? document)
{
if(!Queues.TryGetValue(queue, out Dictionary<uint, string?>? jobs))
{
jobs = [];
Queues[queue] = jobs;
}
jobs[job] = document;
return this;
}
/// <summary>Opens a known queue and hands out a new handle; an unknown name fails with the configured error.</summary>
public bool OpenPrinter(string name, out IntPtr printer)
{
StaleLastError.Leave();
Calls.Add($"OpenPrinter({name})");
if(!Queues.ContainsKey(name))
{
printer = IntPtr.Zero;
return Fail(UnknownQueueError);
}
printer = new IntPtr(0x100 + ++_handles);
_open[printer] = name;
return true;
}
/// <summary>
/// Answers the sizing call the way Windows does: false with the size the details need, or false with a size of zero
/// and the missing-job error when the job is not in the queue.
/// </summary>
public bool GetJobSize(IntPtr printer, uint job, out uint needed)
{
StaleLastError.Leave();
Calls.Add($"GetJobSize({Handle(printer)}, {job})");
if(!Queues[_open[printer]].TryGetValue(job, out string? document))
{
needed = 0;
return Fail(MissingJobError);
}
needed = ReportedSize ?? DetailsSize(document);
return Fail(122);
}
/// <summary>Reports the stored document name of a job, or fails with the configured read error.</summary>
public bool GetJobDocument(IntPtr printer, uint job, uint size, out string? document)
{
StaleLastError.Leave();
Calls.Add($"GetJobDocument({Handle(printer)}, {job}, {size})");
document = null;
if(ReadError is { } error)
{
return Fail(error);
}
document = Queues[_open[printer]][job];
return true;
}
/// <summary>Carries out the delete command (5) by removing the job, or fails with the configured command error.</summary>
public bool SetJob(IntPtr printer, uint job, uint command)
{
StaleLastError.Leave();
Calls.Add($"SetJob({Handle(printer)}, {job}, {command})");
if(CommandError is { } error)
{
return Fail(error);
}
if(command == 5)
{
_ = Queues[_open[printer]].Remove(job);
}
return true;
}
/// <summary>Closes a handle this fake handed out; closing anything else is a test failure.</summary>
public bool ClosePrinter(IntPtr printer)
{
StaleLastError.Leave();
Calls.Add($"ClosePrinter({Handle(printer)})");
Assert.True(_open.Remove(printer), "A handle was closed that was not open.");
return true;
}
/// <summary>The size of a job's level 1 details in this fake: a fixed part plus the document name in UTF-16 with its terminator.</summary>
internal static uint DetailsSize(string? document) => (uint)(64 + 2 * ((document?.Length ?? 0) + 1));
/// <summary>Writes a handle the way the call log shows it.</summary>
private static string Handle(IntPtr handle) => "0x" + handle.ToInt64().ToString("x", CultureInfo.InvariantCulture);
/// <summary>Leaves the error as the thread's last error in place of the marker the call began with, as a failed native call does, and reports the failure.</summary>
private static bool Fail(int error)
{
Marshal.SetLastPInvokeError(error);
return false;
}
}
@@ -0,0 +1,183 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Runtime.InteropServices;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The GDI part of the fake: device contexts that report what their <see cref="FakeQueue"/> says, and documents of
/// bitmap pages that are only counted and kept.
/// </summary>
internal sealed partial class FakeWindowsPrinting
{
private readonly Dictionary<IntPtr, FakeGdiDocument> _openGdiDocuments = [];
private int _gdiJobs = 7300;
/// <summary>The settings each device context was created from, in order; null stands for the queue's current defaults.</summary>
internal List<byte[]?> ContextModes { get; } = [];
/// <summary>Every GDI document that was started, in order.</summary>
internal List<FakeGdiDocument> GdiDocuments { get; } = [];
/// <summary>Creates a device context for a known queue; an unknown device, a queue that refuses or a failure a test asked for returns zero with the error.</summary>
public IntPtr CreateDC(string driver, string device, byte[]? devMode)
{
StaleLastError.Leave();
Calls.Add($"CreateDC({driver}, {device}, {(devMode is null ? "null" : $"{devMode.Length} bytes")})");
ContextModes.Add(devMode?.ToArray());
FakeQueue? queue = Find(device);
if(queue is null || queue.CreateContextError is not null)
{
Marshal.SetLastPInvokeError(queue?.CreateContextError ?? 1801);
return IntPtr.Zero;
}
if(Failing("CreateDC", out _))
{
return IntPtr.Zero;
}
IntPtr dc = new(0xd00 + ++_contextHandles);
_contexts[dc] = queue;
return dc;
}
/// <summary>Reports what the context's queue says for the index; an index the queue does not describe fails the test, so the rules cannot lean on a value nobody chose.</summary>
public int GetDeviceCaps(IntPtr dc, int index)
{
Calls.Add($"GetDeviceCaps({Handle(dc)}, {index})");
return Context(dc).DeviceCaps.TryGetValue(index, out int value) ? value : throw new InvalidOperationException($"Device capability {index} is not one this queue describes.");
}
/// <summary>Deletes a context this fake created; deleting anything else fails the test.</summary>
public bool DeleteDC(IntPtr dc)
{
Calls.Add($"DeleteDC({Handle(dc)})");
Assert.True(_contexts.Remove(dc), "A device context was deleted that does not exist.");
return true;
}
/// <summary>Starts a document on the context and returns a new job number, or the failure value a test asked for.</summary>
public int StartDoc(IntPtr dc, string documentName)
{
StaleLastError.Leave();
Calls.Add($"StartDoc({Handle(dc)}, {documentName})");
FakeQueue queue = Context(dc);
if(Failing("StartDoc", out int result))
{
return result;
}
FakeGdiDocument document = new(queue.Name, documentName, ++_gdiJobs);
GdiDocuments.Add(document);
_openGdiDocuments[dc] = document;
return document.Job;
}
/// <summary>Counts a started page.</summary>
public int StartPage(IntPtr dc)
{
StaleLastError.Leave();
Calls.Add($"StartPage({Handle(dc)})");
if(Failing("StartPage", out int result))
{
return result;
}
_openGdiDocuments[dc].PagesStarted++;
return 1;
}
/// <summary>
/// Accepts a stretch mode and returns the previous one, which is never zero, or the failure value a test asked for. Its
/// import is declared without SetLastError, so neither success nor failure changes the thread's last error.
/// </summary>
public int SetStretchBltMode(IntPtr dc, int mode)
{
Calls.Add($"SetStretchBltMode({Handle(dc)}, {mode})");
return Failing("SetStretchBltMode", out int result, recordsError: false) ? result : 1;
}
/// <summary>
/// Keeps the bitmap that was drawn and returns the number of rows, as Windows does. A destination with no width or no
/// height draws nothing and answers 0, which is how GDI reports that no row was copied; the fake arranges no Windows
/// error for that, so the last error is the marker the call began with.
/// </summary>
public int StretchDIBits(IntPtr dc, int x, int y, int width, int height, int sourceX, int sourceY, int sourceWidth, int sourceHeight, byte[] bits, GdiBitmapHeader header, uint usage, uint operation)
{
StaleLastError.Leave();
Calls.Add(string.Create(CultureInfo.InvariantCulture, $"StretchDIBits({Handle(dc)}, {x}, {y}, {width}, {height}, {sourceX}, {sourceY}, {sourceWidth}, {sourceHeight}, {bits.Length} bytes, {header.Width}x{header.Height} planes {header.Planes} bits {header.BitCount}, {usage}, 0x{operation:x})"));
if(Failing("StretchDIBits", out int result))
{
return result;
}
if(width == 0 || height == 0)
{
return 0;
}
_openGdiDocuments[dc].Drawn.Add(bits.ToArray());
return Math.Abs(header.Height);
}
/// <summary>Counts an ended page.</summary>
public int EndPage(IntPtr dc)
{
StaleLastError.Leave();
Calls.Add($"EndPage({Handle(dc)})");
if(Failing("EndPage", out int result))
{
return result;
}
_openGdiDocuments[dc].PagesEnded++;
return 1;
}
/// <summary>Ends the open document, or leaves it open when told to fail.</summary>
public int EndDoc(IntPtr dc)
{
StaleLastError.Leave();
Calls.Add($"EndDoc({Handle(dc)})");
if(Failing("EndDoc", out int result))
{
return result;
}
_openGdiDocuments[dc].Ended = true;
_ = _openGdiDocuments.Remove(dc);
return 1;
}
/// <summary>Cancels the open document.</summary>
public int AbortDoc(IntPtr dc)
{
StaleLastError.Leave();
Calls.Add($"AbortDoc({Handle(dc)})");
_openGdiDocuments[dc].Aborted = true;
_ = _openGdiDocuments.Remove(dc);
return 1;
}
}
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The printer settings part of the fake: the level 8 machine defaults, the driver's paper lists and its merge of
/// document settings, each answered from the <see cref="FakeQueue"/> the call names.
/// </summary>
internal sealed partial class FakeWindowsPrinting
{
/// <summary>The address the fake says its level 8 answer was written at; the pointer inside the answer is relative to it.</summary>
internal const long AnswerAddress = 0x7ff0_0000_1000;
/// <summary>The settings each paper capability call was given, in order.</summary>
internal List<byte[]> CapabilityModes { get; } = [];
/// <summary>The settings the driver was asked to merge, in order.</summary>
internal List<byte[]> MergeInputs { get; } = [];
/// <summary>Answers the sizing call the way Windows does: false, error 122 and the size of a pointer plus the machine defaults (zero for a queue without any).</summary>
public bool GetPrinterSize(IntPtr printer, uint level, out uint needed)
{
StaleLastError.Leave();
Calls.Add($"GetPrinterSize({Handle(printer)}, {level})");
FakeQueue queue = Printer(printer);
needed = queue.ReportedDefaultsSize ?? (queue.MachineDefaults is null ? 0 : (uint)(IntPtr.Size + queue.MachineDefaults.Length));
Marshal.SetLastPInvokeError(122);
return false;
}
/// <summary>
/// Builds a level 8 answer of the asked size: a pointer to the settings in its first field and the settings themselves
/// at that place, as far as they fit. The queue can make the read fail, leave the pointer null or point it elsewhere.
/// </summary>
public bool GetPrinter(IntPtr printer, uint level, uint size, out byte[] buffer, out long address)
{
StaleLastError.Leave();
Calls.Add($"GetPrinter({Handle(printer)}, {level}, {size})");
FakeQueue queue = Printer(printer);
if(queue.GetPrinterError is { } error)
{
buffer = [];
address = 0;
Marshal.SetLastPInvokeError(error);
return false;
}
buffer = new byte[size];
address = AnswerAddress;
int at = (int)Math.Clamp(queue.DevModeOffset, 0, size);
byte[] defaults = queue.MachineDefaults ?? [];
defaults.AsSpan(0, Math.Min(defaults.Length, buffer.Length - at)).CopyTo(buffer.AsSpan(at));
BinaryPrimitives.WriteInt64LittleEndian(buffer, queue.NullDevModePointer ? 0 : address + queue.DevModeOffset);
return true;
}
/// <summary>Answers how many entries a paper capability has: the length of the queue's list, or the answer a test put in its place.</summary>
public int DeviceCapabilities(string device, string port, ushort capability, byte[] devMode)
{
Calls.Add($"DeviceCapabilities({device}, {port}, {capability}, count)");
CapabilityModes.Add(devMode.ToArray());
FakeQueue queue = Find(device) ?? throw new InvalidOperationException("A capability was asked of a queue that does not exist.");
return queue.CountAnswers.TryGetValue(capability, out int answer) ? answer : Entries(queue, capability);
}
/// <summary>
/// Writes a paper capability's entries into a buffer of the asked size, as far as they fit: identifiers as 16-bit
/// values, sizes as pairs of 32-bit values. It reports the number of entries, or the answer a test put in its place.
/// </summary>
public int DeviceCapabilities(string device, string port, ushort capability, int outputBytes, byte[] devMode, out byte[] output)
{
Calls.Add($"DeviceCapabilities({device}, {port}, {capability}, {outputBytes} bytes)");
CapabilityModes.Add(devMode.ToArray());
FakeQueue queue = Find(device) ?? throw new InvalidOperationException("A capability was asked of a queue that does not exist.");
output = new byte[outputBytes];
for(int index = 0; capability == 2 && index < queue.PaperIds.Length && (index + 1) * 2 <= outputBytes; index++)
{
BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(index * 2), queue.PaperIds[index]);
}
for(int index = 0; capability == 3 && index < queue.PaperSizes.Length && (index + 1) * 8 <= outputBytes; index++)
{
BinaryPrimitives.WriteInt32LittleEndian(output.AsSpan(index * 8), queue.PaperSizes[index].Width);
BinaryPrimitives.WriteInt32LittleEndian(output.AsSpan(index * 8 + 4), queue.PaperSizes[index].Height);
}
return queue.ReadAnswers.TryGetValue(capability, out int answer) ? answer : Entries(queue, capability);
}
/// <summary>Answers the size of the driver's document settings: the size of the machine defaults, or the size a test put in its place.</summary>
public int DocumentPropertiesSize(IntPtr printer, string device)
{
Calls.Add($"DocumentPropertiesSize({Handle(printer)}, {device})");
FakeQueue queue = Printer(printer);
return queue.SettingsSize ?? queue.MachineDefaults?.Length ?? 0;
}
/// <summary>Lets the queue's driver merge the input settings and copies what it made of them into the output, as far as it fits.</summary>
public int DocumentProperties(IntPtr printer, string device, byte[] output, byte[] input, uint mode)
{
Calls.Add($"DocumentProperties({Handle(printer)}, {device}, {output.Length} bytes, {input.Length} bytes, {mode})");
FakeQueue queue = Printer(printer);
MergeInputs.Add(input.ToArray());
byte[] merged = queue.Merge(input.ToArray());
merged.AsSpan(0, Math.Min(merged.Length, output.Length)).CopyTo(output);
return queue.MergeResult;
}
/// <summary>The number of entries the queue really has for a paper capability; any other capability is not one the rules may ask for.</summary>
private static int Entries(FakeQueue queue, ushort capability) => capability switch
{
2 => queue.PaperIds.Length,
3 => queue.PaperSizes.Length,
_ => throw new InvalidOperationException($"Capability {capability} is not one the rules may ask for."),
};
}
@@ -0,0 +1,175 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The spooler part of the fake: listing the queues, opening and closing one, and taking one RAW document.
/// </summary>
internal sealed partial class FakeWindowsPrinting
{
private readonly Dictionary<IntPtr, FakeSpoolDocument> _openDocuments = [];
private uint _spoolJobs = 4100;
/// <summary>When set, the size the sizing call reports for the queue list instead of 136 bytes a queue.</summary>
internal uint? ReportedListSize { get; set; }
/// <summary>Every RAW document that was started, in order.</summary>
internal List<FakeSpoolDocument> RawDocuments { get; } = [];
/// <summary>When set, the most bytes the spooler takes in one write, so a write can be partial.</summary>
internal int? WriteLimit { get; set; }
/// <summary>When set, turns the number of bytes taken into the number the spooler reports, so a test can make it report nonsense.</summary>
internal Func<int, int>? WrittenAnswer { get; set; }
/// <summary>
/// Answers the sizing call the way Windows does: true with no size when there is nothing to list, otherwise false,
/// error 122 and the size the list needs. A failure a test asked for keeps the reported size and sets its own error.
/// </summary>
public bool EnumPrintersSize(uint flags, out uint needed)
{
StaleLastError.Leave();
Calls.Add($"EnumPrintersSize({flags})");
needed = ReportedListSize ?? (uint)(Queues.Count * 136);
if(Failing("EnumPrintersSize", out _))
{
return false;
}
if(needed == 0)
{
return true;
}
Marshal.SetLastPInvokeError(122);
return false;
}
/// <summary>Lists the queues as plain records, or nothing when told to fail.</summary>
public bool EnumPrinters(uint flags, int size, out IReadOnlyList<SpoolerQueue> queues)
{
StaleLastError.Leave();
Calls.Add($"EnumPrinters({flags}, {size})");
if(Failing("EnumPrinters", out _))
{
queues = [];
return false;
}
queues = [.. Queues.Select(queue => new SpoolerQueue(queue.Name, queue.Driver, queue.Port, queue.Status))];
return true;
}
/// <summary>Opens a known queue and hands out a new handle; an unknown name fails with error 1801, a queue can refuse with its own error, and a test can make one particular open fail.</summary>
public bool OpenPrinter(string name, out IntPtr printer)
{
StaleLastError.Leave();
Calls.Add($"OpenPrinter({name})");
printer = IntPtr.Zero;
if(Failing("OpenPrinter", out _))
{
return false;
}
FakeQueue? queue = Find(name);
if(queue is null || queue.OpenError is not null)
{
Marshal.SetLastPInvokeError(queue?.OpenError ?? 1801);
return false;
}
printer = new IntPtr(0x200 + ++_printerHandles);
_printers[printer] = queue;
return true;
}
/// <summary>Starts a document on the open queue and returns a new job number, or zero when told to fail.</summary>
public uint StartDocPrinter(IntPtr printer, string documentName, string dataType)
{
StaleLastError.Leave();
Calls.Add($"StartDocPrinter({Handle(printer)}, {documentName}, {dataType})");
FakeQueue queue = Printer(printer);
if(Failing("StartDocPrinter", out _))
{
return 0;
}
FakeSpoolDocument document = new(queue.Name, documentName, dataType, ++_spoolJobs);
RawDocuments.Add(document);
_openDocuments[printer] = document;
return document.Job;
}
/// <summary>Takes the bytes offered, or only as many as the write limit allows, into the open document and reports the count.</summary>
public bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written)
{
StaleLastError.Leave();
Calls.Add($"WritePrinter({Handle(printer)}, {bytes.Length} bytes, {count})");
written = 0;
if(Failing("WritePrinter", out _))
{
return false;
}
int taken = Math.Min(count, WriteLimit ?? count);
_openDocuments[printer].Bytes.Write(bytes, 0, taken);
written = WrittenAnswer?.Invoke(taken) ?? taken;
return true;
}
/// <summary>Finishes the open document, or leaves it open when told to fail.</summary>
public bool EndDocPrinter(IntPtr printer)
{
StaleLastError.Leave();
Calls.Add($"EndDocPrinter({Handle(printer)})");
if(Failing("EndDocPrinter", out _))
{
return false;
}
_openDocuments[printer].Ended = true;
_ = _openDocuments.Remove(printer);
return true;
}
/// <summary>Discards the open document.</summary>
public bool AbortPrinter(IntPtr printer)
{
StaleLastError.Leave();
Calls.Add($"AbortPrinter({Handle(printer)})");
_openDocuments[printer].Aborted = true;
_ = _openDocuments.Remove(printer);
return true;
}
/// <summary>Closes a handle this fake handed out; closing anything else fails the test.</summary>
public bool ClosePrinter(IntPtr printer)
{
StaleLastError.Leave();
Calls.Add($"ClosePrinter({Handle(printer)})");
Assert.True(_printers.Remove(printer), "A queue handle was closed that was not open.");
return true;
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Runtime.InteropServices;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The Windows spooler, printer settings and GDI as one object that exists only in memory. It answers from the queues a
/// test describes, records every call it was sent with its arguments in one list (so the order across the three is
/// known), keeps what was written to it, and can be told to fail any call the way the real one can. No print queue on
/// this computer is listed, opened or printed to. The calls are split by interface across the partial files.
/// <para>
/// The thread's last error is treated as Windows treats it. Every member that stands for an import declared with
/// SetLastError (EnumPrinters, OpenPrinter, StartDocPrinter, WritePrinter, EndDocPrinter, AbortPrinter, ClosePrinter,
/// GetPrinter, CreateDC, StartDoc, StartPage, StretchDIBits, EndPage, EndDoc and AbortDoc) first replaces the last error
/// with <see cref="StaleLastError.Code"/> (0x0BADC0DE), and sets the arranged Windows error only when it then fails. So
/// a rule sees the arranged error only if it reads it straight after the failing call; read one call late, it is the
/// marker. The members that stand for imports without SetLastError (GetDeviceCaps, DeleteDC, SetStretchBltMode,
/// DeviceCapabilities and DocumentProperties) never touch the last error.
/// </para>
/// </summary>
internal sealed partial class FakeWindowsPrinting : ISpooler, IPrinterSettings, IGdiPrinter
{
private readonly Dictionary<IntPtr, FakeQueue> _printers = [];
private readonly Dictionary<IntPtr, FakeQueue> _contexts = [];
private readonly Dictionary<string, int> _seen = new(StringComparer.Ordinal);
private readonly List<(string Call, int Occurrence, int Error, int Result)> _failures = [];
private int _printerHandles;
private int _contextHandles;
/// <summary>The installed queues, in the order the spooler lists them.</summary>
internal List<FakeQueue> Queues { get; } = [];
/// <summary>Every call sent, in order, written as the call and its arguments.</summary>
internal List<string> Calls { get; } = [];
/// <summary>Queue handles that were opened and not yet closed.</summary>
internal int OpenPrinters => _printers.Count;
/// <summary>Device contexts that were created and not yet deleted.</summary>
internal int OpenContexts => _contexts.Count;
/// <summary>Adds a queue and returns it, so a test can describe it further.</summary>
internal FakeQueue Add(FakeQueue queue)
{
Queues.Add(queue);
return queue;
}
/// <summary>
/// Makes one call fail: the given occurrence of the named call (the first unless said otherwise) sets the Windows error
/// and returns the failure value, which is false for a call that answers yes or no and the given number for one that
/// answers with a number. SetStretchBltMode is the one call that can be failed here whose import records no error:
/// it returns the failure value and the given error is not set.
/// </summary>
internal void Fail(string call, int error, int occurrence = 1, int result = 0) => _failures.Add((call, occurrence, error, result));
/// <summary>
/// Counts a call and says whether this occurrence was told to fail. When it was, the arranged error is left as the
/// thread's last error, unless the call stands for an import that records no error, which leaves the last error alone.
/// </summary>
private bool Failing(string call, out int result, bool recordsError = true)
{
int occurrence = _seen[call] = _seen.GetValueOrDefault(call) + 1;
foreach((string name, int at, int error, int value) in _failures)
{
if(string.Equals(name, call, StringComparison.Ordinal) && at == occurrence)
{
if(recordsError)
{
Marshal.SetLastPInvokeError(error);
}
result = value;
return true;
}
}
result = 0;
return false;
}
/// <summary>Finds a queue by name; a name the spooler does not know is null.</summary>
private FakeQueue? Find(string name) => Queues.Find(queue => string.Equals(queue.Name, name, StringComparison.Ordinal));
/// <summary>The queue an open handle belongs to; using a handle that is not open fails the test.</summary>
private FakeQueue Printer(IntPtr printer) => _printers.TryGetValue(printer, out FakeQueue? queue) ? queue : throw new InvalidOperationException("A queue handle was used that is not open.");
/// <summary>The queue a device context belongs to; using a context that does not exist fails the test.</summary>
private FakeQueue Context(IntPtr dc) => _contexts.TryGetValue(dc, out FakeQueue? queue) ? queue : throw new InvalidOperationException("A device context was used that does not exist.");
/// <summary>Writes a handle the way the call log shows it.</summary>
private static string Handle(IntPtr handle) => "0x" + handle.ToInt64().ToString("x", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The service's side of the print helper boundary, run against the fixture helper (a real process that has no printer):
/// what the service sends, what it makes of every answer a helper can give, and what happens to the helper when the
/// caller gives up. The fixture either runs the real helper loop over a fake backend and a fake spooler, or answers with
/// text the test dictates.
/// </summary>
public sealed class IsolatedPrinterBackendTests
{
private static readonly PrintJob Job = new("job-1", "fixture", "Synthetic", "PDF", new string('0', 64), 1, new PrintOptions(), "2026-11-01T00:00:00Z", "QUEUED");
/// <summary>The fixture helper's executable, which the build puts beside the tests; it has no printer and never prints.</summary>
private static string FixtureExecutable => Path.ChangeExtension(typeof(WorkerFixture.Program).Assembly.Location, ".exe");
/// <summary>
/// A print is one helper started with --printer-worker and this process as its parent, sent one JSON line that names the
/// operation, the job and the absolute path of the staged file (never its content). The helper's receipt is returned.
/// </summary>
[Fact]
public async Task PrintSendsThePathOfTheStagedFileAndReturnsTheReceipt()
{
string record = Path.Combine(Path.GetTempPath(), "qqq-helper-request-" + Guid.NewGuid().ToString("N") + ".txt");
try
{
IsolatedPrinterBackend backend = Backend("--record", record, "--respond", "{\"success\":true,\"spoolerJobId\":\"4242\"}");
string receipt = await backend.PrintFileAsync(Job, Path.Combine("staging", "synthetic.bin"), TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Equal("4242", receipt);
string[] recorded = await File.ReadAllLinesAsync(record);
Assert.Equal("--printer-worker --record " + record, recorded[0]);
Assert.Equal(Environment.ProcessId.ToString(CultureInfo.InvariantCulture), recorded[1]);
PrinterWorkerHost.Request request = Assert.IsType<PrinterWorkerHost.Request>(JsonSerializer.Deserialize<PrinterWorkerHost.Request>(recorded[2], PrintApiClient.JsonOptions));
Assert.Equal(("print-file", Job, Path.GetFullPath(Path.Combine("staging", "synthetic.bin"))), (request.Operation, request.Job, request.ContentPath));
Assert.Equal((null, null, null), (request.PrinterName, request.JobId, request.SpoolerJobId));
}
finally
{
File.Delete(record);
}
}
/// <summary>An answer that claims success but carries no receipt, or no inventory, is refused; the service never invents one.</summary>
[Fact]
public async Task SuccessWithoutAReceiptOrAnInventoryIsRefused()
{
IsolatedPrinterBackend backend = Backend("--respond", "{\"success\":true}");
InvalidDataException noReceipt = await Assert.ThrowsAsync<InvalidDataException>(() => backend.PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.Equal("Printer worker returned no spooler receipt.", noReceipt.Message);
InvalidDataException noInventory = Assert.Throws<InvalidDataException>(() => backend.Discover());
Assert.Equal("Printer worker returned no inventory.", noInventory.Message);
}
/// <summary>
/// A cancellation goes through the helper's own ownership rule (here over the fixture's fake spooler, which holds job 7
/// named "QQQ fixture-job"): the job that is ours is cancelled, a number that belongs to another document is refused as
/// an ownership mismatch and a job that is gone is reported as already absent, each as the same refusal on this side.
/// </summary>
[Fact]
public async Task CancellationCrossesTheBoundaryWithItsRefusals()
{
IsolatedPrinterBackend backend = Backend();
await backend.CancelAsync("Synthetic queue", "fixture-job", "7");
SpoolCancellationException mismatch = await Assert.ThrowsAsync<SpoolCancellationException>(() => backend.CancelAsync("Synthetic queue", "another-job", "7"));
Assert.Equal(("OWNERSHIP_MISMATCH", "The spooler number now belongs to another document; cancellation was refused."), (mismatch.Outcome, mismatch.Message));
SpoolCancellationException absent = await Assert.ThrowsAsync<SpoolCancellationException>(() => backend.CancelAsync("Synthetic queue", "fixture-job", "8"));
Assert.Equal(("ALREADY_ABSENT", "The job is no longer in the Windows queue. Pages already printed cannot be recalled."), (absent.Outcome, absent.Message));
}
/// <summary>A failure the helper reports without a known code arrives as an I/O failure carrying the helper's message, which the worker treats as an uncertain outcome.</summary>
[Fact]
public async Task UnknownHelperFailureIsAnIoFailureWithTheHelpersMessage()
{
IOException failure = await Assert.ThrowsAsync<IOException>(() => Backend().PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.Equal("This fixture never opens documents or prints.", failure.Message);
}
/// <summary>
/// Each failure answer becomes the exception its code stands for, and an answer without a message gets a fixed one: a
/// media mismatch, a refused spool cancellation, or, for no code or one this side does not know, an I/O failure.
/// </summary>
[Theory]
[InlineData("{\"success\":false}", typeof(IOException), "The printer worker stopped unexpectedly. Inspect the printer before retrying.")]
[InlineData("{\"success\":false,\"errorCode\":\"MEDIA_PROFILE_MISMATCH\"}", typeof(PrinterMediaMismatchException), "The selected printer default changed before submission.")]
[InlineData("{\"success\":false,\"errorCode\":\"ALREADY_ABSENT\"}", typeof(SpoolCancellationException), "Verified spool cancellation was refused.")]
[InlineData("{\"success\":false,\"errorCode\":\"OWNERSHIP_MISMATCH\"}", typeof(SpoolCancellationException), "Verified spool cancellation was refused.")]
[InlineData("{\"success\":false,\"errorCode\":\"SOMETHING_NEW\",\"error\":\"Synthetic failure.\"}", typeof(IOException), "Synthetic failure.")]
[InlineData("{\"success\":false,\"errorCode\":\"media_profile_mismatch\",\"error\":\"Synthetic failure.\"}", typeof(IOException), "Synthetic failure.")]
public async Task FailureAnswerBecomesTheExceptionItsCodeStandsFor(string answer, Type expected, string message)
{
Exception failure = await Assert.ThrowsAsync(expected, () => Backend("--respond", answer, "--exit-code", "1").PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.Equal(message, failure.Message);
}
/// <summary>An answer that claims success from a helper that exited with an error is not trusted: it is an I/O failure, so the outcome stays uncertain.</summary>
[Fact]
public async Task SuccessFromAHelperThatExitedWithAnErrorIsNotTrusted()
{
IsolatedPrinterBackend backend = Backend("--respond", "{\"success\":true,\"spoolerJobId\":\"4242\"}", "--exit-code", "3");
IOException failure = await Assert.ThrowsAsync<IOException>(() => backend.PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.Equal("The printer worker stopped unexpectedly. Inspect the printer before retrying.", failure.Message);
}
/// <summary>An answer longer than 1 MiB is refused instead of parsed.</summary>
[Fact]
public async Task OversizedAnswerIsRefused()
{
InvalidDataException refusal = await Assert.ThrowsAsync<InvalidDataException>(() => Backend("--flood").PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.Equal("Printer worker response exceeded its limit.", refusal.Message);
}
/// <summary>An answer that is the JSON word null is no response, and one that is not JSON fails as invalid JSON; neither is taken for success.</summary>
[Fact]
public async Task AnswerThatIsNotAResponseIsRefused()
{
InvalidDataException none = await Assert.ThrowsAsync<InvalidDataException>(() => Backend("--respond", "null").PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.Equal("Printer worker returned no response.", none.Message);
await Assert.ThrowsAsync<JsonException>(() => Backend("--respond", "READY").PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), CancellationToken.None));
}
/// <summary>A caller that has already given up starts no helper at all.</summary>
[Fact]
public async Task CallerThatAlreadyGaveUpStartsNoHelper()
{
int launches = 0;
IsolatedPrinterBackend backend = new(FixtureExecutable)
{
StartProcess = start =>
{
launches++;
return Process.Start(start) ?? throw new InvalidOperationException("Fixture launch failed.");
},
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => backend.PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromSeconds(30), new CancellationToken(canceled: true)));
Assert.Equal(0, launches);
}
/// <summary>When the caller gives up while the helper is stuck, the call ends as a cancellation (not as a timeout to inspect) and the stuck helper is killed.</summary>
[Fact]
public async Task CallerGivingUpIsACancellationAndTheStuckHelperIsKilled()
{
int helperId = 0;
IsolatedPrinterBackend backend = new(FixtureExecutable)
{
StartProcess = start =>
{
start.ArgumentList.Add("--hang");
Process helper = Process.Start(start) ?? throw new InvalidOperationException("Fixture launch failed.");
helperId = helper.Id;
return helper;
},
};
using CancellationTokenSource caller = new(TimeSpan.FromMilliseconds(500));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => backend.PrintFileAsync(Job, "synthetic.bin", TimeSpan.FromMinutes(2), caller.Token));
Assert.True(HelperIsGone(helperId));
}
/// <summary>A backend whose helper is the fixture, started with the given extra arguments.</summary>
private static IsolatedPrinterBackend Backend(params string[] arguments) => new(FixtureExecutable)
{
StartProcess = start =>
{
foreach(string argument in arguments)
{
start.ArgumentList.Add(argument);
}
return Process.Start(start) ?? throw new InvalidOperationException("Fixture launch failed.");
},
};
/// <summary>True when the process has exited or no longer exists.</summary>
private static bool HelperIsGone(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
return process.HasExited;
}
catch(ArgumentException)
{
return true;
}
}
}
@@ -0,0 +1,588 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// How a queue's machine default settings are read and turned into a media profile, and how a job's own settings are
/// prepared through the driver, run against a Windows that exists only in memory. Each test asserts the exact calls sent
/// and their order, with the settings bytes that were handed over, which is the evidence that the reads reach Windows the
/// way they did before the calls were moved behind <see cref="ISpooler"/>, <see cref="IPrinterSettings"/> and
/// <see cref="IGdiPrinter"/>. No print queue on this computer is opened.
/// </summary>
public sealed class PrinterSettingsTests
{
private const string Queue = "Synthetic label queue";
private const string Driver = "Synthetic label driver";
private const string Port = "USB001";
private const string PrinterId = "0123456789abcdef01234567";
/// <summary>The four calls that read the machine defaults of the test queue through the first handle: a pointer plus 224 bytes of settings.</summary>
private static readonly string[] DefaultsRead = [$"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "GetPrinter(0x201, 8, 232)", "ClosePrinter(0x201)"];
/// <summary>The four calls that ask a device context built from the settings for its resolution.</summary>
private static readonly string[] ResolutionRead = [$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd01, 88)", "GetDeviceCaps(0xd01, 90)", "DeleteDC(0xd01)"];
/// <summary>
/// The defaults are read at level 8 (the machine-wide ones, not a user's), copied whole with their private part, and the
/// resolution is asked of a device context created from exactly those bytes. The queue is closed before the context is
/// made and the context is deleted.
/// </summary>
[Fact]
public void MachineDefaultsAreReadAtLevelEightAndResolutionComesFromTheirOwnContext()
{
byte[] defaults = SyntheticDevMode.Label();
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup(defaults);
WindowsPrinters.QueueDefaultMedia media = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Equal(defaults, media.DevMode);
Assert.Equal(WindowsPrinters.BuildMediaProfile(PrinterId, Driver, defaults, 101600, 152400, 300, 300), media.Profile);
Assert.Equal([.. DefaultsRead, .. ResolutionRead], windows.Calls, StringComparer.Ordinal);
Assert.Equal(defaults, Assert.Single(windows.ContextModes));
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>The two resolutions a device context reports are kept apart: 203 dpi across and 300 dpi down become the profile's horizontal and vertical resolution, in that order.</summary>
[Fact]
public void ResolutionFromTheContextKeepsHorizontalAndVerticalApart()
{
(WindowsPrinters printers, _, FakeQueue queue) = Setup();
_ = queue.WithContext(812, 1800, 203, 300);
PrinterMediaProfile profile = Assert.IsType<PrinterMediaProfile>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port)?.Profile);
Assert.Equal((203, 300), (profile.DpiX, profile.DpiY));
}
/// <summary>A positive explicit resolution in the settings is used as it is and no device context is created; a symbolic quality value (negative) is not a resolution, so the context is asked.</summary>
[Fact]
public void ExplicitResolutionNeedsNoContextButASymbolicQualityDoes()
{
byte[] explicitDpi = SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation | SyntheticDevMode.PaperDimensions | SyntheticDevMode.Resolution).WithShort(90, 203).WithShort(96, 204);
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup(explicitDpi);
PrinterMediaProfile profile = Assert.IsType<PrinterMediaProfile>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port)?.Profile);
Assert.Equal((203, 204), (profile.DpiX, profile.DpiY));
Assert.Equal(DefaultsRead, windows.Calls, StringComparer.Ordinal);
byte[] symbolic = explicitDpi.ToArray().WithShort(90, -4);
(printers, windows, _) = Setup(symbolic);
profile = Assert.IsType<PrinterMediaProfile>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port)?.Profile);
Assert.Equal((300, 300), (profile.DpiX, profile.DpiY));
Assert.Equal([.. DefaultsRead, .. ResolutionRead], windows.Calls, StringComparer.Ordinal);
}
/// <summary>
/// Settings that name a paper identifier instead of dimensions are resolved through the driver's two paper lists, asked
/// with the captured settings: both counts first, then both lists into buffers sized from the count. The one entry with
/// that identifier gives the size.
/// </summary>
[Fact]
public void PaperIdentifierIsResolvedThroughTheDriversPaperLists()
{
byte[] defaults = PaperIdSettings(257);
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(defaults);
queue.PaperIds = [1, 9, 257];
queue.PaperSizes = [(2159, 2794), (2100, 2970), (1016, 1524)];
PrinterMediaProfile profile = Assert.IsType<PrinterMediaProfile>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port)?.Profile);
Assert.Equal((101600, 152400), (profile.WidthMicrons, profile.HeightMicrons));
Assert.Equal(
[
.. DefaultsRead,
$"DeviceCapabilities({Queue}, {Port}, 2, count)",
$"DeviceCapabilities({Queue}, {Port}, 3, count)",
$"DeviceCapabilities({Queue}, {Port}, 2, 6 bytes)",
$"DeviceCapabilities({Queue}, {Port}, 3, 24 bytes)",
.. ResolutionRead,
], windows.Calls, StringComparer.Ordinal);
Assert.All(windows.CapabilityModes, mode => Assert.Equal(defaults, mode));
}
/// <summary>
/// A driver whose paper lists cannot be trusted gives no size and so no profile, without guessing a standard form: no
/// entries, too many, lists of different length, a read that returns another count, no entry or two entries with the
/// identifier, or a size outside 0.1 mm to 2 m. The queue itself stays readable.
/// </summary>
[Theory]
[InlineData("no entries", 1)]
[InlineData("too many entries", 1)]
[InlineData("lists differ", 2)]
[InlineData("first read differs", 3)]
[InlineData("second read differs", 4)]
[InlineData("no match", 4)]
[InlineData("two matches", 4)]
[InlineData("zero width", 4)]
[InlineData("width over two meters", 4)]
[InlineData("zero height", 4)]
[InlineData("height over two meters", 4)]
public void UntrustworthyPaperListGivesNoProfile(string fault, int capabilityCalls)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(PaperIdSettings(257));
queue.PaperIds = [1, 257];
queue.PaperSizes = [(2159, 2794), (1016, 1524)];
switch(fault)
{
case "no entries":
queue.CountAnswers[2] = 0;
break;
case "too many entries":
queue.CountAnswers[2] = 65536;
break;
case "lists differ":
queue.CountAnswers[3] = 3;
break;
case "first read differs":
queue.ReadAnswers[2] = 1;
break;
case "second read differs":
queue.ReadAnswers[3] = -1;
break;
case "no match":
queue.PaperIds = [1, 9];
break;
case "two matches":
queue.PaperIds = [257, 257];
break;
case "zero width":
queue.PaperSizes = [(2159, 2794), (0, 1524)];
break;
case "width over two meters":
queue.PaperSizes = [(2159, 2794), (20_001, 1524)];
break;
case "zero height":
queue.PaperSizes = [(2159, 2794), (1016, 0)];
break;
default:
queue.PaperSizes = [(2159, 2794), (1016, 20_001)];
break;
}
WindowsPrinters.QueueDefaultMedia media = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Null(media.Profile);
Assert.Equal(capabilityCalls, windows.Calls.Count(call => call.StartsWith("DeviceCapabilities(", StringComparison.Ordinal)));
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>The largest paper list the rules accept (65,535 entries) is still read, and settings that name neither dimensions nor an identifier are never looked up in the driver.</summary>
[Fact]
public void PaperListBoundAndSettingsWithoutAnyPaperField()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(PaperIdSettings(257));
queue.PaperIds = [257];
queue.PaperSizes = [(1016, 1524)];
queue.CountAnswers[2] = queue.CountAnswers[3] = queue.ReadAnswers[2] = queue.ReadAnswers[3] = 65535;
Assert.NotNull(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port)?.Profile);
Assert.Equal($"DeviceCapabilities({Queue}, {Port}, 3, 524280 bytes)", windows.Calls[7]);
(printers, windows, _) = Setup(SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation));
WindowsPrinters.QueueDefaultMedia media = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Null(media.Profile);
Assert.Equal([.. DefaultsRead, .. ResolutionRead], windows.Calls, StringComparer.Ordinal);
}
/// <summary>A queue that cannot be opened has no media profile and stays discoverable: the Windows error is swallowed here, nothing else is asked and there is no handle to close.</summary>
[Fact]
public void QueueThatCannotBeOpenedHasNoMediaProfile()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
queue.OpenError = 5;
Assert.Null(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Equal([$"OpenPrinter({Queue})"], windows.Calls, StringComparer.Ordinal);
Win32Exception failure = Assert.Throws<Win32Exception>(() => printers.ReadMachineDevMode(Queue));
Assert.Equal(5, failure.NativeErrorCode);
}
/// <summary>
/// The answer's size is bounded before any buffer is asked for: smaller than a pointer plus the fixed part of the settings
/// (110 bytes) or larger than 1 MiB means no defaults, and the queue is closed without a read. Exactly at either bound the
/// read is made.
/// </summary>
[Theory]
[InlineData(0u, false)]
[InlineData(109u, false)]
[InlineData(110u, true)]
[InlineData(1_048_576u, true)]
[InlineData(1_048_577u, false)]
public void DefaultsSizeIsBoundedBeforeTheRead(uint reported, bool read)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(SyntheticDevMode.Label().WithShort(68, 102).WithShort(70, 0));
queue.ReportedDefaultsSize = reported;
byte[]? mode = printers.ReadMachineDevMode(Queue);
Assert.Equal(read, mode is not null);
Assert.Equal(read ? 102 : null, mode?.Length);
Assert.Equal(read
? [$"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", $"GetPrinter(0x201, 8, {reported})", "ClosePrinter(0x201)"]
: new[] { $"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "ClosePrinter(0x201)" }, windows.Calls, StringComparer.Ordinal);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>A read that Windows refuses keeps its error for a caller that asked for the settings, is "no profile" for discovery, and closes the queue either way.</summary>
[Fact]
public void RefusedReadKeepsItsWindowsErrorAndClosesTheQueue()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
queue.GetPrinterError = 1722;
Win32Exception failure = Assert.Throws<Win32Exception>(() => printers.ReadMachineDevMode(Queue));
Assert.Equal(1722, failure.NativeErrorCode);
Assert.Equal(DefaultsRead, windows.Calls, StringComparer.Ordinal);
Assert.Equal(0, windows.OpenPrinters);
Assert.Null(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>
/// The pointer and the two lengths inside the answer are checked against the answer's size before anything is copied: a
/// null pointer, a pointer into the pointer field itself or past the last place a fixed part fits, a fixed part shorter
/// than 102 bytes and settings that run past the end of the answer all mean no defaults.
/// </summary>
[Theory]
[InlineData("null pointer")]
[InlineData("pointer before the settings")]
[InlineData("pointer past the last fixed part")]
[InlineData("pointer before the answer")]
[InlineData("fixed part too short")]
[InlineData("settings run past the answer")]
public void PointerAndLengthsInsideTheAnswerAreChecked(string fault)
{
byte[] defaults = SyntheticDevMode.Label();
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(defaults);
switch(fault)
{
case "null pointer":
queue.NullDevModePointer = true;
break;
case "pointer before the settings":
queue.DevModeOffset = IntPtr.Size - 1;
break;
case "pointer past the last fixed part":
queue.DevModeOffset = 232 - 101;
break;
case "pointer before the answer":
queue.DevModeOffset = -16;
break;
case "fixed part too short":
_ = defaults.WithShort(68, 101);
break;
default:
_ = defaults.WithShort(70, 5);
break;
}
Assert.Null(printers.ReadMachineDevMode(Queue));
Assert.Equal(DefaultsRead, windows.Calls, StringComparer.Ordinal);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>Exactly the settings are copied (their fixed and private parts), from where the pointer says they are, even when the answer is larger than they need.</summary>
[Fact]
public void OnlyTheSettingsAreCopiedOutOfALargerAnswer()
{
byte[] defaults = SyntheticDevMode.Label();
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(defaults);
queue.ReportedDefaultsSize = 4096;
queue.DevModeOffset = 512;
Assert.Equal(defaults, printers.ReadMachineDevMode(Queue));
Assert.Equal("GetPrinter(0x201, 8, 4096)", windows.Calls[2]);
}
/// <summary>When Windows will not create a device context from the settings, the resolution is unknown and the queue has no profile; no context is read or deleted.</summary>
[Fact]
public void RefusedContextMeansUnknownResolutionAndNoProfile()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
queue.CreateContextError = 1801;
WindowsPrinters.QueueDefaultMedia media = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Null(media.Profile);
Assert.Equal([.. DefaultsRead, ResolutionRead[0]], windows.Calls, StringComparer.Ordinal);
}
/// <summary>A job that selected no media profile is left on its existing path: nothing is read and nothing is required.</summary>
[Fact]
public void JobWithoutASelectionReadsNothing()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
Assert.Null(printers.RequireCurrentMedia(Printer(), Job(new PrintOptions())));
Assert.Empty(windows.Calls);
}
/// <summary>A job that selected the queue's current default gets the freshly read settings back; one whose selection no longer matches, or whose queue can no longer be read, is refused as a media mismatch before anything is submitted.</summary>
[Fact]
public void SelectedMediaIsReadAgainAndMustStillMatch()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
PrinterMediaProfile profile = Assert.IsType<PrinterMediaProfile>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port)?.Profile);
PrintJob job = Job(new PrintOptions(MediaProfile: new PrinterMediaSelection(profile.Id, profile.Revision)));
windows.Calls.Clear();
WindowsPrinters.QueueDefaultMedia current = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.RequireCurrentMedia(Printer(), job));
Assert.Equal(profile, current.Profile);
Assert.Equal(queue.MachineDefaults, current.DevMode);
Assert.Equal(
[
$"OpenPrinter({Queue})", "GetPrinterSize(0x202, 8)", "GetPrinter(0x202, 8, 232)", "ClosePrinter(0x202)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd02, 88)", "GetDeviceCaps(0xd02, 90)", "DeleteDC(0xd02)",
], windows.Calls, StringComparer.Ordinal);
queue.MachineDefaults![^1] ^= 0xff;
Assert.Throws<PrinterMediaMismatchException>(() => printers.RequireCurrentMedia(Printer(), job));
queue.OpenError = 5;
Assert.Throws<PrinterMediaMismatchException>(() => printers.RequireCurrentMedia(Printer(), job));
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// A selected PDF job's settings are a copy with one driver copy and 100 percent scale, merged by the driver with the
/// input and output flags only (10: no prompt, no change to the queue), then resolved again through a context built
/// from the merged bytes. The captured defaults are not changed.
/// </summary>
[Fact]
public void SelectedPdfSettingsAreMergedByTheDriverAndResolvedAgain()
{
byte[] defaults = SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation | SyntheticDevMode.PaperDimensions | SyntheticDevMode.Scale | SyntheticDevMode.Copies).WithShort(84, 50).WithShort(86, 3);
byte[] original = defaults.ToArray();
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(defaults);
queue.Merge = input =>
{
byte[] merged = input.ToArray();
merged[^1] = 0x11;
return merged;
};
WindowsPrinters.QueueDefaultMedia media = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
windows.Calls.Clear();
byte[] prepared = printers.PreparePdfMode(Printer(), media);
Assert.Equal(
[
$"OpenPrinter({Queue})", $"DocumentPropertiesSize(0x202, {Queue})", $"DocumentProperties(0x202, {Queue}, 224 bytes, 224 bytes, 10)", "ClosePrinter(0x202)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd02, 88)", "GetDeviceCaps(0xd02, 90)", "DeleteDC(0xd02)",
], windows.Calls, StringComparer.Ordinal);
byte[] asked = Assert.Single(windows.MergeInputs);
Assert.Equal((100, 1), (SyntheticDevMode.Short(asked, 84), SyntheticDevMode.Short(asked, 86)));
Assert.Equal(0x11, prepared[^1]);
Assert.Equal(prepared, windows.ContextModes[^1]);
Assert.Equal(original, defaults);
Assert.Equal(original, media.DevMode);
}
/// <summary>A driver that changes the paper while merging the job's settings is refused as a media mismatch, with the queue closed and the context deleted.</summary>
[Fact]
public void DriverThatChangesThePaperWhileMergingIsRefused()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
WindowsPrinters.QueueDefaultMedia media = Assert.IsType<WindowsPrinters.QueueDefaultMedia>(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
queue.Merge = input => input.ToArray().WithShort(80, 508);
PrinterMediaMismatchException refusal = Assert.Throws<PrinterMediaMismatchException>(() => printers.PreparePdfMode(Printer(), media));
Assert.Equal("The driver changed selected media while applying job-level copies and scale.", refusal.Message);
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// Explicit job paper is applied to a fresh read of the machine defaults and merged by the driver: read the defaults,
/// close, open again, ask the settings size, merge with flags 10, close. The settings handed to the driver carry the
/// requested size as a user-defined form in tenths of a millimeter.
/// </summary>
[Fact]
public void JobPaperIsAppliedToAFreshReadOfTheDefaultsAndMerged()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
byte[] prepared = printers.PrepareCustomPdfMode(Printer(), (101600, 25400));
Assert.Equal(
[
.. DefaultsRead,
$"OpenPrinter({Queue})", $"DocumentPropertiesSize(0x202, {Queue})", $"DocumentProperties(0x202, {Queue}, 224 bytes, 224 bytes, 10)", "ClosePrinter(0x202)",
], windows.Calls, StringComparer.Ordinal);
byte[] asked = Assert.Single(windows.MergeInputs);
Assert.Equal(0xfu, SyntheticDevMode.Fields(asked));
Assert.Equal((1, 256, 254, 1016), (SyntheticDevMode.Short(asked, 76), SyntheticDevMode.Short(asked, 78), SyntheticDevMode.Short(asked, 80), SyntheticDevMode.Short(asked, 82)));
Assert.Equal(asked, prepared);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>Job paper needs machine defaults to start from: a queue without readable ones is refused as a media mismatch, and a queue that cannot be opened keeps its Windows error.</summary>
[Fact]
public void JobPaperNeedsReadableMachineDefaults()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
queue.MachineDefaults = null;
PrinterMediaMismatchException refusal = Assert.Throws<PrinterMediaMismatchException>(() => printers.PrepareCustomPdfMode(Printer(), (101600, 25400)));
Assert.Equal("The queue has no readable machine defaults to apply job paper to.", refusal.Message);
Assert.Equal([$"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "ClosePrinter(0x201)"], windows.Calls, StringComparer.Ordinal);
queue.OpenError = 5;
Assert.Equal(5, Assert.Throws<Win32Exception>(() => printers.PrepareCustomPdfMode(Printer(), (101600, 25400))).NativeErrorCode);
}
/// <summary>
/// The driver's merge is bounded and checked: a settings size under 102 bytes or over 1 MiB, a result other than 1, a
/// fixed part shorter than 102 bytes or settings longer than the buffer are each refused as a media mismatch with the
/// queue closed; a queue that cannot be opened for the merge keeps its Windows error.
/// </summary>
[Theory]
[InlineData("size too small", "The driver did not provide a bounded document-settings buffer.", 2)]
[InlineData("size too large", "The driver did not provide a bounded document-settings buffer.", 2)]
[InlineData("size unknown", "The driver did not provide a bounded document-settings buffer.", 2)]
[InlineData("refused", "The driver refused job-level document settings.", 3)]
[InlineData("cancelled", "The driver refused job-level document settings.", 3)]
[InlineData("fixed part too short", "The driver returned incomplete document settings.", 3)]
[InlineData("settings longer than the buffer", "The driver returned incomplete document settings.", 3)]
public void DriverMergeIsBoundedAndChecked(string fault, string message, int callsOnTheSecondHandle)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
switch(fault)
{
case "size too small":
queue.SettingsSize = 101;
break;
case "size too large":
queue.SettingsSize = 1_048_577;
break;
case "size unknown":
queue.SettingsSize = -1;
break;
case "refused":
queue.MergeResult = 0;
break;
case "cancelled":
queue.MergeResult = 2;
break;
case "fixed part too short":
queue.Merge = input => input.ToArray().WithShort(68, 101);
break;
default:
queue.Merge = input => input.ToArray().WithShort(70, 5);
break;
}
PrinterMediaMismatchException refusal = Assert.Throws<PrinterMediaMismatchException>(() => printers.PrepareCustomPdfMode(Printer(), (101600, 50800)));
Assert.Equal(message, refusal.Message);
Assert.Equal(callsOnTheSecondHandle, windows.Calls.Count(call => call.Contains("(0x202", StringComparison.Ordinal)));
Assert.Equal("ClosePrinter(0x202)", windows.Calls[^1]);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>A queue that can be read but not opened again for the merge keeps its Windows error, and a driver buffer larger than the settings it returns is cut to the settings.</summary>
[Fact]
public void MergeOpenFailureKeepsItsErrorAndALargerBufferIsCutToTheSettings()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
windows.Fail("OpenPrinter", 5, occurrence: 2);
Assert.Equal(5, Assert.Throws<Win32Exception>(() => printers.PrepareCustomPdfMode(Printer(), (101600, 25400))).NativeErrorCode);
Assert.Equal([.. DefaultsRead, $"OpenPrinter({Queue})"], windows.Calls, StringComparer.Ordinal);
queue.SettingsSize = 1_048_576;
byte[] prepared = printers.PrepareCustomPdfMode(Printer(), (101600, 25400));
Assert.Equal(224, prepared.Length);
Assert.Equal($"DocumentProperties(0x203, {Queue}, 1048576 bytes, 224 bytes, 10)", windows.Calls[^2]);
}
/// <summary>A queue without machine defaults is still a queue: discovery gets no media for it, and a selected PDF job with no complete settings to check is refused before a document is started.</summary>
[Fact]
public void QueueWithoutMachineDefaultsHasNoMediaAndCannotCarryASelectedPdf()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
queue.MachineDefaults = null;
Assert.Null(printers.ReadQueueMedia(PrinterId, Queue, Driver, Port));
Assert.Equal([$"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "ClosePrinter(0x201)"], windows.Calls, StringComparer.Ordinal);
PrinterMediaProfile selected = new("default", new string('a', 64), "Selected", 101600, 152400, 300, 300);
int started = 0;
foreach(byte[]? settings in new[] { null, new byte[101] })
{
PrinterMediaMismatchException refusal = Assert.Throws<PrinterMediaMismatchException>(() => WindowsPrinters.StartValidatedPdfDocument(settings, selected, _ => 300, () => ++started));
Assert.Equal("The selected PDF media has no complete machine defaults.", refusal.Message);
}
Assert.Equal(0, started);
}
/// <summary>A driver that drops its support for job-level copies or scale while job paper is merged is refused as a media mismatch.</summary>
[Fact]
public void DriverThatDropsCopySupportWhileMergingJobPaperIsRefused()
{
byte[] defaults = SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation | SyntheticDevMode.PaperDimensions | SyntheticDevMode.Copies).WithShort(86, 1);
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup(defaults);
queue.Merge = input => input.ToArray().WithFields(SyntheticDevMode.Fields(input) & ~SyntheticDevMode.Copies);
PrinterMediaMismatchException refusal = Assert.Throws<PrinterMediaMismatchException>(() => printers.PrepareCustomPdfMode(Printer(), (101600, 25400)));
Assert.Equal("The driver changed support for job-level copies or scale.", refusal.Message);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>The adapter over the real Windows can be built without asking Windows anything; only its members call out.</summary>
[Fact]
public void RealAdapterIsBuiltWithoutCallingWindows()
{
IPrinterBackend backend = new WindowsPrinters();
Assert.IsType<WindowsPrinters>(backend);
}
/// <summary>Builds the rules over a fake Windows that has the one test queue, with a 4 by 6 inch, 300 dpi device context.</summary>
private static (WindowsPrinters Printers, FakeWindowsPrinting Windows, FakeQueue Queue) Setup(byte[]? defaults = null)
{
FakeWindowsPrinting windows = new();
FakeQueue queue = windows.Add(new FakeQueue(Queue, Driver, Port) { MachineDefaults = defaults ?? SyntheticDevMode.Label() }.WithContext(1200, 1800));
return (new WindowsPrinters(windows, windows, windows, new FakePdfRenderer(windows.Calls)), windows, queue);
}
/// <summary>Settings that name a paper identifier and carry no explicit dimensions.</summary>
private static byte[] PaperIdSettings(short paperId) => SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation | SyntheticDevMode.PaperSize).WithShort(78, paperId);
/// <summary>The test queue as discovery reports it.</summary>
private static PrinterInfo Printer() => new(PrinterId, Queue, Driver, Port, "READY", ["PDF"]);
/// <summary>A PDF job for the test queue with the given options.</summary>
private static PrintJob Job(PrintOptions options) => new("job-1", PrinterId, "Synthetic", "PDF", new string('0', 64), 1, options, "2026-11-01T00:00:00Z", "QUEUED");
}
@@ -0,0 +1,321 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The print helper's one-operation loop, run inside the test process with its request and answer as strings, a fake
/// printer backend, a fake spooler and a parent watch that watches nothing. It shows what the helper refuses, what each
/// operation is handed, how a failure is reported to the service, and that the parent is watched for exactly as long as
/// the operation runs. No second process is started and no printer is touched.
/// </summary>
public sealed class PrinterWorkerHostTests
{
private const string Queue = "Synthetic label queue";
private static readonly PrintJob Job = new("job-1", "printer-1", "Synthetic", "PDF", new string('0', 64), 1, new PrintOptions(), "2026-11-01T00:00:00Z", "QUEUED");
/// <summary>A helper that was not given a plain process id for its parent refuses before it starts a watch or reads a single character of its request.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("service")]
[InlineData("-42")]
[InlineData(" 42")]
[InlineData("4.2")]
public async Task HelperWithoutAParentRefusesBeforeItReadsItsRequest(string? parent)
{
FakeParentWatch watch = new();
using StringReader input = new("{\"operation\":\"discover\"}\n");
using StringWriter output = new();
RecordingPrinters printers = new();
int exit = await PrinterWorkerHost.RunAsync(printers, new FakeSpoolerJobs(), watch, parent, input, output);
Assert.Equal(1, exit);
PrinterWorkerHost.Response response = Read(output);
Assert.False(response.Success);
Assert.Equal("Printer workers must be launched by the print connector.", response.Error);
Assert.Null(watch.ParentId);
Assert.Equal('{', input.Peek());
Assert.Equal(0, printers.Discoveries);
}
/// <summary>Discover answers with the backend's inventory and exit code 0; the parent named in the environment is watched while the backend runs and the watch is stopped before the helper returns.</summary>
[Fact]
public async Task DiscoverReturnsTheInventoryWhileTheParentIsWatched()
{
FakeParentWatch watch = new();
bool watchedDuringTheCall = false;
RecordingPrinters printers = new()
{
OnDiscover = () =>
{
watchedDuringTheCall = watch.Watching;
return [new PrinterInfo("printer-1", Queue, "Synthetic label driver", "USB001", "READY", ["PDF"])];
},
};
(int exit, PrinterWorkerHost.Response response) = await RunAsync(new PrinterWorkerHost.Request("discover"), printers, watch: watch);
Assert.Equal(0, exit);
Assert.True(response.Success);
Assert.Equal("printer-1", Assert.Single(response.Printers!).PrinterId);
Assert.Equal(4242, watch.ParentId);
Assert.True(watchedDuringTheCall);
Assert.True(watch.Stopped);
}
/// <summary>
/// Print-file hands the backend the job and the path as they came, with no time limit of its own (the service that
/// started the helper holds the limit and ends the process), and a token that is live during the call. The answer
/// carries the spooler receipt.
/// </summary>
[Fact]
public async Task PrintFileHandsOverTheJobAndPathWithNoLimitOfItsOwn()
{
(PrintJob Job, string Path, TimeSpan Limit, bool Cancelled)? seen = null;
CancellationToken lifetime = CancellationToken.None;
RecordingPrinters printers = new()
{
OnPrint = (job, path, limit, token) =>
{
seen = (job, path, limit, token.IsCancellationRequested);
lifetime = token;
return Task.FromResult("4101");
},
};
(int exit, PrinterWorkerHost.Response response) = await RunAsync(new PrinterWorkerHost.Request("print-file", Job, ContentPath: @"C:\staging\synthetic.bin"), printers);
Assert.Equal(0, exit);
Assert.Equal((true, "4101"), (response.Success, response.SpoolerJobId));
Assert.Equal((Job.JobId, @"C:\staging\synthetic.bin", Timeout.InfiniteTimeSpan, false), (seen!.Value.Job.JobId, seen.Value.Path, seen.Value.Limit, seen.Value.Cancelled));
Assert.True(lifetime.IsCancellationRequested);
}
/// <summary>Print-file without a job, or without the path of a verified file, is refused and the backend is never called.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task PrintFileNeedsBothAJobAndAVerifiedFile(bool withJob)
{
RecordingPrinters printers = new();
PrinterWorkerHost.Request request = withJob ? new PrinterWorkerHost.Request("print-file", Job) : new PrinterWorkerHost.Request("print-file", ContentPath: @"C:\staging\synthetic.bin");
(int exit, PrinterWorkerHost.Response response) = await RunAsync(request, printers);
Assert.Equal(1, exit);
Assert.Equal((false, "The print worker requires a verified document file.", null), (response.Success, response.Error, response.ErrorCode));
Assert.Equal(0, printers.Prints);
}
/// <summary>Cancel runs the ownership rule against the spooler it was given: the job is read back and, being ours, deleted.</summary>
[Fact]
public async Task CancelDeletesTheJobThatIsOurs()
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs().WithJob(Queue, 42, "QQQ job-1");
(int exit, PrinterWorkerHost.Response response) = await RunAsync(new PrinterWorkerHost.Request("cancel", PrinterName: Queue, JobId: "job-1", SpoolerJobId: "42"), spooler: spooler);
Assert.Equal(0, exit);
Assert.Equal((true, null, null), (response.Success, response.Error, response.ErrorCode));
Assert.Equal("SetJob(0x101, 42, 5)", spooler.Calls[^2]);
Assert.Empty(spooler.Queues[Queue]);
}
/// <summary>
/// A refused cancellation leaves the helper with exit code 1 and the refusal's own code, which the service turns back
/// into the same refusal: another document's number is an ownership mismatch, a job that left the queue is already
/// absent. A request that names no receipt at all is an ordinary failure without a code.
/// </summary>
[Theory]
[InlineData("job-other", "42", "OWNERSHIP_MISMATCH", "The spooler number now belongs to another document; cancellation was refused.")]
[InlineData("job-1", "43", "ALREADY_ABSENT", "The job is no longer in the Windows queue. Pages already printed cannot be recalled.")]
[InlineData("job-1", null, null, "This job has no valid Windows spooler receipt.")]
public async Task RefusedCancellationKeepsItsCode(string jobId, string? receipt, string? code, string message)
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs().WithJob(Queue, 42, "QQQ job-1");
(int exit, PrinterWorkerHost.Response response) = await RunAsync(new PrinterWorkerHost.Request("cancel", PrinterName: Queue, JobId: jobId, SpoolerJobId: receipt), spooler: spooler);
Assert.Equal(1, exit);
Assert.Equal((false, message, code), (response.Success, response.Error, response.ErrorCode));
Assert.True(spooler.Queues[Queue].ContainsKey(42));
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>A media refusal made by the backend is reported with the media code, so the service records a known refusal with nothing submitted and not an uncertain outcome.</summary>
[Fact]
public async Task MediaRefusalKeepsItsCode()
{
RecordingPrinters printers = new() { OnPrint = (_, _, _, _) => throw new PrinterMediaMismatchException("The selected printer media profile changed or is unavailable.") };
(int exit, PrinterWorkerHost.Response response) = await RunAsync(new PrinterWorkerHost.Request("print-file", Job, ContentPath: @"C:\staging\synthetic.bin"), printers);
Assert.Equal(1, exit);
Assert.Equal((false, "The selected printer media profile changed or is unavailable.", "MEDIA_PROFILE_MISMATCH"), (response.Success, response.Error, response.ErrorCode));
}
/// <summary>Any other failure is reported with its message, with credential-shaped text redacted before it leaves the process, and no code.</summary>
[Fact]
public async Task OtherFailuresAreReportedRedactedAndWithoutACode()
{
RecordingPrinters printers = new() { OnDiscover = () => throw new InvalidOperationException("The driver answered: Bearer synthetic-token-value") };
(int exit, PrinterWorkerHost.Response response) = await RunAsync(new PrinterWorkerHost.Request("discover"), printers);
Assert.Equal(1, exit);
Assert.Equal((false, "The driver answered: Bearer [REDACTED]", null), (response.Success, response.Error, response.ErrorCode));
}
/// <summary>A request that is absent, is the JSON word null, names an operation the helper does not have, or is not JSON at all is answered with a failure and exit code 1; the watch is stopped all the same.</summary>
[Theory]
[InlineData("", "Invalid printer worker request size.")]
[InlineData("null\n", "Missing printer worker request.")]
[InlineData("{\"operation\":\"print\"}\n", "Unknown printer worker operation.")]
[InlineData("{\"operation\":\"DISCOVER\"}\n", "Unknown printer worker operation.")]
[InlineData("{\"operation\":\n", null)]
public async Task UnusableRequestIsAnsweredWithAFailure(string text, string? message)
{
FakeParentWatch watch = new();
using StringReader input = new(text);
using StringWriter output = new();
RecordingPrinters printers = new();
int exit = await PrinterWorkerHost.RunAsync(printers, new FakeSpoolerJobs(), watch, "4242", input, output);
Assert.Equal(1, exit);
PrinterWorkerHost.Response response = Read(output);
Assert.False(response.Success);
Assert.NotNull(response.Error);
if(message is not null)
{
Assert.Equal(message, response.Error);
}
Assert.Equal((0, 0), (printers.Discoveries, printers.Prints));
Assert.True(watch.Stopped);
}
/// <summary>
/// A watch that fails instead of watching does not stop the helper: the operation is carried out with nobody watching
/// the parent and its answer is written, and only then, when the helper waits for the watch to stop, does the watch's
/// failure come out, in place of the exit code. This is what the helper did before the watch was moved behind
/// <see cref="IParentWatch"/>; the test records it, so a change to it is a decision and not an accident.
/// </summary>
[Fact]
public async Task WatchThatFailsLeavesTheOperationUnwatchedAndSurfacesWhenTheHelperEnds()
{
InvalidOperationException fault = new("Synthetic: the parent could not be waited for.");
using StringReader input = new("{\"operation\":\"discover\"}\n");
using StringWriter output = new();
RecordingPrinters printers = new();
InvalidOperationException thrown = await Assert.ThrowsAsync<InvalidOperationException>(() => PrinterWorkerHost.RunAsync(printers, new FakeSpoolerJobs(), new FailingWatch(fault), "4242", input, output));
Assert.Same(fault, thrown);
Assert.Equal(1, printers.Discoveries);
Assert.True(Read(output).Success);
}
/// <summary>
/// The entry point the service's executable calls takes the parent's process id from the environment. In a process that
/// was not started as a helper there is none, so it refuses with exit code 1 before it reads the console or asks Windows
/// anything; this is the only path of it a test may run.
/// </summary>
[Fact]
public async Task RealEntryPointRefusesToRunOutsideAHelperProcess()
{
Assert.Null(Environment.GetEnvironmentVariable(PrinterWorkerHost.ParentEnvironmentVariable));
Assert.Equal(1, await PrinterWorkerHost.RunAsync());
}
/// <summary>Runs the helper loop on one request with the given fakes, parented to process 4242, and returns its exit code and its single answer.</summary>
private static async Task<(int Exit, PrinterWorkerHost.Response Response)> RunAsync(PrinterWorkerHost.Request request, RecordingPrinters? printers = null, FakeSpoolerJobs? spooler = null, FakeParentWatch? watch = null)
{
using StringReader input = new(JsonSerializer.Serialize(request, PrintApiClient.JsonOptions) + "\n");
using StringWriter output = new();
int exit = await PrinterWorkerHost.RunAsync(printers ?? new RecordingPrinters(), spooler ?? new FakeSpoolerJobs(), watch ?? new FakeParentWatch(), "4242", input, output);
return (exit, Read(output));
}
/// <summary>Parses the helper's answer, which must be exactly one line.</summary>
private static PrinterWorkerHost.Response Read(StringWriter output)
{
string line = Assert.Single(output.ToString().Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries));
return Assert.IsType<PrinterWorkerHost.Response>(JsonSerializer.Deserialize<PrinterWorkerHost.Response>(line, PrintApiClient.JsonOptions));
}
/// <summary>A printer backend that does what a test tells it and counts how often it was asked; it has no printer.</summary>
private sealed class RecordingPrinters : IPrinterBackend
{
/// <summary>What discovery returns or throws; an empty inventory unless a test says otherwise.</summary>
internal Func<IReadOnlyList<PrinterInfo>> OnDiscover { get; init; } = () => [];
/// <summary>What printing a file returns or throws; a fixed receipt unless a test says otherwise.</summary>
internal Func<PrintJob, string, TimeSpan, CancellationToken, Task<string>> OnPrint { get; init; } = (_, _, _, _) => Task.FromResult("1");
/// <summary>How often discovery was asked for.</summary>
internal int Discoveries { get; private set; }
/// <summary>How often a file was handed over.</summary>
internal int Prints { get; private set; }
/// <summary>Counts the call and does what the test said.</summary>
public IReadOnlyList<PrinterInfo> Discover()
{
Discoveries++;
return OnDiscover();
}
/// <summary>Counts the call and does what the test said.</summary>
public Task<string> PrintFileAsync(PrintJob job, string path, TimeSpan submissionTimeout, CancellationToken cancellationToken)
{
Prints++;
return OnPrint(job, path, submissionTimeout, cancellationToken);
}
}
/// <summary>A parent watch whose wait has already failed when it is started, as a wait on a process that cannot be waited for would; it watches nothing and ends no process.</summary>
private sealed class FailingWatch(Exception fault) : IParentWatch
{
/// <summary>Returns a task that has already failed with the given exception.</summary>
public Task WatchAsync(int parentId, CancellationToken token) => Task.FromException(fault);
}
}
@@ -130,6 +130,42 @@ public sealed class PrinterWorkerIsolationTests
/// <summary>
/// A worker whose parent is already gone when it starts exits with code 2 at once, before it reads a request: nothing is
/// sent to it here and its input stays open, so only the parent watch can have ended it, and it writes no answer. The
/// parent is a process that has exited and whose handle this test still holds, so Windows cannot have given its
/// process id to another process.
/// </summary>
[Fact]
public async Task WorkerExitsAtOnceWhenItsParentIsAlreadyGone()
{
ProcessStartInfo gone = new(Path.Combine(Environment.SystemDirectory, "cmd.exe")) { UseShellExecute = false, CreateNoWindow = true };
gone.ArgumentList.Add("/c");
gone.ArgumentList.Add("exit 0");
using Process parent = Process.Start(gone) ?? throw new InvalidOperationException("The process that stands for the parent did not start.");
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(15));
await parent.WaitForExitAsync(timeout.Token);
ProcessStartInfo start = new(FixtureExecutable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true };
start.Environment[PrinterWorkerHost.ParentEnvironmentVariable] = parent.Id.ToString(CultureInfo.InvariantCulture);
using Process worker = Process.Start(start) ?? throw new InvalidOperationException("Fixture launch failed.");
try
{
await worker.WaitForExitAsync(timeout.Token);
Assert.Equal(2, worker.ExitCode);
Assert.Equal("", await worker.StandardOutput.ReadToEndAsync(timeout.Token));
}
finally
{
if(!worker.HasExited)
{
worker.Kill(entireProcessTree: true);
}
}
}
/// <summary>True when the process has exited or no longer exists; a missing process id counts as exited.</summary>
private static bool ProcessExited(int processId)
{
@@ -0,0 +1,196 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The bound on the print helper's one request: at most 1 MiB characters, counted while the line is read, so a request
/// that never ends is refused after a bounded amount of reading and is never held in memory whole. The helper loop runs
/// inside the test process over readers that hand out text the way a pipe does.
/// </summary>
public sealed class PrinterWorkerRequestBoundTests
{
/// <summary>A request of exactly the bound is read and carried out; one character more is refused and nothing is carried out.</summary>
[Theory]
[InlineData(0, true)]
[InlineData(1, false)]
public async Task RequestIsBoundedAtOneMebibyteOfCharacters(int over, bool accepted)
{
string request = Padded(PrinterWorkerHost.MaximumRequestCharacters + over);
using StringReader input = new(request + "\n");
(int exit, PrinterWorkerHost.Response response, CountingPrinters printers) = await RunAsync(input);
Assert.Equal(1_048_576, PrinterWorkerHost.MaximumRequestCharacters);
Assert.Equal(accepted ? 0 : 1, exit);
Assert.Equal(accepted, response.Success);
Assert.Equal(accepted ? null : "Invalid printer worker request size.", response.Error);
Assert.Equal(accepted ? 1 : 0, printers.Discoveries);
}
/// <summary>
/// A request that never ends is refused while it is being read: the helper stops asking for more once the bound is
/// passed, having taken at most one more piece (4,096 characters) than a request may have. Before the bound was counted
/// during reading, this input would have been read without end.
/// </summary>
[Fact]
public async Task EndlessRequestIsRefusedWhileItIsRead()
{
using EndlessReader input = new();
(int exit, PrinterWorkerHost.Response response, CountingPrinters printers) = await RunAsync(input);
Assert.Equal(1, exit);
Assert.Equal((false, "Invalid printer worker request size."), (response.Success, response.Error));
Assert.InRange(input.Handed, PrinterWorkerHost.MaximumRequestCharacters + 1, PrinterWorkerHost.MaximumRequestCharacters + 4096);
Assert.Equal(0, printers.Discoveries);
}
/// <summary>A request that arrives a character at a time, as it can from a pipe, is put together and carried out.</summary>
[Fact]
public async Task RequestArrivingInPiecesIsReadWhole()
{
using TricklingReader input = new("{\"operation\":\"discover\"}\n");
(int exit, PrinterWorkerHost.Response response, CountingPrinters printers) = await RunAsync(input);
Assert.Equal((0, true, 1), (exit, response.Success, printers.Discoveries));
Assert.True(input.Reads >= 25);
}
/// <summary>
/// The request is the first line, whichever way the line ends (line feed, carriage return and line feed, a bare carriage
/// return, or the end of the input), and nothing after it is used: a helper carries out exactly one request. The last
/// two cases are the ones that show a bare carriage return ends the line: were it only white space inside the line,
/// the text after it would be part of the request, which would then not be one JSON value and would be refused.
/// </summary>
[Theory]
[InlineData("\n")]
[InlineData("\r\n")]
[InlineData("\r")]
[InlineData("")]
[InlineData("\n{\"operation\":\"cancel\"}\n")]
[InlineData("\nnot a request at all")]
[InlineData("\r{\"operation\":\"cancel\"}\n")]
[InlineData("\rnot a request at all")]
public async Task RequestIsTheFirstLineHoweverItEnds(string ending)
{
using StringReader input = new("{\"operation\":\"discover\"}" + ending);
(int exit, PrinterWorkerHost.Response response, CountingPrinters printers) = await RunAsync(input);
Assert.Equal((0, true, 1), (exit, response.Success, printers.Discoveries));
}
/// <summary>An empty first line is a request with nothing in it, which is not JSON: it is answered with a failure, as before, and is not mistaken for input that ended.</summary>
[Fact]
public async Task EmptyFirstLineIsNotARequest()
{
using StringReader input = new("\n{\"operation\":\"discover\"}\n");
(int exit, PrinterWorkerHost.Response response, CountingPrinters printers) = await RunAsync(input);
Assert.Equal((1, false, 0), (exit, response.Success, printers.Discoveries));
Assert.NotEqual("Invalid printer worker request size.", response.Error, StringComparer.Ordinal);
}
/// <summary>Runs the helper loop over the given input with a backend that only counts, and returns the exit code, the single answer and the backend.</summary>
private static async Task<(int Exit, PrinterWorkerHost.Response Response, CountingPrinters Printers)> RunAsync(TextReader input)
{
CountingPrinters printers = new();
using StringWriter output = new();
int exit = await PrinterWorkerHost.RunAsync(printers, new FakeSpoolerJobs(), new FakeParentWatch(), "4242", input, output);
string line = Assert.Single(output.ToString().Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries));
return (exit, Assert.IsType<PrinterWorkerHost.Response>(JsonSerializer.Deserialize<PrinterWorkerHost.Response>(line, PrintApiClient.JsonOptions)), printers);
}
/// <summary>A discover request padded with an ignored member to exactly the given number of characters.</summary>
private static string Padded(int length)
{
const string start = "{\"operation\":\"discover\",\"jobId\":\"";
const string end = "\"}";
return start + new string('a', length - start.Length - end.Length) + end;
}
/// <summary>A printer backend with no printers that counts how often it was asked for its inventory.</summary>
private sealed class CountingPrinters : IPrinterBackend
{
/// <summary>How often discovery was asked for.</summary>
internal int Discoveries { get; private set; }
/// <summary>Counts the call and returns an empty inventory.</summary>
public IReadOnlyList<PrinterInfo> Discover()
{
Discoveries++;
return [];
}
/// <summary>Never reached by these tests; it has nothing to print on.</summary>
public Task<string> PrintFileAsync(PrintJob job, string path, TimeSpan submissionTimeout, CancellationToken cancellationToken)
=> throw new InvalidOperationException("This backend has no printer.");
}
/// <summary>Input that never ends and never has a line end: every read is filled with the letter x. It counts what it handed out.</summary>
private sealed class EndlessReader : TextReader
{
/// <summary>How many characters were handed out so far.</summary>
internal long Handed { get; private set; }
/// <summary>Fills the whole request with the letter x.</summary>
public override int Read(char[] buffer, int index, int count)
{
Array.Fill(buffer, 'x', index, count);
Handed += count;
return count;
}
}
/// <summary>Input that hands out one character for each read, however many were asked for, and counts the reads.</summary>
private sealed class TricklingReader(string text) : TextReader
{
private int _position;
/// <summary>How many reads were made.</summary>
internal int Reads { get; private set; }
/// <summary>Hands out the next character, or nothing at the end.</summary>
public override int Read(char[] buffer, int index, int count)
{
Reads++;
if(_position == text.Length || count == 0)
{
return 0;
}
buffer[index] = text[_position++];
return 1;
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A made-up document staged as a real file in a temporary folder of its own, the way the service hands a verified
/// download to the printer adapter, with the job that describes it. The folder is deleted when the test is done. The
/// bytes are synthetic and are never sent to a printer.
/// </summary>
internal sealed class StagedDocument : IDisposable
{
private readonly string _directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "qqq-staged-document-" + Guid.NewGuid().ToString("N"));
/// <summary>Writes the bytes to a new file in a new temporary folder.</summary>
internal StagedDocument(byte[] content)
{
Content = content;
Directory.CreateDirectory(_directory);
Path = System.IO.Path.Combine(_directory, "document.bin");
File.WriteAllBytes(Path, content);
}
/// <summary>The absolute path of the staged file.</summary>
internal string Path { get; }
/// <summary>The bytes that were staged.</summary>
internal byte[] Content { get; }
/// <summary>A job for these exact bytes (their length and SHA-256) on the given printer, in the given format.</summary>
internal PrintJob Job(string printerId, string format, PrintOptions? options = null)
=> new("job-1", printerId, "Synthetic document", format, Convert.ToHexString(SHA256.HashData(Content)), Content.Length, options ?? new PrintOptions(), "2026-11-01T00:00:00Z", "QUEUED");
/// <summary>Deletes the temporary folder and the file in it.</summary>
public void Dispose() => Directory.Delete(_directory, recursive: true);
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The printer adapter's refusal of a link where the staged file should be. The link is a directory junction inside the
/// test's own temporary folder, the one kind of link Windows lets any user create, made with the system's mklink and
/// removed afterwards. Windows printing is a fake, and nothing is asked of it.
/// </summary>
public sealed class StagedFileLinkTests
{
/// <summary>A staging path that is a link is refused before the file is opened, hashed or printed, and before Windows is asked anything.</summary>
[Fact]
public async Task LinkInPlaceOfTheStagedFileIsRefusedBeforeItIsOpened()
{
FakeWindowsPrinting windows = new();
windows.Add(new FakeQueue("Synthetic Zebra queue", "ZDesigner ZT411-300dpi ZPL", "USB001"));
WindowsPrinters printers = new(windows, windows, windows, new FakePdfRenderer(windows.Calls));
using StagedDocument staged = new(Encoding.ASCII.GetBytes("^XA^FDsynthetic^FS^XZ"));
string link = staged.Path + ".link";
CreateJunction(link, Path.GetDirectoryName(staged.Path)!);
try
{
Assert.True(File.GetAttributes(link).HasFlag(FileAttributes.ReparsePoint));
InvalidDataException refusal = await Assert.ThrowsAsync<InvalidDataException>(() => printers.PrintFileAsync(staged.Job("6119358ca3e4aafc30d6a3da", "ZPL"), link, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal("The worker requires a regular, absolute staging file.", refusal.Message);
Assert.Empty(windows.Calls);
}
finally
{
Directory.Delete(link);
}
}
/// <summary>Creates a directory junction with the system's own mklink, which needs no special right, and requires it to succeed.</summary>
private static void CreateJunction(string link, string target)
{
ProcessStartInfo start = new(Path.Combine(Environment.SystemDirectory, "cmd.exe")) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
foreach(string argument in new[] { "/d", "/c", "mklink", "/J", link, target })
{
start.ArgumentList.Add(argument);
}
using Process command = Process.Start(start) ?? throw new InvalidOperationException("The junction could not be created.");
_ = command.StandardOutput.ReadToEnd();
string error = command.StandardError.ReadToEnd();
command.WaitForExit();
Assert.True(command.ExitCode == 0, error);
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The marker the printing fakes leave as the thread's last error at the start of every call that stands for a native
/// import declared with SetLastError. Such an import replaces the thread's last error each time it is called, whether it
/// succeeds or fails, so an error is only there to be read straight after the call that failed. The fakes behave the
/// same way: a call that fails the way a test arranged then sets the arranged code, and every other such call leaves
/// this marker. A rule that reads the error one call late (after a close, an abort or any other call that records an
/// error) therefore gets the marker and not the arranged code, and the test that expects the code fails. A fake member
/// that stands for an import without SetLastError does not touch the last error at all, as the real call does not.
/// </summary>
internal static class StaleLastError
{
/// <summary>
/// 0x0BADC0DE (195,936,478). No test arranges it as a Windows error; seeing it as the code of a failure means the error
/// was read after another call had replaced it, or was read for a call that records no error.
/// </summary>
internal const int Code = 0x0BAD_C0DE;
/// <summary>Replaces the thread's last error with the marker, as the start of a call that records its error replaces whatever an earlier call left.</summary>
internal static void Leave() => Marshal.SetLastPInvokeError(Code);
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Builds the driver settings (DEVMODE) the fake queues hand out. The bytes are made up: a 220-byte public part and four
/// private bytes, with only the fields the printing rules read filled in. No driver was asked for them.
/// </summary>
internal static class SyntheticDevMode
{
/// <summary>The fields flag for orientation.</summary>
internal const uint Orientation = 0x1;
/// <summary>The fields flag for a paper identifier.</summary>
internal const uint PaperSize = 0x2;
/// <summary>The fields flags for an explicit paper length and width.</summary>
internal const uint PaperDimensions = 0x4 | 0x8;
/// <summary>The fields flag for driver scaling.</summary>
internal const uint Scale = 0x10;
/// <summary>The fields flag for driver copies.</summary>
internal const uint Copies = 0x100;
/// <summary>The fields flags for an explicit horizontal and vertical resolution.</summary>
internal const uint Resolution = 0x400 | 0x2000;
/// <summary>
/// A portrait label 101.6 mm wide and the given length (in tenths of a millimeter) long, with explicit dimensions and no
/// explicit resolution, so the resolution has to come from a device context.
/// </summary>
internal static byte[] Label(short length = 1524)
{
byte[] mode = new byte[224];
BinaryPrimitives.WriteUInt16LittleEndian(mode.AsSpan(68), 220);
BinaryPrimitives.WriteUInt16LittleEndian(mode.AsSpan(70), 4);
BinaryPrimitives.WriteUInt32LittleEndian(mode.AsSpan(72), Orientation | PaperDimensions);
BinaryPrimitives.WriteInt16LittleEndian(mode.AsSpan(76), 1);
BinaryPrimitives.WriteInt16LittleEndian(mode.AsSpan(80), length);
BinaryPrimitives.WriteInt16LittleEndian(mode.AsSpan(82), 1016);
mode[^1] = 0x5a;
return mode;
}
/// <summary>Replaces the fields flags and returns the same settings, so calls can be chained.</summary>
internal static byte[] WithFields(this byte[] mode, uint fields)
{
BinaryPrimitives.WriteUInt32LittleEndian(mode.AsSpan(72), fields);
return mode;
}
/// <summary>Writes one 16-bit setting at its offset and returns the same settings, so calls can be chained.</summary>
internal static byte[] WithShort(this byte[] mode, int offset, short value)
{
BinaryPrimitives.WriteInt16LittleEndian(mode.AsSpan(offset), value);
return mode;
}
/// <summary>Reads the fields flags.</summary>
internal static uint Fields(byte[] mode) => BinaryPrimitives.ReadUInt32LittleEndian(mode.AsSpan(72));
/// <summary>Reads one 16-bit setting at its offset.</summary>
internal static short Short(byte[] mode, int offset) => BinaryPrimitives.ReadInt16LittleEndian(mode.AsSpan(offset));
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Printer discovery run against a Windows that exists only in memory: which queues are listed, how each is identified
/// and classified, which formats it is offered with, and what a failing spooler turns into. Each test asserts the calls
/// sent and their order, which is the evidence that discovery reaches Windows the way it did before the calls were moved
/// behind <see cref="ISpooler"/>. No print queue on this computer is listed or opened.
/// </summary>
public sealed class WindowsPrintersDiscoveryTests
{
/// <summary>A spooler with nothing installed answers the sizing call with success and no size; discovery returns an empty list without a second call.</summary>
[Fact]
public void NothingInstalled_IsAnEmptyInventoryFromOneCall()
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
Assert.Empty(printers.Discover());
Assert.Equal(["EnumPrintersSize(6)"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>
/// Local queues and connections (flags 6) are listed in spooler order. A label queue with a ZDesigner driver is offered
/// PDF and ZPL with its default media; a busy laser queue without readable defaults is offered PDF, is READY and has no
/// media; a print-to-PDF queue and a FILE: port are UNKNOWN with no formats whatever their status, and their settings are
/// never read. The identifier is the first 24 hexadecimal digits of the SHA-256 of the queue name.
/// </summary>
[Fact]
public void QueuesAreListedIdentifiedAndClassified()
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
byte[] defaults = SyntheticDevMode.Label();
windows.Add(new FakeQueue("Synthetic Zebra queue", "ZDesigner ZT411-300dpi ZPL", "USB001") { MachineDefaults = defaults }.WithContext(1200, 1800));
windows.Add(new FakeQueue("Synthetic laser queue", "Synthetic laser driver", "192.0.2.10") { Status = 0x400 });
windows.Add(new FakeQueue("Document writer (synthetic)", "Microsoft Print To PDF", "PORTPROMPT:") { MachineDefaults = defaults });
windows.Add(new FakeQueue("Print to file (synthetic)", "Generic / Text Only", "FILE:") { Status = 0x80, MachineDefaults = defaults });
IReadOnlyList<PrinterInfo> found = printers.Discover();
Assert.Equal(["6119358ca3e4aafc30d6a3da", "f00632792c722747d33552e2", "6c32da906f525a7720e3104d", "bc61693d292c6ddf47a614c6"], found.Select(printer => printer.PrinterId), StringComparer.Ordinal);
Assert.Equal(["Synthetic Zebra queue", "Synthetic laser queue", "Document writer (synthetic)", "Print to file (synthetic)"], found.Select(printer => printer.Name), StringComparer.Ordinal);
Assert.Equal(["ZDesigner ZT411-300dpi ZPL", "Synthetic laser driver", "Microsoft Print To PDF", "Generic / Text Only"], found.Select(printer => printer.DriverName), StringComparer.Ordinal);
Assert.Equal(["USB001", "192.0.2.10", "PORTPROMPT:", "FILE:"], found.Select(printer => printer.PortName), StringComparer.Ordinal);
Assert.Equal(["READY", "READY", "UNKNOWN", "UNKNOWN"], found.Select(printer => printer.Status), StringComparer.Ordinal);
Assert.Equal(["PDF,ZPL", "PDF", "", ""], found.Select(printer => string.Join(',', printer.Formats)), StringComparer.Ordinal);
Assert.Equal(WindowsPrinters.BuildMediaProfile("6119358ca3e4aafc30d6a3da", "ZDesigner ZT411-300dpi ZPL", defaults, 101600, 152400, 300, 300), Assert.Single(found[0].MediaProfiles!));
Assert.All(found.Skip(1), printer => Assert.Empty(printer.MediaProfiles!));
Assert.Equal(
[
"EnumPrintersSize(6)", "EnumPrinters(6, 544)",
"OpenPrinter(Synthetic Zebra queue)", "GetPrinterSize(0x201, 8)", "GetPrinter(0x201, 8, 232)", "ClosePrinter(0x201)",
"CreateDC(WINSPOOL, Synthetic Zebra queue, 224 bytes)", "GetDeviceCaps(0xd01, 88)", "GetDeviceCaps(0xd01, 90)", "DeleteDC(0xd01)",
"OpenPrinter(Synthetic laser queue)", "GetPrinterSize(0x202, 8)", "ClosePrinter(0x202)",
], windows.Calls, StringComparer.Ordinal);
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>A queue that can print unattended keeps the health word its spooler status maps to, so the operator sees why it is unavailable.</summary>
[Theory]
[InlineData(0x80u, "OFFLINE")]
[InlineData(0x1u, "PAUSED")]
[InlineData(0x8u, "ERROR")]
[InlineData(0x800000u, "UNKNOWN")]
[InlineData(0x400u, "READY")]
public void UnattendedQueueKeepsItsHealth(uint status, string expected)
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.Add(new FakeQueue("Synthetic laser queue") { Status = status });
PrinterInfo found = Assert.Single(printers.Discover());
Assert.Equal(expected, found.Status);
Assert.Equal(["PDF"], found.Formats, StringComparer.Ordinal);
}
/// <summary>ZPL is offered only when the queue name or driver names Zebra, or the driver is a ZDesigner one, in any letter case; every other unattended queue gets PDF alone.</summary>
[Theory]
[InlineData("Warehouse ZEBRA 3", "Generic / Text Only", true)]
[InlineData("Dock label 1", "zebra technologies zt230", true)]
[InlineData("Dock label 1", "ZDesigner GK420d", true)]
[InlineData("Dock label 1", "zdesigner gk420d", true)]
[InlineData("Dock label 1", "Synthetic label driver", false)]
[InlineData("Front office", "Synthetic laser driver", false)]
public void ZplIsOfferedOnlyToZebraQueues(string name, string driver, bool zpl)
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.Add(new FakeQueue(name, driver));
Assert.Equal(zpl ? ["PDF", "ZPL"] : new[] { "PDF" }, Assert.Single(printers.Discover()).Formats, StringComparer.Ordinal);
}
/// <summary>A queue whose driver needs a person (XPS, OneNote, fax) is never offered a format, whatever its port.</summary>
[Theory]
[InlineData("Microsoft XPS Document Writer v4")]
[InlineData("Send to Microsoft OneNote 16 Driver")]
[InlineData("Microsoft Shared Fax Driver")]
public void DriverThatNeedsAPersonIsNeverOfferedAFormat(string driver)
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.Add(new FakeQueue("Synthetic interactive queue", driver, "USB001") { MachineDefaults = SyntheticDevMode.Label() });
PrinterInfo found = Assert.Single(printers.Discover());
Assert.Equal("UNKNOWN", found.Status);
Assert.Empty(found.Formats);
Assert.Empty(found.MediaProfiles!);
Assert.Equal(["EnumPrintersSize(6)", "EnumPrinters(6, 136)"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>A queue the spooler lists without a name, driver or port is reported with empty strings and the identifier of the empty name; nothing is null.</summary>
[Fact]
public void NamesTheSpoolerLeftOutBecomeEmptyStrings()
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.Add(new FakeQueue(null, null, null));
PrinterInfo found = Assert.Single(printers.Discover());
Assert.Equal(("e3b0c44298fc1c149afbf4c8", "", "", ""), (found.PrinterId, found.Name, found.DriverName, found.PortName));
Assert.Equal(["PDF"], found.Formats, StringComparer.Ordinal);
Assert.Equal(["EnumPrintersSize(6)", "EnumPrinters(6, 136)", "OpenPrinter()"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>
/// A sizing call that fails for any reason but "buffer too small with a size" is thrown with its Windows error and the
/// list is never read, so a broken spooler is not mistaken for an empty inventory that would withdraw every printer.
/// </summary>
[Theory]
[InlineData(1722, 0u)]
[InlineData(5, 0u)]
[InlineData(5, 512u)]
[InlineData(122, 0u)]
public void FailedSizingCall_IsThrownAndTheListIsNeverRead(int error, uint reported)
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.Add(new FakeQueue("Synthetic laser queue"));
windows.ReportedListSize = reported;
windows.Fail("EnumPrintersSize", error);
Win32Exception failure = Assert.Throws<Win32Exception>(() => printers.Discover());
Assert.Equal(error, failure.NativeErrorCode);
Assert.Equal(["EnumPrintersSize(6)"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>A list read that fails after a good sizing call is thrown with its Windows error, and no queue is opened.</summary>
[Fact]
public void FailedListRead_IsThrownWithItsWindowsError()
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.Add(new FakeQueue("Synthetic laser queue"));
windows.Fail("EnumPrinters", 1722);
Win32Exception failure = Assert.Throws<Win32Exception>(() => printers.Discover());
Assert.Equal(1722, failure.NativeErrorCode);
Assert.Equal(["EnumPrintersSize(6)", "EnumPrinters(6, 136)"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>A size no buffer can have is an overflow before the list is read, not a truncated request.</summary>
[Fact]
public void ImpossibleListSize_IsRefusedBeforeTheListIsRead()
{
(WindowsPrinters printers, FakeWindowsPrinting windows) = Setup();
windows.ReportedListSize = 2_147_483_648;
Assert.Throws<OverflowException>(() => printers.Discover());
Assert.Equal(["EnumPrintersSize(6)"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>Builds the rules over a fake Windows with no queues yet.</summary>
private static (WindowsPrinters Printers, FakeWindowsPrinting Windows) Setup()
{
FakeWindowsPrinting windows = new();
return (new WindowsPrinters(windows, windows, windows, new FakePdfRenderer(windows.Calls)), windows);
}
}
@@ -0,0 +1,589 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Text;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Printing a staged PDF, run against a Windows and a PDF renderer that exist only in memory: the device context a
/// document is built on, the media check before it starts, where and how large each page is drawn, the copy and page
/// loops, and the cleanup on each failure. Each test asserts the calls sent and their order with their arguments, which
/// is the evidence that what reaches GDI and the spooler is what it was before the calls were moved behind
/// <see cref="IGdiPrinter"/> and <see cref="IPdfRenderer"/>. The staged files are synthetic; nothing is rendered or printed.
/// </summary>
public sealed class WindowsPrintersPdfTests
{
private const string Queue = "Synthetic label queue";
private const string Driver = "Synthetic label driver";
private const string PrinterId = "72a900462ca6a6e18ecb8fb2";
private const string LabelDrawn = "0, 0, 1200, 1800, 0, 0, 600, 900, 4 bytes, 600x-900 planes 1 bits 32";
/// <summary>What discovery sends for the test queue when it has no machine defaults.</summary>
private static readonly string[] Discovery = ["EnumPrintersSize(6)", "EnumPrinters(6, 136)", $"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "ClosePrinter(0x201)"];
/// <summary>What discovery sends for the test queue when it has machine defaults: the level 8 read, then the resolution from a context built on them.</summary>
private static readonly string[] DiscoveryWithDefaults =
[
"EnumPrintersSize(6)", "EnumPrinters(6, 136)", $"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "GetPrinter(0x201, 8, 232)", "ClosePrinter(0x201)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd01, 88)", "GetDeviceCaps(0xd01, 90)", "DeleteDC(0xd01)",
];
private static readonly byte[] Content = Encoding.ASCII.GetBytes("%PDF-synthetic; the fake renderer never reads this");
/// <summary>
/// A job without a media selection prints on the queue's current defaults (a context created with no settings), as one
/// document named "QQQ" plus the job identifier. Each copy is every page in order; a page is taken, rendered, drawn
/// between StartPage and EndPage and released before the next one. The receipt is the spooler's job number.
/// </summary>
[Fact]
public async Task UnselectedPdfPrintsEveryPageOfEveryCopyInOneDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages.Add((384, 576));
using StagedDocument staged = new(Content);
string receipt = await printers.PrintFileAsync(staged.Job(PrinterId, "PDF", new PrintOptions(Copies: 2)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal("7301", receipt);
Assert.Equal(
[
.. Discovery, "LoadPdf", $"CreateDC(WINSPOOL, {Queue}, null)", "StartDoc(0xd01, QQQ job-1)",
.. Page("0xd01", 0, "600, 900", LabelDrawn), .. Page("0xd01", 1, "600, 900", LabelDrawn),
.. Page("0xd01", 0, "600, 900", LabelDrawn), .. Page("0xd01", 1, "600, 900", LabelDrawn),
"EndDoc(0xd01)", "DeleteDC(0xd01)",
], windows.Calls, StringComparer.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.Equal((Queue, "QQQ job-1", 7301, 4, 4), (document.Queue, document.Name, document.Job, document.PagesStarted, document.PagesEnded));
Assert.Equal([0, 1, 0, 1], document.Drawn.Select(bits => (int)bits[0]));
Assert.True(document.Ended);
Assert.False(document.Aborted);
Assert.Null(Assert.Single(windows.ContextModes));
Assert.Equal((4, 4), (pdf.PagesTaken, pdf.PagesReleased));
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// At actual size a page is drawn at its true physical size for the context's own resolution, moved up and left by the
/// context's unprintable margin so the page origin is the paper edge. Any scale word but FIT, in exact letter case,
/// means actual size.
/// </summary>
[Theory]
[InlineData("ACTUAL_SIZE")]
[InlineData("fit")]
[InlineData("SHRINK")]
public async Task ActualSizeUsesTheContextsResolutionAndPaperEdge(string scale)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _, FakeQueue queue) = Setup();
_ = queue.WithContext(812, 1218, 203, 203);
queue.DeviceCaps[112] = 12;
queue.DeviceCaps[113] = 9;
using StagedDocument staged = new(Content);
await printers.PrintFileAsync(staged.Job(PrinterId, "PDF", new PrintOptions(Scale: scale)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal(Page("0xd01", 0, "600, 900", "-12, -9, 812, 1218, 0, 0, 600, 900, 4 bytes, 600x-900 planes 1 bits 32"), windows.Calls.Skip(8).Take(11), StringComparer.Ordinal);
}
/// <summary>
/// FIT asks the context for its printable area, scales the page by the smaller of the two ratios (down for a page that
/// is too large, up for one that is smaller) and centers it in that area.
/// </summary>
[Theory]
[InlineData(768, 1152, "1200, 1800")]
[InlineData(192, 288, "300, 450")]
public async Task FitScalesToThePrintableAreaAndCenters(double width, double height, string rendered)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, FakeQueue queue) = Setup();
pdf.Pages[0] = (width, height);
queue.DeviceCaps[10] = 2000;
using StagedDocument staged = new(Content);
await printers.PrintFileAsync(staged.Job(PrinterId, "PDF", new PrintOptions(Scale: "FIT")), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
string drawn = $"0, 100, 1200, 1800, 0, 0, {rendered}, 4 bytes, {rendered.Replace(", ", "x-", StringComparison.Ordinal)} planes 1 bits 32";
Assert.Equal(Page("0xd01", 0, rendered, drawn, fit: true), windows.Calls.Skip(8).Take(13), StringComparer.Ordinal);
}
/// <summary>
/// The rendered bitmap is bounded whatever the document says: at most 150 dpi equivalent, at most 5,000 pixels on the
/// long side, and never less than one pixel (the second page here would be 0.78 of a pixel). The bitmap is drawn with
/// the size the renderer reports, not the size asked.
/// </summary>
[Fact]
public async Task RenderedBitmapIsBoundedAndDrawnAtItsReportedSize()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages[0] = (8192, 4096);
pdf.Pages.Add((0.5, 0.5));
pdf.ReportedSize = (601, 899);
using StagedDocument staged = new(Content);
await printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal(["RenderPage(0, 5000, 2500)", "RenderPage(1, 1, 1)"], windows.Calls.Where(call => call.StartsWith("RenderPage", StringComparison.Ordinal)), StringComparer.Ordinal);
Assert.Equal(
[
"StretchDIBits(0xd01, 0, 0, 25600, 12800, 0, 0, 601, 899, 4 bytes, 601x-899 planes 1 bits 32, 0, 0xcc0020)",
"StretchDIBits(0xd01, 0, 0, 1, 1, 0, 0, 601, 899, 4 bytes, 601x-899 planes 1 bits 32, 0, 0xcc0020)",
], windows.Calls.Where(call => call.StartsWith("StretchDIBits", StringComparison.Ordinal)), StringComparer.Ordinal);
}
/// <summary>
/// A page too small to cover one device pixel (a hundredth of an inch square at 300 dpi comes to 0.3 of a pixel) is
/// still sent to GDI, with a destination that has no width and no height. The fake answers 0 for such a destination,
/// which is what StretchDIBits is documented to return when it copies no row, and the rules take 0 as a failure: the
/// job fails with a Windows error, the document that was started is aborted and nothing stays queued. The code of that
/// error is whatever StretchDIBits left behind and is not asserted, because the fake arranges none.
/// </summary>
[Fact]
public async Task PageSmallerThanOneDevicePixelFailsTheJobAndAbortsTheDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages[0] = (0.1, 0.1);
using StagedDocument staged = new(Content);
await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(
[
"StartPage(0xd01)", "SetStretchBltMode(0xd01, 4)", "StretchDIBits(0xd01, 0, 0, 0, 0, 0, 0, 1, 1, 4 bytes, 1x-1 planes 1 bits 32, 0, 0xcc0020)",
"DisposePage(0)", "AbortDoc(0xd01)", "DeleteDC(0xd01)",
], windows.Calls.TakeLast(6), StringComparer.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.Equal((true, false, 1, 0), (document.Aborted, document.Ended, document.PagesStarted, document.PagesEnded));
Assert.Empty(document.Drawn);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// A document the renderer loads with no pages in it is not refused. It is started and ended as one document that holds
/// no page, once, however many copies were asked for, and the job gets that document's receipt; nothing is drawn and
/// nothing is aborted. The service therefore records such a job as spooled.
/// </summary>
[Fact]
public async Task PdfWithoutPagesIsStartedAndEndedAsOneEmptyDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages.Clear();
using StagedDocument staged = new(Content);
string receipt = await printers.PrintFileAsync(staged.Job(PrinterId, "PDF", new PrintOptions(Copies: 2)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal("7301", receipt);
Assert.Equal([.. Discovery, "LoadPdf", $"CreateDC(WINSPOOL, {Queue}, null)", "StartDoc(0xd01, QQQ job-1)", "EndDoc(0xd01)", "DeleteDC(0xd01)"], windows.Calls, StringComparer.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.Equal((0, 0, true, false), (document.PagesStarted, document.PagesEnded, document.Ended, document.Aborted));
Assert.Equal(0, pdf.PagesTaken);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// The renderer is handed the staged file from its first byte. The file has just been read to its end to check its
/// SHA-256, so it is put back to the start before it is handed over; the fake renderer reads the stream from where it
/// stands when it gets it, and what it reads is the whole file.
/// </summary>
[Fact]
public async Task RendererIsHandedTheStagedFileFromItsFirstByte()
{
(WindowsPrinters printers, _, FakePdfRenderer pdf, _) = Setup();
using StagedDocument staged = new(Content);
await printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal(Content, pdf.Handed);
}
/// <summary>
/// Each axis of a page is sized with its own resolution. On a context that reports 203 dpi across and 300 dpi down, a
/// 4 by 6 inch page is drawn 812 pixels wide and 1,800 pixels high; with the two resolutions the wrong way round it
/// would be 1,200 by 1,218.
/// </summary>
[Fact]
public async Task PageIsSizedAcrossWithTheHorizontalResolutionAndDownWithTheVertical()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _, FakeQueue queue) = Setup();
_ = queue.WithContext(812, 1800, 203, 300);
using StagedDocument staged = new(Content);
await printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal(Page("0xd01", 0, "600, 900", "0, 0, 812, 1800, 0, 0, 600, 900, 4 bytes, 600x-900 planes 1 bits 32"), windows.Calls.Skip(8).Take(11), StringComparer.Ordinal);
}
/// <summary>A page whose stated size is not a positive finite number is refused before it is rendered; the document that was started is aborted and the context released.</summary>
[Theory]
[InlineData(double.NaN, 576d)]
[InlineData(384d, double.PositiveInfinity)]
[InlineData(0d, 576d)]
[InlineData(384d, -1d)]
public async Task PageWithAnInvalidSizeAbortsTheDocument(double width, double height)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages[0] = (width, height);
using StagedDocument staged = new(Content);
InvalidDataException refusal = await Assert.ThrowsAsync<InvalidDataException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal("PDF page dimensions are invalid.", refusal.Message);
Assert.Equal(["StartDoc(0xd01, QQQ job-1)", "GetPage(0)", "DisposePage(0)", "AbortDoc(0xd01)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(5), StringComparer.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.True(document.Aborted);
Assert.Equal(0, document.PagesStarted);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>A page too large to express in device pixels is an overflow, not a wrapped size: the document is aborted before any page is started.</summary>
[Fact]
public async Task PageTooLargeForTheDeviceAbortsTheDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages[0] = (1_073_741_824, 576);
using StagedDocument staged = new(Content);
await Assert.ThrowsAsync<OverflowException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(["RenderPage(0, 5000, 1)", "GetDeviceCaps(0xd01, 88)", "DisposePage(0)", "AbortDoc(0xd01)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(5), StringComparer.Ordinal);
Assert.True(Assert.Single(windows.GdiDocuments).Aborted);
}
/// <summary>
/// A job that selected the queue's default media: the selection is read again, a job-only copy of the settings is merged
/// by the driver and resolved again, the context is created from that copy (never from null), and StartDoc is sent only
/// after the context reported the selected resolution and physical page.
/// </summary>
[Fact]
public async Task SelectedMediaIsCheckedOnTheContextBeforeTheDocumentStarts()
{
byte[] defaults = SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation | SyntheticDevMode.PaperDimensions | SyntheticDevMode.Scale | SyntheticDevMode.Copies).WithShort(84, 50).WithShort(86, 3);
(WindowsPrinters printers, FakeWindowsPrinting windows, _, _) = Setup(defaults);
using StagedDocument staged = new(Content);
string receipt = await printers.PrintFileAsync(staged.Job(PrinterId, "PDF", Selection(defaults)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal("7301", receipt);
Assert.Equal(
[
.. DiscoveryWithDefaults, "LoadPdf",
$"OpenPrinter({Queue})", "GetPrinterSize(0x202, 8)", "GetPrinter(0x202, 8, 232)", "ClosePrinter(0x202)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd02, 88)", "GetDeviceCaps(0xd02, 90)", "DeleteDC(0xd02)",
$"OpenPrinter({Queue})", $"DocumentPropertiesSize(0x203, {Queue})", $"DocumentProperties(0x203, {Queue}, 224 bytes, 224 bytes, 10)", "ClosePrinter(0x203)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd03, 88)", "GetDeviceCaps(0xd03, 90)", "DeleteDC(0xd03)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd04, 88)", "GetDeviceCaps(0xd04, 90)", "GetDeviceCaps(0xd04, 110)", "GetDeviceCaps(0xd04, 111)",
"StartDoc(0xd04, QQQ job-1)", .. Page("0xd04", 0, "600, 900", LabelDrawn), "EndDoc(0xd04)", "DeleteDC(0xd04)",
], windows.Calls, StringComparer.Ordinal);
byte[] used = Assert.IsType<byte[]>(windows.ContextModes[^1]);
Assert.Equal((100, 1), (SyntheticDevMode.Short(used, 84), SyntheticDevMode.Short(used, 86)));
Assert.Equal(defaults, windows.ContextModes[0]);
Assert.True(Assert.Single(windows.GdiDocuments).Ended);
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>When the context built from the job's settings does not report the selected page, the job is refused as a media mismatch: StartDoc is never sent, nothing is aborted and the context is released.</summary>
[Fact]
public async Task SubstitutedContextNeverStartsADocument()
{
byte[] defaults = SyntheticDevMode.Label();
(WindowsPrinters printers, FakeWindowsPrinting windows, _, FakeQueue queue) = Setup(defaults);
queue.DeviceCaps[111] = 600;
using StagedDocument staged = new(Content);
PrinterMediaMismatchException refusal = await Assert.ThrowsAsync<PrinterMediaMismatchException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF", Selection(defaults)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal("The printer device context does not match the selected media dimensions and resolution.", refusal.Message);
Assert.Equal(["GetDeviceCaps(0xd04, 88)", "GetDeviceCaps(0xd04, 90)", "GetDeviceCaps(0xd04, 110)", "GetDeviceCaps(0xd04, 111)", "DeleteDC(0xd04)"], windows.Calls.TakeLast(5), StringComparer.Ordinal);
Assert.Empty(windows.GdiDocuments);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// Explicit job paper: the machine defaults are read fresh, a copy carrying the requested size is merged by the driver,
/// and the context made from it must report that physical size at its own resolution before StartDoc is sent. A driver
/// that keeps its 4 by 6 inch default is refused before StartDoc.
/// </summary>
[Theory]
[InlineData(300, true)]
[InlineData(1800, false)]
public async Task JobPaperMustBeWhatTheContextReports(int contextHeight, bool printed)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, FakeQueue queue) = Setup(SyntheticDevMode.Label());
_ = queue.WithContext(1200, contextHeight);
pdf.Pages[0] = (384, 96);
using StagedDocument staged = new(Content);
PrintJob job = staged.Job(PrinterId, "PDF", new PrintOptions(PaperWidthMm: 101.6, PaperHeightMm: 25.4));
string[] prepared =
[
.. DiscoveryWithDefaults, "LoadPdf",
$"OpenPrinter({Queue})", "GetPrinterSize(0x202, 8)", "GetPrinter(0x202, 8, 232)", "ClosePrinter(0x202)",
$"OpenPrinter({Queue})", $"DocumentPropertiesSize(0x203, {Queue})", $"DocumentProperties(0x203, {Queue}, 224 bytes, 224 bytes, 10)", "ClosePrinter(0x203)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd02, 88)", "GetDeviceCaps(0xd02, 90)",
"GetDeviceCaps(0xd02, 88)", "GetDeviceCaps(0xd02, 90)", "GetDeviceCaps(0xd02, 110)", "GetDeviceCaps(0xd02, 111)",
];
if(printed)
{
Assert.Equal("7301", await printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal([.. prepared, "StartDoc(0xd02, QQQ job-1)", .. Page("0xd02", 0, "600, 150", "0, 0, 1200, 300, 0, 0, 600, 150, 4 bytes, 600x-150 planes 1 bits 32"), "EndDoc(0xd02)", "DeleteDC(0xd02)"], windows.Calls, StringComparer.Ordinal);
}
else
{
await Assert.ThrowsAsync<PrinterMediaMismatchException>(() => printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal([.. prepared, "DeleteDC(0xd02)"], windows.Calls, StringComparer.Ordinal);
Assert.Empty(windows.GdiDocuments);
}
byte[] used = Assert.IsType<byte[]>(windows.ContextModes[^1]);
Assert.Equal((256, 254, 1016), (SyntheticDevMode.Short(used, 78), SyntheticDevMode.Short(used, 80), SyntheticDevMode.Short(used, 82)));
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// The media check before StartDoc is made per axis. The selection is 4 by 6 inches at 203 dpi across and 300 dpi down,
/// taken from settings that state both resolutions, so the context is asked only at this check. A context that reports
/// 203 across, 300 down and a physical page of 812 by 1,800 pixels starts the document. A context that reports the same
/// two resolutions the other way round (with the page that would match them that way) is a mismatch and starts nothing.
/// </summary>
[Theory]
[InlineData(203, 300, 812, 1800, true)]
[InlineData(300, 203, 1200, 1218, false)]
public async Task SelectedMediaIsCheckedWithEachAxisAgainstItsOwnResolution(int dpiX, int dpiY, int widthPixels, int heightPixels, bool printed)
{
byte[] defaults = SyntheticDevMode.Label().WithFields(SyntheticDevMode.Orientation | SyntheticDevMode.PaperDimensions | SyntheticDevMode.Resolution).WithShort(90, 203).WithShort(96, 300);
(WindowsPrinters printers, FakeWindowsPrinting windows, _, FakeQueue queue) = Setup(defaults);
_ = queue.WithContext(widthPixels, heightPixels, dpiX, dpiY);
using StagedDocument staged = new(Content);
PrintJob job = staged.Job(PrinterId, "PDF", Selection(defaults, 203, 300));
if(printed)
{
Assert.Equal("7301", await printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(
[
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd01, 88)", "GetDeviceCaps(0xd01, 90)", "GetDeviceCaps(0xd01, 110)", "GetDeviceCaps(0xd01, 111)", "StartDoc(0xd01, QQQ job-1)",
], windows.Calls.SkipWhile(call => !call.StartsWith("CreateDC", StringComparison.Ordinal)).Take(6), StringComparer.Ordinal);
Assert.Contains("StretchDIBits(0xd01, 0, 0, 812, 1800, 0, 0, 600, 900, 4 bytes, 600x-900 planes 1 bits 32, 0, 0xcc0020)", windows.Calls, StringComparer.Ordinal);
Assert.True(Assert.Single(windows.GdiDocuments).Ended);
}
else
{
PrinterMediaMismatchException refusal = await Assert.ThrowsAsync<PrinterMediaMismatchException>(() => printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal("The printer device context does not match the selected media dimensions and resolution.", refusal.Message);
Assert.Equal([$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd01, 88)", "GetDeviceCaps(0xd01, 90)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(4), StringComparer.Ordinal);
Assert.Empty(windows.GdiDocuments);
}
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// Job paper is checked per axis as well: 101.6 by 25.4 mm on a context that reports 203 dpi across and 300 dpi down
/// must be a physical page of 812 by 300 pixels, and the page is then drawn at that size.
/// </summary>
[Fact]
public async Task JobPaperIsCheckedWithEachAxisAgainstItsOwnResolution()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, FakeQueue queue) = Setup(SyntheticDevMode.Label());
_ = queue.WithContext(812, 300, 203, 300);
pdf.Pages[0] = (384, 96);
using StagedDocument staged = new(Content);
string receipt = await printers.PrintFileAsync(staged.Job(PrinterId, "PDF", new PrintOptions(PaperWidthMm: 101.6, PaperHeightMm: 25.4)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal("7301", receipt);
Assert.Contains("StretchDIBits(0xd02, 0, 0, 812, 300, 0, 0, 600, 150, 4 bytes, 600x-150 planes 1 bits 32, 0, 0xcc0020)", windows.Calls, StringComparer.Ordinal);
Assert.True(Assert.Single(windows.GdiDocuments).Ended);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>A context Windows will not create fails with its Windows error before anything is started; there is nothing to release.</summary>
[Fact]
public async Task RefusedContextStartsNothing()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _, FakeQueue queue) = Setup();
queue.CreateContextError = 1801;
using StagedDocument staged = new(Content);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(1801, failure.NativeErrorCode);
Assert.Equal([.. Discovery, "LoadPdf", $"CreateDC(WINSPOOL, {Queue}, null)"], windows.Calls, StringComparer.Ordinal);
Assert.Empty(windows.GdiDocuments);
}
/// <summary>A document Windows refuses to start (a job number of zero or less) fails with its Windows error; there is nothing to abort, and the context is released.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public async Task RefusedDocumentIsNotAbortedAndTheContextIsReleased(int answer)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _, _) = Setup();
windows.Fail("StartDoc", 1905, result: answer);
using StagedDocument staged = new(Content);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(1905, failure.NativeErrorCode);
Assert.Equal([$"CreateDC(WINSPOOL, {Queue}, null)", "StartDoc(0xd01, QQQ job-1)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(3), StringComparer.Ordinal);
Assert.Empty(windows.GdiDocuments);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// Any page call or the end of the document that Windows refuses keeps its Windows error; the document that was started
/// is aborted, so nothing partial is printed, and the context is released. A failure on a later page aborts the pages
/// before it too.
/// </summary>
[Theory]
[InlineData("StartPage", 0, 1)]
[InlineData("StartPage", -1, 1)]
[InlineData("StretchDIBits", 0, 1)]
[InlineData("StretchDIBits", -1, 1)]
[InlineData("EndPage", 0, 1)]
[InlineData("EndPage", -1, 2)]
[InlineData("EndDoc", 0, 1)]
[InlineData("EndDoc", -1, 1)]
public async Task FailureAfterTheDocumentStartedAbortsIt(string call, int answer, int occurrence)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages.Add((384, 576));
windows.Fail(call, 29, occurrence, answer);
using StagedDocument staged = new(Content);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(29, failure.NativeErrorCode);
Assert.Equal(["AbortDoc(0xd01)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(2), StringComparer.Ordinal);
Assert.StartsWith(call, windows.Calls[^(string.Equals(call, "EndDoc", StringComparison.Ordinal) ? 3 : 4)], StringComparison.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.True(document.Aborted);
Assert.False(document.Ended);
Assert.Equal(pdf.PagesTaken, pdf.PagesReleased);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>
/// A refused stretch mode aborts the document like any other page call, but the Windows error it is reported with is
/// not its own. The import of SetStretchBltMode is declared without SetLastError, so the failure carries whatever the
/// last call that records an error left, which here is the StartPage that succeeded just before; the fake's marker
/// stands for that, and the error the test arranges for the stretch mode is never seen. The code did the same before
/// the calls were moved behind <see cref="IGdiPrinter"/>.
/// </summary>
[Fact]
public async Task RefusedStretchModeAbortsTheDocumentWithAnErrorThatIsNotItsOwn()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
windows.Fail("SetStretchBltMode", 29);
using StagedDocument staged = new(Content);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(StaleLastError.Code, failure.NativeErrorCode);
Assert.Equal(["StartPage(0xd01)", "SetStretchBltMode(0xd01, 4)", "DisposePage(0)", "AbortDoc(0xd01)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(5), StringComparer.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.Equal((true, false, 1, 0), (document.Aborted, document.Ended, document.PagesStarted, document.PagesEnded));
Assert.Equal(pdf.PagesTaken, pdf.PagesReleased);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>Cancellation is checked before every page: a job cancelled after its first page is aborted and never ended.</summary>
[Fact]
public async Task CancellationBetweenPagesAbortsTheDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.Pages.Add((384, 576));
using CancellationTokenSource cancel = new();
pdf.OnGetPage = _ => cancel.Cancel();
using StagedDocument staged = new(Content);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, cancel.Token));
Assert.Equal(["EndPage(0xd01)", "DisposePage(0)", "AbortDoc(0xd01)", "DeleteDC(0xd01)"], windows.Calls.TakeLast(4), StringComparer.Ordinal);
FakeGdiDocument document = Assert.Single(windows.GdiDocuments);
Assert.Equal((1, 1, true, false), (document.PagesStarted, document.PagesEnded, document.Aborted, document.Ended));
}
/// <summary>A file the renderer cannot load fails with the renderer's own error before any context is created.</summary>
[Fact]
public async Task UnreadablePdfCreatesNoContext()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakePdfRenderer pdf, _) = Setup();
pdf.LoadFailure = new InvalidDataException("Synthetic: not a PDF.");
using StagedDocument staged = new(Content);
InvalidDataException failure = await Assert.ThrowsAsync<InvalidDataException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "PDF"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Same(pdf.LoadFailure, failure);
Assert.Equal([.. Discovery, "LoadPdf"], windows.Calls, StringComparer.Ordinal);
}
/// <summary>Builds the rules over a fake Windows with the one label queue (a 4 by 6 inch, 300 dpi context) and a fake renderer holding one 4 by 6 inch page.</summary>
private static (WindowsPrinters Printers, FakeWindowsPrinting Windows, FakePdfRenderer Pdf, FakeQueue Queue) Setup(byte[]? defaults = null)
{
FakeWindowsPrinting windows = new();
FakePdfRenderer pdf = new(windows.Calls);
FakeQueue queue = windows.Add(new FakeQueue(Queue, Driver, "USB001") { MachineDefaults = defaults }.WithContext(1200, 1800));
return (new WindowsPrinters(windows, windows, windows, pdf), windows, pdf, queue);
}
/// <summary>Options that select the profile discovery reports for the test queue with the given machine defaults, at 300 dpi both ways unless a test says otherwise.</summary>
private static PrintOptions Selection(byte[] defaults, int dpiX = 300, int dpiY = 300)
{
PrinterMediaProfile profile = WindowsPrinters.BuildMediaProfile(PrinterId, Driver, defaults, 101600, 152400, dpiX, dpiY)!;
return new PrintOptions(MediaProfile: new PrinterMediaSelection(profile.Id, profile.Revision));
}
/// <summary>
/// The calls one page costs, in order: take the page, render it, read the context's resolution and margin (and its
/// printable area for FIT), start the page, set the stretch mode to 4, draw the whole bitmap with a plain copy, end the
/// page and release it.
/// </summary>
private static string[] Page(string dc, int index, string rendered, string drawn, bool fit = false) =>
[
$"GetPage({index})", $"RenderPage({index}, {rendered})",
$"GetDeviceCaps({dc}, 88)", $"GetDeviceCaps({dc}, 90)", $"GetDeviceCaps({dc}, 112)", $"GetDeviceCaps({dc}, 113)",
.. fit ? new[] { $"GetDeviceCaps({dc}, 8)", $"GetDeviceCaps({dc}, 10)" } : [],
$"StartPage({dc})", $"SetStretchBltMode({dc}, 4)", $"StretchDIBits({dc}, {drawn}, 0, 0xcc0020)", $"EndPage({dc})", $"DisposePage({index})",
];
}
@@ -0,0 +1,364 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Text;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Printing a staged file, run against a Windows that exists only in memory: the checks made before anything is
/// spooled, and the RAW ZPL document from its first call to its last. Each test asserts the calls sent and their order
/// and, where a document was started, what the fake spooler was handed. That is the evidence that what reaches the
/// spooler (the document name, the RAW data type, the bytes, the copies, and the cleanup on each failure) is what it was
/// before the calls were moved behind <see cref="ISpooler"/>. The staged files are synthetic and nothing is printed.
/// </summary>
public sealed class WindowsPrintersRawTests
{
private const string Queue = "Synthetic Zebra queue";
private const string PrinterId = "6119358ca3e4aafc30d6a3da";
/// <summary>What discovery sends for the one test queue when it has no machine defaults; every print starts with it.</summary>
private static readonly string[] Discovery = ["EnumPrintersSize(6)", "EnumPrinters(6, 136)", $"OpenPrinter({Queue})", "GetPrinterSize(0x201, 8)", "ClosePrinter(0x201)"];
private static readonly byte[] Label = Encoding.ASCII.GetBytes("^XA^FO50,50^FDsynthetic^FS^XZ");
/// <summary>
/// A ZPL file becomes one spool document named "QQQ" plus the job identifier with data type RAW: open the queue, start
/// the document, write the bytes, end the document, close the queue. The receipt is the spooler's job number.
/// </summary>
[Fact]
public async Task ZplFileIsSpooledAsOneRawDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
using StagedDocument staged = new(Label);
PrintJob job = staged.Job(PrinterId, "ZPL") with { ContentSha256 = staged.Job(PrinterId, "ZPL").ContentSha256.ToLowerInvariant() };
string receipt = await printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal("4101", receipt);
Assert.Equal(
[
.. Discovery,
$"OpenPrinter({Queue})", "StartDocPrinter(0x202, QQQ job-1, RAW)", $"WritePrinter(0x202, {Label.Length} bytes, {Label.Length})", "EndDocPrinter(0x202)", "ClosePrinter(0x202)",
], windows.Calls, StringComparer.Ordinal);
FakeSpoolDocument document = Assert.Single(windows.RawDocuments);
Assert.Equal((Queue, "QQQ job-1", "RAW", 4101u), (document.Queue, document.Name, document.DataType, document.Job));
Assert.Equal(Label, document.Bytes.ToArray());
Assert.True(document.Ended);
Assert.False(document.Aborted);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>Every copy is the whole file again, written in pieces of at most 64 KiB inside the same document: three copies of 150,000 bytes are nine writes and 450,000 bytes.</summary>
[Fact]
public async Task EveryCopyIsTheWholeFileIn64KibPieces()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
byte[] content = Pattern(150_000);
using StagedDocument staged = new(content);
await printers.PrintFileAsync(staged.Job(PrinterId, "ZPL", new PrintOptions(Copies: 3)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
string[] copy = ["WritePrinter(0x202, 65536 bytes, 65536)", "WritePrinter(0x202, 65536 bytes, 65536)", "WritePrinter(0x202, 18928 bytes, 18928)"];
Assert.Equal([.. Discovery, $"OpenPrinter({Queue})", "StartDocPrinter(0x202, QQQ job-1, RAW)", .. copy, .. copy, .. copy, "EndDocPrinter(0x202)", "ClosePrinter(0x202)"], windows.Calls, StringComparer.Ordinal);
FakeSpoolDocument document = Assert.Single(windows.RawDocuments);
Assert.Equal([.. content, .. content, .. content], document.Bytes.ToArray());
Assert.True(document.Ended);
}
/// <summary>When the spooler takes fewer bytes than it was offered, the rest is offered again from where it stopped, so nothing is lost or repeated.</summary>
[Fact]
public async Task PartialWriteIsContinuedFromWhereTheSpoolerStopped()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
windows.WriteLimit = 1000;
byte[] content = Pattern(2500);
using StagedDocument staged = new(content);
await printers.PrintFileAsync(staged.Job(PrinterId, "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal(["WritePrinter(0x202, 2500 bytes, 2500)", "WritePrinter(0x202, 1500 bytes, 1500)", "WritePrinter(0x202, 500 bytes, 500)"], windows.Calls.Where(call => call.StartsWith("WritePrinter", StringComparison.Ordinal)), StringComparer.Ordinal);
Assert.Equal(content, Assert.Single(windows.RawDocuments).Bytes.ToArray());
}
/// <summary>The largest copy count the adapter accepts (32,767) is printed; the document holds that many copies.</summary>
[Fact]
public async Task LargestCopyCountIsAccepted()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
using StagedDocument staged = new([0x5e]);
await printers.PrintFileAsync(staged.Job(PrinterId, "ZPL", new PrintOptions(Copies: 32767)), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None);
Assert.Equal(32767, Assert.Single(windows.RawDocuments).Bytes.Length);
}
/// <summary>A written count that cannot be true (none, negative, or more than was offered) stops the document: it is aborted, the queue is closed and nothing is ended.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(1)]
public async Task ImpossibleWrittenCountAbortsTheDocument(int excess)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
windows.WrittenAnswer = taken => excess <= 0 ? excess : taken + excess;
using StagedDocument staged = new(Label);
IOException failure = await Assert.ThrowsAsync<IOException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal("Printer returned an invalid byte count.", failure.Message);
Assert.Equal([$"WritePrinter(0x202, {Label.Length} bytes, {Label.Length})", "AbortPrinter(0x202)", "ClosePrinter(0x202)"], windows.Calls.TakeLast(3), StringComparer.Ordinal);
FakeSpoolDocument document = Assert.Single(windows.RawDocuments);
Assert.True(document.Aborted);
Assert.False(document.Ended);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>A queue that cannot be opened for the document fails with its Windows error; no document is started and there is no handle to close.</summary>
[Fact]
public async Task QueueThatCannotBeOpenedStartsNoDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
windows.Fail("OpenPrinter", 5, occurrence: 2);
using StagedDocument staged = new(Label);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(5, failure.NativeErrorCode);
Assert.Equal([.. Discovery, $"OpenPrinter({Queue})"], windows.Calls, StringComparer.Ordinal);
Assert.Empty(windows.RawDocuments);
}
/// <summary>A document Windows refuses to start fails with its Windows error; there is nothing to abort, and the queue is closed.</summary>
[Fact]
public async Task RefusedDocumentIsNotAbortedAndTheQueueIsClosed()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
windows.Fail("StartDocPrinter", 1905);
using StagedDocument staged = new(Label);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(1905, failure.NativeErrorCode);
Assert.Equal([.. Discovery, $"OpenPrinter({Queue})", "StartDocPrinter(0x202, QQQ job-1, RAW)", "ClosePrinter(0x202)"], windows.Calls, StringComparer.Ordinal);
Assert.Empty(windows.RawDocuments);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>A write or the end of the document that Windows refuses keeps its Windows error; the started document is aborted, so no partial label stays queued, and the queue is closed.</summary>
[Theory]
[InlineData("WritePrinter", 2)]
[InlineData("EndDocPrinter", 3)]
public async Task FailureAfterTheDocumentStartedAbortsIt(string call, int callsBeforeTheAbort)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
windows.Fail(call, 29);
using StagedDocument staged = new(Label);
Win32Exception failure = await Assert.ThrowsAsync<Win32Exception>(() => printers.PrintFileAsync(staged.Job(PrinterId, "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(29, failure.NativeErrorCode);
string[] document = ["StartDocPrinter(0x202, QQQ job-1, RAW)", $"WritePrinter(0x202, {Label.Length} bytes, {Label.Length})", "EndDocPrinter(0x202)"];
Assert.Equal([.. Discovery, $"OpenPrinter({Queue})", .. document.Take(callsBeforeTheAbort), "AbortPrinter(0x202)", "ClosePrinter(0x202)"], windows.Calls, StringComparer.Ordinal);
FakeSpoolDocument started = Assert.Single(windows.RawDocuments);
Assert.True(started.Aborted);
Assert.False(started.Ended);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>Cancellation is checked before every write: a job cancelled after its first piece is aborted and never ended.</summary>
[Fact]
public async Task CancellationBetweenWritesAbortsTheDocument()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
using CancellationTokenSource cancel = new();
windows.WrittenAnswer = taken =>
{
cancel.Cancel();
return taken;
};
using StagedDocument staged = new(Pattern(150_000));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => printers.PrintFileAsync(staged.Job(PrinterId, "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, cancel.Token));
Assert.Equal(["StartDocPrinter(0x202, QQQ job-1, RAW)", "WritePrinter(0x202, 65536 bytes, 65536)", "AbortPrinter(0x202)", "ClosePrinter(0x202)"], windows.Calls.TakeLast(4), StringComparer.Ordinal);
Assert.True(Assert.Single(windows.RawDocuments).Aborted);
}
/// <summary>
/// A ZPL job that selected the queue's default media has that selection read again after the queue is opened and before
/// the document is started. A selection that is still current prints; one that changed is refused as a media mismatch
/// with no document started and the queue closed.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task SelectedMediaIsReadAgainJustBeforeTheDocumentStarts(bool current)
{
byte[] defaults = SyntheticDevMode.Label();
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
queue.MachineDefaults = defaults;
_ = queue.WithContext(1200, 1800);
PrinterMediaProfile profile = WindowsPrinters.BuildMediaProfile(PrinterId, queue.Driver!, current ? defaults : [.. defaults[..^1], 0x00], 101600, 152400, 300, 300)!;
using StagedDocument staged = new(Label);
PrintJob job = staged.Job(PrinterId, "ZPL", new PrintOptions(MediaProfile: new PrinterMediaSelection(profile.Id, profile.Revision)));
string[] recheck =
[
$"OpenPrinter({Queue})", "GetPrinterSize(0x203, 8)", "GetPrinter(0x203, 8, 232)", "ClosePrinter(0x203)",
$"CreateDC(WINSPOOL, {Queue}, 224 bytes)", "GetDeviceCaps(0xd02, 88)", "GetDeviceCaps(0xd02, 90)", "DeleteDC(0xd02)",
];
if(current)
{
Assert.Equal("4101", await printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal([$"OpenPrinter({Queue})", .. recheck, "StartDocPrinter(0x202, QQQ job-1, RAW)", $"WritePrinter(0x202, {Label.Length} bytes, {Label.Length})", "EndDocPrinter(0x202)", "ClosePrinter(0x202)"], windows.Calls.Skip(10), StringComparer.Ordinal);
}
else
{
await Assert.ThrowsAsync<PrinterMediaMismatchException>(() => printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal([$"OpenPrinter({Queue})", .. recheck, "ClosePrinter(0x202)"], windows.Calls.Skip(10), StringComparer.Ordinal);
Assert.Empty(windows.RawDocuments);
}
Assert.Equal(0, windows.OpenPrinters);
Assert.Equal(0, windows.OpenContexts);
}
/// <summary>A path that is not absolute, or a file whose length or SHA-256 is not the job's, is refused before Windows is asked anything, discovery included.</summary>
[Theory]
[InlineData("relative", "The worker requires a regular, absolute staging file.")]
[InlineData("length", "Staged document integrity check failed.")]
[InlineData("hash", "Staged document integrity check failed.")]
public async Task UnverifiedFileIsRefusedBeforeWindowsIsAsked(string fault, string message)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
using StagedDocument staged = new(Label);
PrintJob job = staged.Job(PrinterId, "ZPL");
string path = staged.Path;
switch(fault)
{
case "relative":
path = Path.GetFileName(staged.Path);
break;
case "length":
job = job with { ContentLength = Label.Length + 1 };
break;
default:
job = job with { ContentSha256 = new string('0', 64) };
break;
}
InvalidDataException refusal = await Assert.ThrowsAsync<InvalidDataException>(() => printers.PrintFileAsync(job, path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(message, refusal.Message);
Assert.Empty(windows.Calls);
}
/// <summary>
/// After discovery, a job is refused without a document when its copy count is outside 1 to 32,767, when the queue does
/// not offer its format (ZPL on a queue that is not a Zebra, any format on a queue that needs a person, a format in the
/// wrong letter case) or when it asks for job paper with ZPL, which carries its own dimensions.
/// </summary>
[Theory]
[InlineData("no copies", "The queue or print options are unsupported.")]
[InlineData("negative copies", "The queue or print options are unsupported.")]
[InlineData("too many copies", "The queue or print options are unsupported.")]
[InlineData("format not offered", "The queue or print options are unsupported.")]
[InlineData("format in lower case", "The queue or print options are unsupported.")]
[InlineData("queue needs a person", "The queue or print options are unsupported.")]
[InlineData("job paper with ZPL", "Job paper dimensions apply to PDF documents only; ZPL carries its own dimensions.")]
public async Task UnsupportedJobIsRefusedAfterDiscoveryWithoutADocument(string fault, string message)
{
(WindowsPrinters printers, FakeWindowsPrinting windows, FakeQueue queue) = Setup();
using StagedDocument staged = new(Label);
PrintJob job = staged.Job(PrinterId, "ZPL");
switch(fault)
{
case "no copies":
job = job with { Options = new PrintOptions(Copies: 0) };
break;
case "negative copies":
job = job with { Options = new PrintOptions(Copies: -1) };
break;
case "too many copies":
job = job with { Options = new PrintOptions(Copies: 32768) };
break;
case "format not offered":
windows.Queues[0] = new FakeQueue("Synthetic laser queue", "Synthetic laser driver");
job = job with { PrinterId = "f00632792c722747d33552e2" };
break;
case "format in lower case":
job = job with { Format = "zpl" };
break;
case "queue needs a person":
queue.Port = "FILE:";
break;
default:
job = job with { Options = new PrintOptions(PaperWidthMm: 101.6, PaperHeightMm: 25.4) };
break;
}
InvalidDataException refusal = await Assert.ThrowsAsync<InvalidDataException>(() => printers.PrintFileAsync(job, staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(message, refusal.Message);
Assert.Equal("EnumPrinters(6, 136)", windows.Calls[1]);
Assert.DoesNotContain(windows.Calls, call => call.StartsWith("StartDoc", StringComparison.Ordinal));
Assert.Empty(windows.RawDocuments);
Assert.Empty(windows.GdiDocuments);
Assert.Equal(0, windows.OpenPrinters);
}
/// <summary>A job for a printer that is not installed any more fails after discovery and starts nothing.</summary>
[Fact]
public async Task JobForAPrinterThatIsGoneStartsNothing()
{
(WindowsPrinters printers, FakeWindowsPrinting windows, _) = Setup();
using StagedDocument staged = new(Label);
await Assert.ThrowsAsync<InvalidOperationException>(() => printers.PrintFileAsync(staged.Job("000000000000000000000000", "ZPL"), staged.Path, Timeout.InfiniteTimeSpan, CancellationToken.None));
Assert.Equal(Discovery, windows.Calls, StringComparer.Ordinal);
Assert.Empty(windows.RawDocuments);
}
/// <summary>Builds the rules over a fake Windows that has one Zebra label queue without machine defaults.</summary>
private static (WindowsPrinters Printers, FakeWindowsPrinting Windows, FakeQueue Queue) Setup()
{
FakeWindowsPrinting windows = new();
FakeQueue queue = windows.Add(new FakeQueue(Queue, "ZDesigner ZT411-300dpi ZPL", "USB001"));
return (new WindowsPrinters(windows, windows, windows, new FakePdfRenderer(windows.Calls)), windows, queue);
}
/// <summary>Synthetic bytes of the given length that do not repeat at the 64 KiB piece size, so a misplaced piece would show.</summary>
private static byte[] Pattern(int length)
{
byte[] bytes = new byte[length];
for(int index = 0; index < length; index++)
{
bytes[index] = (byte)(index % 251);
}
return bytes;
}
}
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The rules of cancelling one spooler job, run against a spooler that exists only in memory: a job is deleted only when
/// its document name is exactly "QQQ" plus the job's identifier, a job that left the queue is reported as absent, every
/// Windows failure keeps its error code, and the queue handle is closed on every path. Each test also asserts the exact
/// calls sent to the spooler and their order, which is the evidence that the rules reach Windows the way they did before
/// the calls were moved behind <see cref="ISpoolerJobs"/>.
/// </summary>
public sealed class WindowsSpoolerJobsTests
{
private const string Queue = "Synthetic label queue";
private const string JobId = "job-7f3a";
private const string OwnDocument = "QQQ job-7f3a";
/// <summary>A job whose document name is exactly ours is read back, deleted with command 5 and the queue closed, in that order; nothing else in the queue is touched.</summary>
[Fact]
public void OwnedJob_IsReadBackThenDeleted()
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs().WithJob(Queue, 42, OwnDocument).WithJob(Queue, 43, "Someone else's invoice");
WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, "42");
Assert.Equal(
[
$"OpenPrinter({Queue})",
"GetJobSize(0x101, 42)",
$"GetJobDocument(0x101, 42, {FakeSpoolerJobs.DetailsSize(OwnDocument)})",
"SetJob(0x101, 42, 5)",
"ClosePrinter(0x101)",
], spooler.Calls);
Assert.Equal([43u], spooler.Queues[Queue].Keys);
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>
/// A spooler number that now belongs to another document is refused as an ownership mismatch and nothing is deleted. The
/// comparison is exact: another job's identifier, a longer identifier that starts with ours, different letter case,
/// surrounding spaces, the bare identifier and a missing name are all somebody else's document.
/// </summary>
[Theory]
[InlineData("Quarterly report.pdf")]
[InlineData("QQQ job-other")]
[InlineData("QQQ job-7f3a0")]
[InlineData("QQQ job-7f3")]
[InlineData("qqq job-7f3a")]
[InlineData("QQQ JOB-7F3A")]
[InlineData("QQQ job-7f3a ")]
[InlineData(" QQQ job-7f3a")]
[InlineData("QQQ job-7f3a")]
[InlineData("job-7f3a")]
[InlineData("")]
[InlineData(null)]
public void ReusedSpoolerNumber_IsRefusedAndNothingIsDeleted(string? document)
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs().WithJob(Queue, 42, document);
SpoolCancellationException refusal = Assert.Throws<SpoolCancellationException>(() => WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, "42"));
Assert.Equal("OWNERSHIP_MISMATCH", refusal.Outcome);
Assert.Equal("The spooler number now belongs to another document; cancellation was refused.", refusal.Message);
Assert.Equal(
[
$"OpenPrinter({Queue})",
"GetJobSize(0x101, 42)",
$"GetJobDocument(0x101, 42, {FakeSpoolerJobs.DetailsSize(document)})",
"ClosePrinter(0x101)",
], spooler.Calls);
Assert.True(spooler.Queues[Queue].ContainsKey(42));
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>A job that is no longer in the queue (Windows answers the sizing call with no size and error 87 or 1168) is reported as already absent, without a read or a delete.</summary>
[Theory]
[InlineData(87)]
[InlineData(1168)]
public void JobThatLeftTheQueue_IsReportedAsAlreadyAbsent(int error)
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs { MissingJobError = error }.WithJob(Queue, 43, OwnDocument);
SpoolCancellationException refusal = Assert.Throws<SpoolCancellationException>(() => WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, "42"));
Assert.Equal("ALREADY_ABSENT", refusal.Outcome);
Assert.Equal("The job is no longer in the Windows queue. Pages already printed cannot be recalled.", refusal.Message);
Assert.Equal([$"OpenPrinter({Queue})", "GetJobSize(0x101, 42)", "ClosePrinter(0x101)"], spooler.Calls);
Assert.True(spooler.Queues[Queue].ContainsKey(43));
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>A sizing call that fails for any other reason (access denied, spooler unreachable) is a Windows failure with its own error code, never "already absent".</summary>
[Theory]
[InlineData(5)]
[InlineData(1722)]
public void UnreadableJob_IsAWindowsFailureAndNotAnAbsentJob(int error)
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs { MissingJobError = error }.WithJob(Queue, 43, OwnDocument);
Win32Exception failure = Assert.Throws<Win32Exception>(() => WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, "42"));
Assert.Equal(error, failure.NativeErrorCode);
Assert.Equal([$"OpenPrinter({Queue})", "GetJobSize(0x101, 42)", "ClosePrinter(0x101)"], spooler.Calls);
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>A queue that cannot be opened fails with the Windows error and no further call is made; there is no handle to close.</summary>
[Fact]
public void QueueThatCannotBeOpened_FailsBeforeAnyJobIsRead()
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs { UnknownQueueError = 1801 }.WithJob(Queue, 42, OwnDocument);
Win32Exception failure = Assert.Throws<Win32Exception>(() => WindowsSpoolerJobs.Cancel(spooler, "A queue that was removed", JobId, "42"));
Assert.Equal(1801, failure.NativeErrorCode);
Assert.Equal(["OpenPrinter(A queue that was removed)"], spooler.Calls);
Assert.True(spooler.Queues[Queue].ContainsKey(42));
}
/// <summary>When the job's details cannot be read, the failure keeps its Windows error, nothing is deleted and the queue is closed.</summary>
[Fact]
public void FailedRead_DeletesNothingAndClosesTheQueue()
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs { ReadError = 5 }.WithJob(Queue, 42, OwnDocument);
Win32Exception failure = Assert.Throws<Win32Exception>(() => WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, "42"));
Assert.Equal(5, failure.NativeErrorCode);
Assert.Equal(
[
$"OpenPrinter({Queue})",
"GetJobSize(0x101, 42)",
$"GetJobDocument(0x101, 42, {FakeSpoolerJobs.DetailsSize(OwnDocument)})",
"ClosePrinter(0x101)",
], spooler.Calls);
Assert.True(spooler.Queues[Queue].ContainsKey(42));
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>When Windows refuses the delete of a job that is ours, the failure keeps its Windows error, the job stays and the queue is closed.</summary>
[Fact]
public void RefusedDelete_KeepsItsWindowsErrorAndClosesTheQueue()
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs { CommandError = 5 }.WithJob(Queue, 42, OwnDocument);
Win32Exception failure = Assert.Throws<Win32Exception>(() => WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, "42"));
Assert.Equal(5, failure.NativeErrorCode);
Assert.Equal("SetJob(0x101, 42, 5)", spooler.Calls[^2]);
Assert.Equal("ClosePrinter(0x101)", spooler.Calls[^1]);
Assert.True(spooler.Queues[Queue].ContainsKey(42));
Assert.Equal(0, spooler.OpenHandles);
}
/// <summary>An answer larger than 1 MiB is refused before any buffer of that size is asked for; an answer of exactly 1 MiB is still read.</summary>
[Fact]
public void SpoolerAnswerIsBoundedBeforeItIsRead()
{
FakeSpoolerJobs oversized = new FakeSpoolerJobs { ReportedSize = 1_048_577 }.WithJob(Queue, 42, OwnDocument);
InvalidOperationException refusal = Assert.Throws<InvalidOperationException>(() => WindowsSpoolerJobs.Cancel(oversized, Queue, JobId, "42"));
Assert.Equal("The Windows spooler response exceeded its bound.", refusal.Message);
Assert.Equal([$"OpenPrinter({Queue})", "GetJobSize(0x101, 42)", "ClosePrinter(0x101)"], oversized.Calls);
Assert.True(oversized.Queues[Queue].ContainsKey(42));
FakeSpoolerJobs atTheBound = new FakeSpoolerJobs { ReportedSize = 1_048_576 }.WithJob(Queue, 42, OwnDocument);
WindowsSpoolerJobs.Cancel(atTheBound, Queue, JobId, "42");
Assert.Equal("GetJobDocument(0x101, 42, 1048576)", atTheBound.Calls[2]);
Assert.Empty(atTheBound.Queues[Queue]);
}
/// <summary>A receipt that is not a plain unsigned number (empty, signed, spaced, fractional, hexadecimal or too large) is refused before the spooler is asked anything.</summary>
[Theory]
[InlineData("")]
[InlineData("abc")]
[InlineData("-1")]
[InlineData("+42")]
[InlineData(" 42")]
[InlineData("42 ")]
[InlineData("4.2")]
[InlineData("0x2a")]
[InlineData("4294967296")]
public void InvalidReceipt_IsRefusedBeforeTheSpoolerIsAsked(string receipt)
{
FakeSpoolerJobs spooler = new FakeSpoolerJobs().WithJob(Queue, 42, OwnDocument);
InvalidOperationException refusal = Assert.Throws<InvalidOperationException>(() => WindowsSpoolerJobs.Cancel(spooler, Queue, JobId, receipt));
Assert.Equal("This job has no valid Windows spooler receipt.", refusal.Message);
Assert.IsNotType<SpoolCancellationException>(refusal);
Assert.Empty(spooler.Calls);
}
}
+103 -3
View File
@@ -17,8 +17,10 @@ public static class Program
/// <summary>
/// Selects the fixture behavior from the arguments. With --launch-parent it starts a hanging child worker whose parent id is this
/// process, reports the child's id, and exits, so a test can check the child dies with its parent. With --stand-in first it is
/// the plain child program of <see cref="StandIn"/> and never touches the worker. Otherwise it runs the real
/// worker loop against a fake backend that can hang (--hang) or report a media change (--media-drift).
/// the plain child program of <see cref="StandIn"/> and never touches the worker. With --respond or --flood it stands in for a
/// helper that answers something of its own (see <see cref="AnswerAsync"/>). Otherwise it runs the real worker loop, with the
/// real parent watch, against a fake backend that can hang (--hang) or report a media change (--media-drift) and a fake spooler
/// that holds one job.
/// </summary>
public static async Task<int> Main(string[] args)
{
@@ -41,7 +43,40 @@ public static class Program
Console.WriteLine(child.Id.ToString(CultureInfo.InvariantCulture));
return 0;
}
return await PrinterWorkerHost.RunAsync(new FakePrinters(args.Contains("--hang", StringComparer.Ordinal), args.Contains("--media-drift", StringComparer.Ordinal))).ConfigureAwait(false);
if(args.Contains("--respond", StringComparer.Ordinal) || args.Contains("--flood", StringComparer.Ordinal))
{
return await AnswerAsync(args).ConfigureAwait(false);
}
return await PrinterWorkerHost.RunAsync(new FakePrinters(args.Contains("--hang", StringComparer.Ordinal), args.Contains("--media-drift", StringComparer.Ordinal)), new FakeJobs(),
new ParentProcessWatch(), Environment.GetEnvironmentVariable("QQQ_PRINT_WORKER_PARENT"), Console.In, Console.Out).ConfigureAwait(false);
}
/// <summary>
/// Stands in for a helper whose answer a test dictates, so the service side can be shown every answer a helper could give.
/// It reads the one request line first, as a helper does. With --record FILE it writes its arguments, the parent id it was
/// given and the request to that file. Then it prints the text after --respond, or with --flood more than the service
/// accepts, and exits with the code after --exit-code (0 when absent).
/// </summary>
private static async Task<int> AnswerAsync(string[] args)
{
string? request = await Console.In.ReadLineAsync().ConfigureAwait(false);
if(Value(args, "--record") is { } record)
{
await File.WriteAllLinesAsync(record, [string.Join(' ', args.TakeWhile(argument => !string.Equals(argument, "--respond", StringComparison.Ordinal))), Environment.GetEnvironmentVariable("QQQ_PRINT_WORKER_PARENT") ?? "", request ?? ""]).ConfigureAwait(false);
}
await Console.Out.WriteLineAsync(args.Contains("--flood", StringComparer.Ordinal) ? new string('x', 1_048_577) : Value(args, "--respond")).ConfigureAwait(false);
return int.Parse(Value(args, "--exit-code") ?? "0", CultureInfo.InvariantCulture);
}
/// <summary>The argument that follows the named one, or null when the name is absent or last.</summary>
private static string? Value(string[] args, string name)
{
int index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
}
@@ -73,4 +108,69 @@ public static class Program
throw new InvalidOperationException("This fixture never opens documents or prints.");
}
}
/// <summary>
/// The fake spooler the worker loop cancels in: one queue, "Synthetic queue", holding job 7 with the document name
/// "QQQ fixture-job". The real cancellation rules run against it, so a test gets a deletion, an ownership refusal or an
/// absent job by what it asks for. No Windows print queue is opened.
/// </summary>
private sealed class FakeJobs : ISpoolerJobs
{
/// <summary>
/// 0x0BADC0DE, the marker every call here leaves as the thread's last error before it does anything else. The native
/// imports these calls stand for are declared with SetLastError and replace the last error each time, so a rule that
/// reads an error after a later call gets the marker and not the 87 or 122 the sizing call set.
/// </summary>
private const int StaleError = 0x0BAD_C0DE;
/// <summary>Opens the one synthetic queue; any other name fails.</summary>
public bool OpenPrinter(string name, out IntPtr printer)
{
System.Runtime.InteropServices.Marshal.SetLastPInvokeError(StaleError);
printer = new IntPtr(1);
return string.Equals(name, "Synthetic queue", StringComparison.Ordinal);
}
/// <summary>Reports a size for job 7 and no size, with error 87, for any other job, as Windows does for an unknown job number.</summary>
public bool GetJobSize(IntPtr printer, uint job, out uint needed)
{
needed = job == 7 ? 128u : 0u;
System.Runtime.InteropServices.Marshal.SetLastPInvokeError(job == 7 ? 122 : 87);
return false;
}
/// <summary>Reports the document name of the one job.</summary>
public bool GetJobDocument(IntPtr printer, uint job, uint size, out string? document)
{
System.Runtime.InteropServices.Marshal.SetLastPInvokeError(StaleError);
document = "QQQ fixture-job";
return true;
}
/// <summary>Accepts the command; there is nothing to delete.</summary>
public bool SetJob(IntPtr printer, uint job, uint command)
{
System.Runtime.InteropServices.Marshal.SetLastPInvokeError(StaleError);
return true;
}
/// <summary>Accepts the close.</summary>
public bool ClosePrinter(IntPtr printer)
{
System.Runtime.InteropServices.Marshal.SetLastPInvokeError(StaleError);
return true;
}
}
}