Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/HeadlessApp.cs
T
jaap-janandClaude Opus 5 cf1a321d1e
ci / build and test (push) Failing after 42s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
Pin the font the layout suite measures, and let the slice say it means plaintext
Two failures left on the runner, with nothing in common except that both only appear on a
machine unlike the one anybody develops on. The runner is Alpine, musl, inside a container,
with no fonts installed at all — and that combination is now reproducible locally, which is
how these were fixed rather than guessed at. Both are verified by running the suite in it.

The layout suite had two causes stacked, and the first hid the second completely. Missing
libfontconfig stops libSkiaSharp loading, which the last commit fixed and which then
revealed the real one: Avalonia takes its default font family from the platform, and on an
image with no fonts there is no answer, so FontManager throws "Default font family name
can't be null or empty" inside AppBuilder.SetupUnsafe — before a single test body runs, for
all sixty-eight of them, naming none of their subjects. WithInterFont does not prevent it:
it registers a collection without nominating a default. HeadlessApp's own comment already
claimed it measured "the same Inter font the application registers", which was an intention
the code never carried out.

Both heads now name it, through FontManagerOptions.DefaultFamilyName. That is worth more
than getting CI green: a suite whose entire job is measuring text was taking its metrics
from whatever the machine happened to have — Segoe UI here, DejaVu there — and reporting
the two as one number. It also means the application uses the font it has been shipping and
declining to use since it first referenced the package; almost nothing moves visually,
because App.axaml already sets MonoFont on essentially everything that draws.

The end-to-end slice was the product being right and the test leaning on an accident.
ServerConnection permits an http authority only when it is loopback. Testcontainers reports
the host a container can actually be reached at, so running the suite directly gives
localhost and passes, while running it inside a container gives the bridge gateway
172.17.0.1 and is refused — correctly, since a client that accepted plaintext metadata from
a routable address would be a weakness for everyone who is not a test. Loosening that rule
was the wrong repair. The slice now passes configureOidc and says out loud that it accepts
plaintext from the Keycloak it started itself.

Verified by reproducing the runner rather than approximating it: dotnet/sdk:10.0-alpine,
musl-x64, fc-list returning zero, the docker socket mounted so Testcontainers resolves the
gateway exactly as it does in CI. The whole solution passes there — 19 suites, 0 failures,
4 skipped — and the end-to-end failure was confirmed causal by reverting only that one file
and watching it fail again in the same container. The layout suite also still passes on a
Fedora desktop with 595 fonts, so the two agree now.

Not verified on Windows, and it should be said plainly rather than left to be discovered:
pinning the family changed the measured metrics there too, so a tight layout assertion
could have moved. platform-flags.md records that, and corrects the entry the last commit
added — "libfontconfig, and nothing else" was true of the container it was tested in and
false of the runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:48:30 +02:00

63 lines
3.2 KiB
C#

using Avalonia;
using Avalonia.Headless;
using Avalonia.Media;
using DodoSSH.Client.App.Layout.Tests;
[assembly: AvaloniaTestApplication(typeof(HeadlessApp))]
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// Builds the real application for a headless renderer.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DodoSshApp"/> itself, not a stand-in, because the thing being measured is how the shipped
/// styles lay out — a harness with its own theme would measure a window nobody sees. Its
/// <c>OnFrameworkInitializationCompleted</c> only composes the object graph when the lifetime is a classic
/// desktop one, and a headless session's is not, so loading it here brings the Fluent theme and the dark
/// variant and starts no vault, no listener and no browser.
/// </para>
/// <para>
/// Skia rather than the headless drawing stub, which is the one deliberate cost in this file. The stub's
/// font manager returns invented glyph metrics, and text height is an input to every stacked panel in this
/// window — measuring against it would produce numbers that are self-consistent and unrelated to the
/// application. With Skia and the same Inter font the application registers, a measured height is the
/// height a user gets.
/// </para>
/// </remarks>
internal static class HeadlessApp
{
/// <summary>Found by name; <see cref="AvaloniaTestApplicationAttribute"/> is the contract.</summary>
/// <remarks>
/// <para>
/// The default family is named explicitly, because the paragraph above was describing an intention
/// rather than what happened. <c>WithInterFont</c> registers a font collection; it does not make it
/// the default. Avalonia resolves that from the platform — which is to say from whatever the machine
/// has installed — so a suite whose whole job is measuring text was measuring Segoe UI on a
/// developer's Windows and DejaVu on their Linux, and treating the two numbers as one.
/// </para>
/// <para>
/// On a machine with no fonts at all it does not merely differ, it stops: <c>FontManager</c> throws
/// "Default font family name can't be null or empty" during <c>AppBuilder.SetupUnsafe</c>, before any
/// test body runs. That is how a container CI runner reports all sixty-eight of these as failures with
/// not one assertion among them. Naming Inter costs the runner nothing, because the font travels
/// inside the package rather than on the host.
/// </para>
/// </remarks>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<DodoSshApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.WithInterFont()
.With(new FontManagerOptions { DefaultFamilyName = InterFamily });
/// <summary>The family <c>WithInterFont</c> registers.</summary>
/// <remarks>
/// Kept in step with <c>Program.BuildAvaloniaApp</c> by hand, and it matters that they agree: a
/// harness pinning a different default from the one the application runs with would certify heights
/// for a window nobody sees, which is the failure the opening paragraph of this file is about.
/// </remarks>
internal const string InterFamily = "avares://Avalonia.Fonts.Inter/Assets#Inter";
}