fix(adminui,client): error boundary inside the interactive scope, stale-commit OnSaved, AbCip browse delay emit, DisposeBridge cancel (review R3)

M1: PageErrorBoundary wraps the body of every InteractiveServer page; the inert layout boundary is removed.
m1: DisposeBridge cancels its CTS when the 5 s wait times out.
m2: a CSV import that commits after the modal closed still raises OnSaved.
m3: document SecurityMode=None no-fallback in Client.CLI.md / Client.UI.md.
m4: browsePageDelayMs written only when loaded or changed; negative flagged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:31:19 -04:00
parent ca0f1e7735
commit 1475991fd6
39 changed files with 478 additions and 48 deletions
@@ -57,6 +57,50 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
Field<string>(modal, "_effectiveDeviceId").ShouldBe("");
}
[Fact]
public async Task CsvImport_commit_that_lands_after_the_modal_closed_still_raises_OnSaved()
{
// A write that has landed must refresh the parent's tree even when the operator closed the modal
// mid-commit; before the fix the stale guard returned ahead of OnSaved.
var saved = await CommitWhileClosed(new RawTagImportResult(1, []));
saved.ShouldBe(1);
// Control: a failed (all-or-nothing) import wrote nothing, so there is nothing to refresh.
var savedOnFailure = await CommitWhileClosed(new RawTagImportResult(0, ["row 1: nope"]));
savedOnFailure.ShouldBe(0);
}
private async Task<int> CommitWhileClosed(RawTagImportResult outcome)
{
var gate = new TaskCompletionSource();
var svc = Stub<IRawTreeService>(async (method, _) =>
{
if (method.Name != nameof(IRawTreeService.ImportTagsAsync)) { return null; }
await gate.Task;
return outcome;
});
var modal = Make<RawCsvImportModal>(("Svc", svc));
var saved = 0;
SetParams(modal,
("Visible", true), ("DeviceId", "dev-A"), ("TagGroupId", null),
("DriverType", "Modbus"),
("OnSaved", EventCallback.Factory.Create(this, () => { saved++; })));
await Open(modal);
modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(
modal, RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\""));
var commit = (Task)modal.GetType().GetMethod("Commit", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(modal, null)!;
SetParams(modal, ("Visible", false));
await Open(modal);
gate.SetResult();
await commit;
return saved;
}
[Fact]
public async Task CsvExport_stale_group_resolve_never_retargets_the_live_open()
{