fix(navigation): reveal same-document fragment targets

Route same-document SourceChanged events through an exact-document and bounded-heading policy. Rearm repeated links after recollapse; retain physical click and actual reveal outcomes. Core 206 tests/100% lines and build/format/docs pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-10 23:07:20 -05:00
1 parent 9edd982e41
commit 5a9af6328f
5 files changed
+106 -5

No files matched your search

+17
View File
@@ -245,3 +245,20 @@ not pushed while the existing producer is live. 195 core tests/100% line coverag
zero-warning build/format/docs/hooks pass. Headless capture-fixture self-check
preserves one literal Unicode/metacharacter argument; native launch/UI acceptance
still requires the prepared three hosted tests. Full PRODUCT.md scope remains.
## Same-document fragment event correction
9edd982 native job 114383828733 in CI 38110140930 completed 40/41:
all configured-editor launch/validation/missing-file tests and paired-find tests
pass; physical fragment click does not reveal the hidden heading. Microsoft's
navigation contract says same-document fragments do not raise NavigationStarting.
Use SourceChanged for those events and a pure exact-document/query-free/in-range
generated-heading policy. Core now passes 206 tests/100% lines. Authored scripts,
messages and host objects stay disabled; fragments select only bounded heading reveals.
After a valid reveal, remove only this document's fragment using guarded host
history.replaceState so clicking the same link after recollapse can reveal again.
The physical test repeats collapse/link/reveal and retains owned point/bounds checks
and actual heading/body outcomes. Hosted acceptance remains required. Retained log:
artifacts/editor-find-ui-38110140930.log. Existing installed producer is still
being observed; no new push supersedes a live producer. Full product scope active.
@@ -38,6 +38,19 @@ public sealed class DocumentResourcePolicy
/// <summary>Allows only an in-range generated heading fragment on this exact document, without query/native arguments.</summary>
public int? GetHeadingIndex(Uri request, int headingCount)
{
ArgumentNullException.ThrowIfNull(request);
if(!request.IsAbsoluteUri || request.Query.Length != 0 || !string.Equals(request.GetLeftPart(UriPartial.Path), DocumentUri.AbsoluteUri, StringComparison.Ordinal))
{
return null;
}
return request.Fragment.StartsWith("#mv-heading-", StringComparison.Ordinal) &&
int.TryParse(request.Fragment.AsSpan(12), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int index) && index < headingCount ? index : null;
}
/// <summary>Returns a decoded relative image path, or denies the request before filesystem access.</summary>
public string? GetImageRelativePath(Uri request)
{
+23 -2
View File
@@ -73,6 +73,7 @@ internal sealed partial class DocumentBrowser
core.FrameNavigationStarting += (_, args) => args.Cancel = true;
core.NavigationStarting += NavigationStarting;
core.NavigationCompleted += NavigationCompleted;
core.SourceChanged += SourceChanged;
core.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All, CoreWebView2WebResourceRequestSourceKinds.All);
core.WebResourceRequested += ResourceRequested;
Show(null, "<h1>MarkdownViewr</h1><p>Choose File → Open to read a Markdown document.</p>", "MarkdownViewr");
@@ -260,6 +261,26 @@ internal sealed partial class DocumentBrowser
/// <summary>Same-document fragment changes do not raise NavigationStarting; reveal only a validated generated target.</summary>
private async void SourceChanged(CoreWebView2 sender, CoreWebView2SourceChangedEventArgs args)
{
if(_closed || args.IsNewDocument || !Uri.TryCreate(sender.Source, UriKind.Absolute, out Uri? uri))
{
return;
}
if(_policy.GetHeadingIndex(uri, _headings.Count) is { } heading)
{
Uri document = _policy.DocumentUri;
await JumpToHeadingAsync(heading);
if(document == _policy.DocumentUri)
{
// Rearm an identical link after the user collapses its target again; keep the displayed viewport.
await ExecuteDocumentScriptAsync($"if(location.href.split('#')[0] === '{document.AbsoluteUri}') history.replaceState(null,'','{document.AbsoluteUri}');");
}
}
}
/// <summary>Allows only the current document and fragment navigation; external links require an actual user gesture.</summary>
private async void NavigationStarting(CoreWebView2 sender, CoreWebView2NavigationStartingEventArgs args)
{
@@ -272,8 +293,8 @@ internal sealed partial class DocumentBrowser
string.Equals(uri.GetLeftPart(UriPartial.Path), _policy.DocumentUri.AbsoluteUri, StringComparison.Ordinal) &&
uri.Query.Length == 0)
{
if(args.IsUserInitiated && uri.Fragment.StartsWith("#mv-heading-", StringComparison.Ordinal) &&
int.TryParse(uri.Fragment.AsSpan(12), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int heading))
if(args.IsUserInitiated && _policy.GetHeadingIndex(uri, _headings.Count) is { } heading)
{
args.Cancel = true;
await JumpToHeadingAsync(heading);
@@ -11,6 +11,43 @@ namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Verifies document image isolation and Windows path rejection before any native file access.</summary>
public sealed class DocumentResourcePolicyTests
{
/// <summary>Generated fragments address only bounded heading identities, never an arbitrary native command.</summary>
[Theory]
[InlineData("#mv-heading-0", 1, 0)]
[InlineData("#mv-heading-2", 3, 2)]
[InlineData("#mv-heading-3", 3, null)]
[InlineData("#mv-heading-0", 0, null)]
[InlineData("#mv-heading--1", 3, null)]
[InlineData("#mv-heading-+1", 3, null)]
[InlineData("#mv-heading-2147483648", 3, null)]
[InlineData("#mv-heading-", 3, null)]
[InlineData("#heading", 3, null)]
[InlineData("?command=edit#mv-heading-0", 3, null)]
public void GetHeadingIndex_Fragment_RequiresBoundedGeneratedIndex(string suffix, int count, int? expected)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(expected, policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + suffix), count));
}
/// <summary>Relative requests, another namespace/path/authority, and missing input cannot impersonate this document.</summary>
[Fact]
public void GetHeadingIndex_OtherDocumentOrAuthority_RejectsTarget()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetHeadingIndex(new Uri("#mv-heading-0", UriKind.Relative), 1));
Assert.Null(policy.GetHeadingIndex(new Uri(new DocumentResourcePolicy().DocumentUri.AbsoluteUri + "#mv-heading-0"), 1));
Assert.Null(policy.GetHeadingIndex(new Uri(policy.DocumentUri, "other.html#mv-heading-0"), 1));
var authority = new UriBuilder(policy.DocumentUri) { Host = "example.com", Fragment = "mv-heading-0" };
Assert.Null(policy.GetHeadingIndex(authority.Uri, 1));
authority.Host = policy.DocumentUri.Host;
authority.UserName = "attacker";
Assert.Null(policy.GetHeadingIndex(authority.Uri, 1));
Assert.Throws<ArgumentNullException>(() => policy.GetHeadingIndex(null!, 1));
}
/// <summary>Each opened document has a distinct, inert HTTPS namespace.</summary>
[Fact]
public void Constructor_DifferentDocuments_UsesDistinctNamespaces()
@@ -534,6 +534,21 @@ public class AppTests
session.ClickElement("ToggleSection");
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByAutomationId("mv-heading-1").And(cf.ByName("Target heading"))), "the heading inside its collapsed ancestor");
AutomationElement link = session.WaitFor(() => session.MainWindow.FindFirstDescendant(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Hyperlink).And(cf.ByName("Jump to target"))), "the local fragment link");
ClickDocumentLink(session, link);
session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("mv-heading-1").And(cf.ByName("Target heading"))), "the fragment's revealed target heading");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target body")), "the revealed target body");
Assert.NotNull(session.FindInApp(cf => cf.ByName("Fragment root")));
session.ClickElement("ToggleSection");
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByAutomationId("mv-heading-1")), "the target collapsed a second time");
ClickDocumentLink(session, link);
session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("mv-heading-1").And(cf.ByName("Target heading"))), "the same fragment's revealed target after recollapse");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target body")), "the target body revealed by the repeated link click");
}
/// <summary>Clicks the exact link bounds through its owned composed provider, retaining physical input checks.</summary>
private void ClickDocumentLink(AppSession session, AutomationElement link)
{
session.MainWindow.SetForeground();
Assert.True(link.TryGetClickablePoint(out System.Drawing.Point point));
Assert.True(session.MainWindow.BoundingRectangle.Contains(point));
@@ -554,12 +569,10 @@ public class AppTests
FlaUI.Core.Input.Wait.UntilInputIsProcessed();
FlaUI.Core.Input.Mouse.Up(FlaUI.Core.Input.MouseButton.Left);
FlaUI.Core.Input.Wait.UntilInputIsProcessed();
session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("mv-heading-1").And(cf.ByName("Target heading"))), "the fragment's revealed target heading");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target body")), "the revealed target body");
Assert.NotNull(session.FindInApp(cf => cf.ByName("Fragment root")));
}
/// <summary>A heading selection moves the actual browser viewport and all-section operations change displayed content.</summary>
[Fact]
public void Outline_HeadingJumpAndCollapse_UpdatesActualDocument()