Skip to main content

Migrating the testing harness

Applies to: consumers on 0.11.0 or earlier, upgrading to the first release that contains this change. Package: BlazorNative.Testing — no other package's public surface moved. Kind of change: one source-compatible signature change and one public-constructor removal, plus two shell runtime behaviour changes (Android deep-link parsing, iOS cold-launch route seeding). Neither runtime change is a compile-time break; both are described below because a consumer with nested routes can observe them.

Phase 13.4 closed one bug class: two copies of one truth, one of them unpinned. Every item here is a place where the second copy was wrong and nothing said so.


1. BnTestHost.Mount gained a BnShell shell = BnShell.Ios parameter

public static BnTestHost Mount<TComponent>(
IDictionary<string, object?>? parameters = null,
Action<IServiceCollection>? configureServices = null,
BnShell shell = BnShell.Ios) // ← new, defaulted

Source-compatible. The parameter is optional and trails the existing two, so every existing call compiles unchanged and keeps the projection it already had — iOS's, which is what the harness modelled before it could name a shell at all.

Binary-compatible? No. Adding a parameter changes the method's signature, so a pre-13.4 assembly compiled against BlazorNative.Testing 0.10.0/0.11.0 and dropped next to a 13.4 build without recompiling will throw MissingMethodException. This is a test-only, dev-time-only package — the normal consumption path is a package reference from a test project that recompiles — so in practice this affects nobody. It is stated rather than assumed.

2. BnTestTree's public parameterless constructor is gone

BnTestTree now declares internal BnTestTree(BnShell shell). Declaring any constructor removes the compiler-supplied public BnTestTree(), so this is a removal from the published surface:

-BlazorNative.Testing.BnTestTree.BnTestTree() -> void

Why it is named here rather than left to a baseline diff. BlazorNative.Testing's PublicAPI.Shipped.txt is empty — the package shipped in 0.10.0 before its surface was ever promoted to the shipped baseline — so this removal appears in the analyzer's Unshipped file and produces no RS0017. Nothing in CI would have told a reader that a public constructor disappeared.

Harmless in practice. A BnTestTree a consumer constructed was never populated: only BnTestHost subscribes to NativeRenderer.Frames and drives Apply, and the only reachable instance is BnTestHost.Tree. new BnTestTree() produced an object on which Root throws. If your test does construct one, delete the line — you were not testing anything.

3. Why the shell is now named: node.Text on BnCheckbox / BnSwitch

The two shells genuinely differ on the text-collapse rule — the one projection decision that reaches a test author:

text-bearing (absorbs a lone text child)
iOS (BnWidgetMapper)text, button, input
Android (WidgetMapper)text, button, input, checkbox, switch — the rule is "a TextView that is not a ViewGroup", and the framework CheckBox/Switch this shell builds are both

Before 13.4 the harness hard-coded iOS's set and presented it as the shells'. So a test asserting the label of a BnCheckbox or BnSwitch was asserting one platform's behaviour while reading as though it asserted both — on Android the text is the widget's own content and node.Text should carry it; the harness reported null and put a separate text child in Children.

Both spellings, and they are both correct — of different platforms. The shape at issue is a checkbox (or switch) element carrying a lone text child. BnCheckbox and BnSwitch are childless leaves today, so this shape is reachable only through the raw-element hatch — a page that writes the wire element directly:

@* SettingsPage.razor — the raw-element hatch, not BnCheckbox *@
<checkbox value="true">Enable audio</checkbox>
// iOS's projection (unchanged, and still the default).
// checkbox is NOT text-bearing there, so the text stays a child node.
using BnTestHost ios = BnTestHost.Mount<SettingsPage>();
BnTestNode iosBox = ios.Tree.FindAll("checkbox")[0];
Assert.Null(iosBox.Text);
Assert.Equal("Enable audio", iosBox.Children[0].Text);

// Android's projection — pass the shell.
// checkbox IS text-bearing there, so it absorbs the child.
using BnTestHost android = BnTestHost.Mount<SettingsPage>(shell: BnShell.Android);
BnTestNode droidBox = android.Tree.FindAll("checkbox")[0];
Assert.Equal("Enable audio", droidBox.Text);
Assert.Empty(droidBox.Children);

shell: is a named argument here because the two preceding parameters are optional; positionally it is the third.

What to do. Nothing, unless a test asserts Text or child structure on a checkbox or switch node. If it does, decide which platform the test is about and say so — and note that the answer it was getting only ever described iOS. If it is about both, mount twice. BnTestHost is cheap: it builds a ServiceCollection and mounts synchronously.

4. BnTestHost.Mount now disposes its ServiceProvider when the mount throws

Mount sets renderer.StrictErrors = true precisely so a render fault surfaces as an exception at the mount call instead of a green test that rendered nothing. Until 13.4 that throw escaped with the ServiceProvider — and the NativeRenderer it owns — already constructed and never disposed, so a suite full of Assert.Throws(() => BnTestHost.Mount<Broken>()) leaked one container per assertion (#281).

No action needed. Noted only because a test that reached into the leaked provider after the throw (there is no supported way to do so — Mount never returned it) would now observe a disposed one.

The Android parser read intent.data.host and dropped everything after it. iOS has always used host plus path.

LinkAndroid beforeAndroid nowiOS (unchanged)
blazornative://settings/settings/settings/settings
blazornative://settings/audio/settings/settings/audio/settings/audio
blazornative://about//about/about/about
blazornative:///settingsnull/settings/settings

.NET's route table is an exact-string lookup, so the new answer is a route your app must actually declare. No shipped route is multi-segment, so no shipped app changes behaviour — but an app with its own nested routes should re-check: a link that used to land on /settings will now ask for /settings/audio, and will miss the table unless that route exists.

The rule now lives once, in src/deeplink-vectors.json, and tools/BlazorNative.WireGen emits the .NET, Kotlin and Swift assertion tables from it. All three suites assert the same seven cases. Do not hand-edit a BnDeepLinkVectors.g.*.

Known residual, deliberately deferred: scheme comparison is case-sensitive on Android and case-insensitive on iOS. Filed separately; see §7.

On a cold launch from a blazornative:// URL, iOS constructed the default AppleShellBridge() — whose route is "/" — and nothing on that path replaced it. The right page mounted, from a separate component lookup, while IMobileBridge.GetCurrentRouteAsync reported "/". A page reading its own route, or any navigation computed relative to it, disagreed with what was on screen (#282).

The route is now seeded from the pending launch URL whether or not the shell's component map resolves it — which routes exist is .NET's question, and the shell must not answer it by discarding the link. Android has behaved this way since the deep link was added.

Unchanged, deliberately: which view goes on screen first still falls back to BnDemo for an unmapped route. That is a decision the shell genuinely has to make before .NET exists.

No action needed. A test that pinned the old behaviour — asserting "/" after a cold link — was pinning the bug.

7. Not fixed here

  • Deep-link scheme case-sensitivity (Android case-sensitive, iOS case-insensitive) — a residual instance of this phase's own bug class, deferred on a reachability argument (Android's manifest intent-filter matching is itself case-sensitive against a lowercase value, so the parser never sees such a URL). Written up as its own issue rather than left as a comment.
  • The low-severity items grouped in #283, triaged in writing on that issue.