diff --git a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs new file mode 100644 index 000000000..6ee67d23c --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -0,0 +1,208 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Threading.Tasks; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; +using Elastic.Markdown.IO; +using Elastic.Markdown.Myst; +using Elastic.Markdown.Myst.InlineParsers; +using Elastic.Markdown.Tests; +using FluentAssertions; +using Xunit; + +namespace Elastic.Markdown.Tests.Inline; + +public class ImagePathResolutionTests(ITestOutputHelper output) +{ + [Fact] + public async Task UpdateRelativeUrlUsesNavigationPathWhenAssemblerBuildEnabled() + { + const string relativeAssetPath = "images/pic.png"; + var nonAssemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: false, pathPrefix: "this-is-not-relevant"); + var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: "platform"); + + nonAssemblerResult.Should().Be("/docs/setup/images/pic.png"); + assemblerResult.Should().Be("/docs/platform/setup/images/pic.png"); + } + + [Fact] + public async Task UpdateRelativeUrlWithoutPathPrefixKeepsGlobalPrefix() + { + var relativeAssetPath = "images/funny-image.png"; + var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: null); + + assemblerResult.Should().Be("/docs/setup/images/funny-image.png"); + } + + [Fact] + public async Task UpdateRelativeUrlAppliesCustomPathPrefix() + { + var relativeAssetPath = "images/image.png"; + var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: "custom"); + + assemblerResult.Should().Be("/docs/custom/setup/images/image.png"); + } + + /// + /// Resolves a relative asset URL the same way the assembler would for a single markdown file, using the provided navigation path prefix. + /// + private async Task ResolveUrlForBuildMode(string relativeAssetPath, bool assemblerBuild, string? pathPrefix) + { + const string guideRelativePath = "setup/guide.md"; + var navigationUrl = BuildNavigationUrl(pathPrefix, guideRelativePath); + var files = new Dictionary + { + ["docs/docset.yml"] = new( + $""" + project: test + toc: + - file: index.md + - file: {guideRelativePath} + """ + ), + ["docs/index.md"] = new("# Home"), + ["docs/" + guideRelativePath] = new( + $""" + # Guide + + ![Alt]({relativeAssetPath}) + """ + ), + ["docs/setup/" + relativeAssetPath] = new([]) + }; + + var fileSystem = new MockFileSystem(files, new MockFileSystemOptions + { + CurrentDirectory = Paths.WorkingDirectoryRoot.FullName + }); + + var collector = new TestDiagnosticsCollector(output); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + + var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); + var buildContext = new BuildContext(collector, fileSystem, configurationContext) + { + UrlPathPrefix = "/docs", + AssemblerBuild = assemblerBuild + }; + + var documentationSet = new DocumentationSet(buildContext, new TestLoggerFactory(output), new TestCrossLinkResolver()); + + await documentationSet.ResolveDirectoryTree(TestContext.Current.CancellationToken); + + // Normalize path for cross-platform compatibility (Windows uses backslashes) + var normalizedPath = guideRelativePath.Replace('/', Path.DirectorySeparatorChar); + if (documentationSet.TryFindDocumentByRelativePath(normalizedPath) is not MarkdownFile markdownFile) + throw new InvalidOperationException($"Failed to resolve markdown file for test. Tried path: {normalizedPath}"); + + // For assembler builds DocumentationSetNavigation seeds MarkdownNavigationLookup with navigation items whose Url already + // includes the computed path_prefix. To exercise the same branch in isolation, inject a stub navigation entry with the + // expected Url (and minimal metadata for the surrounding API contract). + _ = documentationSet.MarkdownNavigationLookup.Remove(markdownFile); + documentationSet.MarkdownNavigationLookup.Add(markdownFile, new NavigationItemStub(navigationUrl)); + documentationSet.MarkdownNavigationLookup.TryGetValue(markdownFile, out var navigation).Should() + .BeTrue("navigation lookup should contain current page"); + navigation?.Url.Should().Be(navigationUrl); + + var parserState = new ParserState(buildContext) + { + MarkdownSourcePath = markdownFile.SourceFile, + YamlFrontMatter = null, + CrossLinkResolver = documentationSet.CrossLinkResolver, + TryFindDocument = file => documentationSet.TryFindDocument(file), + TryFindDocumentByRelativePath = path => documentationSet.TryFindDocumentByRelativePath(path), + PositionalNavigation = documentationSet + }; + + var context = new ParserContext(parserState); + context.TryFindDocument(context.MarkdownSourcePath).Should().BeSameAs(markdownFile); + context.Build.AssemblerBuild.Should().Be(assemblerBuild); + + var resolved = DiagnosticLinkInlineParser.UpdateRelativeUrl(context, relativeAssetPath); + + await collector.StopAsync(TestContext.Current.CancellationToken); + + return resolved; + } + + /// + /// Helper that mirrors the assembler's path-prefix handling in DocumentationSetNavigation: + /// combines the relative path_prefix from navigation.yml with the markdown path (stripped of ".md") so our stub + /// navigation item carries the same Url the production code would have provided. + /// + private static string BuildNavigationUrl(string? pathPrefix, string docRelativePath) + { + var docPath = docRelativePath.Replace('\\', '/').Trim('/'); + if (docPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + docPath = docPath[..^3]; + + var segments = new List(); + if (!string.IsNullOrWhiteSpace(pathPrefix)) + segments.Add(pathPrefix.Trim('/')); + if (!string.IsNullOrWhiteSpace(docPath)) + segments.Add(docPath); + + var combined = string.Join('/', segments); + return "/" + combined.Trim('/'); + } + + /// + /// Minimal navigation stub so UpdateRelativeUrl can rely on navigation metadata without constructing the full site navigation tree. + /// + private sealed class NavigationItemStub(string url) : INavigationItem + { + private sealed class NavigationModelStub : INavigationModel + { + } + + /// + /// Simplified root navigation item to satisfy the IRootNavigationItem contract. + /// + private sealed class RootNavigationItemStub : IRootNavigationItem + { + /// + /// Leaf implementation used by the root stub. Navigation requires both root and leaf nodes present. + /// + private sealed class LeafNavigationItemStub(RootNavigationItemStub root) : ILeafNavigationItem + { + public string Url => "/"; + public string NavigationTitle => "Root"; + public IRootNavigationItem NavigationRoot { get; } = root; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + public INavigationModel Model { get; } = new NavigationModelStub(); + } + + public RootNavigationItemStub() => Index = new LeafNavigationItemStub(this); + + public string Url => "/"; + public string NavigationTitle => "Root"; + public IRootNavigationItem NavigationRoot => this; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + public string Id => "root"; + public ILeafNavigationItem Index { get; } + public IReadOnlyCollection NavigationItems { get; private set; } = []; + public bool IsUsingNavigationDropdown => false; + public Uri Identifier => new("https://example.test/"); + public void SetNavigationItems(IReadOnlyCollection navigationItems) => NavigationItems = navigationItems; + } + + private static readonly RootNavigationItemStub Root = new(); + + public string Url { get; } = url; + public string NavigationTitle => "Stub"; + public IRootNavigationItem NavigationRoot => Root; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + } +}