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:
5 files changed
+106
-5
No files matched your search
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user