From 62867c6e28548157ab8c60318591d3e26838dda8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:26:24 +0100 Subject: [PATCH 01/18] Wire Nullean.Snapshots into every test project The ApiExplorer pilot (#4654) used Nullean.Snapshots.Html and .TUnit 0.2.0 in one project. This makes the library available to all unit and integration test projects at 0.4.0 and adds the shared pieces the next migration steps build on: - tests-shared/Elastic.Documentation.Testing: DocsHtml (page section without the fixture H1 and htmx attributes), DiagnosticsSnapshot, NavigationDump, MockFileSystemDump. A scrubber for temp and repo paths is compiled into each test project. - The snap CLI as a local tool; CI uploads received snapshots when a test run fails; the build fails on orphaned snapshots after a unit run. - AGENTS.md describes the workflow. The PR review prompt no longer claims the repo uses xUnit. The five TestDiagnosticsCollector copies stay: they differ in lifecycle (Write vs HandleItem), so merging them is a behaviour change. DiagnosticsSnapshot works on any of them. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 9 +++ .github/workflows/pr-review.yml | 2 +- AGENTS.md | 6 ++ Directory.Packages.props | 8 ++- build/Targets.fs | 6 ++ docs-builder.slnx | 4 ++ dotnet-tools.json | 7 ++ tests-integration/Directory.Build.props | 7 ++ tests-shared/Directory.Build.props | 11 +++ .../DiagnosticsSnapshot.cs | 32 +++++++++ .../Elastic.Documentation.Testing/DocsHtml.cs | 34 +++++++++ .../Elastic.Documentation.Testing.csproj | 28 ++++++++ .../Linked/SnapshotScrubbing.cs | 39 +++++++++++ .../MockFileSystemDump.cs | 35 ++++++++++ .../NavigationDump.cs | 51 ++++++++++++++ tests/Directory.Build.props | 7 ++ .../Elastic.ApiExplorer.Tests.csproj | 2 - .../Testing/SnapshotHelpersTests.cs | 70 +++++++++++++++++++ 18 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 tests-shared/Directory.Build.props create mode 100644 tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs create mode 100644 tests-shared/Elastic.Documentation.Testing/DocsHtml.cs create mode 100644 tests-shared/Elastic.Documentation.Testing/Elastic.Documentation.Testing.csproj create mode 100644 tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs create mode 100644 tests-shared/Elastic.Documentation.Testing/MockFileSystemDump.cs create mode 100644 tests-shared/Elastic.Documentation.Testing/NavigationDump.cs create mode 100644 tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50c34ac2c2..7d4066e86c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,15 @@ jobs: # --single-target skips the compile dependency; the Compile step above already built everything run: dotnet run --project build -c release -- unit-test --single-target + - name: Upload received snapshots + # A failed snapshot test leaves its received file here. Download it into .artifacts/.snapshots/received/ and run `dotnet snap review`. + if: ${{ failure() && needs.changes.outputs.code == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: received-snapshots-${{ matrix.os }} + path: .artifacts/.snapshots/received/ + if-no-files-found: ignore + - name: Publish AOT if: ${{ needs.changes.outputs.code == 'true' && (github.event_name == 'push' && matrix.os != 'ubuntu-latest') }} # publish containers already validates AOT build run: dotnet run --project build -c release -- publishbinaries diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 3c884acdc3..314cf54941 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -27,7 +27,7 @@ jobs: validates cross-repo references, and ships as native AOT binaries for CI. ## Language and frameworks - - C# (.NET) with xUnit v3 + AwesomeAssertions for tests. Do NOT suggest TUnit APIs — the migration hasn't happened yet. + - C# (.NET) with TUnit + AwesomeAssertions for tests. Expected output lives in Nullean.Snapshots snapshots (inline literals and `.snapshots/` files); never suggest accepting snapshots in code or CI. - TypeScript/React frontend under src/Elastic.Documentation.Site/. - Test naming convention: Method_Scenario_Expected. diff --git a/AGENTS.md b/AGENTS.md index 0f31e021e8..feb01f4633 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,12 @@ Tests live in `tests/` (unit) and `tests-integration/` (integration). - **C#**: TUnit · AwesomeAssertions · FakeItEasy across all test projects. All test projects (unit and integration) use TUnit (wired centrally via `tests/Directory.Build.props` and `tests-integration/Directory.Build.props`; no per-project flag needed). Write new tests using TUnit APIs: `[Test]`, `[Arguments(...)]`, `[MethodDataSource(nameof(X))]`. - **TypeScript**: Vitest +- **Snapshots** (`Nullean.Snapshots`, wired for every test project in `tests/Directory.Build.props`; helpers in `tests-shared/Elastic.Documentation.Testing`): expected output lives in snapshots, not in chains of `Contain` assertions. + - Small values go inline: `x.Should().MatchInlineSnapshot("""...""")`, `MatchInlineHtmlSnapshot`, `MatchInlineJsonSnapshot`. Leave the argument out to write a new one. Large values go to a verified file: `MatchSnapshot()`, `MatchHtmlSnapshot()`, `MatchJsonSnapshot()`, `MatchYamlSnapshot()`. + - Diagnostics: `collector.Diagnostics.ToSnapshotText()` (`DiagnosticsSnapshot`). A clean run is the empty snapshot. + - Navigation trees and generated files: `NavigationDump.ToText(...)`, `MockFileSystemDump.ToText(fs, dir)`. Rendered docs HTML: `DocsHtml.Section()` (the page content, no fixture H1, no htmx attributes) or `DocsHtml.Body()`. + - Accept with the `snap` tool (`dotnet snap status`, `dotnet snap review`, `dotnet snap accept `), or `SNAPSHOT_ACCEPT=1`. Read the diff of the changed `.verified.*` files and inline literals before committing. Nothing is accepted in CI. When a CI test run fails, download the `received-snapshots-*` artifact into `.artifacts/.snapshots/received/` and run `dotnet snap review`. + - Changing a snapshot test's output is a behaviour change. Mutate the code once and confirm the test fails before you commit. - **HTML snapshots** (`Nullean.Snapshots.Html`, used in `Elastic.ApiExplorer.Tests`): when a test would check one rendered block with many `Contain` assertions, compare it to a verified snapshot instead and keep one assertion that states the intent. - Write `html.Should().MatchHtmlSnapshot()`. Narrow it with `new HtmlSnapshotOptions().Select("#some-id")` or `.RemoveAttributes(...)` so unrelated markup and volatile values (bundle names, CI git info) stay out. - Snapshot a partial or one section, not a whole page. Whole-page snapshots break on unrelated changes. diff --git a/Directory.Packages.props b/Directory.Packages.props index 782c52aab0..b1dcf0d2c0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -127,8 +127,12 @@ - - + + + + + + diff --git a/build/Targets.fs b/build/Targets.fs index a73833190f..432a81e056 100644 --- a/build/Targets.fs +++ b/build/Targets.fs @@ -205,6 +205,12 @@ let private runTests (testSuite: TestSuite) (parsed: ParseResults) = |> Array.toList if not (List.isEmpty failures) then failwithf "Test projects failed:\n%s" (String.concat "\n" failures) + + // Every test project has now run, so the run manifests are complete: a verified snapshot nobody used belongs to a deleted or renamed test. + if testSuite <> Integration then + match exec { exit_code_of "dotnet" "snap" "orphans" } with + | 0 -> () + | _ -> failwith "Orphaned snapshots found. Delete them with `dotnet snap orphans --delete` and commit the removal." let private compressibleExtensions = set [".html"; ".css"; ".js"; ".json"; ".svg"; ".xml"; ".txt"] diff --git a/docs-builder.slnx b/docs-builder.slnx index 47468e9ede..31e5d66687 100644 --- a/docs-builder.slnx +++ b/docs-builder.slnx @@ -90,6 +90,10 @@ + + + + diff --git a/dotnet-tools.json b/dotnet-tools.json index 084330e010..d77fdfb621 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -43,6 +43,13 @@ "curb" ], "rollForward": false + }, + "nullean.snapshots.cli": { + "version": "0.4.0", + "commands": [ + "snap" + ], + "rollForward": false } } } diff --git a/tests-integration/Directory.Build.props b/tests-integration/Directory.Build.props index 379438ea3e..1717682543 100644 --- a/tests-integration/Directory.Build.props +++ b/tests-integration/Directory.Build.props @@ -18,6 +18,13 @@ + + + + + + + diff --git a/tests-shared/Directory.Build.props b/tests-shared/Directory.Build.props new file mode 100644 index 0000000000..5b92d69384 --- /dev/null +++ b/tests-shared/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + + CA1822;CA1707;IDE0058 + false + false + + diff --git a/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs b/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs new file mode 100644 index 0000000000..0e8f94f1f2 --- /dev/null +++ b/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs @@ -0,0 +1,32 @@ +// 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.Text; +using Elastic.Documentation.Diagnostics; + +namespace Elastic.Documentation.Testing; + +public static class DiagnosticsSnapshot +{ + /// + /// One line per diagnostic, error|warning|hint file:line message, in the order they were written. Empty when + /// there are none, so a clean run reads as MatchInlineSnapshot(""). + /// + public static string ToSnapshotText(this IEnumerable diagnostics) + { + var sb = new StringBuilder(); + foreach (var d in diagnostics) + { + var severity = d.Severity switch + { + Severity.Error => "error", + Severity.Warning => "warning", + _ => "hint" + }; + var position = d.Line is { } line ? $"{d.File}:{line}" : d.File; + sb.Append(severity).Append(' ').Append(position).Append(' ').Append(d.Message).Append('\n'); + } + return sb.ToString(); + } +} diff --git a/tests-shared/Elastic.Documentation.Testing/DocsHtml.cs b/tests-shared/Elastic.Documentation.Testing/DocsHtml.cs new file mode 100644 index 0000000000..961b026997 --- /dev/null +++ b/tests-shared/Elastic.Documentation.Testing/DocsHtml.cs @@ -0,0 +1,34 @@ +// 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 Nullean.Snapshots.Html; + +namespace Elastic.Documentation.Testing; + +/// +/// How docs-builder's tests normalise rendered HTML before comparing or snapshotting it. One place for what +/// Elastic.Markdown.Tests.PrettyHtmlExtensions and Elastic.Authoring.Tests.Framework.HtmlAssertions +/// used to do separately. +/// +public static class DocsHtml +{ + /// htmx navigation attributes on links are plumbing, not content. + private static readonly string[] HtmxAttributes = ["hx-get", "hx-select-oob", "hx-swap", "hx-indicator", "hx-push-url", "preload"]; + + /// The whole body, with htmx attributes removed from links. + public static HtmlSnapshotOptions Body() => new HtmlSnapshotOptions().RemoveAttributesFrom("a", HtmxAttributes); + + /// + /// The rendered page's content section, without the leading H1 the test fixtures wrap every document in + /// (# Test Document), and with htmx attributes removed from links. + /// + public static HtmlSnapshotOptions Section() => + Body() + .Select("section#elastic-docs-v3") + .Transform(root => + { + if (root.FirstElementChild is { TagName: "H1" } h1) + h1.Remove(); + }); +} diff --git a/tests-shared/Elastic.Documentation.Testing/Elastic.Documentation.Testing.csproj b/tests-shared/Elastic.Documentation.Testing/Elastic.Documentation.Testing.csproj new file mode 100644 index 0000000000..ab56af73cd --- /dev/null +++ b/tests-shared/Elastic.Documentation.Testing/Elastic.Documentation.Testing.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + Elastic.Documentation.Testing + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs new file mode 100644 index 0000000000..6a80bb71fc --- /dev/null +++ b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs @@ -0,0 +1,39 @@ +// 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.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Nullean.Snapshots; + +namespace Elastic.Documentation.Testing.Linked; + +/// +/// Scrubbers every snapshot in docs-builder's tests gets: machine-specific paths and git SHAs never belong in a +/// verified file. Runs when any test project referencing this assembly loads it. +/// +internal static class SnapshotScrubbing +{ +#pragma warning disable CA2255 // compiled into each test project, which is application code + [ModuleInitializer] +#pragma warning restore CA2255 + internal static void Register() + { + SnapshotDefaults.AddScrubber( + Scrubbers.Regex( + new Regex(Regex.Escape(Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar)) + @"[^\s""'<>]*", RegexOptions.Compiled), + "{temp}" + ) + ); + if (FindSolutionRoot() is { } root) + SnapshotDefaults.AddScrubber(Scrubbers.Regex(new Regex(Regex.Escape(root), RegexOptions.Compiled), "{repo}")); + } + + private static string? FindSolutionRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "docs-builder.slnx"))) + dir = dir.Parent; + return dir?.FullName.TrimEnd(Path.DirectorySeparatorChar); + } +} diff --git a/tests-shared/Elastic.Documentation.Testing/MockFileSystemDump.cs b/tests-shared/Elastic.Documentation.Testing/MockFileSystemDump.cs new file mode 100644 index 0000000000..daaf2d8ed6 --- /dev/null +++ b/tests-shared/Elastic.Documentation.Testing/MockFileSystemDump.cs @@ -0,0 +1,35 @@ +// 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.IO.Abstractions; +using System.Text; + +namespace Elastic.Documentation.Testing; + +public static class MockFileSystemDump +{ + /// + /// Every file under , sorted by path, each under an === relative/path === + /// header. Meant for tests that read generated files back from a mock file system. + /// + public static string ToText(IFileSystem fileSystem, string directory, string searchPattern = "*") + { + var root = fileSystem.Path.GetFullPath(directory); + var sb = new StringBuilder(); + var files = fileSystem + .Directory + .EnumerateFiles(root, searchPattern, SearchOption.AllDirectories) + .Select(f => (Full: f, Relative: fileSystem.Path.GetRelativePath(root, f).Replace('\\', '/'))) + .OrderBy(f => f.Relative, StringComparer.Ordinal); + foreach (var (full, relative) in files) + { + sb.Append("=== ").Append(relative).Append(" ===\n"); + var content = fileSystem.File.ReadAllText(full).Replace("\r\n", "\n"); + sb.Append(content); + if (!content.EndsWith('\n')) + sb.Append('\n'); + } + return sb.ToString(); + } +} diff --git a/tests-shared/Elastic.Documentation.Testing/NavigationDump.cs b/tests-shared/Elastic.Documentation.Testing/NavigationDump.cs new file mode 100644 index 0000000000..a70dfa66f6 --- /dev/null +++ b/tests-shared/Elastic.Documentation.Testing/NavigationDump.cs @@ -0,0 +1,51 @@ +// 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.Text; +using Elastic.Documentation.Navigation; + +namespace Elastic.Documentation.Testing; + +public static class NavigationDump +{ + /// + /// The navigation tree as indented text, one node per line: Type url "title", with [hidden] and + /// [island] flags. A node lists its index page first (index: Type url "title"), then its children. The + /// index page is where most of the interesting URLs live. A child whose Parent is not the node it is listed + /// under is flagged [wrong parent]; index pages are not checked, because a virtual file's index has a + /// different parent. + /// + public static string ToText(INavigationItem root) + { + var sb = new StringBuilder(); + Write(sb, root, 0, null, null); + return sb.ToString(); + } + + private static void Write(StringBuilder sb, INavigationItem item, int depth, string? label, INavigationItem? container) + { + sb + .Append(' ', depth * 2) + .Append(label) + .Append(item.GetType().Name.Split('`')[0]) + .Append(" ") + .Append(item.Url) + .Append(" \"") + .Append(item.NavigationTitle) + .Append('"'); + if (item.Hidden) + sb.Append(" [hidden]"); + if (item.IsIsland) + sb.Append(" [island]"); + if (container is not null && !ReferenceEquals(item.Parent, container)) + sb.Append(" [wrong parent]"); + sb.Append('\n'); + + if (item is not INodeNavigationItem node) + return; + Write(sb, node.Index, depth + 1, "index: ", null); + foreach (var child in node.NavigationItems) + Write(sb, child, depth + 1, null, item); + } +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 6ce509c511..b3667da5e1 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -27,5 +27,12 @@ + + + + + + + diff --git a/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj b/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj index b6138fd950..17b5bb72f8 100644 --- a/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj +++ b/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj @@ -12,8 +12,6 @@ - - diff --git a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs new file mode 100644 index 0000000000..5897c67b4a --- /dev/null +++ b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs @@ -0,0 +1,70 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; + +namespace Elastic.Documentation.Tests.Testing; + +public class SnapshotHelpersTests +{ + [Test] + public void ToSnapshotText_NoDiagnostics_IsEmpty() => new List().ToSnapshotText().Should().MatchInlineSnapshot(""); + + [Test] + public void ToSnapshotText_MixedSeverities_OneLineEachInOrder() + { + var diagnostics = new List + { + new() { Severity = Severity.Error, File = "a.md", Line = 3, Message = "bad link" }, + new() { Severity = Severity.Warning, File = "b.md", Message = "no line" }, + new() { Severity = Severity.Hint, File = "c.md", Line = 1, Message = "consider" } + }; + diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + """ + error a.md:3 bad link + warning b.md no line + hint c.md:1 consider + """ + ); + } + + [Test] + public void MockFileSystemDump_SortsByPathAndNormalisesLineEndings() + { + var fs = new MockFileSystem(); + fs.AddFile("/out/b/two.md", new MockFileData("two\r\nlines")); + fs.AddFile("/out/a.md", new MockFileData("one\n")); + MockFileSystemDump + .ToText(fs, "/out") + .Should() + .MatchInlineSnapshot( + """ + === a.md === + one + === b/two.md === + two + lines + """ + ); + } + + [Test] + public void DocsHtmlSection_DropsFixtureHeadingAndHtmxAttributes() => + """ +

Test Document

x

nav
+ """.Should().MatchInlineHtmlSnapshot( + """ +

x

+ """, + DocsHtml.Section() + ); +} From a9fd42330390df7a7c5f9ced0b4102e1d1f7f9fa Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:29:06 +0100 Subject: [PATCH 02/18] Compare rendered HTML with Nullean.Snapshots.Html ShouldBeHtml and ShouldContainHtml (Elastic.Markdown.Tests) and ConvertsToHtml, ConvertsToContainingHtml and the per-file ToHtml and ContainsHtml (Elastic.Authoring.Tests) keep their names and call sites, and now run on BeHtml and ContainHtml with the shared DocsHtml options. That removes the second copy of the AngleSharp.Diffing diff code and the AngleSharp.Diffing package. DiffPlex stays in Authoring for the plain text assertions. One test passed without testing anything. "marks exactly two options as a fork" gave the bare text `is-fork` to ConvertsToContainingHtml. The old normaliser printed only element children, so a text-only fragment became an empty string and every page contains an empty string. It now uses ConvertsToContainingRawHtml, like the test above it. Checked by corrupting one ShouldContainHtml and one ConvertsToHtml expectation: both fail. Co-Authored-By: Claude Sonnet 5.5 --- Directory.Packages.props | 1 - .../Blocks/Hub/GetStarted.cs | 2 +- .../Elastic.Authoring.Tests.csproj | 1 - .../Framework/HtmlAssertions.cs | 177 ++---------------- .../Elastic.Markdown.Tests.csproj | 2 - .../PrettyHtmlExtensions.cs | 127 +------------ 6 files changed, 22 insertions(+), 288 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b1dcf0d2c0..0fdb1d5ee6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -126,7 +126,6 @@
- diff --git a/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs b/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs index dec67a93fb..d0ccf282e0 100644 --- a/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs +++ b/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs @@ -164,7 +164,7 @@ public async Task PutsTheFirstStepInItsOwnBox() => await Docs.ConvertsToContainingRawHtml("""
  • """); [Test, DisplayName("marks exactly two options as a fork")] - public async Task MarksExactlyTwoOptionsAsAFork() => await Docs.ConvertsToContainingHtml("""is-fork"""); + public async Task MarksExactlyTwoOptionsAsAFork() => await Docs.ConvertsToContainingRawHtml("""is-fork"""); [Test, DisplayName("has no errors")] public async Task HasNoErrors() => await Docs.HasNoErrors(); diff --git a/tests/Elastic.Authoring.Tests/Elastic.Authoring.Tests.csproj b/tests/Elastic.Authoring.Tests/Elastic.Authoring.Tests.csproj index ee73b53ebd..4e5e05627f 100644 --- a/tests/Elastic.Authoring.Tests/Elastic.Authoring.Tests.csproj +++ b/tests/Elastic.Authoring.Tests/Elastic.Authoring.Tests.csproj @@ -5,7 +5,6 @@ - diff --git a/tests/Elastic.Authoring.Tests/Framework/HtmlAssertions.cs b/tests/Elastic.Authoring.Tests/Framework/HtmlAssertions.cs index 2bf599f62b..7fbebbdf03 100644 --- a/tests/Elastic.Authoring.Tests/Framework/HtmlAssertions.cs +++ b/tests/Elastic.Authoring.Tests/Framework/HtmlAssertions.cs @@ -3,13 +3,12 @@ // See the LICENSE file in the project root for more information using System.Diagnostics; -using AngleSharp.Diffing; -using AngleSharp.Diffing.Core; -using AngleSharp.Html; -using AngleSharp.Html.Parser; +using AwesomeAssertions; using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder.Model; +using Elastic.Documentation.Testing; using JetBrains.Annotations; +using Nullean.Snapshots.Html; namespace Elastic.Authoring.Tests.Framework; @@ -27,8 +26,7 @@ namespace Elastic.Authoring.Tests.Framework; /// would appear verbatim in every assertion snapshot otherwise. /// /// -/// Produces a humanized diff via that names the diff type -/// (NodeDiff / AttrDiff / MissingNodeDiff etc.) and shows expected vs. actual node text. +/// Compares through Nullean.Snapshots.Html, which reports a structural diff and then a line diff. /// /// /// All assertion methods are synchronous — the async wrapper lives on . @@ -73,173 +71,28 @@ internal static string Diff(string expected, string actual) } // ──────────────────────────────────────────────────────────────────────────────── - // AngleSharp deep HTML diff + // HTML comparison on Nullean.Snapshots.Html // ──────────────────────────────────────────────────────────────────────────────── - internal static string HtmlDiffString(IEnumerable diffs) - { - var formatter = new PrettyMarkupFormatter(); - - string NodeText(ComparisonSource source) - { - using var sw = new StringWriter(); - source.Node.ToHtml(sw, formatter); - return sw.ToString(); - } - - string AttrText(AttributeComparisonSource source) - { - using var sw = new StringWriter(); - source.Attribute.ToHtml(sw, formatter); - return sw.ToString(); - } - - // NodeDiffText and AttrDiffText accept non-nullable (callers pass .Control / .Test directly). - // For Missing*/Unexpected* cases we pass a sentinel "missing" string instead of calling these helpers. - string NodeDiffText(ComparisonSource control, ComparisonSource test) => - $"\nexpected: {NodeText(control)}\nactual: {NodeText(test)}\n"; - - string MissingNodeText(ComparisonSource control) => $"\nexpected: {NodeText(control)}\nactual: missing\n"; - - string UnexpectedNodeText(ComparisonSource test) => $"\nexpected: missing\nactual: {NodeText(test)}\n"; - - string AttrDiffText(AttributeComparisonSource control, AttributeComparisonSource test) => - $"\nexpected: {AttrText(control)}\nactual: {AttrText(test)}\n"; - - string MissingAttrText(AttributeComparisonSource control) => $"\nexpected: {AttrText(control)}\nactual: missing\n"; - - string UnexpectedAttrText(AttributeComparisonSource test) => $"\nexpected: missing\nactual: {AttrText(test)}\n"; - - static string NodeName(ComparisonSource source) => source.Node.NodeType.ToString().ToLowerInvariant(); - - return string.Join('\n', diffs.Select(d => - { - var label = d switch - { - NodeDiff nd when nd.Target == DiffTarget.Text && nd.Control.Path.Equals(nd.Test.Path, StringComparison.Ordinal) => - $"The text in {nd.Control.Path} is different.", - NodeDiff nd when nd.Target == DiffTarget.Text => - $"The expected {NodeName(nd.Control)} at {nd.Control.Path} and the actual {NodeName(nd.Test)} at {nd.Test.Path} is different.", - NodeDiff nd when nd.Control.Path.Equals(nd.Test.Path, StringComparison.Ordinal) => - $"The {NodeName(nd.Control)}s at {nd.Control.Path} are different.", - NodeDiff nd => - $"The expected {NodeName(nd.Control)} at {nd.Control.Path} and the actual {NodeName(nd.Test)} at {nd.Test.Path} are different.", - AttrDiff ad when ad.Control.Path.Equals(ad.Test.Path, StringComparison.Ordinal) => - $"The values of the attributes at {ad.Control.Path} are different.", - AttrDiff ad => $"The value of the attribute {ad.Control.Path} and actual attribute {ad.Test.Path} are different.", - MissingNodeDiff md => $"The {NodeName(md.Control)} at {md.Control.Path} is missing.", - MissingAttrDiff ma => $"The attribute at {ma.Control.Path} is missing.", - UnexpectedNodeDiff ud => $"The {NodeName(ud.Test)} at {ud.Test.Path} was not expected.", - UnexpectedAttrDiff ua => $"The attribute at {ua.Test.Path} was not expected.", - _ => throw new InvalidOperationException($"Unknown diff type detected: {d.GetType()}") - }; - - var detail = d switch - { - NodeDiff nd => NodeDiffText(nd.Control, nd.Test), - AttrDiff ad => AttrDiffText(ad.Control, ad.Test), - MissingNodeDiff md => MissingNodeText(md.Control), - MissingAttrDiff ma => MissingAttrText(ma.Control), - UnexpectedNodeDiff ud => UnexpectedNodeText(ud.Test), - UnexpectedAttrDiff ua => UnexpectedAttrText(ua.Test), - _ => throw new InvalidOperationException($"Unknown diff type detected: {d.GetType()}") - }; - - return label + detail; - })); - } - - // ──────────────────────────────────────────────────────────────────────────────── - // Pretty printer — matches the F# prettyHtml behaviour exactly - // ──────────────────────────────────────────────────────────────────────────────── - - private static string PrettyHtml(string html, string? querySelector) - { - var parser = new HtmlParser(); - var document = parser.ParseDocument(html); - var element = querySelector is null ? document.Body! : document.QuerySelector(querySelector)!; - - // Strip HTMX attributes from links — they are navigation artifacts, not content. - foreach (var link in element.QuerySelectorAll("a")) - { - link.RemoveAttribute("hx-get"); - link.RemoveAttribute("hx-select-oob"); - link.RemoveAttribute("hx-swap"); - link.RemoveAttribute("hx-indicator"); - link.RemoveAttribute("hx-push-url"); - link.RemoveAttribute("preload"); - } - - var formatter = new PrettyMarkupFormatter(); - using var sw = new StringWriter(); - - // Skip the first child if it is an H1 — the fixture wraps Setup.Markdown content in one. - var children = element.Children.ToList(); - for (var i = 0; i < children.Count; i++) - { - if (i == 0 && children[i].TagName == "H1") - continue; - children[i].ToHtml(sw, formatter); - } - - return sw.ToString().TrimStart('\n'); - } - - private static void CreateDiff(string expected, string actual) - { - // Identical pretty-printed HTML has no structural differences, so skip the AngleSharp comparison for the usual passing case. - if (string.Equals(expected, actual, StringComparison.Ordinal)) - return; - - var diffs = DiffBuilder.Compare(expected).WithTest(actual).Build(); - - var deepComparison = HtmlDiffString(diffs); - if (string.IsNullOrEmpty(deepComparison)) - return; - - var textDiff = Diff(expected, actual); - throw new AwesomeAssertions.Execution.AssertionFailedException( - $""" -Html was not equal --- DIFF -- -{textDiff} + /// + /// The rendered page's content section, minus the fixture's leading H1 and the htmx attributes on links. + /// The expected fragment is wrapped in the same section so both sides go through identical normalisation. + /// + private static HtmlSnapshotOptions Options => DocsHtml.Section(); --- Comparison -- -{deepComparison} -""" - ); - } + private static string InSection(string fragment) => $"
    {fragment}
    "; // ──────────────────────────────────────────────────────────────────────────────── // Per-file assertions (called from MarkdownResultTask / Scenario) // ──────────────────────────────────────────────────────────────────────────────── [DebuggerStepThrough] - internal static void ToHtml([LanguageInjection("html")] string expected, MarkdownResult actual) - { - var expectedHtml = PrettyHtml(expected, null); - var actualHtml = PrettyHtml(actual.Html, "section#elastic-docs-v3"); - CreateDiff(expectedHtml, actualHtml); - } + internal static void ToHtml([LanguageInjection("html")] string expected, MarkdownResult actual) => + actual.Html.Should().BeHtml(InSection(expected), Options); [DebuggerStepThrough] - internal static void ContainsHtml([LanguageInjection("html")] string expected, MarkdownResult actual) - { - var prettyExpected = PrettyHtml(expected, null); - var prettyActual = PrettyHtml(actual.Html, "section#elastic-docs-v3"); - - if (!prettyActual.Contains(prettyExpected, StringComparison.Ordinal)) - throw new AwesomeAssertions.Execution.AssertionFailedException( - $""" -Expected html to contain: -{prettyExpected} - -But was not found in: - -{prettyActual} -""" - ); - } + internal static void ContainsHtml([LanguageInjection("html")] string expected, MarkdownResult actual) => + actual.Html.Should().ContainHtml(InSection(expected), Options); [DebuggerStepThrough] internal static void ContainsRawHtml(string expected, MarkdownResult actual) diff --git a/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj b/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj index a128c14af7..e791376c2a 100644 --- a/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj +++ b/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj @@ -5,8 +5,6 @@ - - diff --git a/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs b/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs index eb552208b7..29cd3b7467 100644 --- a/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs +++ b/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs @@ -2,142 +2,27 @@ // 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 AngleSharp.Diffing; -using AngleSharp.Html; -using AngleSharp.Html.Parser; using AwesomeAssertions; -using AwesomeAssertions.Execution; -using DiffPlex.DiffBuilder; -using DiffPlex.DiffBuilder.Model; +using Elastic.Documentation.Testing; using JetBrains.Annotations; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests; +/// HTML comparisons on Nullean.Snapshots.Html: structural, so attribute order and whitespace never fail a test. public static class PrettyHtmlExtensions { - public static string PrettyHtml([LanguageInjection("html")] this string html, bool sanitize = true) - { - var parser = new HtmlParser(); - - var document = parser.ParseDocument(html); - var element = document.Body; - if (element is null) - return string.Empty; - - if (sanitize) - { - var links = element.QuerySelectorAll("a"); - foreach (var l in links) - { - l.RemoveAttribute("hx-get"); - l.RemoveAttribute("hx-select-oob"); - l.RemoveAttribute("hx-swap"); - l.RemoveAttribute("hx-indicator"); - l.RemoveAttribute("hx-push-url"); - l.RemoveAttribute("preload"); - } - } - - using var sw = new StringWriter(); - var formatter = new PrettyMarkupFormatter(); - foreach (var c in element.Children) - { - // ReSharper disable once AccessToDisposedClosure - c.ToHtml(sw, formatter); - } - return sw.ToString().TrimStart('\n'); - } + private static HtmlSnapshotOptions Options(bool sanitize) => sanitize ? DocsHtml.Body() : new HtmlSnapshotOptions(); public static void ShouldBeHtml( [LanguageInjection("html")] this string actual, [LanguageInjection("html")] string expected, bool sanitize = true - ) - { - expected = expected.Trim('\n').PrettyHtml(sanitize); - actual = actual.Trim('\n').PrettyHtml(sanitize); + ) => actual.Trim('\n').Should().BeHtml(expected.Trim('\n'), Options(sanitize)); - var diff = DiffBuilder.Compare(actual).WithTest(expected).Build().ToArray(); - - if (diff.Length == 0) - return; - - throw new AssertionFailedException(CreateDiff(actual, expected, sanitize)); - } public static void ShouldContainHtml( [LanguageInjection("html")] this string actual, [LanguageInjection("html")] string expected, bool sanitize = true - ) - { - expected = expected.Trim('\n').PrettyHtml(sanitize); - actual = actual.Trim('\n').PrettyHtml(sanitize); - - var actualCompare = actual.Replace("\t", string.Empty); - var expectedCompare = expected.Replace("\t", string.Empty); - - // we compare over unindented HTML, but if that fails, we rely on the pretty HTML Contain(). - // to throw for improved error messages - if (!actualCompare.Contains(expectedCompare)) - actual.Should().Contain(expected); - } - - public static string CreateDiff(this string actual, string expected, bool sanitize = true) - { - expected = expected.Trim('\n').PrettyHtml(sanitize); - actual = actual.Trim('\n').PrettyHtml(sanitize); - var diffLines = InlineDiffBuilder.Diff(expected, actual).Lines; - - var mutatedCount = diffLines.Count( - l => - l.Type switch - { - ChangeType.Unchanged => false, - ChangeType.Deleted => true, - ChangeType.Inserted => true, - ChangeType.Imaginary => false, - ChangeType.Modified => true, - _ => false - } - ); - if (mutatedCount == 0) - return string.Empty; - - var actualLineLength = actual.Split("\n").Length; - if (mutatedCount >= actualLineLength) - { - return $$""" - Mutations {{mutatedCount}} on all {{actualLineLength}} showing - EXPECTED: - {{expected}} - ACTUAL: - {{actual}} - """; - } - - using var sw = new StringWriter(); - foreach (var l in diffLines) - { - switch (l.Type) - { - case ChangeType.Unchanged: - sw.WriteLine(l.Text); - break; - case ChangeType.Deleted: - sw.WriteLine("- " + l.Text); - break; - case ChangeType.Inserted: - sw.WriteLine("+ " + l.Text); - break; - case ChangeType.Imaginary: - sw.WriteLine("? " + l.Text); - break; - case ChangeType.Modified: - sw.WriteLine("+ " + l.Text); - break; - } - } - - return sw.ToString(); - } + ) => actual.Trim('\n').Should().ContainHtml(expected.Trim('\n'), Options(sanitize)); } From 87a7c87341e63e1ccac3978613615e3d1879ef3e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:35:30 +0100 Subject: [PATCH 03/18] Markdown tests: inline HTML snapshots where ShouldBeHtml/ShouldContainHtml were used The 17 ShouldBeHtml/ShouldContainHtml call sites outside Directives become MatchInlineHtmlSnapshot with DocsHtml.Body(). The ShouldBeHtml sites kept their literals unchanged and pass as they are. The ShouldContainHtml sites compared a fragment of the page; the snapshot is the whole rendered output, so those tests became stricter. Every old fragment appears in the accepted snapshot (the htmx attributes are removed by DocsHtml.Body(), as ShouldContainHtml already did). Co-Authored-By: Claude Sonnet 5.5 --- .../CodeBlocks/CallOutTests.cs | 90 +++++++++++---- .../FileInclusion/IncludeTests.cs | 4 +- .../Inline/AnchorLinkTests.cs | 109 ++++++++++++++++-- .../Inline/CommentTest.cs | 16 ++- .../Inline/DirectiveBlockLinkTests.cs | 37 +++++- .../Inline/HardBreakTests.cs | 10 +- .../Inline/InlineAnchorTests.cs | 57 +++++++-- .../Inline/InlineImageTest.cs | 37 ++++-- .../Inline/InlineLinkTests.cs | 53 ++++++--- .../Inline/SubstitutionTest.cs | 13 ++- .../TaskList/BasicTaskListTests.cs | 32 ++++- 11 files changed, 373 insertions(+), 85 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs index b5a28959c9..48e9086765 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs @@ -4,9 +4,11 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; using JetBrains.Annotations; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.CodeBlocks; @@ -63,12 +65,26 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => [Test] public void RendersFormattedInlineMarkdown() => - Html.ShouldContainHtml( + Html.Should().MatchInlineHtmlSnapshot( """ +
    +
    +
    +						var x = 1;
    +							
    +						
    +					
    +
    +
      -
    1. this uses formatting and a link
    2. +
    3. this uses + formatting + and a + link +
    - """ + """, + DocsHtml.Body() ); [Test] @@ -146,22 +162,28 @@ public void ParsesMagicCallOuts() => [Test] public void RendersExpectedHtml() => - Html.ShouldBeHtml( + Html.Should().MatchInlineHtmlSnapshot( """
    -
    var x = 1;
    -					  var y = x - 2;
    -					  var z = y - 2;
    -					
    +
    +						var x = 1;
    +							
    +							var y = x - 2; var z = y - 2;
    +							
    +						
    +					
    +
    - -

    OUTPUT:

    +

    + OUTPUT: +

      -
    1. Marking the first callout
    2. -
    3. Marking the second callout
    4. +
    5. Marking the first callout
    6. +
    7. Marking the second callout
    - """ + """, + DocsHtml.Body() ); [Test] @@ -395,13 +417,25 @@ public void ParsesCallouts() => [Test] public void RenderedHtmlContainsCallouts() => - Html.ShouldContainHtml( + Html.Should().MatchInlineHtmlSnapshot( """ +
    +
    +
    +						var x = 1;
    +							
    +							var y = x - 2; var z = y - 2;
    +							
    +						
    +					
    +
    +
      -
    1. First callout
    2. -
    3. Second callout
    4. +
    5. First callout
    6. +
    7. Second callout
    - """ + """, + DocsHtml.Body() ); } @@ -456,14 +490,28 @@ 2. Second callout [Test] public void RendersIntermediateParagraph() => - Html.ShouldContainHtml( + Html.Should().MatchInlineHtmlSnapshot( """ -

    This is an intermediate paragraph

    +
    +
    +
    +						var x = 1;
    +							
    +							var y = x - 2; var z = y - 2;
    +							
    +						
    +					
    +
    +
    +

    + This is an intermediate paragraph +

    1. First callout
    2. -
    3. Second callout
    4. +
    5. Second callout
    - """ + """, + DocsHtml.Body() ); } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs index 325151d68f..7bbab23991 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs @@ -5,8 +5,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.FileInclusion; @@ -27,7 +29,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Test] - public void IncludesInclusionHtml() => Html.ShouldBeHtml("

    Hello world

    "); + public void IncludesInclusionHtml() => Html.Should().MatchInlineHtmlSnapshot("

    Hello world

    ", DocsHtml.Body()); } [InheritsTests] diff --git a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs index 215c42a63b..a126648b56 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs @@ -4,8 +4,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -54,7 +56,21 @@ public class InPageAnchorTests() : AnchorLinkTestBase(""" """) { [Test] - public void GeneratesHtml() => Html.ShouldContainHtml("""

    Hello

    """); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Hello +

    + """, + DocsHtml.Body() + ); [Test] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); @@ -68,8 +84,19 @@ [Sub Requirements](testing/req.md#sub-requirements) [Test] public void GeneratesHtml() => // language=html - Html.ShouldContainHtml( - """

    Sub Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Sub Requirements +

    + """, + DocsHtml.Body() ); [Test] @@ -84,8 +111,19 @@ [Sub Requirements](testing/req.md#new-reqs) [Test] public void GeneratesHtml() => // language=html - Html.ShouldContainHtml( - """

    Sub Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Sub Requirements +

    + """, + DocsHtml.Body() ); [Test] @@ -99,8 +137,19 @@ public class ExternalPageAnchorAutoTitleTests() : AnchorLinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Special Requirements > Sub Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Special Requirements > Sub Requirements +

    + """, + DocsHtml.Body() ); [Test] @@ -113,7 +162,21 @@ public class InPageBadAnchorTests() : AnchorLinkTestBase(""" """) { [Test] - public void GeneratesHtml() => Html.ShouldContainHtml("""

    Hello

    """); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Hello +

    + """, + DocsHtml.Body() + ); [Test] public void HasError() => @@ -127,8 +190,19 @@ [Sub Requirements](testing/req.md#sub-requirements2) { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Sub Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Sub Requirements +

    + """, + DocsHtml.Body() ); [Test] @@ -143,8 +217,19 @@ [Heading inside dropdown](testing/req.md#heading-inside-dropdown) { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """Heading inside dropdown""" + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Heading inside dropdown +

    + """, + DocsHtml.Body() ); [Test] public void HasError() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs b/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs index f55dbed559..1e4b4efae7 100644 --- a/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs @@ -2,6 +2,8 @@ // 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 AwesomeAssertions; +using Elastic.Documentation.Testing; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -15,9 +17,9 @@ public void GeneratesAttributesInHtml() { // language=html Html.Should().NotContain("""

    % comment""").And.Contain("""

    not a comment

    """); - Html.ShouldBeHtml(""" + Html.Should().MatchInlineHtmlSnapshot("""

    not a comment

    - """); + """, DocsHtml.Body()); } } @@ -49,11 +51,12 @@ public void GeneratesAttributesInHtml() ) .And .ContainAll("

    not a comment, and multi line comment below

    ", "

    also not a comment

    "); - Html.ShouldBeHtml( + Html.Should().MatchInlineHtmlSnapshot( """

    not a comment, and multi line comment below

    also not a comment

    - """ + """, + DocsHtml.Body() ); } } @@ -92,11 +95,12 @@ public void GeneratesAttributesInHtml() ) .And .ContainAll("

    not a comment, and multi line comment below

    ", "

    also not a comment

    "); - Html.ShouldBeHtml( + Html.Should().MatchInlineHtmlSnapshot( """

    not a comment, and multi line comment below

    also not a comment

    - """ + """, + DocsHtml.Body() ); } } diff --git a/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs index 326c4f4a53..6668d8169b 100644 --- a/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs @@ -4,8 +4,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -46,7 +48,22 @@ public class InPageDirectiveLinkTests() : DirectiveBlockLinkTests(""" [Test] public void GeneratesHtml() => // language=html - Html.ShouldContainHtml("""

    Hello

    """); + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +
    + Warning +
    +
    +

    This is a 'warning' admonition

    +
    +
    +

    + Hello +

    + """, + DocsHtml.Body() + ); [Test] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); @@ -58,7 +75,23 @@ [Sub Requirements](testing/req.md#hint_ref) """) { [Test] - public void GeneratesHtml() => Html.ShouldContainHtml("""

    Sub Requirements

    """); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +
    + Warning +
    +
    +

    This is a 'warning' admonition

    +
    +
    +

    + Sub Requirements +

    + """, + DocsHtml.Body() + ); [Test] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs b/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs index 506d6aea65..4b2882bb60 100644 --- a/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs @@ -2,23 +2,27 @@ // 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 AwesomeAssertions; +using Elastic.Documentation.Testing; +using Nullean.Snapshots.Html; + namespace Elastic.Markdown.Tests.Inline; public class AllowBrTagTest() : InlineTest("Hello,
    World!") { [Test] - public void GeneratesHtml() => Html.ShouldContainHtml("

    Hello,
    World!

    "); + public void GeneratesHtml() => Html.Should().MatchInlineHtmlSnapshot("

    Hello,
    World!

    ", DocsHtml.Body()); } public class BrTagNeedsToBeExact() : InlineTest("Hello,
    World
    !") { [Test] - public void GeneratesHtml() => Html.ShouldContainHtml("

    Hello,<br >World<br />!

    "); + public void GeneratesHtml() => Html.Should().MatchInlineHtmlSnapshot("

    Hello,<br >World<br />!

    ", DocsHtml.Body()); } public class DisallowSpanTag() : InlineTest("Hello,World!") { [Test] // span tag is rendered as text - public void GeneratesHtml() => Html.ShouldContainHtml("

    Hello,<span>World!</span>

    "); + public void GeneratesHtml() => Html.Should().MatchInlineHtmlSnapshot("

    Hello,<span>World!</span>

    ", DocsHtml.Body()); } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs index daf95f63f6..a14677f3b6 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs @@ -4,10 +4,12 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.InlineParsers; using JetBrains.Annotations; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -27,8 +29,9 @@ public void ParsesBlock() [Test] public void GeneratesAttributesInHtml() => - Html.ShouldContainHtml( - """

    this is regular text and this and this continues to be regular text

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    this is regular text and this and this continues to be regular text

    """, + DocsHtml.Body() ); } @@ -67,7 +70,7 @@ public void ParsesBlock() [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml("""

    this is regular text and this

    """); + Html.Should().MatchInlineHtmlSnapshot("""

    this is regular text and this

    """, DocsHtml.Body()); } [InheritsTests] @@ -89,7 +92,7 @@ this is regular text and this $$$is-an-inline-anchor$$ [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml("""

    this is regular text and this $$$is-an-inline-anchor$$

    """); + Html.Should().MatchInlineHtmlSnapshot("""

    this is regular text and this $$$is-an-inline-anchor$$

    """, DocsHtml.Body()); } [InheritsTests] @@ -100,11 +103,12 @@ public class InlineAnchorInHeading() : BlockTest(""" [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldBeHtml( + Html.Should().MatchInlineHtmlSnapshot( """ - """ + """, + DocsHtml.Body() ); [Test] @@ -123,12 +127,13 @@ public class ExplicitSlugInHeader() : BlockTest(""" [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldBeHtml( + Html.Should().MatchInlineHtmlSnapshot( """ - """ + """, + DocsHtml.Body() ); } @@ -180,7 +185,23 @@ public class InlineAnchorCanBeLinkedToo() : InlineAnchorLinkTestBase(""" [Test] public void GeneratesHtml() => // language=html - Html.ShouldContainHtml("""

    Hello

    """); + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Hello +

    +

    + +

    + """, + DocsHtml.Body() + ); [Test] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); @@ -193,8 +214,22 @@ [Sub Requirements](testing/req.md#custom-anchor) { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Sub Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +
    +

    + Hello world +

    +
    +

    A paragraph

    +

    + Sub Requirements +

    +

    + +

    + """, + DocsHtml.Body() ); [Test] diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs b/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs index 6bc36625e1..5d6b4f184e 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs @@ -2,7 +2,9 @@ // 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 AwesomeAssertions; +using Elastic.Documentation.Testing; using Markdig.Syntax.Inlines; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -17,7 +19,10 @@ public class InlineImageTest() : InlineTest(""" [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml("""

    Elasticsearch

    """); + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() + ); } [InheritsTests] @@ -31,7 +36,10 @@ public class RelativeInlineImageTest() : InlineTest(""" [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml("""

    Elasticsearch

    """); + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() + ); } // Test image sizing with space before = @@ -48,8 +56,9 @@ public class InlineImageWithSizingSpaceBeforeTest() : InlineTest( [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() ); } @@ -67,8 +76,9 @@ public class InlineImageWithSizingNoSpaceBeforeTest() : InlineTest( [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() ); } @@ -84,8 +94,9 @@ public class InlineImageWithPixelSizingTest() : InlineTest(""" [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() ); } @@ -103,8 +114,9 @@ public class InlineImageWithTitleAndSizingTest() : InlineTest( [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() ); } @@ -120,7 +132,8 @@ public class InlineImageWithWidthOnlyTest() : InlineTest(""" [Test] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    Elasticsearch

    """, + DocsHtml.Body() ); } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs index 425e28b63b..9df6a29a9a 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs @@ -5,8 +5,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -43,8 +45,13 @@ public class InlineLinkTests() : LinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Elasticsearch

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

    + Elasticsearch +

    + """, + DocsHtml.Body() ); [Test] @@ -58,8 +65,13 @@ public class LinkToPageTests() : LinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

    + Requirements +

    + """, + DocsHtml.Body() ); [Test] @@ -76,8 +88,13 @@ public class InsertPageTitleTests() : LinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Special Requirements

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

    + Special Requirements +

    + """, + DocsHtml.Body() ); [Test] @@ -96,8 +113,13 @@ public class RepositoryLinksTest() : LinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    test

    """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

    + test +

    + """, + DocsHtml.Body() ); [Test] @@ -116,8 +138,9 @@ public class CrossLinkReferenceTest() : LinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    test

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    test

    """, + DocsHtml.Body() ); [Test] @@ -235,8 +258,9 @@ [link to app]({{some-url-with-a-version}}) { [Test] public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    link to app

    """ + Html.Should().MatchInlineHtmlSnapshot( + """

    link to app

    """, + DocsHtml.Body() ); [Test] @@ -303,7 +327,7 @@ public class CommentedNonExistingLinks2() : LinkTestBase( { [Test] public void GeneratesHtml() => - Html.ShouldBeHtml( + Html.Should().MatchInlineHtmlSnapshot( """

    Links:

      @@ -312,7 +336,8 @@ public void GeneratesHtml() => - """ + """, + DocsHtml.Body() ); [Test] diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index 67e87856dd..1c93d59c86 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -3,8 +3,10 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.InlineParsers.Substitution; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -151,7 +153,16 @@ public class ReplaceInHeader() : InlineTest( { [Test] public void OnlySeesGlobalVariable() => - Html.ShouldContainHtml("""

      Hello World!

      """); + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +

      + Hello World! +

      +
      + """, + DocsHtml.Body() + ); [Test] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs b/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs index 3c85e32a17..9da44bab31 100644 --- a/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs +++ b/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs @@ -3,7 +3,9 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Tests.Inline; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.TaskList; @@ -19,8 +21,34 @@ public class BasicTaskListTests() : InlineTest(""" public void RendersTaskListItem() => Html.Should().Contain("class=\"task-list-item\""); [Test] - public void RendersUncheckedCheckbox() => Html.ShouldContainHtml(""""""); + public void RendersUncheckedCheckbox() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
        +
      • + + A pending task
      • +
      • + + A completed task
      • +
      + """, + DocsHtml.Body() + ); [Test] - public void RendersCheckedCheckbox() => Html.ShouldContainHtml(""""""); + public void RendersCheckedCheckbox() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
        +
      • + + A pending task
      • +
      • + + A completed task
      • +
      + """, + DocsHtml.Body() + ); } From 674a485c859438a156dae27b1c4276bb29f0bd01 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:40:52 +0100 Subject: [PATCH 04/18] Markdown tests (Inline): whole-output snapshots instead of Contain fragments Output of the Inline fixtures now lives in inline snapshots. Where a test (or a cluster of tests of one class) asserted Contain/NotContain fragments on the rendered HTML, it is one MatchInlineHtmlSnapshot of the whole output; where it asserted a count or message on Collector.Diagnostics, it is a MatchInlineSnapshot of the diagnostics text ("" when the run is clean). These tests became stricter: they now fail on any unexpected change in the output, not only on the fragments they named. Before accepting, every old Contain fragment was checked to appear in the accepted snapshot (and no NotContain fragment to appear); the htmx attributes on links are removed by DocsHtml.Body(), as ShouldContainHtml already did. Tests of one class that only asserted fragments of the same output are merged into one test; their explanatory comments moved onto it. Kept as they were: the footnote index/regex-count tests, the raw-output nested anchor check (the HTML parser would split nested anchors in a snapshot), and BeNullOrWhiteSpace. Found: MutationOperatorsWorkInLinks asserted Contain("ELASTICSEARCH 9.0"), which a literal "[text](ELASTICSEARCH 9.0)" in the output satisfied, so the test passed while the link did not render as a link. The snapshot now shows it. Co-Authored-By: Claude Sonnet 5.5 --- .../Inline/AnchorLinkTests.cs | 48 +- .../Inline/AutoLinkTests.cs | 272 +++++++---- .../Inline/CommentTest.cs | 86 +--- .../Inline/DirectiveBlockLinkTests.cs | 5 +- .../Inline/FootnotesTests.cs | 433 +++++++++++++----- .../Inline/IconParserTests.cs | 91 ++-- .../Inline/InlineAnchorTests.cs | 23 +- .../Inline/InlineLinkTests.cs | 154 +++++-- .../Inline/MathRoleTests.cs | 22 +- .../Inline/SubstitutionInlineCodeTest.cs | 35 +- .../Inline/SubstitutionTest.cs | 195 ++++---- 11 files changed, 888 insertions(+), 476 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs index a126648b56..a87944016f 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -73,7 +74,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -100,7 +101,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -127,7 +128,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -153,7 +154,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -180,7 +181,11 @@ public void GeneratesHtml() => [Test] public void HasError() => - Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("`hello-world2` does not exist")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot("error {repo}/docs/index.md:7 `hello-world2` does not exist in index.md."); } [InheritsTests] @@ -207,7 +212,11 @@ public void GeneratesHtml() => [Test] public void HasError() => - Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("`sub-requirements2` does not exist")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot("error {repo}/docs/index.md:7 `sub-requirements2` does not exist in testing/req.md."); } [InheritsTests] @@ -232,7 +241,7 @@ public void GeneratesHtml() => DocsHtml.Body() ); [Test] - public void HasError() => Collector.Diagnostics.Should().HaveCount(0); + public void HasError() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -242,7 +251,13 @@ public class MissingMdExtensionTests() : AnchorLinkTestBase(""" { [Test] public void HasMdExtensionHintError() => - Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("Did you forget to add the .md extension?")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/index.md:7 `testing/req` is not a valid internal link. Did you forget to add the .md extension?" + ); } [InheritsTests] @@ -252,7 +267,13 @@ public class MissingMdExtensionWithAnchorTests() : AnchorLinkTestBase(""" { [Test] public void HasMdExtensionHintError() => - Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("Did you forget to add the .md extension?")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/index.md:7 `testing/req` is not a valid internal link. Did you forget to add the .md extension?" + ); } [InheritsTests] @@ -264,10 +285,9 @@ public class MissingFileNoMdHintTests() : AnchorLinkTestBase(""" public void HasGenericNotFoundError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .Contain(d => d.Message.Contains("does not exist")) - .And - .NotContain(d => d.Message.Contains("Did you forget to add the .md extension?")); + .MatchInlineSnapshot( + "error {repo}/docs/index.md:7 `testing/nonexistent` does not exist. If it was recently removed add a redirect. resolved to `{repo}/docs/testing/nonexistent`" + ); } diff --git a/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs index de40af1a39..251e3c087a 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs @@ -4,8 +4,11 @@ using AwesomeAssertions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -33,10 +36,17 @@ public class BasicAutoLinkTests() : AutoLinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.Should().Contain("""https://docs.test.io"""); + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Check out + https://docs.test.io + for more info.

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -46,12 +56,17 @@ public class AutoLinkWithPathTests() : AutoLinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.Should().Contain( - """https://docs.test.io/path/to/page""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Visit + https://docs.test.io/path/to/page + for details.

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -61,12 +76,17 @@ public class AutoLinkWithQueryStringTests() : AutoLinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.Should().Contain( - """https://docs.test.io/search?q=test&page=1""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      See + https://docs.test.io/search?q=test&page=1 + for results.

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -76,12 +96,17 @@ public class AutoLinkWithAnchorTests() : AutoLinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.Should().Contain( - """https://docs.test.io/page#section""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Jump to + https://docs.test.io/page#section + for the section.

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -91,10 +116,16 @@ public class AutoLinkTrailingPeriodTests() : AutoLinkTestBase(""" { [Test] public void ExcludesTrailingPeriod() => - Html.Should().Contain("""https://docs.test.io."""); + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Check out + https://docs.test.io.

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -106,16 +137,19 @@ public class AutoLinkTrailingCommaTests() : AutoLinkTestBase( { [Test] public void ExcludesTrailingCommas() => - Html - .Should() - .Contain("""https://first.test.io,""") - .And - .Contain("""https://second.test.io,""") - .And - .Contain("""https://third.test.io"""); + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Visit + https://first.test.io, + https://second.test.io, or + https://third.test.io + for info.

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -125,10 +159,13 @@ public class AutoLinkInParenthesesTests() : AutoLinkTestBase(""" { [Test] public void ExcludesClosingParen() => - Html.Should().Contain("""(https://docs.test.io)"""); + Html.Should().MatchInlineHtmlSnapshot( + "

      See the docs (https://docs.test.io) for details.

      ", + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -140,12 +177,17 @@ public class AutoLinkWithBalancedParensTests() : AutoLinkTestBase( { [Test] public void IncludesBalancedParens() => - Html.Should().Contain( - """https://en.wikipedia.org/wiki/Rust_(programming_language)""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Check + https://en.wikipedia.org/wiki/Rust_(programming_language) + for more.

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -155,19 +197,23 @@ public class AutoLinkElasticDocsHintTests() : AutoLinkTestBase(""" { [Test] public void GeneratesHtml() => - Html.Should().Contain( - """https://www.elastic.co/docs/deploy-manage""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      See + https://www.elastic.co/docs/deploy-manage + for deployment info.

      + """, + DocsHtml.Body() ); [Test] public void EmitsHint() => Collector .Diagnostics + .ToSnapshotText() .Should() - .ContainSingle( - d => d.Severity == Severity.Hint && d.Message.Contains("elastic.co/docs") && d.Message.Contains( - "crosslink or relative link" - ) + .MatchInlineSnapshot( + "hint {repo}/docs/index.md:3 Autolink points to elastic.co/docs. Consider using a crosslink or relative link instead." ); } @@ -178,10 +224,23 @@ public class AutoLinkInCodeBlockTests() : AutoLinkNotFoundTestBase(""" """) { [Test] - public void DoesNotCreateLink() => Html.Should().NotContain(" +
      +
      +					https://docs.test.io/should/not/be/linked
      +					
      +				
      +
      + + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkInInlineCodeTests() : AutoLinkNotFoundTestBase(""" @@ -190,10 +249,17 @@ public class AutoLinkInInlineCodeTests() : AutoLinkNotFoundTestBase(""" { [Test] public void DoesNotCreateLinkInInlineCode() => - Html.Should().Contain("https://docs.test.io/api").And.NotContain("""
      Use the URL + https://docs.test.io/api + in your config.

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkDoesNotMatchHttpTests() : AutoLinkNotFoundTestBase(""" @@ -201,10 +267,11 @@ public class AutoLinkDoesNotMatchHttpTests() : AutoLinkNotFoundTestBase(""" """) { [Test] - public void DoesNotCreateLink() => Html.Should().NotContain("
      This http://docs.test.io should not be autolinked.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -216,14 +283,19 @@ public class AutoLinkWithStandardLinkTests() : AutoLinkTestBase( { [Test] public void BothLinksWork() => - Html - .Should() - .Contain("""
      Docs""") - .And - .Contain("""https://other.test.io"""); + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Visit + Docs + or + https://other.test.io + for more.

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -235,15 +307,21 @@ public class AutoLinkInsideLinkTextTests() : AutoLinkTestBase( ) { [Test] - public void DoesNotCreateNestedAnchor() => - Html - .Should() - .Contain("""https://gist.github.com""") - .And - .NotMatchRegex(@"]*>Upload to a service like + https://gist.github.com.

      + """, + DocsHtml.Body() + ); + // The HTML parser would split nested anchors in the snapshot, so look for them in the raw output. + Html.Should().NotMatchRegex(@"]*> Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -255,12 +333,16 @@ public class AutoLinkInsideLinkTextWithSurroundingTextTests() : AutoLinkTestBase { [Test] public void DoesNotAutolinkUrlInsideLinkText() => - Html.Should().Contain( - """the page at https://example.test.io for details""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      See + the page at https://example.test.io for details.

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } // Verify that image-inside-link is unaffected by the IsNestedInsideLink guard (images bypass it via the IsImage branch). @@ -270,14 +352,20 @@ public class ImageInsideLinkTests() : InlineTest(""" """) { [Test] - public void RendersOuterAnchor() => - Html.Should().Contain(""""""); - - [Test] - public void RendersImage() => Html.Should().Contain(" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + + alt text + +

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -289,16 +377,21 @@ public class MultipleAutoLinksTests() : AutoLinkTestBase( { [Test] public void AllLinksAreCreated() => - Html - .Should() - .Contain("""First + https://first.com + then + https://second.com + and finally + https://third.com + are all linked.

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } // === Exclusion rule tests === @@ -308,10 +401,11 @@ public class AutoLinkWithPortExclusionTests() : AutoLinkNotFoundTestBase(""" """) { [Test] - public void DoesNotCreateLinkForUrlWithPort() => Html.Should().NotContain(" + Html.Should().MatchInlineHtmlSnapshot("

      Connect to https://www.elastic.co:443/guide for the guide.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkLocalhostExclusionTests() : AutoLinkNotFoundTestBase(""" @@ -319,10 +413,11 @@ public class AutoLinkLocalhostExclusionTests() : AutoLinkNotFoundTestBase(""" """) { [Test] - public void DoesNotCreateLinkForLocalhost() => Html.Should().NotContain("
      + Html.Should().MatchInlineHtmlSnapshot("

      Check https://localhost/api for the local API.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkLoopbackExclusionTests() : AutoLinkNotFoundTestBase(""" @@ -330,10 +425,11 @@ public class AutoLinkLoopbackExclusionTests() : AutoLinkNotFoundTestBase(""" """) { [Test] - public void DoesNotCreateLinkForLoopback() => Html.Should().NotContain("
      + Html.Should().MatchInlineHtmlSnapshot("

      Test at https://127.0.0.1/health for health check.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkExampleDomainExclusionTests() : AutoLinkNotFoundTestBase(""" @@ -341,10 +437,11 @@ public class AutoLinkExampleDomainExclusionTests() : AutoLinkNotFoundTestBase("" """) { [Test] - public void DoesNotCreateLinkForExampleDomain() => Html.Should().NotContain("
      + Html.Should().MatchInlineHtmlSnapshot("

      See https://example.com/docs for examples.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkExampleSubdomainExclusionTests() : AutoLinkNotFoundTestBase(""" @@ -353,10 +450,10 @@ public class AutoLinkExampleSubdomainExclusionTests() : AutoLinkNotFoundTestBase { [Test] public void DoesNotCreateLinkForExampleSubdomain() => - Html.Should().NotContain("
      Visit https://system.example.com/setup for setup.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class AutoLinkTemplatePlaceholderExclusionTests() : AutoLinkNotFoundTestBase( @@ -366,7 +463,8 @@ public class AutoLinkTemplatePlaceholderExclusionTests() : AutoLinkNotFoundTestB ) { [Test] - public void DoesNotCreateLinkForTemplatePlaceholder() => Html.Should().NotContain("
      + Html.Should().MatchInlineHtmlSnapshot("

      Use https://{{cluster_id}}.es.test.co/api for your cluster.

      ", DocsHtml.Body()); // Note: We expect an error because {{cluster_id}} is an undefined substitution key, // but the important assertion is that the URL is not autolinked. @@ -379,10 +477,11 @@ public class AutoLinkAsciiDocStyleExclusionTests() : AutoLinkNotFoundTestBase( ) { [Test] - public void DoesNotCreateLinkForAsciiDocStyle() => Html.Should().NotContain("""
      + Html.Should().MatchInlineHtmlSnapshot("

      See https://www.iana.org/assignments[IANA for assignments.

      ", DocsHtml.Body()); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -392,10 +491,15 @@ public class AutoLinkValidUrlStillWorksTests() : AutoLinkTestBase(""" { [Test] public void CreatesLinkForValidUrl() => - Html.Should().Contain( - """
      https://www.elastic.co/guide""" + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Check + https://www.elastic.co/guide + for docs.

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs b/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs index 1e4b4efae7..897a6be0a4 100644 --- a/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; using Elastic.Documentation.Testing; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -13,14 +14,10 @@ not a comment """) { [Test] - public void GeneratesAttributesInHtml() - { - // language=html - Html.Should().NotContain("""

      % comment""").And.Contain("""

      not a comment

      """); + public void GeneratesAttributesInHtml() => Html.Should().MatchInlineHtmlSnapshot("""

      not a comment

      """, DocsHtml.Body()); - } } public class MultipleLineCommentTest() : InlineTest( @@ -37,20 +34,7 @@ also not a comment ) { [Test] - public void GeneratesAttributesInHtml() - { - // language=html - Html - .Should() - .NotContainAny( - "

      " - ) - .And - .ContainAll("

      not a comment, and multi line comment below

      ", "

      also not a comment

      "); + public void GeneratesAttributesInHtml() => Html.Should().MatchInlineHtmlSnapshot( """

      not a comment, and multi line comment below

      @@ -58,7 +42,6 @@ public void GeneratesAttributesInHtml() """, DocsHtml.Body() ); - } } public class MultipleLineCommentWithLinkTest() : InlineTest( @@ -76,25 +59,10 @@ also not a comment ) { [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] - public void GeneratesAttributesInHtml() - { - // language=html - Html - .ReplaceLineEndings() - .Should() - .NotContainAny( - "

      " - ) - .And - .ContainAll("

      not a comment, and multi line comment below

      ", "

      also not a comment

      "); + public void GeneratesAttributesInHtml() => Html.Should().MatchInlineHtmlSnapshot( """

      not a comment, and multi line comment below

      @@ -102,7 +70,6 @@ public void GeneratesAttributesInHtml() """, DocsHtml.Body() ); - } } /// @@ -123,18 +90,16 @@ content after comment ) { [Test] - public void ContentAfterCommentShouldBeRendered() - { - // This test verifies GitHub issue #2456 is fixed. - // The "content after comment" should be rendered, not silently dropped. - Html.Should().Contain("

      content after comment

      "); - } - - [Test] - public void ContentBeforeCommentShouldBeRendered() => Html.Should().Contain("

      content before comment

      "); - - [Test] - public void CommentContentShouldNotBeRendered() => Html.Should().NotContain("TODO: Uncomment once page is live."); + // This test verifies GitHub issue #2456 is fixed. + // The "content after comment" should be rendered, not silently dropped. + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      content before comment

      +

      content after comment

      + """, + DocsHtml.Body() + ); } /// @@ -149,11 +114,11 @@ content after """) { [Test] - public void ContentBeforeAndAfterShouldBeRendered() => - Html.Should().Contain("

      content before

      ").And.Contain("

      content after

      "); - - [Test] - public void CommentContentShouldNotBeRendered() => Html.Should().NotContain("single line comment"); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot(""" +

      content before

      +

      content after

      + """, DocsHtml.Body()); } /// @@ -172,10 +137,9 @@ content after ) { [Test] - public void ContentBeforeAndAfterShouldBeRendered() => - Html.Should().Contain("

      content before

      ").And.Contain("

      content after

      "); - - [Test] - public void CommentContentShouldNotBeRendered() => - Html.Should().NotContain("start of comment").And.NotContain("middle of comment").And.NotContain("end of comment"); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot(""" +

      content before

      +

      content after

      + """, DocsHtml.Body()); } diff --git a/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs index 6668d8169b..39bab26f60 100644 --- a/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -66,7 +67,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -94,5 +95,5 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs index d188d15864..f67483e97f 100644 --- a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs @@ -3,7 +3,9 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; using JetBrains.Annotations; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -18,35 +20,32 @@ public class FootnotesBasicTests() : InlineTest( ) { [Test] - public void ContainsFootnoteReferences() - { - Html.Should().Contain("footnote-ref"); - Html.Should().Contain("href=\"#fn:1\""); - Html.Should().Contain("href=\"#fn:2\""); - } - - [Test] - public void ContainsFootnoteContainer() => Html.Should().Contain("class=\"footnotes\""); - - [Test] - public void ContainsFootnoteDefinitions() - { - Html.Should().Contain("id=\"fn:1\""); - Html.Should().Contain("id=\"fn:2\""); - Html.Should().Contain("This is the first footnote."); - Html.Should().Contain("This is the second footnote."); - } - - [Test] - public void ContainsBackReferences() - { - Html.Should().Contain("footnote-back-ref"); - Html.Should().Contain("href=\"#fnref:1\""); - Html.Should().Contain("href=\"#fnref:2\""); - } - - [Test] - public void RendersFootnotesHeading() => Html.Should().Contain("

      Footnotes

      "); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Here's a simple footnote + 1 + + and another + 2 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        This is the first footnote.↩ +

        +
      2. +
      3. +

        This is the second footnote.↩ +

        +
      4. +
      +
      + """, + DocsHtml.Body() + ); [Test] public void FootnotesHeadingPrecedesFootnoteContainer() @@ -83,12 +82,30 @@ public void ContainsMultipleReferencesToSameFootnote() } [Test] - public void ContainsMultipleBackReferences() - { - // Should have references back to both instances - Html.Should().Contain("href=\"#fnref:1\""); - Html.Should().Contain("href=\"#fnref:2\""); - } + // Should have references back to both instances + public void ContainsMultipleBackReferences() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      First reference + 1 + + and second reference + 1 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        This footnote is referenced twice.↩ + ↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); [System.Text.RegularExpressions.GeneratedRegex("href=\"#fn:1\"")] private static partial System.Text.RegularExpressions.Regex MyRegex(); @@ -111,26 +128,35 @@ It has multiple paragraphs. ) { [Test] - public void ContainsComplexFootnoteStructure() - { - Html.Should().Contain("href=\"#fn:1\""); - Html.Should().Contain("This footnote has multiple elements."); - Html.Should().Contain("It has multiple paragraphs."); - } - - [Test] - public void ContainsBlockquoteInFootnote() - { - Html.Should().Contain("blockquote"); - Html.Should().Contain("And even a blockquote."); - } - - [Test] - public void ContainsListInFootnote() - { - Html.Should().Contain("List item 1"); - Html.Should().Contain("List item 2"); - } + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Here's a complex footnote + 1 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        This footnote has multiple elements.

        +

        It has multiple paragraphs.

        +
        +

        And even a blockquote.

        +
        +
          +
        • List item 1
        • +
        • List item 2
        • +
        +

        + ↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); } public class FootnotesWithCodeTests() : InlineTest( @@ -148,11 +174,35 @@ def hello(): ) { [Test] - public void ContainsCodeBlockInFootnote() - { - Html.Should().Contain("Example code:"); - Html.Should().Contain("hello"); - } + public void ContainsCodeBlockInFootnote() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      See the code example + 1 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        Example code:

        +
        +
        +
        +								def hello():     print("Hello, world!")
        +								
        +							
        +
        +
        +

        + ↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); } public class FootnotesConsecutiveDefinitionsTests() : InlineTest( @@ -167,20 +217,37 @@ public class FootnotesConsecutiveDefinitionsTests() : InlineTest( ) { [Test] - public void HandlesConsecutiveFootnoteDefinitions() - { - Html.Should().Contain("First footnote."); - Html.Should().Contain("Second footnote."); - Html.Should().Contain("Third footnote."); - } - - [Test] - public void AllFootnoteReferencesAreLinked() - { - Html.Should().Contain("href=\"#fn:1\""); - Html.Should().Contain("href=\"#fn:2\""); - Html.Should().Contain("href=\"#fn:3\""); - } + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      First + 1 + , second + 2 + , third + 3 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        First footnote.↩ +

        +
      2. +
      3. +

        Second footnote.↩ +

        +
      4. +
      5. +

        Third footnote.↩ +

        +
      6. +
      +
      + """, + DocsHtml.Body() + ); } public class FootnotesInListTests() : InlineTest( @@ -194,11 +261,29 @@ public class FootnotesInListTests() : InlineTest( ) { [Test] - public void FootnoteWorksInListItem() - { - Html.Should().Contain("href=\"#fn:1\""); - Html.Should().Contain("Footnote from list item."); - } + public void FootnoteWorksInListItem() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
        +
      • Item one
      • +
      • Item two with footnote + 1 + +
      • +
      +
      +
      +

      Footnotes

      +
        +
      1. +

        Footnote from list item.↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); } public class FootnotesWithNamedReferencesTests() : InlineTest( @@ -211,11 +296,25 @@ Named reference[^my-footnote]. ) { [Test] - public void HandlesNamedFootnoteIdentifiers() - { - Html.Should().Contain("footnote-ref"); - Html.Should().Contain("This uses a named identifier."); - } + public void HandlesNamedFootnoteIdentifiers() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Named reference + 1 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        This uses a named identifier.↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); } public partial class FootnotesInlineCodeNotParsedTests() : InlineTest( @@ -228,11 +327,28 @@ public partial class FootnotesInlineCodeNotParsedTests() : InlineTest( ) { [Test] - public void InlineCodeFootnoteSyntaxNotParsed() - { - // The inline code `[^1]` should render as code, not as a footnote reference - Html.Should().Contain("[^1]"); - } + // The inline code `[^1]` should render as code, not as a footnote reference + public void InlineCodeFootnoteSyntaxNotParsed() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Real reference + 1 + . Inline code example: + [^1] + should not be parsed.

      +
      +
      +

      Footnotes

      +
        +
      1. +

        This is the footnote.↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); [Test] public void OnlyOneBackReference() @@ -260,7 +376,33 @@ Code block with [^1] reference. ) { [Test] - public void CodeBlockRendered() => Html.Should().Contain("language-markdown"); + public void CodeBlockRendered() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Real reference + 1 + .

      +
      +
      +
      +					Code block with [^1] reference.
      +					
      +				
      +
      +
      +
      +
      +

      Footnotes

      +
        +
      1. +

        This is the footnote.↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); [Test] public void OnlyOneBackReference() @@ -304,7 +446,50 @@ public partial class FootnotesCodeBlockInDirectiveTests() : InlineTest( ) { [Test] - public void CodeBlockRendered() => Html.Should().Contain("language-markdown"); + public void CodeBlockRendered() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      + + +
      +

      Here's a simple footnote + 1 + + and another one + 2 + .

      +
      + + +
      +
      +
      +
      +							Here's a simple footnote[^1] and another one[^2].  [^1]: This is the first footnote. [^2]: This is the second footnote.
      +							
      +						
      +
      +
      +
      +
      +
      +
      +

      Footnotes

      +
        +
      1. +

        This is the first footnote.↩ +

        +
      2. +
      3. +

        This is the second footnote.↩ +

        +
      4. +
      +
      + """, + DocsHtml.Body() + ); [Test] public void CorrectBackReferenceCount() @@ -347,26 +532,48 @@ public class FootnotesInsideDirectiveTests() : InlineTest( ) { [Test] - public void OtherInlineElementsWorkInsideDirectives() - { - // Do other inline elements work? - Html.Should().Contain("bold"); - Html.Should().Contain("href=\"https://example.com\""); - } - - [Test] - public void FootnoteReferencesWorkInsideDirectives() - { - // Footnote REFERENCES work inside directives - Html.Should().Contain("footnote-ref"); - Html.Should().Contain("href=\"#fn:1\""); - } - - [Test] - public void FootnoteDefinitionsAreAtDocumentLevel() - { - // Footnote DEFINITIONS are rendered at the document level - Html.Should().Contain("
      "); - Html.Should().Contain("Footnote definitions must be at the document level"); - } + // Do other inline elements work? + // Footnote REFERENCES work inside directives + // Footnote DEFINITIONS are rendered at the document level + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      + + +
      +

      Here's a + bold + and a + link + and footnote + 1 + .

      +
      + + +
      +
      +
      +
      +							Here's footnote[^1].  [^1]: Example definition in code block.
      +							
      +						
      +
      +
      +
      +
      +
      +
      +

      Footnotes

      +
        +
      1. +

        Footnote definitions must be at the document level, not inside directives.↩ +

        +
      2. +
      +
      + """, + DocsHtml.Body() + ); } diff --git a/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs b/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs index e729a80290..51ee2fd8d2 100644 --- a/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -19,25 +21,32 @@ An unknown icon {icon}`not_a_real_icon` should not be replaced. { [Test] public void Render() => - Html - .Should() - .Contain("") - .And - .Contain("") - .And - .Contain("") - .And - .NotContain("{icon}`check`") - .And - .NotContain("{icon}`cross`") - .And - .NotContain("{icon}`warning`") - .And - .Contain("/this:apm_trace:is:not:an:icon") - .And - .Contain(":invalid-icon:") - .And - .Contain("::"); + Html.Should().MatchInlineHtmlSnapshot( + """ +

      A check mark + + + + + . A cross + + + + + . A warning + + + + + + + .

      +

      An unknown icon + + should not be replaced. This should not be an icon either/this:apm_trace:is:not:an:icon. Nor should this be an icon :invalid-icon:. An empty one is not an icon either ::.

      + """, + DocsHtml.Body() + ); } public class IconInListItemTest() : InlineTest(""" @@ -46,13 +55,20 @@ public class IconInListItemTest() : InlineTest(""" { [Test] public void Render() => - Html - .Should() - .Contain("") - .And - .NotContain("{icon}`check`") - .And - .NotContain("
    • "); + Html.Should().MatchInlineHtmlSnapshot( + """ +
        +
      • + + + + + + A check mark.
      • +
      + """, + DocsHtml.Body() + ); } public class IconInHeadingShouldBeRemovedFromAnchor() : InlineTest(""" @@ -61,11 +77,20 @@ public class IconInHeadingShouldBeRemovedFromAnchor() : InlineTest(""" { [Test] public void Render() => - Html - .Should() - .Contain("") - .And - .Contain("Users +

      + Users + + + + + + +

      +
      + """, + DocsHtml.Body() + ); } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs index a14677f3b6..d9863d7d55 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs @@ -9,6 +9,7 @@ using JetBrains.Annotations; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -52,7 +53,10 @@ public void ParsesBlock() [Test] public void GeneratesAttributesInHtml() => // language=html - Html.Should().Be("""

      and this continues to be regular text

      """); + Html.Should().MatchInlineHtmlSnapshot( + """

      and this continues to be regular text

      """, + DocsHtml.Body() + ); } [InheritsTests] @@ -80,8 +84,7 @@ this is regular text and this $$is-an-inline-anchor$$$ { [Test] public void GeneratesAttributesInHtml() => - // language=html - Html.Should().Contain("""

      this is regular text and this $$is-an-inline-anchor$$$

      """); + Html.Should().MatchInlineHtmlSnapshot("

      this is regular text and this $$is-an-inline-anchor$$$

      ", DocsHtml.Body()); } [InheritsTests] @@ -204,7 +207,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -233,7 +236,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -244,7 +247,7 @@ [Install steps](testing/req.md#install-steps) ) { [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -257,7 +260,11 @@ [Install steps](testing/req.md#install-steps-install-anchor) // The heading renders as id="install-steps"; the inline anchor text is not part of its slug. [Test] public void HasAnError() => - Collector.Diagnostics.Should().ContainSingle().Which.Message.Should().Contain("install-steps-install-anchor"); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot("error {repo}/docs/index.md:7 `install-steps-install-anchor` does not exist in testing/req.md."); } [InheritsTests] @@ -268,5 +275,5 @@ [Install steps](testing/req.md#install-anchor) ) { [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs index 9df6a29a9a..53e333e39f 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Testing; using JetBrains.Annotations; using Markdig.Syntax.Inlines; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -55,7 +56,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -75,7 +76,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -98,7 +99,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -123,7 +124,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -144,7 +145,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsCrossLink() @@ -162,13 +163,17 @@ Go to [test](kibana://index.md) { [Test] public void GeneratesHtml() => - // language=html - Html.Should().Contain( - """

      Go to test

      """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + test +

      + """, + DocsHtml.Body() ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsCrossLink() @@ -186,14 +191,24 @@ Go to [](kibana://index.md) { [Test] public void GeneratesHtml() => - // language=html - empty crosslinks now emit an error - Html.Should().Contain( - """

      Go to

      """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + +

      + """, + DocsHtml.Body() ); [Test] public void HasError() => - Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("empty link text")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/index.md:4 Crosslink 'kibana://index.md' has empty link text. Please provide explicit link text." + ); [Test] public void EmitsCrossLink() @@ -211,14 +226,24 @@ Go to [](kibana://get-started/index.md) { [Test] public void GeneratesHtml() => - // language=html - empty crosslinks emit an error; isolated builds get target=_blank - Html.Should().Contain( - """

      Go to

      """ + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + +

      + """, + DocsHtml.Body() ); [Test] public void HasError() => - Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("empty link text")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/index.md:4 Crosslink 'kibana://get-started/index.md' has empty link text. Please provide explicit link text." + ); [Test] public void EmitsCrossLink() @@ -236,19 +261,14 @@ [global search field]({{this-variable-does-not-exist}}/introduction.html#kibana- ) { [Test] - public void HasErrors() - { - Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.First().Severity.Should().Be(Severity.Error); + public void HasErrors() => Collector .Diagnostics - .First() - .Message + .ToSnapshotText() .Should() - .Contain( - "he url contains unresolved template expressions: '{{this-variable-does-not-exist}}/introduction.html#kibana-navigation-search'. Please check if there is an appropriate global or frontmatter subs variable." + .MatchInlineSnapshot( + "error {repo}/docs/index.md:3 The url contains unresolved template expressions: '{{this-variable-does-not-exist}}/introduction.html#kibana-navigation-search'. Please check if there is an appropriate global or frontmatter subs variable." ); - } } [InheritsTests] @@ -264,7 +284,7 @@ public void GeneratesHtml() => ); [Test] - public void HasNoWarningsOrErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoWarningsOrErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -273,19 +293,14 @@ [link to app]({{some-url-path-prefix}}/hello-world) """) { [Test] - public void HasWarnings() - { - Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.First().Severity.Should().Be(Severity.Error); + public void HasWarnings() => Collector .Diagnostics - .First() - .Message + .ToSnapshotText() .Should() - .Contain( - "Link is resolved to '/something/hello-world'. Only external links are allowed to be resolved from template expressions." + .MatchInlineSnapshot( + "error {repo}/docs/index.md:3 Link is resolved to '/something/hello-world'. Only external links are allowed to be resolved from template expressions." ); - } } [InheritsTests] @@ -294,10 +309,14 @@ [Non Existing Link](/non-existing.md) """) { [Test] - public void HasErrors() => Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Should().HaveCount(1); - - [Test] - public void HasNoWarning() => Collector.Diagnostics.Where(d => d.Severity == Severity.Warning).Should().HaveCount(0); + public void EmitsDiagnostics() => + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/index.md:3 `/non-existing.md` does not exist. If it was recently removed add a redirect. resolved to `{repo}/docs/non-existing.md`" + ); } [InheritsTests] @@ -311,7 +330,7 @@ public void GeneratesHtml() => Html.Should().BeNullOrWhiteSpace(); [Test] - public void HasErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -341,7 +360,7 @@ public void GeneratesHtml() => ); [Test] - public void HasErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -355,7 +374,18 @@ This is another [Non Existing Link](/non-existing.md) ) { [Test] - public void HasErrors() => Collector.Diagnostics.Should().HaveCount(3); + public void HasErrors() => + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + """ + error {repo}/docs/index.md:3 `/non-existing.md` does not exist. If it was recently removed add a redirect. resolved to `{repo}/docs/non-existing.md` + error {repo}/docs/index.md:4 `/non-existing.md` does not exist. If it was recently removed add a redirect. resolved to `{repo}/docs/non-existing.md` + error {repo}/docs/index.md:5 `/non-existing.md` does not exist. If it was recently removed add a redirect. resolved to `{repo}/docs/non-existing.md` + """ + ); } [InheritsTests] @@ -366,10 +396,18 @@ [Install with Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=elasti ) { [Test] - public void GeneratesHtml() => Html.Should().Contain("""href="cursor://"""); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Install with Cursor +

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsNoCrossLinks() => Collector.CrossLinks.Should().HaveCount(0); @@ -381,10 +419,18 @@ [Install VS Code Extension](vscode:extension/elastic.elasticsearch) """) { [Test] - public void GeneratesHtml() => Html.Should().Contain("""href="vscode:"""); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Install VS Code Extension +

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsNoCrossLinks() => Collector.CrossLinks.Should().HaveCount(0); @@ -398,10 +444,18 @@ [Install with VS Code Insiders](vscode-insiders:mcp/install?%7B%22name%22%3A%22o ) { [Test] - public void GeneratesHtml() => Html.Should().Contain("""href="vscode-insiders:"""); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Install with VS Code Insiders +

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void EmitsNoCrossLinks() => Collector.CrossLinks.Should().HaveCount(0); @@ -438,7 +492,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] diff --git a/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs b/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs index 960a18cb80..344c4f8f37 100644 --- a/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -11,7 +13,15 @@ public class MathRoleTests() : InlineTest(""" """) { [Test] - public void Render() => Html.Should().Contain("E = mc^2").And.NotContain("{math}`E = mc^2`"); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Einstein's famous equation + E = mc^2 + relates energy and mass.

      + """, + DocsHtml.Body() + ); } public class MathRoleEscapesContentTests() : InlineTest(""" @@ -19,5 +29,13 @@ public class MathRoleEscapesContentTests() : InlineTest(""" """) { [Test] - public void Render() => Html.Should().Contain("a < b & b > c"); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Compare + a < b & b > c + for ordering.

      + """, + DocsHtml.Body() + ); } diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs index 4631aac3bc..3df00c0e60 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs @@ -3,6 +3,9 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -27,17 +30,27 @@ public class SubstitutionInlineCodeTest() : InlineTest( ) { [Test] - public void TestSubstitutionInlineCode() - { - // Check that regular code blocks are not processed - Html.Should().Contain("wget elasticsearch-{{version}}.tar.gz"); - - // Check that {subs} inline code blocks have substitutions applied - Html.Should().Contain("wget elasticsearch-8.15.0.tar.gz"); - Html.Should().Contain("export MY_VAR=8.15.0"); - Html.Should().Contain("version 8.15"); - } + // Check that regular code blocks are not processed + // Check that {subs} inline code blocks have substitutions applied + public void TestSubstitutionInlineCode() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Regular code: + wget elasticsearch-{{version}}.tar.gz +

      +

      Code with substitutions: + wget elasticsearch-8.15.0.tar.gz +

      +

      Multiple substitutions: + export MY_VAR=8.15.0 +

      +

      With mutations: + version 8.15 +

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index 1c93d59c86..1e569de9fb 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.InlineParsers.Substitution; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Inline; @@ -24,7 +25,7 @@ not a comment { [Test] public void ReplacesSubsFromFrontMatter() => - Html.Should().Contain("""Hello World!""").And.Contain("""not a comment""").And.NotContain("""{{hello-world}}"""); + Html.Should().MatchInlineHtmlSnapshot("

      The following should be subbed: Hello World! not a comment

      ", DocsHtml.Body()); } public class NeedsDoubleBrackets() : InlineTest( @@ -44,19 +45,12 @@ not a {substitution} ) { [Test] + // treated as attributes to the block public void PreservesSingleBracket() => - Html - .Should() - .Contain("""Hello World!""") - .And - .Contain("""not a comment""") - .And - .NotContain("""{{hello-world}}""") - .And - .Contain( // treated as attributes to the block - """{substitution}""") - .And - .Contain("""{{valid-key}}"""); + Html.Should().MatchInlineHtmlSnapshot( + "

      The following should be subbed: Hello World! not a comment not a {{valid-key}} not a {substitution}

      ", + DocsHtml.Body() + ); } [InheritsTests] @@ -80,7 +74,23 @@ cd elasticsearch-{{version}}/ <2> ) { [Test] - public void ReplacesSubsInCode() => Html.Should().Contain("7.17.0"); + public void ReplacesSubsInCode() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.17.0-linux-x86_64.tar.gz wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.17.0-linux-x86_64.tar.gz.sha512 shasum -a 512 -c elasticsearch-7.17.0-linux-x86_64.tar.gz.sha512
      +						
      +						tar -xzf elasticsearch-7.17.0-linux-x86_64.tar.gz cd elasticsearch-7.17.0/
      +						
      +					
      +				
      +
      +
      + """, + DocsHtml.Body() + ); } public class SupportsSubstitutionsFromDocSet() : InlineTest( @@ -97,15 +107,10 @@ public class SupportsSubstitutionsFromDocSet() : InlineTest( { [Test] public void EmitsGlobalVariable() => - Html - .Should() - .Contain("Hello World!") - .And - .NotContain("{{hello-world}}") - .And - .Contain("A variable from docset.yml") - .And - .NotContain("{{global-var}}"); + Html.Should().MatchInlineHtmlSnapshot( + "

      The following should be subbed: Hello World! The following should be subbed as well: A variable from docset.yml

      ", + DocsHtml.Body() + ); } public class CanNotShadeGlobalVariables() : InlineTest( @@ -125,16 +130,20 @@ public class CanNotShadeGlobalVariables() : InlineTest( { [Test] public void OnlySeesGlobalVariable() => - Html.Should().NotContain("Hello World!
      ").And.NotContain("{{hello-world}}").And.Contain("A variable from docset.yml"); + Html.Should().MatchInlineHtmlSnapshot( + "

      The following should be subbed: A variable from docset.yml The following should be subbed as well: A variable from docset.yml

      ", + DocsHtml.Body() + ); [Test] public void HasError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .Contain(d => d.Message.Contains("{hello-world} can not be redeclared in front matter as its a global substitution")); + .MatchInlineSnapshot( + "error {repo}/docs/index.md {hello-world} can not be redeclared in front matter as its a global substitution" + ); } public class ReplaceInHeader() : InlineTest( @@ -165,7 +174,7 @@ public void OnlySeesGlobalVariable() => ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class ReplaceInImageAlt() : InlineTest( @@ -182,7 +191,15 @@ public class ReplaceInImageAlt() : InlineTest( ) { [Test] - public void OnlySeesGlobalVariable() => Html.Should().NotContain("alt=\"{{hello-world}}\"").And.Contain("alt=\"Hello World\""); + public void OnlySeesGlobalVariable() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Hello World +

      + """, + DocsHtml.Body() + ); } public class ReplaceInImageTitle() : InlineTest( @@ -199,7 +216,15 @@ public class ReplaceInImageTitle() : InlineTest( ) { [Test] - public void OnlySeesGlobalVariable() => Html.Should().NotContain("title=\"{{hello-world}}\"").And.Contain("title=\"Observability\""); + public void OnlySeesGlobalVariable() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Observability +

      + """, + DocsHtml.Body() + ); } public class MutationOperatorTest() : InlineTest( @@ -225,34 +250,15 @@ public class MutationOperatorTest() : InlineTest( ) { [Test] - public void MutationOperatorsWorkWithAndWithoutSpaces() - { - // Both versions with and without spaces should render the same way - Html - .Should() - .Contain("Version: 9.0") - .And - .Contain("Version with space: 9.0") - .And - .Contain("Major only: 9") - .And - .Contain("Major only with space: 9") - .And - .Contain("Major.x: 9.x") - .And - .Contain("Major.x with space: 9.x") - .And - .Contain("Increase major: 10.0.0") - .And - .Contain("Increase major with space: 10.0.0") - .And - .Contain("Increase minor: 9.1.0") - .And - .Contain("Increase minor with space: 9.1.0"); - } + // Both versions with and without spaces should render the same way + public void MutationOperatorsWorkWithAndWithoutSpaces() => + Html.Should().MatchInlineHtmlSnapshot( + "

      Version: 9.0 Version with space: 9.0 Major only: 9 Major only with space: 9 Major.x: 9.x Major.x with space: 9.x Increase major: 10.0.0 Increase major with space: 10.0.0 Increase minor: 9.1.0 Increase minor with space: 9.1.0

      ", + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class MultipleMutationOperatorsTest() : InlineTest( @@ -273,22 +279,15 @@ public class MultipleMutationOperatorsTest() : InlineTest( ) { [Test] - public void MultipleMutationOperatorsWorkWithAndWithoutSpaces() - { - // Both versions with and without spaces should render the same way - Html - .Should() - .Contain("Version: 9.0") - .And - .Contain("Version with spaces: 9.0") - .And - .Contain("Product: ELASTICSEARCH") - .And - .Contain("Product with spaces: ELASTICSEARCH"); - } + // Both versions with and without spaces should render the same way + public void MultipleMutationOperatorsWorkWithAndWithoutSpaces() => + Html.Should().MatchInlineHtmlSnapshot( + "

      Version: 9.0 Version with spaces: 9.0 Product: ELASTICSEARCH Product with spaces: ELASTICSEARCH

      ", + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class MutationOperatorsInLinksTest() : InlineTest( @@ -310,26 +309,22 @@ [Link text with mutation and space]({{product | uc}} {{version | M.M}}) ) { [Test] - public void MutationOperatorsWorkInLinks() - { - // Check URL mutations - Html - .Should() - .Contain("href=\"https://www.elastic.co/guide/en/elasticsearch/reference/9.0/index.html\"") - .And - .NotContain("{{version|M.M}}") - .And - .NotContain("{{version | M.M}}"); - - // Check link text mutations - Html.Should().Contain("ELASTICSEARCH 9.0").And.NotContain("{{product|uc}}").And.NotContain("{{version|M.M}}"); - - // Check link text mutations with spaces - Html.Should().Contain("ELASTICSEARCH 9.0").And.NotContain("{{product | uc}}").And.NotContain("{{version | M.M}}"); - } + // Check URL mutations + // Check link text mutations + // Check link text mutations with spaces + public void MutationOperatorsWorkInLinks() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Link with mutation operator + Link with mutation operator and space + [Link text with mutation](ELASTICSEARCH 9.0) [Link text with mutation and space](ELASTICSEARCH 9.0)

      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -355,16 +350,20 @@ public class MutationOperatorsInCodeBlocksTest() : BlockTest( { [Test] public void MutationOperatorsWorkInCodeBlocks() => - Html - .Should() - .Contain("# Install Elasticsearch 9.0") - .And - .Contain("elasticsearch-9.0-linux-x86_64.tar.gz") - .And - .NotContain("{{version|M.M}}") - .And - .NotContain("{{version | M.M}}"); + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					# Install Elasticsearch 9.0 wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-9.0-linux-x86_64.tar.gz  # With space in mutation wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-9.0-linux-x86_64.tar.gz
      +					
      +				
      +
      +
      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } From ab175678d000f552b801018ede485ec77dffb483 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:45:15 +0100 Subject: [PATCH 05/18] Markdown tests (Inline): footnote outputs over 25 lines move to file snapshots Same expectations as before (the accepted inline snapshots, moved to .snapshots/Inline/*.verified.html) so the test file stays readable. Co-Authored-By: Claude Sonnet 5.5 --- .../CodeBlockRendered.verified.html | 38 ++++++ .../GeneratesHtml.verified.html | 25 ++++ .../GeneratesHtml.verified.html | 35 ++++++ .../Inline/FootnotesTests.cs | 119 +----------------- 4 files changed, 101 insertions(+), 116 deletions(-) create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesCodeBlockInDirectiveTests/CodeBlockRendered.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesConsecutiveDefinitionsTests/GeneratesHtml.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesInsideDirectiveTests/GeneratesHtml.verified.html diff --git a/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesCodeBlockInDirectiveTests/CodeBlockRendered.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesCodeBlockInDirectiveTests/CodeBlockRendered.verified.html new file mode 100644 index 0000000000..d07f4cf799 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesCodeBlockInDirectiveTests/CodeBlockRendered.verified.html @@ -0,0 +1,38 @@ +
      + + +
      +

      Here's a simple footnote + 1 + + and another one + 2 + .

      +
      + + +
      +
      +
      +
      +					Here's a simple footnote[^1] and another one[^2].  [^1]: This is the first footnote. [^2]: This is the second footnote.
      +					
      +				
      +
      +
      +
      +
      +
      +
      +

      Footnotes

      +
        +
      1. +

        This is the first footnote.↩ +

        +
      2. +
      3. +

        This is the second footnote.↩ +

        +
      4. +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesConsecutiveDefinitionsTests/GeneratesHtml.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesConsecutiveDefinitionsTests/GeneratesHtml.verified.html new file mode 100644 index 0000000000..ce67e419b8 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesConsecutiveDefinitionsTests/GeneratesHtml.verified.html @@ -0,0 +1,25 @@ +

      First + 1 + , second + 2 + , third + 3 + .

      +
      +
      +

      Footnotes

      +
        +
      1. +

        First footnote.↩ +

        +
      2. +
      3. +

        Second footnote.↩ +

        +
      4. +
      5. +

        Third footnote.↩ +

        +
      6. +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesInsideDirectiveTests/GeneratesHtml.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesInsideDirectiveTests/GeneratesHtml.verified.html new file mode 100644 index 0000000000..3558917aa1 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/Inline/FootnotesInsideDirectiveTests/GeneratesHtml.verified.html @@ -0,0 +1,35 @@ +
      + + +
      +

      Here's a + bold + and a + link + and footnote + 1 + .

      +
      + + +
      +
      +
      +
      +					Here's footnote[^1].  [^1]: Example definition in code block.
      +					
      +				
      +
      +
      +
      +
      +
      +
      +

      Footnotes

      +
        +
      1. +

        Footnote definitions must be at the document level, not inside directives.↩ +

        +
      2. +
      +
      diff --git a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs index f67483e97f..8541dccf98 100644 --- a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs @@ -217,37 +217,7 @@ public class FootnotesConsecutiveDefinitionsTests() : InlineTest( ) { [Test] - public void GeneratesHtml() => - Html.Should().MatchInlineHtmlSnapshot( - """ -

      First - 1 - , second - 2 - , third - 3 - .

      -
      -
      -

      Footnotes

      -
        -
      1. -

        First footnote.↩ -

        -
      2. -
      3. -

        Second footnote.↩ -

        -
      4. -
      5. -

        Third footnote.↩ -

        -
      6. -
      -
      - """, - DocsHtml.Body() - ); + public void GeneratesHtml() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } public class FootnotesInListTests() : InlineTest( @@ -446,50 +416,7 @@ public partial class FootnotesCodeBlockInDirectiveTests() : InlineTest( ) { [Test] - public void CodeBlockRendered() => - Html.Should().MatchInlineHtmlSnapshot( - """ -
      - - -
      -

      Here's a simple footnote - 1 - - and another one - 2 - .

      -
      - - -
      -
      -
      -
      -							Here's a simple footnote[^1] and another one[^2].  [^1]: This is the first footnote. [^2]: This is the second footnote.
      -							
      -						
      -
      -
      -
      -
      -
      -
      -

      Footnotes

      -
        -
      1. -

        This is the first footnote.↩ -

        -
      2. -
      3. -

        This is the second footnote.↩ -

        -
      4. -
      -
      - """, - DocsHtml.Body() - ); + public void CodeBlockRendered() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); [Test] public void CorrectBackReferenceCount() @@ -535,45 +462,5 @@ public class FootnotesInsideDirectiveTests() : InlineTest( // Do other inline elements work? // Footnote REFERENCES work inside directives // Footnote DEFINITIONS are rendered at the document level - public void GeneratesHtml() => - Html.Should().MatchInlineHtmlSnapshot( - """ -
      - - -
      -

      Here's a - bold - and a - link - and footnote - 1 - .

      -
      - - -
      -
      -
      -
      -							Here's footnote[^1].  [^1]: Example definition in code block.
      -							
      -						
      -
      -
      -
      -
      -
      -
      -

      Footnotes

      -
        -
      1. -

        Footnote definitions must be at the document level, not inside directives.↩ -

        -
      2. -
      -
      - """, - DocsHtml.Body() - ); + public void GeneratesHtml() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } From 92a3fcf109da9056fc23af907f695d260361b7b0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:45:17 +0100 Subject: [PATCH 06/18] Markdown tests (CodeBlocks): whole-output snapshots instead of fragments Contain/NotContain fragments on the rendered HTML become one MatchInlineHtmlSnapshot per test and diagnostics counts/prefix checks become MatchInlineSnapshot of the diagnostics text, so these tests became stricter. Every old Contain fragment was checked to appear in the accepted snapshot. Caveat: the HTML snapshot collapses whitespace inside pre, so a change in the line structure of a code block alone is not caught by it. Co-Authored-By: Claude Sonnet 5.5 --- .../CodeBlocks/CallOutTests.cs | 81 ++++++++------ .../CodeBlocks/CodeBlockArgumentsTests.cs | 101 ++++++++++++++++-- .../CodeBlocks/ConsoleCodeBlockTests.cs | 20 ++-- 3 files changed, 153 insertions(+), 49 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs index 48e9086765..1b03be55af 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs @@ -8,6 +8,7 @@ using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; using JetBrains.Annotations; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.CodeBlocks; @@ -49,7 +50,7 @@ public void ParsesMagicCallOuts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.NotContain(c => c.Text.Contains("not a callout")); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -88,7 +89,7 @@ and a ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -106,10 +107,11 @@ public void ParsesMagicCallOuts() => public void RequiresContentToFollow() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by any content")); + .MatchInlineSnapshot( + "error {repo}/docs/index.md:3 Code block with annotations is not followed by any content, needs numbered list" + ); } [InheritsTests] @@ -133,10 +135,9 @@ public void ParsesMagicCallOuts() => public void RequiresContentToFollow() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by a list")); + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Code block with annotations is not followed by a list"); } [InheritsTests] @@ -187,7 +188,7 @@ public void RendersExpectedHtml() => ); [Test] - public void AllowsAParagraphInBetween() => Collector.Diagnostics.Should().BeEmpty(); + public void AllowsAParagraphInBetween() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -217,10 +218,11 @@ public void ParsesMagicCallOuts() => public void RequiresContentToFollow() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .OnlyContain(c => c.Message.StartsWith("More than one content block between code block with annotations and its list")); + .MatchInlineSnapshot( + "error {repo}/docs/index.md:3 More than one content block between code block with annotations and its list" + ); } [InheritsTests] @@ -244,10 +246,9 @@ public void ParsesMagicCallOuts() => public void RequiresContentToFollow() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .OnlyContain(c => c.Message.StartsWith("Code block has 2 callouts but the following list only has 1")); + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Code block has 2 callouts but the following list only has 1"); } [InheritsTests] @@ -273,7 +274,7 @@ public void ParsesAllForLineInformation() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(3).And.OnlyContain(c => c.Text.StartsWith('<')); [Test] - public void RequiresContentToFollow() => Collector.Diagnostics.Should().BeEmpty(); + public void RequiresContentToFollow() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -341,7 +342,7 @@ public void ParsesClassicCallouts() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -363,7 +364,7 @@ public void ParsesMagicCallOuts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(3).And.OnlyContain(c => c.Text.StartsWith('<')); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -388,7 +389,7 @@ public void ParsesMagicCallOuts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(5).And.OnlyContain(c => c.Text.StartsWith('<')); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -413,7 +414,7 @@ public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Test] - public void HandlesCommentBlocksCorrectly() => Collector.Diagnostics.Should().BeEmpty(); + public void HandlesCommentBlocksCorrectly() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void RenderedHtmlContainsCallouts() => @@ -460,7 +461,7 @@ 2. Second callout public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Test] - public void HandlesCommentBlocksCorrectly() => Collector.Diagnostics.Should().BeEmpty(); + public void HandlesCommentBlocksCorrectly() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -486,7 +487,7 @@ 2. Second callout public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Test] - public void HandlesCommentBlocksAndParagraphCorrectly() => Collector.Diagnostics.Should().BeEmpty(); + public void HandlesCommentBlocksAndParagraphCorrectly() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] public void RendersIntermediateParagraph() => @@ -542,10 +543,11 @@ 2. Second callout public void EmitsErrorForTooManyParagraphs() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .OnlyContain(c => c.Message.StartsWith("More than one content block between code block with annotations and its list")); + .MatchInlineSnapshot( + "error {repo}/docs/index.md:3 More than one content block between code block with annotations and its list" + ); } [InheritsTests] @@ -570,10 +572,9 @@ public class CodeBlockWithManyCommentBlocksNoList() : CodeBlockCallOutTests( public void EmitsErrorForNoList() => Collector .Diagnostics + .ToSnapshotText() .Should() - .HaveCount(1) - .And - .OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by a list")); + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Code block with annotations is not followed by a list"); } [InheritsTests] @@ -596,8 +597,28 @@ 2. Second callout public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Test] - public void HandlesCommentsCorrectly() => Collector.Diagnostics.Should().BeEmpty(); + public void HandlesCommentsCorrectly() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] - public void RenderedHtmlDoesNotContainComments() => Html.Should().NotContain("basque_keywords"); + public void RenderedHtmlDoesNotContainComments() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					var x = 1;
      +						
      +						var y = x - 2; var z = y - 2;
      +						
      +					
      +				
      +
      +
      +
        +
      1. First callout
      2. +
      3. Second callout
      4. +
      + """, + DocsHtml.Body() + ); } diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs index 0757e24b50..3d022e0902 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs @@ -3,9 +3,12 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; using JetBrains.Annotations; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.CodeBlocks; @@ -79,10 +82,23 @@ public class DisabledCallouts() : CodeBlockArgumentsTests( ) { [Test] - public void Render() => Html.Should().Contain("<1>"); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					var x = 1; <1> var y = x - 2; var z = y - 2; <2>
      +					
      +				
      +
      +
      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -98,10 +114,27 @@ 1. This is a callout ) { [Test] - public void Render() => Html.Should().Contain(""); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					var x = 1;
      +						
      +					
      +				
      +
      +
      +
        +
      1. This is a callout
      2. +
      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -117,10 +150,26 @@ 1. This is a callout ) { [Test] - public void Render() => Html.Should().Contain("This is a variable"); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					This is a variable
      +					
      +				
      +
      +
      +
        +
      1. This is a callout
      2. +
      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -136,10 +185,26 @@ 1. This is a callout ) { [Test] - public void Render() => Html.Should().Contain("{{a-variable}}"); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					{{a-variable}}
      +					
      +				
      +
      +
      +
        +
      1. This is a callout
      2. +
      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -155,8 +220,24 @@ 1. This is a callout ) { [Test] - public void Render() => Html.Should().Contain("This is a variable").And.Contain("<1>"); + public void Render() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      +
      +
      +					This is a variable <1>
      +					
      +				
      +
      +
      +
        +
      1. This is a callout
      2. +
      + """, + DocsHtml.Body() + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs index eeb4d93d95..0958b3c372 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs @@ -3,9 +3,11 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; using JetBrains.Annotations; +using Nullean.Snapshots; namespace Elastic.Markdown.Tests.CodeBlocks; @@ -50,7 +52,7 @@ public void CreatesSingleApiSegment() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -101,7 +103,7 @@ public void CreatesMultipleApiSegments() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -135,7 +137,7 @@ public void HandlesDifferentHttpVerbs() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -169,7 +171,7 @@ public void CreatesMultipleApiSegmentsWithCallouts() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -198,7 +200,7 @@ public void HandlesEmptyLinesBetweenApiCalls() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -224,7 +226,7 @@ public void HandlesApiCallsWithoutBodies() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -282,7 +284,7 @@ public void RendersCalloutHtmlInConsoleCodeBlocks() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -348,7 +350,7 @@ public void RendersCalloutsInJsonContent() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -424,5 +426,5 @@ public void EscapesHtmlCharsInHeader() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } From ed96287641932c67877d3f545e2206d37b5ada19 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:45:18 +0100 Subject: [PATCH 07/18] Markdown tests (FileInclusion): whole-output snapshots instead of fragments Fragment checks on the included output become HTML snapshots (files for the larger ones) and the diagnostics assertions become diagnostics snapshots. These tests became stricter; every old Contain fragment appears in the accepted snapshot. Co-Authored-By: Claude Sonnet 5.5 --- ...ludesSnippetAfterMainContent.verified.html | 40 +++++++++++++ .../EachIncludeHasUniqueIds.verified.html | 41 ++++++++++++++ .../EachIncludeHasUniqueIds.verified.html | 25 +++++++++ .../FileInclusion/HeadingOrderTests.cs | 14 +++-- .../FileInclusion/IncludeTests.cs | 56 +++++++++---------- .../IncludedAppliesSwitchTests.cs | 55 +++++++++++------- .../FileInclusion/IncludedTabSetTests.cs | 47 +++++++++------- .../FileInclusion/LiteralIncludeTests.cs | 26 ++++----- 8 files changed, 217 insertions(+), 87 deletions(-) create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludeHeadingOrderTests/IncludesSnippetAfterMainContent.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedAppliesSwitchTests/EachIncludeHasUniqueIds.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedTabSetTests/EachIncludeHasUniqueIds.verified.html diff --git a/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludeHeadingOrderTests/IncludesSnippetAfterMainContent.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludeHeadingOrderTests/IncludesSnippetAfterMainContent.verified.html new file mode 100644 index 0000000000..82f8393297 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludeHeadingOrderTests/IncludesSnippetAfterMainContent.verified.html @@ -0,0 +1,40 @@ +
      +

      + One +

      +
      +
      +

      + Two +

      +
      +
      +

      + Three +

      +
      +
      +

      + Four +

      +
      +
      +

      + Five +

      +
      +
      +

      + Six +

      +
      +
      +

      + Seven +

      +
      +
      +

      + Eight +

      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedAppliesSwitchTests/EachIncludeHasUniqueIds.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedAppliesSwitchTests/EachIncludeHasUniqueIds.verified.html new file mode 100644 index 0000000000..9b6a458501 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedAppliesSwitchTests/EachIncludeHasUniqueIds.verified.html @@ -0,0 +1,41 @@ +
      + + +
      +

      Content for Stack

      +
      + + +
      +

      Content for Serverless

      +
      +
      +

      Some content between includes.

      +
      + + +
      +

      Content for Stack

      +
      + + +
      +

      Content for Serverless

      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedTabSetTests/EachIncludeHasUniqueIds.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedTabSetTests/EachIncludeHasUniqueIds.verified.html new file mode 100644 index 0000000000..3861e29398 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/FileInclusion/IncludedTabSetTests/EachIncludeHasUniqueIds.verified.html @@ -0,0 +1,25 @@ +
      + + +
      +

      Content for first tab

      +
      + + +
      +

      Content for second tab

      +
      +
      +

      Some content between includes.

      +
      + + +
      +

      Content for first tab

      +
      + + +
      +

      Content for second tab

      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs index 9b451c4cc2..8495ba5d98 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs @@ -4,10 +4,13 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.IO; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Stepper; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.FileInclusion; @@ -40,7 +43,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Test] - public void IncludesSnippetAfterMainContent() => Html.Should().Contain("Two").And.Contain("Six"); + public void IncludesSnippetAfterMainContent() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); [Test] public void TableOfContentsRespectsOrder() @@ -861,11 +864,10 @@ public void InternalHeadingIsAdjustedToOneLevelDeeper() public void HintIsEmittedForAdjustedHeading() => Collector .Diagnostics + .ToSnapshotText() .Should() - .ContainSingle( - d => d.Severity == Documentation.Diagnostics.Severity.Hint && d.Message.Contains("h3") && d.Message.Contains( - "h4" - ) && d.Message.Contains("####") + .MatchInlineSnapshot( + "hint {repo}/docs/index.md:8 Heading level h3 inside a step renders at the same or higher level as the step itself (h3). It has been adjusted to h4 — write it as '####' to avoid this hint." ); } @@ -908,7 +910,7 @@ public void DeepHeadingIsUntouched() } [Test] - public void NoHintEmittedForValidHeading() => Collector.Diagnostics.Should().BeEmpty(); + public void NoHintEmittedForValidHeading() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } /// diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs index 7bbab23991..541a996a19 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots; using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.FileInclusion; @@ -69,12 +70,12 @@ public class IncludeNotFoundTests() : DirectiveTest(""" public void IncludesNothing() => Html.Should().Be(""); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); - Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("notfound.md` does not exist")); - } + public void EmitsError() => + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot("error {repo}/docs/index.md:3 `{repo}/docs/_snippets/notfound.md` does not exist."); } [InheritsTests] @@ -90,12 +91,8 @@ public class IncludeRequiresArgument() : DirectiveTest(""" public void IncludesNothing() => Html.Should().Be(""); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); - Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("include requires an argument.")); - } + public void EmitsError() => + Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot("error {repo}/docs/index.md:3 include requires an argument."); } [InheritsTests] @@ -118,14 +115,17 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void IncludesNothing() => Html.Should().Be(""); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); + public void EmitsError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain(d => d.Severity == Severity.Error && d.Message.Contains("only supports including snippets from `_snippet` folders.")); - } + .MatchInlineSnapshot( + """ + warning {repo}/docs/test.md Document has no title, using file name as title. + error {repo}/docs/index.md:3 {include} only supports including snippets from `_snippet` folders. `{repo}/docs/test.md` is not a snippet + """ + ); } [InheritsTests] @@ -137,14 +137,12 @@ public class IncludeRelativeTraversalBlocked() : DirectiveTest(""" protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"outside.txt", "some content"); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); + public void EmitsError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must resolve within the documentation source directory")); - } + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Include path must resolve within the documentation source directory."); } [InheritsTests] @@ -170,10 +168,12 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void IncludesNothing() => Html.Should().Be(""); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); - Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("cyclical include detected")); - } + public void EmitsError() => + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/_snippets/test.md:1 {include} cyclical include detected `{repo}/docs/_snippets/test.md` points to itself" + ); } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs index 7c75bf1ab9..107a038bc1 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs @@ -4,8 +4,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.FileInclusion; @@ -47,18 +49,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Test] - public void EachIncludeHasUniqueIds() - { - // First include at line 2: (2 * 1000) + 0 = 2000 - // Second include at line 7: (7 * 1000) + 0 = 7000 - Html.Should().Contain("applies-switch-item-2000-0"); - Html.Should().Contain("applies-switch-item-2000-1"); - Html.Should().Contain("applies-switch-set-2000"); - - Html.Should().Contain("applies-switch-item-7000-0"); - Html.Should().Contain("applies-switch-item-7000-1"); - Html.Should().Contain("applies-switch-set-7000"); - } + // First include at line 2: (2 * 1000) + 0 = 2000 + // Second include at line 7: (7 * 1000) + 0 = 7000 + public void EachIncludeHasUniqueIds() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } /// @@ -96,11 +89,35 @@ Some content between. public void ParsesBlock() => Block.Should().NotBeNull(); [Test] - public void EachAppliesSwitchHasUniqueIds() - { - // Include at line 2, first applies-switch at line 0: (2 * 1000) + 0 = 2000 - // Include at line 2, second applies-switch at line 8: (2 * 1000) + 8 = 2008 - Html.Should().Contain("applies-switch-set-2000"); - Html.Should().Contain("applies-switch-set-2008"); - } + // Include at line 2, first applies-switch at line 0: (2 * 1000) + 0 = 2000 + // Include at line 2, second applies-switch at line 8: (2 * 1000) + 8 = 2008 + public void EachAppliesSwitchHasUniqueIds() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      + + +
      +

      First switch - Stack

      +
      +
      +

      Some content between.

      +
      + + +
      +

      Second switch - Serverless

      +
      +
      + """, + DocsHtml.Body() + ); } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs index 853be6f911..38860fd128 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs @@ -4,8 +4,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.FileInclusion; @@ -47,18 +49,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Test] - public void EachIncludeHasUniqueIds() - { - // First include at line 2: (2 * 1000) + 0 = 2000 - // Second include at line 7: (7 * 1000) + 0 = 7000 - Html.Should().Contain("tabs-item-2000-0"); - Html.Should().Contain("tabs-item-2000-1"); - Html.Should().Contain("tabs-set-2000"); - - Html.Should().Contain("tabs-item-7000-0"); - Html.Should().Contain("tabs-item-7000-1"); - Html.Should().Contain("tabs-set-7000"); - } + // First include at line 2: (2 * 1000) + 0 = 2000 + // Second include at line 7: (7 * 1000) + 0 = 7000 + public void EachIncludeHasUniqueIds() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } /// @@ -96,11 +89,27 @@ Second tab set public void ParsesBlock() => Block.Should().NotBeNull(); [Test] - public void EachTabSetHasUniqueIds() - { - // Include at line 2, first tab-set at line 0: (2 * 1000) + 0 = 2000 - // Include at line 2, second tab-set at line 8: (2 * 1000) + 8 = 2008 - Html.Should().Contain("tabs-set-2000"); - Html.Should().Contain("tabs-set-2008"); - } + // Include at line 2, first tab-set at line 0: (2 * 1000) + 0 = 2000 + // Include at line 2, second tab-set at line 8: (2 * 1000) + 8 = 2008 + public void EachTabSetHasUniqueIds() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
      + + +
      +

      First tab set

      +
      +
      +

      Some content between.

      +
      + + +
      +

      Second tab set

      +
      +
      + """, + DocsHtml.Body() + ); } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs index 1b26bd9e04..7a6158af59 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs @@ -5,8 +5,10 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots; namespace Elastic.Markdown.Tests.FileInclusion; @@ -60,14 +62,12 @@ public class LiteralIncludeRelativeTraversalBlocked() : DirectiveTest fileSystem.AddFile(@"outside.txt", "some content"); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); + public void EmitsError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must resolve within the documentation source directory")); - } + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Include path must resolve within the documentation source directory."); } [InheritsTests] @@ -79,14 +79,12 @@ public class LiteralIncludeAbsoluteTraversalBlocked() : DirectiveTest fileSystem.AddFile(@"outside.txt", "some content"); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); + public void EmitsError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must resolve within the documentation source directory")); - } + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Include path must resolve within the documentation source directory."); } [InheritsTests] @@ -98,12 +96,10 @@ public class LiteralIncludeHiddenDirectoryBlocked() : DirectiveTest fileSystem.AddFile(@"docs/.config/data.txt", "some content"); [Test] - public void EmitsError() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty(); + public void EmitsError() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must not traverse hidden directories")); - } + .MatchInlineSnapshot("error {repo}/docs/index.md:3 Path must not traverse hidden directories."); } From 28a8550e973438eb79fc43dce5b26f7c312126b8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:45:19 +0100 Subject: [PATCH 08/18] Markdown tests (SettingsInclusion): HTML snapshots of the included settings The settings fixtures rendered several hundred lines each, so those snapshots are files. The tests of one fixture that asserted fragments of the same output are one snapshot test now; they became stricter. Every old Contain fragment appears in the accepted snapshot. RandomFileEmitsAnError keeps its targeted assertions: the diagnostic message embeds a YAML parser stack trace, which a snapshot should not pin. Co-Authored-By: Claude Sonnet 5.5 --- .../GeneratesHtml.verified.html | 1020 +++++ ...lSettingsWhenFilterIsInvalid.verified.html | 3773 +++++++++++++++++ .../IncludesInclusionHtml.verified.html | 1294 ++++++ .../GeneratesHtml.verified.html | 54 + .../RendersBothSettings.verified.html | 31 + ...tesAndInterpolatedGroupTitle.verified.html | 27 + .../SettingsInclusion/IncludeTests.cs | 381 +- 7 files changed, 6494 insertions(+), 86 deletions(-) create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterEchOnKibanaGeneralSettings/GeneratesHtml.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterWithUnknownValueEmitsWarning/StillRendersAllSettingsWhenFilterIsInvalid.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/IncludeTests/IncludesInclusionHtml.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/NewSchemaRendersMetadataAndNestedSettings/GeneratesHtml.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingReusesAnAnchoredAppliesToBlock/RendersBothSettings.verified.html create mode 100644 tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingsTopMatterAndTitlesRender/RendersPageDescriptionNotesAndInterpolatedGroupTitle.verified.html diff --git a/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterEchOnKibanaGeneralSettings/GeneratesHtml.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterEchOnKibanaGeneralSettings/GeneratesHtml.verified.html new file mode 100644 index 0000000000..d3dbed58e5 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterEchOnKibanaGeneralSettings/GeneratesHtml.verified.html @@ -0,0 +1,1020 @@ +

      Use these settings to configure general features available in Kibana.

      +

      General settings

      +
      +
      execution_context.enabled + + +
      +
      +

      + Supported on: + + + + +

      +

      Propagate request-specific metadata to Elasticsearch server by way of the + x-opaque-id + header. It is available in {{ecloud}} 8.1.0 and later versions.

      +

      + Datatype: + bool +

      +
      +
      +

      Elasticsearch connection settings

      +
      +
      elasticsearch.compression + + +
      +
      +

      + Supported on: + + + + +

      +

      Specifies whether {{kib}} should use compression for communications with {{es}}. It is available in {{ecloud}} 8.3.0 and later versions.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      elasticsearch.maxSockets + + +
      +
      +

      + Supported on: + + + + +

      +

      The maximum number of sockets that can be used for communications with {{es}}. It is available in {{ecloud}} 8.2.0 and later versions.

      +

      + Datatype: + int +

      +

      + Default: + Infinity +

      +
      +
      +

      Logging settings

      +
      +
      logging.root.level + + +
      +
      +

      + Supported on: + + + + +

      +

      Level at which a log record should be logged. Supported levels are: + all, + fatal, + error, + warn, + info, + debug, + trace, + off. Levels are ordered from + all + (highest) to + off + and a log record will be logged it its level is higher than or equal to the level of its logger, otherwise the log record is ignored. Use this value to + change the overall log level.

      +

      + Datatype: + enum +

      +

      + Default: + info +

      +

      + Options: +

      +
        +
      • + all +
      • +
      • + fatal +
      • +
      • + error +
      • +
      • + warn +
      • +
      • + info +
      • +
      • + debug +
      • +
      • + trace +
      • +
      • + off +
      • +
      +
      +
      + Tip +
      +
      +

      Set to + all + to log all events, including system usage information and all requests. Set to + off + to silence all logs. You can also use the logging + cli commands + to set log level to + verbose + or silence all logs.

      +
      +
      +

      The following example shows a valid verbose + logging.root + configuration:

      +
      +
      +
      +					logging:   appenders:     console_appender:       type: console       layout:         type: pattern         highlight: true   root:     appenders: [console_appender]     level: all
      +					
      +				
      +
      +
      +
      +
      +

      Map settings

      +
      +
      map.includeElasticMapsService + + +
      +
      +

      + Supported on: + + + + +

      +

      Set to + false + to disable connections to Elastic Maps Service. When + includeElasticMapsService + is turned off, only tile layer configured by + + map.tilemap.url + + is available in + Maps.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      map.tilemap.options.attribution + + +
      +
      +

      + Supported on: + + + + +

      +

      The map attribution string. Provide attributions in markdown and use + \| + to delimit attributions, for example: + "[attribution 1](https://www.attribution1)\|[attribution 2](https://www.attribution2)".

      +

      + Datatype: + string +

      +

      + Default: + "© [Elastic Maps Service](https://www.elastic.co/elastic-maps-service)" +

      +
      +
      map.tilemap.options.maxZoom + + +
      +
      +

      + Supported on: + + + + +

      +

      The maximum zoom level.

      +

      + Datatype: + int +

      +

      + Default: + 10 +

      +
      +
      map.tilemap.options.minZoom + + +
      +
      +

      + Supported on: + + + + +

      +

      The minimum zoom level.

      +

      + Datatype: + int +

      +

      + Default: + 1 +

      +
      +
      map.tilemap.options.subdomains + + +
      +
      +

      + Supported on: + + + + +

      +

      An array of subdomains used by the tile service. Specify the position of the subdomain the URL with the token + {{s}}.

      +

      + Datatype: + string +

      +
      +
      map.tilemap.url + + +
      +
      +

      + Supported on: + + + + +

      +

      The URL to the service that {{kib}} uses as the default basemap in + maps + and + vega maps. By default, {{kib}} sets a basemap from the + Elastic Maps Service, but users can point to their own Tile Map Service. For example: + "https://tiles.elastic.co/v2/default/{{z}}/{{x}}/{{y}}.png?elastic_tile_service_tos=agree&my_app_name=kibana" +

      +

      + Datatype: + string +

      +
      +
      +

      Migrations settings

      +
      +
      migrations.batchSize + + +
      +
      +

      + Supported on: + + + + +

      +

      Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If upgrade migrations results in {{kib}} crashing with an out of memory exception or fails due to an Elasticsearch + circuit_breaking_exception, use a smaller + batchSize + value to reduce the memory pressure.

      +

      + Datatype: + int +

      +

      + Default: + 1000 +

      +
      +
      migrations.discardUnknownObjects + + +
      +
      +

      + Supported on: + + + + +

      +

      Discard saved objects with unknown types during a migration. Must be set to the target version, for example: + 8.4.0. Default: undefined. It is available in {{ecloud}} 8.4.0 and later versions.

      +

      + Datatype: + string +

      +
      +
      migrations.discardCorruptObjects + + +
      +
      +

      + Supported on: + + + + +

      +

      Discard corrupt saved objects, as well as those that cause transform errors during a migration. Must be set to the target version, for example: + 8.4.0. Default: undefined. It is available in {{ecloud}} 8.4.0 and later versions.

      +

      + Datatype: + string +

      +
      +
      +

      Search and autocomplete settings

      +
      +
      data.autocomplete.valueSuggestions.terminateAfter + + +
      +
      +

      + Supported on: + + + + +

      +

      Specifies the max number of documents loaded by each shard to generate autocomplete suggestions. Allowed values are between 1 and 10000000.

      +

      + Datatype: + int +

      +

      + Default: + 100000 +

      +
      +
      data.autocomplete.valueSuggestions.timeout + + +
      +
      +

      + Supported on: + + + + +

      +

      Specifies the time in milliseconds to wait for autocomplete suggestions from Elasticsearch. Allowed values are between 1 and 1200000.

      +

      + Datatype: + int +

      +

      + Default: + 1000 +

      +
      +
      unifiedSearch.autocomplete.valueSuggestions.timeout + + +
      +
      +

      + Supported on: + + + + +

      +

      Time in milliseconds to wait for autocomplete suggestions from {{es}}. This value must be a whole number greater than zero.

      +

      + Datatype: + int +

      +

      + Default: + 1000 +

      +
      +
      unifiedSearch.autocomplete.valueSuggestions.terminateAfter + + +
      +
      +

      + Supported on: + + + + +

      +

      Maximum number of documents loaded by each shard to generate autocomplete suggestions. This value must be a whole number greater than zero.

      +

      + Datatype: + int +

      +

      + Default: + 100000 +

      +
      +
      + Note +
      +
      +

      To reload the logging settings, send a SIGHUP signal to {{kib}}. For more logging configuration options, see the + Configure Logging in {{kib}} + guide.

      +
      +
      +
      +
      +

      Server settings

      +
      +
      server.maxPayload + + +
      +
      +

      + Supported on: + + + + +

      +

      The maximum payload size in bytes for incoming server requests.

      +

      + Datatype: + int +

      +

      + Default: + 1048576 +

      +
      +
      server.customResponseHeaders + + +
      +
      +

      + Supported on: + + + + +

      +

      Header names and values to send on all responses to the client from the {{kib}} server.

      +

      + Datatype: + string +

      +

      + Default: + {} +

      +
      +
      +

      Server compression settings

      +
      +
      server.compression.brotli.enabled + + +
      +
      +

      + Supported on: + + + + +

      +

      Set to + true + to enable brotli (br) compression format. Browsers not supporting brotli compression will fallback to using gzip instead. This setting may not be used when + + server.compression.enabled + + is set to + false. It is available in {{ecloud}} 8.6.0 and later versions.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      +

      Server security response headers

      +
      +
      server.securityResponseHeaders.strictTransportSecurity + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Strict-Transport-Security + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are any text value or + null. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + null +

      +
      +
      server.securityResponseHeaders.xContentTypeOptions + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + X-Content-Type-Options + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are + nosniff + or + null. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + "nosniff" +

      +
      +
      server.securityResponseHeaders.referrerPolicy + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Referrer-Policy + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are + no-referrer, + no-referrer-when-downgrade, + origin, + origin-when-cross-origin, + same-origin, + strict-origin, + strict-origin-when-cross-origin, + unsafe-url, or + null. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + "strict-origin-when-cross-origin" +

      +
      +
      server.securityResponseHeaders.permissionsPolicy + + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Permissions-Policy + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are any text value or + null. Refer to the + + Permissions-Policy + documentation + for defined directives, values, and text format. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + camera=(), display-capture=(), fullscreen=(self), geolocation=(), microphone=(), web-share=() +

      +
      +
      server.securityResponseHeaders.permissionsPolicyReportOnly + + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Permissions-Policy-Report-Only + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are any text value or + null. Refer to the + + Permissions-Policy + documentation + for defined directives, values, and text format.

      +

      + Datatype: + string +

      +
      +
      server.securityResponseHeaders.disableEmbedding + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Content-Security-Policy + + and + + X-Frame-Options + + headers are configured to disable embedding {{kib}} in other webpages using iframes. When set to + true, secure headers are used to disable embedding, which adds the + frame-ancestors: 'self' + directive to the + Content-Security-Policy + response header and adds the + X-Frame-Options: SAMEORIGIN + response header.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.securityResponseHeaders.crossOriginOpenerPolicy + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Cross-Origin-Opener-Policy + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are + unsafe-none, + same-origin-allow-popups, + same-origin, or + null. To disable, set to + null. It is available in {{ecloud}} 8.7.0 and later versions.

      +

      + Datatype: + enum +

      +

      + Default: + "same-origin" +

      +

      + Options: +

      +
        +
      • + unsafe-none +
      • +
      • + same-origin-allow-popups +
      • +
      • + same-origin +
      • +
      • + null +
      • +
      +
      +
      +

      Server XSRF settings

      +
      +
      server.xsrf.allowlist + + +
      +
      +

      + Supported on: + + + + +

      +

      It is not recommended to disable protections for arbitrary API endpoints. Instead, supply the + kbn-xsrf + header. The + + server.xsrf.allowlist + + setting requires the following format:

      +
      +
      +
      +					*Default: [ ]* An array of API endpoints which should be exempt from Cross-Site Request Forgery ("XSRF") protections.
      +					
      +				
      +
      +
      +

      It is available in {{ecloud}} 8.0.0 and later versions.

      +

      + Datatype: + string +

      +

      + Default: + [] +

      +
      +
      +

      UI and visualization settings

      +
      +
      uiSettings.globalOverrides.hideAnnouncements + + + +
      +
      +

      Set to + true + to stop showing messages and tours that highlight new features.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      uiSettings.globalOverrides.hideFeedback + + + +
      +
      +

      Set to + true + to stop showing elements requesting user feedback.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      vis_type_table.legacyVisEnabled + + +
      +
      +

      + Supported on: + + + + +

      +

      Starting from version 7.11, a new datatable visualization is used. Set to + true + to enable the legacy version. In version 8.0 and later, the old implementation is removed and this setting is no longer supported.

      +

      + Datatype: + bool +

      +
      +
      vis_type_vega.enable + + +
      +
      +

      + Supported on: + + + + +

      +

      For 7.7 version and later, set to + false + to disable Vega vizualizations.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      vega.enableExternalUrls + + + +
      +
      +

      + Supported on: + + + + +

      +

      Set to + true + to allow Vega vizualizations to use data from sources other than the linked Elasticsearch cluster. In version 8.0 and later, the + vega.enableExternalUrls + is not supported. Use + vis_type_vega.enableExternalUrls + instead.

      +

      + Datatype: + bool +

      +
      +
      + Deprecation details +
      +
      +

      In version 8.0 and later, this setting is not supported. Use + vis_type_vega.enableExternalUrls + instead.

      +
      +
      +
      +
      vis_type_vega.enableExternalUrls + + +
      +
      +

      + Supported on: + + + + +

      +

      Set this value to true to allow Vega to use any URL to access external data sources and images. When false, Vega can only get data from {{es}}.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      +

      Feature and plugin settings

      +
      +
      xpack.securitySolution.maxUploadResponseActionFileBytes + + +
      +
      +

      + Supported on: + + + + +

      +

      Allow to configure the max file upload size for use with the Upload File Response action available with the Defend Integration. To learn more, check + Endpoint Response actions. It is available in {{ecloud}} 8.9.0 and later versions.

      +

      + Datatype: + string +

      +
      +
      xpack.securitySolution.disableEndpointRuleAutoInstall + + + +
      +
      +

      Set to + true + to disable the automatic installation of Elastic Defend SIEM rules when a new Endpoint integration policy is created. Introduced with v9.2.4.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterWithUnknownValueEmitsWarning/StillRendersAllSettingsWhenFilterIsInvalid.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterWithUnknownValueEmitsWarning/StillRendersAllSettingsWhenFilterIsInvalid.verified.html new file mode 100644 index 0000000000..ec59d86f70 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/DeploymentFilterWithUnknownValueEmitsWarning/StillRendersAllSettingsWhenFilterIsInvalid.verified.html @@ -0,0 +1,3773 @@ +

      Use these settings to configure general features available in Kibana.

      +

      General settings

      +
      +
      console.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Toggling this causes the server to regenerate assets on the next startup, which may cause a delay before pages start being served. Set to + false + to disable Console.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      execution_context.enabled + + +
      +
      +

      + Supported on: + + + + +

      +

      Propagate request-specific metadata to Elasticsearch server by way of the + x-opaque-id + header. It is available in {{ecloud}} 8.1.0 and later versions.

      +

      + Datatype: + bool +

      +
      +
      newsfeed.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Controls whether to enable the newsfeed system for the {{kib}} UI notification center. Set to + false + to disable the newsfeed system.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      node.roles + + + +
      +
      +

      + Supported on: + + + +

      +

      Indicates which roles to configure the {{kib}} process with, which will effectively run {{kib}} in different modes. Valid options are + background_tasks + and + ui, or + * + to select all roles.

      +

      + Datatype: + string +

      +

      + Default: + * +

      +
      +
      notifications.connectors.default.email + + +
      +
      +

      + Supported on: + + + +

      +

      Choose the default email connector for user notifications. As of + 8.6.0, {{kib}} is shipping with a new notification mechanism that will send email notifications for various user actions, e.g. assigning a + Case + to a user. To enable notifications, an email connector must be + preconfigured + in the system via + kibana.yml, and the notifications plugin must be configured to point to the ID of that connector.

      +

      + Datatype: + string +

      +
      +
      ops.interval + + +
      +
      +

      + Supported on: + + + +

      +

      Set the interval in milliseconds to sample system and process performance metrics. The minimum value is 100.

      +

      + Datatype: + int +

      +

      + Default: + 5000 +

      +
      +
      ops.cGroupOverrides.cpuPath + + +
      +
      +

      + Supported on: + + + +

      +

      Override for cgroup cpu path when mounted in a manner that is inconsistent with + /proc/self/cgroup.

      +

      + Datatype: + string +

      +
      +
      ops.cGroupOverrides.cpuAcctPath + + +
      +
      +

      + Supported on: + + + +

      +

      Override for cgroup cpuacct path when mounted in a manner that is inconsistent with + /proc/self/cgroup.

      +

      + Datatype: + string +

      +
      +
      path.data + + +
      +
      +

      + Supported on: + + + +

      +

      The path where {{kib}} stores persistent data not saved in {{es}}.

      +

      + Datatype: + string +

      +

      + Default: + data +

      +
      +
      permissionsPolicy.report_to + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Permissions Policy + report-to + directive.

      +

      + Datatype: + string +

      +
      +
      pid.file + + +
      +
      +

      + Supported on: + + + +

      +

      Specifies the path where {{kib}} creates the process ID file.

      +

      + Datatype: + string +

      +
      +
      status.allowAnonymous + + +
      +
      +

      + Supported on: + + + +

      +

      If authentication is enabled, setting this to + true + enables unauthenticated users to access the {{kib}} server status API and status page.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      +

      Content Security Policy (CSP) settings

      +
      +
      csp.script_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + script-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.disableUnsafeEval + + + +
      +
      +

      Set this to + false + to add the + + unsafe-eval + + source expression to the + script-src + directive.

      +

      When + csp.disableUnsafeEval + is set to + true, Kibana will use a custom version of the Handlebars template library. Handlebars is used in various locations in the Kibana frontend where custom templates can be supplied by the user when for instance setting up a visualisation. If you experience any issues rendering Handlebars templates, please set this setting to + false + and + open an issue + in the Kibana GitHub repository.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      + Deprecation details +
      +
      +

      Deprecated in 8.7.0. Use + csp.script_src: ['unsafe-eval'] + instead if you wish to enable + unsafe-eval. This config option will have no effect in a future version.

      +
      +
      +
      +
      csp.worker_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + worker-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.style_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + style-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.connect_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + connect-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.default_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + default-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.font_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + font-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.frame_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + frame-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.img_src + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + img-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.object_src + + + +
      +
      +

      Add sources for the + Content Security Policy + object-src + directive.

      +

      + Datatype: + string +

      +
      +
      csp.frame_ancestors + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + frame-ancestors + directive.

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      The + frame-ancestors + directive can also be configured by using + + server.securityResponseHeaders.disableEmbedding + . In that case, that takes precedence and any values in + csp.frame_ancestors + are ignored.

      +
      +
      +
      +
      csp.report_only.form_action + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + form-action + directive + in reporting mode.

      +

      + Datatype: + string +

      +
      +
      csp.report_only.object_src + + + +
      +
      +

      Add sources for the + Content Security Policy + object-src + directive + in reporting mode.

      +

      + Datatype: + string +

      +
      +
      + Deprecation details +
      +
      +

      This setting is deprecated in favor of + csp.object_src.

      +
      +
      +
      +
      csp.report_uri + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + report-uri + directive.

      +

      + Datatype: + string +

      +
      +
      csp.report_to + + +
      +
      +

      + Supported on: + + + +

      +

      Add sources for the + Content Security Policy + report-to + directive.

      +

      + Datatype: + string +

      +
      +
      csp.strict + + +
      +
      +

      + Supported on: + + + +

      +

      Blocks {{kib}} access to any browser that does not enforce even rudimentary CSP rules. In practice, this disables support for older, less safe browsers like Internet Explorer.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      csp.warnLegacyBrowsers + + +
      +
      +

      + Supported on: + + + +

      +

      Shows a warning message after loading {{kib}} to any browser that does not enforce even rudimentary CSP rules, though {{kib}} is still accessible. This configuration is effectively ignored when + + csp.strict + + is enabled.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      +

      Elasticsearch connection settings

      +
      +
      elasticsearch.customHeaders + + +
      +
      +

      + Supported on: + + + +

      +

      Header names and values to send to {{es}}. Any custom headers cannot be overwritten by client-side headers, regardless of the + + elasticsearch.requestHeadersWhitelist + + configuration.

      +

      + Datatype: + string +

      +

      + Default: + {} +

      +
      +
      elasticsearch.hosts + + +
      +
      +

      + Supported on: + + + +

      +

      The URLs of the {{es}} instances to use for all your queries. All nodes listed here must be on the same cluster.

      +

      To enable SSL/TLS for outbound connections to {{es}}, use the + https + protocol in this setting.

      +

      + Datatype: + string +

      +

      + Default: + [ "http://localhost:9200" ] +

      +
      +
      elasticsearch.publicBaseUrl + + +
      +
      +

      + Supported on: + + + +

      +

      The URL through which Elasticsearch is publicly accessible, if any. This will be shown to users in Kibana when they need connection details for your Elasticsearch cluster.

      +

      + Datatype: + string +

      +
      +
      elasticsearch.pingTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      Time in milliseconds to wait for {{es}} to respond to pings. Defaults to the value of the + + elasticsearch.requestTimeout + + setting.

      +

      + Datatype: + int +

      +
      +
      elasticsearch.requestHeadersWhitelist + + +
      +
      +

      + Supported on: + + + +

      +

      List of {{kib}} client-side headers to send to {{es}}. To send + no + client-side headers, set this value to [] (an empty list). Removing the + authorization + header from being whitelisted means that you cannot use + basic authentication + in {{kib}}.

      +

      + Datatype: + string +

      +

      + Default: + [ 'authorization', 'es-client-authentication' ] +

      +
      +
      elasticsearch.requestTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      Time in milliseconds to wait for responses from the back end or {{es}}. This value must be a positive integer.

      +

      + Datatype: + int +

      +

      + Default: + 30000 +

      +
      +
      elasticsearch.shardTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      Time in milliseconds for {{es}} to wait for responses from shards. Set to 0 to disable.

      +

      + Datatype: + int +

      +

      + Default: + 30000 +

      +
      +
      elasticsearch.compression + + +
      +
      +

      + Supported on: + + + + +

      +

      Specifies whether {{kib}} should use compression for communications with {{es}}. It is available in {{ecloud}} 8.3.0 and later versions.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      elasticsearch.sniffInterval + + +
      +
      +

      + Supported on: + + + +

      +

      Time in milliseconds between requests to check {{es}} for an updated list of nodes.

      +

      + Datatype: + string +

      +

      + Default: + false +

      +
      +
      elasticsearch.sniffOnStart + + +
      +
      +

      + Supported on: + + + +

      +

      Attempt to find other {{es}} nodes on startup.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      elasticsearch.sniffOnConnectionFault + + +
      +
      +

      + Supported on: + + + +

      +

      Update the list of {{es}} nodes immediately following a connection fault.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      elasticsearch.maxSockets + + +
      +
      +

      + Supported on: + + + + +

      +

      The maximum number of sockets that can be used for communications with {{es}}. It is available in {{ecloud}} 8.2.0 and later versions.

      +

      + Datatype: + int +

      +

      + Default: + Infinity +

      +
      +
      elasticsearch.maxResponseSize + + +
      +
      +

      + Supported on: + + + +

      +

      Either + false + or a + byteSize + value. When set, responses from {{es}} with a size higher than the defined limit will be rejected. This is intended to be used as a circuit-breaker mechanism to avoid memory errors in case of unexpectedly high responses coming from {{es}}.

      +

      + Datatype: + string +

      +

      + Default: + false +

      +
      +
      elasticsearch.maxIdleSockets + + +
      +
      +

      + Supported on: + + + +

      +

      The maximum number of idle sockets to keep open between {{kib}} and {{es}}. If more sockets become idle, they will be closed.

      +

      + Datatype: + int +

      +

      + Default: + 256 +

      +
      +
      elasticsearch.idleSocketTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      The timeout for idle sockets kept open between {{kib}} and {{es}}. If the socket is idle for longer than this timeout, it will be closed. If you have a transparent proxy between {{kib}} and {{es}} be sure to set this value lower than or equal to the proxy's timeout.

      +

      + Datatype: + string +

      +

      + Default: + 60s +

      +
      +
      elasticsearch.username + + +
      +
      +

      + Supported on: + + + +

      +

      If your {{es}} is protected with basic authentication, this setting provides the username that the {{kib}} server uses to perform maintenance on the {{kib}} index at startup. {{kib}} users still need to authenticate with {{es}}, which is proxied through the {{kib}} server.

      +

      + Datatype: + string +

      +
      +
      elasticsearch.password + + +
      +
      +

      + Supported on: + + + +

      +

      If your {{es}} is protected with basic authentication, this setting provides the password that the {{kib}} server uses to perform maintenance on the {{kib}} index at startup. {{kib}} users still need to authenticate with {{es}}, which is proxied through the {{kib}} server.

      +

      + Datatype: + string +

      +
      +
      elasticsearch.serviceAccountToken + + +
      +
      +

      + Supported on: + + + +

      +

      If your {{es}} is protected with basic authentication, this token provides the credentials that the {{kib}} server uses to perform maintenance on the {{kib}} index at startup. This setting is an alternative to + elasticsearch.username + and + elasticsearch.password.

      +

      + Datatype: + string +

      +
      +
      +

      Elasticsearch SSL settings

      +
      +
      elasticsearch.ssl.alwaysPresentCertificate + + +
      +
      +

      + Supported on: + + + +

      +

      Controls {{kib}} behavior in regard to presenting a client certificate when requested by {{es}}. This setting applies to all outbound SSL/TLS connections to {{es}}, including requests that are proxied for end users.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      + Warning +
      +
      +

      When {{es}} uses certificates to authenticate end users with a PKI realm and + + elasticsearch.ssl.alwaysPresentCertificate + + is + true, proxied requests may be executed as the identity that is tied to the {{kib}} server.

      +
      +
      +
      +
      elasticsearch.ssl.certificate + + +
      +
      +

      + Supported on: + + + +

      +

      Path to a PEM-encoded X.509 client certificate. This is used by {{kib}} to authenticate itself when making outbound SSL/TLS connections to {{es}}. For this setting to take effect, the + xpack.security.http.ssl.client_authentication + setting in {{es}} must be also be set to + "required" + or + "optional" + to request a client certificate from {{kib}}.

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      This setting cannot be used in conjunction with + + elasticsearch.ssl.keystore.path + .

      +
      +
      +
      +
      elasticsearch.ssl.key + + +
      +
      +

      + Supported on: + + + +

      +

      Path to the corresponding private key for the PEM-encoded X.509 client certificate specified via + + elasticsearch.ssl.certificate + . These are used by {{kib}} to authenticate itself when making outbound SSL/TLS connections to {{es}}. For this setting to take effect, the + xpack.security.http.ssl.client_authentication + setting in {{es}} must be also be set to + "required" + or + "optional" + to request a client certificate from {{kib}}.

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      This setting cannot be used in conjunction with + + elasticsearch.ssl.keystore.path + .

      +
      +
      +
      +
      elasticsearch.ssl.certificateAuthorities + + +
      +
      +

      + Supported on: + + + +

      +

      Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates, which make up a trusted certificate chain for {{es}}. This chain is used by {{kib}} to establish trust when making outbound SSL/TLS connections to {{es}}.

      +

      In addition to this setting, trusted certificates may be specified via + + elasticsearch.ssl.keystore.path + + and/or + + elasticsearch.ssl.truststore.path + .

      +

      + Datatype: + string +

      +
      +
      elasticsearch.ssl.keyPassphrase + + +
      +
      +

      + Supported on: + + + +

      +

      The password that decrypts the private key that is specified via + + elasticsearch.ssl.key + . This value is optional, as the key may not be encrypted.

      +

      + Datatype: + string +

      +
      +
      elasticsearch.ssl.keystore.path + + +
      +
      +

      + Supported on: + + + +

      +

      Path to a PKCS#12 keystore that contains an X.509 client certificate and it's corresponding private key. These are used by {{kib}} to authenticate itself when making outbound SSL/TLS connections to {{es}}. For this setting, you must also set the + xpack.security.http.ssl.client_authentication + setting in {{es}} to + "required" + or + "optional" + to request a client certificate from {{kib}}.

      +

      If the keystore contains any additional certificates, they are used as a trusted certificate chain for {{es}}. This chain is used by {{kib}} to establish trust when making outbound SSL/TLS connections to {{es}}. In addition to this setting, trusted certificates may be specified via + + elasticsearch.ssl.certificateAuthorities + + and/or + + elasticsearch.ssl.truststore.path + .

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      This setting cannot be used in conjunction with + + elasticsearch.ssl.certificate + + or + + elasticsearch.ssl.key + .

      +
      +
      +
      +
      elasticsearch.ssl.keystore.password + + +
      +
      +

      + Supported on: + + + +

      +

      The password that decrypts the keystore specified via + + elasticsearch.ssl.keystore.path + . If the keystore has no password, leave this as blank. If the keystore has an empty password, set this to + "".

      +

      + Datatype: + string +

      +
      +
      elasticsearch.ssl.truststore.path + + +
      +
      +

      + Supported on: + + + +

      +

      Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates, which make up a trusted certificate chain for {{es}}. This chain is used by {{kib}} to establish trust when making outbound SSL/TLS connections to {{es}}.

      +

      In addition to this setting, trusted certificates may be specified via + + elasticsearch.ssl.certificateAuthorities + + and/or + + elasticsearch.ssl.keystore.path + .

      +

      + Datatype: + string +

      +
      +
      elasticsearch.ssl.truststore.password + + +
      +
      +

      + Supported on: + + + +

      +

      The password that decrypts the trust store specified via + + elasticsearch.ssl.truststore.path + . If the trust store has no password, leave this as blank. If the trust store has an empty password, set this to + "".

      +

      + Datatype: + string +

      +
      +
      elasticsearch.ssl.verificationMode + + +
      +
      +

      + Supported on: + + + +

      +

      Controls the verification of the server certificate that {{kib}} receives when making an outbound SSL/TLS connection to {{es}}. Valid values are + "full", + "certificate", and + "none". Using + "full" + performs hostname verification, using + "certificate" + skips hostname verification, and using + "none" + skips verification entirely.

      +

      + Datatype: + enum +

      +

      + Default: + full +

      +

      + Options: +

      +
        +
      • + full +
      • +
      • + certificate +
      • +
      • + none +
      • +
      +
      +
      +

      Logging settings

      +
      +
      logging.root + + +
      +
      +

      + Supported on: + + + +

      +

      The + root + logger is a + dedicated logger + and is pre-configured. The + root + logger logs at + info + level by default. If any other logging configuration is specified, + root + must + also be explicitly configured.

      +

      + Datatype: + string +

      +
      +
      logging.root.appenders + + +
      +
      +

      + Supported on: + + + +

      +

      A list of logging appenders to forward the root level logger instance to. By default + root + is configured with the + default + appender that logs to stdout with a + pattern + layout. This is the configuration that all custom loggers will use unless they're re-configured explicitly. You can override the default behavior by configuring a different + appender + to apply to + root.

      +

      + Datatype: + string +

      +
      +
      logging.root.level + + +
      +
      +

      + Supported on: + + + + +

      +

      Level at which a log record should be logged. Supported levels are: + all, + fatal, + error, + warn, + info, + debug, + trace, + off. Levels are ordered from + all + (highest) to + off + and a log record will be logged it its level is higher than or equal to the level of its logger, otherwise the log record is ignored. Use this value to + change the overall log level.

      +

      + Datatype: + enum +

      +

      + Default: + info +

      +

      + Options: +

      +
        +
      • + all +
      • +
      • + fatal +
      • +
      • + error +
      • +
      • + warn +
      • +
      • + info +
      • +
      • + debug +
      • +
      • + trace +
      • +
      • + off +
      • +
      +
      +
      + Tip +
      +
      +

      Set to + all + to log all events, including system usage information and all requests. Set to + off + to silence all logs. You can also use the logging + cli commands + to set log level to + verbose + or silence all logs.

      +
      +
      +

      The following example shows a valid verbose + logging.root + configuration:

      +
      +
      +
      +					logging:   appenders:     console_appender:       type: console       layout:         type: pattern         highlight: true   root:     appenders: [console_appender]     level: all
      +					
      +				
      +
      +
      +
      +
      logging.loggers[] + + +
      +
      +

      + Supported on: + + + +

      +

      Allows you to + customize a specific logger instance.

      +

      + Datatype: + string +

      +
      +
      logging.appenders[] + + +
      +
      +

      + Supported on: + + + +

      +

      + Appenders + define how and where log messages are displayed (eg. + stdout + or console) and stored (eg. file on the disk).

      +

      + Datatype: + string +

      +
      +
      +

      Map settings

      +
      +
      map.includeElasticMapsService + + +
      +
      +

      + Supported on: + + + + +

      +

      Set to + false + to disable connections to Elastic Maps Service. When + includeElasticMapsService + is turned off, only tile layer configured by + + map.tilemap.url + + is available in + Maps.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      map.emsUrl + + +
      +
      +

      + Supported on: + + + +

      +

      Specifies the URL of a self hosted + {{hosted-ems}} +

      +

      + Datatype: + string +

      +
      +
      map.tilemap.options.attribution + + +
      +
      +

      + Supported on: + + + + +

      +

      The map attribution string. Provide attributions in markdown and use + \| + to delimit attributions, for example: + "[attribution 1](https://www.attribution1)\|[attribution 2](https://www.attribution2)".

      +

      + Datatype: + string +

      +

      + Default: + "© [Elastic Maps Service](https://www.elastic.co/elastic-maps-service)" +

      +
      +
      map.tilemap.options.maxZoom + + +
      +
      +

      + Supported on: + + + + +

      +

      The maximum zoom level.

      +

      + Datatype: + int +

      +

      + Default: + 10 +

      +
      +
      map.tilemap.options.minZoom + + +
      +
      +

      + Supported on: + + + + +

      +

      The minimum zoom level.

      +

      + Datatype: + int +

      +

      + Default: + 1 +

      +
      +
      map.tilemap.options.subdomains + + +
      +
      +

      + Supported on: + + + + +

      +

      An array of subdomains used by the tile service. Specify the position of the subdomain the URL with the token + {{s}}.

      +

      + Datatype: + string +

      +
      +
      map.tilemap.url + + +
      +
      +

      + Supported on: + + + + +

      +

      The URL to the service that {{kib}} uses as the default basemap in + maps + and + vega maps. By default, {{kib}} sets a basemap from the + Elastic Maps Service, but users can point to their own Tile Map Service. For example: + "https://tiles.elastic.co/v2/default/{{z}}/{{x}}/{{y}}.png?elastic_tile_service_tos=agree&my_app_name=kibana" +

      +

      + Datatype: + string +

      +
      +
      +

      Migrations settings

      +
      +
      migrations.batchSize + + +
      +
      +

      + Supported on: + + + + +

      +

      Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If upgrade migrations results in {{kib}} crashing with an out of memory exception or fails due to an Elasticsearch + circuit_breaking_exception, use a smaller + batchSize + value to reduce the memory pressure.

      +

      + Datatype: + int +

      +

      + Default: + 1000 +

      +
      +
      migrations.discardUnknownObjects + + +
      +
      +

      + Supported on: + + + + +

      +

      Discard saved objects with unknown types during a migration. Must be set to the target version, for example: + 8.4.0. Default: undefined. It is available in {{ecloud}} 8.4.0 and later versions.

      +

      + Datatype: + string +

      +
      +
      migrations.discardCorruptObjects + + +
      +
      +

      + Supported on: + + + + +

      +

      Discard corrupt saved objects, as well as those that cause transform errors during a migration. Must be set to the target version, for example: + 8.4.0. Default: undefined. It is available in {{ecloud}} 8.4.0 and later versions.

      +

      + Datatype: + string +

      +
      +
      migrations.maxBatchSizeBytes + + +
      +
      +

      + Supported on: + + + +

      +

      Defines the maximum payload size for indexing batches of upgraded saved objects to avoid migrations failing due to a 413 Request Entity Too Large response from Elasticsearch. This value should be lower than or equal to your Elasticsearch cluster's + http.max_content_length + configuration option.

      +

      + Datatype: + string +

      +

      + Default: + 100mb +

      +
      +
      migrations.retryAttempts + + +
      +
      +

      + Supported on: + + + +

      +

      The number of times migrations retry temporary failures, such as a network timeout, 503 status code, or + snapshot_in_progress_exception. When upgrade migrations frequently fail after exhausting all retry attempts with a message such as + Unable to complete the [...] step after 15 attempts, terminating., increase the setting value.

      +

      + Datatype: + int +

      +

      + Default: + 15 +

      +
      +
      migrations.useCumulativeLogger + + + +
      +
      +

      Skip logging migration progress unless there are any errors. Set to + false + when troubleshooting migration issues and not automatically shown.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      +

      Saved Objects settings

      +
      +
      savedObjects.maxImportExportSize + + +
      +
      +

      + Supported on: + + + +

      +

      The maximum count of saved objects that can be imported or exported. This setting exists to prevent the {{kib}} server from running out of memory when handling large numbers of saved objects. It is recommended to only raise this setting if you are confident your server can hold this many objects in memory.

      +

      + Datatype: + int +

      +

      + Default: + 10000 +

      +
      +
      savedObjects.maxImportPayloadBytes + + +
      +
      +

      + Supported on: + + + +

      +

      The maximum byte size of a saved objects import that the {{kib}} server will accept. This setting exists to prevent the {{kib}} server from running out of memory when handling a large import payload. Note that this setting overrides the more general + + server.maxPayload + + for saved object imports only.

      +

      + Datatype: + int +

      +

      + Default: + 26214400 +

      +
      +
      +

      Search and autocomplete settings

      +
      +
      data.autocomplete.valueSuggestions.terminateAfter + + +
      +
      +

      + Supported on: + + + + +

      +

      Specifies the max number of documents loaded by each shard to generate autocomplete suggestions. Allowed values are between 1 and 10000000.

      +

      + Datatype: + int +

      +

      + Default: + 100000 +

      +
      +
      data.autocomplete.valueSuggestions.timeout + + +
      +
      +

      + Supported on: + + + + +

      +

      Specifies the time in milliseconds to wait for autocomplete suggestions from Elasticsearch. Allowed values are between 1 and 1200000.

      +

      + Datatype: + int +

      +

      + Default: + 1000 +

      +
      +
      unifiedSearch.autocomplete.valueSuggestions.timeout + + +
      +
      +

      + Supported on: + + + + +

      +

      Time in milliseconds to wait for autocomplete suggestions from {{es}}. This value must be a whole number greater than zero.

      +

      + Datatype: + int +

      +

      + Default: + 1000 +

      +
      +
      unifiedSearch.autocomplete.valueSuggestions.terminateAfter + + +
      +
      +

      + Supported on: + + + + +

      +

      Maximum number of documents loaded by each shard to generate autocomplete suggestions. This value must be a whole number greater than zero.

      +

      + Datatype: + int +

      +

      + Default: + 100000 +

      +
      +
      + Note +
      +
      +

      To reload the logging settings, send a SIGHUP signal to {{kib}}. For more logging configuration options, see the + Configure Logging in {{kib}} + guide.

      +
      +
      +
      +
      +

      Server settings

      +
      +
      server.basePath + + +
      +
      +

      + Supported on: + + + +

      +

      Enables you to specify a path to mount {{kib}} at if you are running behind a proxy. Use the + + server.rewriteBasePath + + setting to tell {{kib}} if it should remove the basePath from requests it receives, and to prevent a deprecation warning at startup. This setting cannot end in a slash (/).

      +

      + Datatype: + string +

      +
      +
      server.publicBaseUrl + + +
      +
      +

      + Supported on: + + + +

      +

      The publicly available URL that end-users access Kibana at. Must include the protocol, hostname, port (if different than the defaults for + http + and + https, 80 and 443 respectively), and the + + server.basePath + + (when that setting is configured explicitly). This setting cannot end in a slash (/).

      +

      + Datatype: + string +

      +
      +
      server.host + + +
      +
      +

      + Supported on: + + + +

      +

      This setting specifies the host of the back end server. To allow remote users to connect, set the value to the IP address or DNS name of the {{kib}} server. Use + 0.0.0.0 + to make Kibana listen on all IPs (public and private).

      +

      + Datatype: + string +

      +

      + Default: + "localhost" +

      +
      +
      server.keepaliveTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      The number of milliseconds to wait for additional data before restarting the + + server.socketTimeout + + counter.

      +

      + Datatype: + int +

      +

      + Default: + 120000 +

      +
      +
      server.maxPayload + + +
      +
      +

      + Supported on: + + + + +

      +

      The maximum payload size in bytes for incoming server requests.

      +

      + Datatype: + int +

      +

      + Default: + 1048576 +

      +
      +
      server.name + + +
      +
      +

      + Supported on: + + + +

      +

      A human-readable display name that identifies this {{kib}} instance.

      +

      + Datatype: + string +

      +

      + Default: + "your-hostname" +

      +
      +
      server.port + + +
      +
      +

      + Supported on: + + + +

      +

      {{kib}} is served by a back end server. This setting specifies the port to use.

      +

      + Datatype: + int +

      +

      + Default: + 5601 +

      +
      +
      server.protocol + + + +
      +
      +

      + Supported on: + + + +

      +

      The HTTP protocol to use, either + http1 + or + http2. Set to + http1 + to opt out of + HTTP/2 + support when TLS is enabled. Use of + http1 + may impact browser loading performance especially for dashboards with many panels. Default is + http2 + if TLS is enabled, otherwise + http1.

      +

      + Datatype: + enum +

      +

      + Options: +

      +
        +
      • + http1 +
      • +
      • + http2 +
      • +
      +
      +
      + Note +
      +
      +

      By default, enabling + http2 + requires a valid + h2c + configuration, meaning that TLS must be enabled via + + server.ssl.enabled + + and + + server.ssl.supportedProtocols + , if specified, must contain at least + TLSv1.2 + or + TLSv1.3. Strict validation of the + h2c + setup can be disabled by adding + server.http2.allowUnsecure: true + to the configuration.

      +
      +
      +
      +
      server.shutdownTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      Sets the grace period for {{kib}} to attempt to resolve any ongoing HTTP requests after receiving a + SIGTERM/SIGINT + signal, and before shutting down. Any new HTTP requests received during this period are rejected, because the incoming socket is closed without further processing.

      +

      + Datatype: + string +

      +

      + Default: + 30s +

      +
      +
      server.socketTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      The number of milliseconds to wait before closing an inactive socket.

      +

      + Datatype: + int +

      +

      + Default: + 120000 +

      +
      +
      server.payloadTimeout + + +
      +
      +

      + Supported on: + + + +

      +

      Sets the maximum time allowed for the client to transmit the request payload (body) before giving up and responding with a Request Timeout (408) error response.

      +

      + Datatype: + int +

      +

      + Default: + 20000 +

      +
      +
      server.customResponseHeaders + + +
      +
      +

      + Supported on: + + + + +

      +

      Header names and values to send on all responses to the client from the {{kib}} server.

      +

      + Datatype: + string +

      +

      + Default: + {} +

      +
      +
      server.requestId.allowFromAnyIp + + +
      +
      +

      + Supported on: + + + +

      +

      Sets whether or not the + X-Opaque-Id + header should be trusted from any IP address for identifying requests in logs and forwarded to Elasticsearch.

      +

      + Datatype: + bool +

      +
      +
      server.requestId.ipAllowlist + + +
      +
      +

      + Supported on: + + + +

      +

      A list of IPv4 and IPv6 address which the + X-Opaque-Id + header should be trusted from. Normally this would be set to the IP addresses of the load balancers or reverse-proxy that end users use to access Kibana. If any are set, + + server.requestId.allowFromAnyIp + + must also be set to + false. +

      +

      + Datatype: + string +

      +
      +
      server.rewriteBasePath + + +
      +
      +

      + Supported on: + + + +

      +

      Specifies whether {{kib}} should rewrite requests that are prefixed with + + server.basePath + + or require that they are rewritten by your reverse proxy.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.uuid + + +
      +
      +

      + Supported on: + + + +

      +

      The unique identifier for this {{kib}} instance. It must be a valid UUIDv4. It gets automatically generated on the first startup if not specified and persisted in the + data + path.

      +

      + Datatype: + string +

      +
      +
      +

      Server compression settings

      +
      +
      server.compression.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set to + false + to disable HTTP compression for all responses.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      server.compression.referrerWhitelist + + +
      +
      +

      + Supported on: + + + +

      +

      Specifies an array of trusted hostnames, such as the {{kib}} host, or a reverse proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request + Referer + header. This setting may not be used when + + server.compression.enabled + + is set to + false.

      +

      + Datatype: + string +

      +
      +
      server.compression.brotli.enabled + + +
      +
      +

      + Supported on: + + + + +

      +

      Set to + true + to enable brotli (br) compression format. Browsers not supporting brotli compression will fallback to using gzip instead. This setting may not be used when + + server.compression.enabled + + is set to + false. It is available in {{ecloud}} 8.6.0 and later versions.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      +

      Server CORS settings

      +
      +
      server.cors.enabled + + + +
      +
      +

      + Supported on: + + + +

      +

      Set to + true + to allow cross-origin API calls.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.cors.allowCredentials + + + +
      +
      +

      + Supported on: + + + +

      +

      Set to + true + to allow browser code to access response body whenever request performed with user credentials.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.cors.allowOrigin + + + +
      +
      +

      + Supported on: + + + +

      +

      List of origins permitted to access resources. You must specify explicit hostnames and not use + server.cors.allowOrigin: ["*"] + when + server.cors.allowCredentials: true.

      +

      + Datatype: + string +

      +

      + Default: + ["*"] +

      +
      +
      +

      Server rate limiter settings

      +
      +
      server.rateLimiter.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Enables rate-limiting of requests to the {{kib}} server based on Node.js' Event Loop Utilization. If the average event loop utilization for the specified term exceeds the configured threshold, the server will respond with a + 429 Too Many Requests + status code.

      +

      This functionality should be used carefully as it may impact the server's availability. The configuration options vary per environment, so it is recommended to enable this option in a testing environment first, adjust the rate-limiter configuration, and then roll it out to production.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.rateLimiter.elu + + +
      +
      +

      + Supported on: + + + +

      +

      The Event Loop Utilization (ELU) threshold for rate-limiting requests to the {{kib}} server. The ELU is a value between 0 and 1, representing the average event loop utilization over the specified term. If the average ELU exceeds this threshold, the server will respond with a + 429 Too Many Requests + status code.

      +

      In a multi-instance environment with autoscaling, this value is usually between 0.6 and 0.8 to give the autoscaler enough time to react. This value can be higher in a single-instance environment but should not exceed 1.0. In general, the lower the value, the more aggressive the rate limiting. And the highest possible option should be used to prevent the {{kib}} server from being terminated.

      +

      + Datatype: + float +

      +
      +
      server.rateLimiter.term + + +
      +
      +

      + Supported on: + + + +

      +

      This value is one of + short, + medium, or + long, representing the term over which the average event loop utilization is calculated. It uses exponential moving averages (EMA) to smooth out the utilization values. Each term corresponds to + 15s, + 30s, and + 60s, respectively.

      +

      The term value also changes the way the rate limiter sees the trend in the load:

      +
        +
      • + short: + elu.short > server.rateLimiter.term;
      • +
      • + medium: + elu.short > server.rateLimiter.elu AND elu.medium > server.rateLimiter.elu;
      • +
      • + long: + elu.short > server.rateLimiter.elu AND elu.medium > server.rateLimiter.elu AND elu.long > server.rateLimiter.elu.
      • +
      +

      This behavior prevents requests from being throttled if the load starts decreasing. In general, the shorter the term, the more aggressive the rate limiting. In the multi-instance environment, the + medium + term makes the most sense as it gives the {{kib}} server enough time to spin up a new instance and prevents the existing instances from being terminated.

      +

      + Datatype: + enum +

      +

      + Options: +

      +
        +
      • + short +
      • +
      • + medium +
      • +
      • + long +
      • +
      +
      +
      +

      Server security response headers

      +
      +
      server.securityResponseHeaders.strictTransportSecurity + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Strict-Transport-Security + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are any text value or + null. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + null +

      +
      +
      server.securityResponseHeaders.xContentTypeOptions + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + X-Content-Type-Options + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are + nosniff + or + null. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + "nosniff" +

      +
      +
      server.securityResponseHeaders.referrerPolicy + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Referrer-Policy + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are + no-referrer, + no-referrer-when-downgrade, + origin, + origin-when-cross-origin, + same-origin, + strict-origin, + strict-origin-when-cross-origin, + unsafe-url, or + null. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + "strict-origin-when-cross-origin" +

      +
      +
      server.securityResponseHeaders.permissionsPolicy + + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Permissions-Policy + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are any text value or + null. Refer to the + + Permissions-Policy + documentation + for defined directives, values, and text format. To disable, set to + null.

      +

      + Datatype: + string +

      +

      + Default: + camera=(), display-capture=(), fullscreen=(self), geolocation=(), microphone=(), web-share=() +

      +
      +
      server.securityResponseHeaders.permissionsPolicyReportOnly + + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Permissions-Policy-Report-Only + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are any text value or + null. Refer to the + + Permissions-Policy + documentation + for defined directives, values, and text format.

      +

      + Datatype: + string +

      +
      +
      server.securityResponseHeaders.disableEmbedding + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Content-Security-Policy + + and + + X-Frame-Options + + headers are configured to disable embedding {{kib}} in other webpages using iframes. When set to + true, secure headers are used to disable embedding, which adds the + frame-ancestors: 'self' + directive to the + Content-Security-Policy + response header and adds the + X-Frame-Options: SAMEORIGIN + response header.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.securityResponseHeaders.crossOriginOpenerPolicy + + +
      +
      +

      + Supported on: + + + + +

      +

      Controls whether the + + Cross-Origin-Opener-Policy + + header is used in all responses to the client from the {{kib}} server, and specifies what value is used. Allowed values are + unsafe-none, + same-origin-allow-popups, + same-origin, or + null. To disable, set to + null. It is available in {{ecloud}} 8.7.0 and later versions.

      +

      + Datatype: + enum +

      +

      + Default: + "same-origin" +

      +

      + Options: +

      +
        +
      • + unsafe-none +
      • +
      • + same-origin-allow-popups +
      • +
      • + same-origin +
      • +
      • + null +
      • +
      +
      +
      +

      Server SSL settings

      +
      +
      server.ssl.certificate + + +
      +
      +

      + Supported on: + + + +

      +

      Path to a PEM-encoded X.509 server certificate. This is used by {{kib}} to establish trust when receiving inbound SSL/TLS connections from users.

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      This setting cannot be used in conjunction with + + server.ssl.keystore.path + .

      +
      +
      +
      +
      server.ssl.key + + +
      +
      +

      + Supported on: + + + +

      +

      Path to the corresponding private key for the PEM-encoded X.509 server certificate specified via + + server.ssl.certificate + . This is used by {{kib}} to establish trust when receiving inbound SSL/TLS connections from users.

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      This setting cannot be used in conjunction with + + server.ssl.keystore.path + .

      +
      +
      +
      +
      server.ssl.certificateAuthorities + + +
      +
      +

      + Supported on: + + + +

      +

      Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a trusted certificate chain for {{kib}}. This chain is used by {{kib}} to establish trust when receiving inbound SSL/TLS connections from end users. If PKI authentication is enabled, this chain is also used by {{kib}} to verify client certificates from end users.

      +

      In addition to this setting, trusted certificates may be specified via + + server.ssl.keystore.path + + and/or + + server.ssl.truststore.path + .

      +

      + Datatype: + string +

      +
      +
      server.ssl.cipherSuites + + +
      +
      +

      + Supported on: + + + +

      +

      Details on the format, and the valid options, are available via the + OpenSSL cipher list format documentation.

      +

      + Datatype: + string +

      +

      + Default: + TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256 TLS_AES_128_GCM_SHA256 ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA +

      +
      +
      server.ssl.clientAuthentication + + +
      +
      +

      + Supported on: + + + +

      +

      Controls the behavior in {{kib}} for requesting a certificate from client connections. Valid values are + "required", + "optional", and + "none". Using + "required" + will refuse to establish the connection unless a client presents a certificate, using + "optional" + will allow a client to present a certificate if it has one, and using + "none" + will prevent a client from presenting a certificate.

      +

      + Datatype: + enum +

      +

      + Default: + "none" +

      +

      + Options: +

      +
        +
      • + required +
      • +
      • + optional +
      • +
      • + none +
      • +
      +
      +
      server.ssl.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Enables SSL/TLS for inbound connections to {{kib}}. When set to + true, a certificate and its corresponding private key must be provided. These can be specified via + + server.ssl.keystore.path + + or the combination of + + server.ssl.certificate + + and + + server.ssl.key + .

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      server.ssl.keyPassphrase + + +
      +
      +

      + Supported on: + + + +

      +

      The password that decrypts the private key that is specified via + + server.ssl.key + . This value is optional, as the key may not be encrypted.

      +

      + Datatype: + string +

      +
      +
      server.ssl.keystore.path + + +
      +
      +

      + Supported on: + + + +

      +

      Path to a PKCS#12 keystore that contains an X.509 server certificate and its corresponding private key. If the keystore contains any additional certificates, those will be used as a trusted certificate chain for {{kib}}. All of these are used by {{kib}} to establish trust when receiving inbound SSL/TLS connections from end users. The certificate chain is also used by {{kib}} to verify client certificates from end users when PKI authentication is enabled.

      +

      In addition to this setting, trusted certificates may be specified via + + server.ssl.certificateAuthorities + + and/or + + server.ssl.truststore.path + .

      +

      + Datatype: + string +

      +
      +
      + Note +
      +
      +

      This setting cannot be used in conjunction with + + server.ssl.certificate + + or + + server.ssl.key + .

      +
      +
      +
      +
      server.ssl.keystore.password + + +
      +
      +

      + Supported on: + + + +

      +

      The password that will be used to decrypt the keystore specified via + + server.ssl.keystore.path + . If the keystore has no password, leave this unset. If the keystore has an empty password, set this to + "".

      +

      + Datatype: + string +

      +
      +
      server.ssl.truststore.path + + +
      +
      +

      + Supported on: + + + +

      +

      Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates which make up a trusted certificate chain for {{kib}}. This chain is used by {{kib}} to establish trust when receiving inbound SSL/TLS connections from end users. If PKI authentication is enabled, this chain is also used by {{kib}} to verify client certificates from end users.

      +

      In addition to this setting, trusted certificates may be specified via + + server.ssl.certificateAuthorities + + and/or + + server.ssl.keystore.path + .

      +

      + Datatype: + string +

      +
      +
      server.ssl.truststore.password + + +
      +
      +

      + Supported on: + + + +

      +

      The password that will be used to decrypt the trust store specified via + + server.ssl.truststore.path + . If the trust store has no password, leave this unset. If the trust store has an empty password, set this to + "".

      +

      + Datatype: + string +

      +
      +
      server.ssl.redirectHttpFromPort + + +
      +
      +

      + Supported on: + + + +

      +

      {{kib}} binds to this port and redirects all http requests to https over the port configured as + + server.port + .

      +

      + Datatype: + int +

      +
      +
      server.ssl.supportedProtocols + + +
      +
      +

      + Supported on: + + + +

      +

      An array of supported protocols with versions. Valid protocols: + TLSv1, + TLSv1.1, + TLSv1.2, + TLSv1.3. Enabling + TLSv1.1 + would require both setting the + --tls-min-1.1 + option in the + node.options + configuration and adding + TLSv1.1 + to + server.ssl.supportedProtocols. + HTTP/2 + requires the use of minimum + TLSv1.2 + for secure connections.

      +

      + Datatype: + string +

      +

      + Default: + TLSv1.2, TLSv1.3 +

      +
      +
      +

      Server XSRF settings

      +
      +
      server.xsrf.allowlist + + +
      +
      +

      + Supported on: + + + + +

      +

      It is not recommended to disable protections for arbitrary API endpoints. Instead, supply the + kbn-xsrf + header. The + + server.xsrf.allowlist + + setting requires the following format:

      +
      +
      +
      +					*Default: [ ]* An array of API endpoints which should be exempt from Cross-Site Request Forgery ("XSRF") protections.
      +					
      +				
      +
      +
      +

      It is available in {{ecloud}} 8.0.0 and later versions.

      +

      + Datatype: + string +

      +

      + Default: + [] +

      +
      +
      server.xsrf.disableProtection + + +
      +
      +

      + Supported on: + + + +

      +

      Setting this to + true + will completely disable Cross-site request forgery protection in Kibana. This is not recommended.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      +

      Telemetry settings

      +
      +
      telemetry.allowChangingOptInStatus + + +
      +
      +

      + Supported on: + + + +

      +

      When + false, users cannot change the opt-in status through + Advanced Settings, and {{kib}} only looks at the value of + + telemetry.optIn + + to determine whether to send telemetry data or not.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      telemetry.optIn + + +
      +
      +

      + Supported on: + + + +

      +

      Set to + false + to stop sending any telemetry data to Elastic. Reporting your cluster statistics helps us improve your user experience. When + false, the telemetry data is never sent to Elastic.

      +

      This setting can be changed at any time in + Advanced Settings. To prevent users from changing it, set + + telemetry.allowChangingOptInStatus + + to + false.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      +

      UI and visualization settings

      +
      +
      uiSettings.globalOverrides.hideAnnouncements + + + +
      +
      +

      Set to + true + to stop showing messages and tours that highlight new features.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      uiSettings.globalOverrides.hideFeedback + + + +
      +
      +

      Set to + true + to stop showing elements requesting user feedback.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      vis_type_table.legacyVisEnabled + + +
      +
      +

      + Supported on: + + + + +

      +

      Starting from version 7.11, a new datatable visualization is used. Set to + true + to enable the legacy version. In version 8.0 and later, the old implementation is removed and this setting is no longer supported.

      +

      + Datatype: + bool +

      +
      +
      vis_type_vega.enable + + +
      +
      +

      + Supported on: + + + + +

      +

      For 7.7 version and later, set to + false + to disable Vega vizualizations.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      vega.enableExternalUrls + + + +
      +
      +

      + Supported on: + + + + +

      +

      Set to + true + to allow Vega vizualizations to use data from sources other than the linked Elasticsearch cluster. In version 8.0 and later, the + vega.enableExternalUrls + is not supported. Use + vis_type_vega.enableExternalUrls + instead.

      +

      + Datatype: + bool +

      +
      +
      + Deprecation details +
      +
      +

      In version 8.0 and later, this setting is not supported. Use + vis_type_vega.enableExternalUrls + instead.

      +
      +
      +
      +
      vis_type_vega.enableExternalUrls + + +
      +
      +

      + Supported on: + + + + +

      +

      Set this value to true to allow Vega to use any URL to access external data sources and images. When false, Vega can only get data from {{es}}.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      +

      Feature and plugin settings

      +
      +
      xpack.ccr.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the Cross-Cluster Replication UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Enables the + Explore underlying data + option that allows you to open + Discover + from a dashboard panel and view the panel data.

      +

      When you create visualizations using the + Lens + drag-and-drop editor, you can use the toolbar to open and explore your data in + Discover. For more information, check out + Explore the data in Discover.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      xpack.discoverEnhanced.actions.exploreDataInChart.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Enables you to view the underlying documents in a data series from a dashboard panel.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      xpack.ilm.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the Index Lifecycle Policies UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      xpack.index_management.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the Index Management UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      xpack.license_management.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the License Management UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      xpack.remote_clusters.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the Remote Clusters UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      xpack.rollup.ui.enabled + + + +
      +
      +

      Set this value to false to disable the Rollup Jobs UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      + Deprecation details +
      +
      +

      Rollups are deprecated and will be removed in a future version. Use + downsampling + instead.

      +
      +
      +
      +
      xpack.securitySolution.maxUploadResponseActionFileBytes + + +
      +
      +

      + Supported on: + + + + +

      +

      Allow to configure the max file upload size for use with the Upload File Response action available with the Defend Integration. To learn more, check + Endpoint Response actions. It is available in {{ecloud}} 8.9.0 and later versions.

      +

      + Datatype: + string +

      +
      +
      xpack.securitySolution.disableEndpointRuleAutoInstall + + + +
      +
      +

      Set to + true + to disable the automatic installation of Elastic Defend SIEM rules when a new Endpoint integration policy is created. Introduced with v9.2.4.

      +

      + Datatype: + bool +

      +

      + Default: + false +

      +
      +
      xpack.snapshot_restore.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the Snapshot and Restore UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      xpack.upgrade_assistant.ui.enabled + + +
      +
      +

      + Supported on: + + + +

      +

      Set this value to false to disable the Upgrade Assistant UI.

      +

      + Datatype: + bool +

      +

      + Default: + true +

      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/IncludeTests/IncludesInclusionHtml.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/IncludeTests/IncludesInclusionHtml.verified.html new file mode 100644 index 0000000000..30e719857e --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/IncludeTests/IncludesInclusionHtml.verified.html @@ -0,0 +1,1294 @@ +

      General settings

      +
      +
      xpack.encryptedSavedObjects.encryptionKey +
      +
      +

      A string of 32 or more characters used to encrypt sensitive properties on alerting rules and actions before they're stored in Elasticsearch. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. Kibana offers a <<kibana-encryption-keys, CLI tool>> to help generate this encryption key. If not set, Kibana will generate a random key on startup, but all alerting and action functions will be blocked. Generated keys are not allowed for alerting and actions because when a new key is generated on restart, existing encrypted data becomes inaccessible. For the same reason, alerting and actions in high-availability deployments of Kibana will behave unexpectedly if the key isn't the same on all instances of Kibana. Although the key can be specified in clear text in + kibana.yml, it's recommended to store this key securely in the <<secure-settings,Kibana Keystore>>. Be sure to back up the encryption key value somewhere safe, as your alerting rules and actions will cease to function due to decryption failures should you lose it. If you want to rotate the encryption key, be sure to follow the instructions on <<encryption-key-rotation, encryption key rotation>>.

      +
      +
      +

      Action settings

      +
      +
      xpack.actions.allowedHosts +
      +
      +

      A list of hostnames that Kibana is allowed to connect to when built-in actions are triggered. It defaults to + ["*"], allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list + [] + can be used to block built-in actions from making any external connections. Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default + ["*"] + setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well.

      +
      +
      xpack.actions.customHostSettings +
      +
      +

      A list of custom host settings to override existing global settings. Each entry in the list must have a + url + property, to associate a connection type (mail or https), hostname and port with the remaining options in the entry. The settings in + xpack.actions.customHostSettings + can be used to override the global option + xpack.actions.ssl.verificationMode + and provide customized TLS settings on a per-server basis. Set + xpack.actions.ssl.verificationMode + to the value to be used by default for all servers, then add an entry in + xpack.actions.customHostSettings + for every server that requires customized settings.

      +

      + Default: + an empty list +

      +

      In the following example, two custom host settings are defined. The first provides a custom host setting for mail server + mail.example.com + using port 465 that supplies server certificate authentication data from both a file and inline, and requires TLS for the connection. The second provides a custom host setting for https server + webhook.example.com + which turns off server certificate authentication, that will allow Kibana to connect to the server if it's using a self-signed certificate. The individual properties that can be used in the settings are documented below.

      +
      +
      +
      +					xpack.actions.customHostSettings:   - url: smtp://mail.example.com:465     ssl:       verificationMode: 'full'       certificateAuthoritiesFiles: [ 'one.crt' ]       certificateAuthoritiesData: |           -----BEGIN CERTIFICATE-----           MIIDTD...           CwUAMD...           ... multiple lines of certificate data ...           -----END CERTIFICATE-----     smtp:       requireTLS: true   - url: https://webhook.example.com     ssl:       verificationMode: 'none'
      +					
      +				
      +
      +
      +
      +
      xpack.actions.customHostSettings[n].url +
      +
      +

      A URL associated with this custom host setting. Should be in the form of + protocol://hostname:port, where + protocol + is + https + or + smtp. If the port is not provided, 443 is used for + https + and 25 is used for + smtp. The + smtp + URLs are used for the Email actions that use this server, and the + https + URLs are used for actions which use + https + to connect to services. Entries with + https + URLs can use the + ssl + options, and entries with + smtp + URLs can use both the + ssl + and + smtp + options. No other URL values should be part of this URL, including paths, query strings, and authentication information. When an http or smtp request is made as part of running an action, only the protocol, hostname, and port of the URL for that request are used to look up these configuration values.

      +
      +
      xpack.actions.customHostSettings[n].smtp.ignoreTLS +
      +
      +

      A boolean value indicating that TLS must not be used for this connection. The options + smtp.ignoreTLS + and + smtp.requireTLS + can not both be set to true.

      +

      + Default: + false +

      +
      +
      xpack.actions.customHostSettings[n].smtp.requireTLS +
      +
      +

      A boolean value indicating that TLS must be used for this connection. The options + smtp.ignoreTLS + and + smtp.requireTLS + can not both be set to true.

      +

      + Default: + false +

      +
      +
      xpack.actions.customHostSettings[n].ssl.verificationMode +
      +
      +

      Controls the verification of the server certificate that Kibana receives when making an outbound SSL/TLS connection to the host server. Valid values are + full, + certificate, and + none. Use + full + to perform hostname verification, + certificate + to skip hostname verification, and + none + to skip verification. Default: + full. <<elasticsearch-ssl-verificationMode,Equivalent Kibana setting>>. Overrides the general + xpack.actions.ssl.verificationMode + configuration for requests made for this hostname/port.

      +
      +
      xpack.actions.customHostSettings[n].ssl.certificateAuthoritiesFiles +
      +
      +

      A file name or list of file names of PEM-encoded certificate files to use to validate the server.

      +
      +
      xpack.actions.customHostSettings[n].ssl.certificateAuthoritiesData +
      +
      +

      The contents of one or more PEM-encoded certificate files in multiline format. This configuration can be used for environments where the files cannot be made available.

      +
      +
      xpack.actions.email.domain_allowlist +
      +
      +

      A list of allowed email domains which can be used with the email connector. When this setting is not used, all email domains are allowed. When this setting is used, if any email is attempted to be sent that (a) includes an addressee with an email domain that is not in the allowlist, or (b) includes a from address domain that is not in the allowlist, it will fail with a message indicating the email is not allowed.

      +
      +
      + Warning +
      +
      +

      This feature is available in Kibana 7.17.4 and 8.3.0 onwards but is not supported in Kibana 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly.

      +
      +
      +
      +
      xpack.actions.enableFooterInEmail +
      +
      +

      A boolean value indicating that a footer with a relevant link should be added to emails sent as alerting actions.

      +

      + Default: + true +

      +
      +
      xpack.actions.enabledActionTypes +
      +
      +
        +
      • 'A list of action types that are enabled. It defaults to + ["*"], enabling all types. The names for built-in Kibana action types are prefixed with a + . + and include: + .email, + .index, + .jira, + .opsgenie, + .pagerduty, + .resilient, + .server-log, + .servicenow, .servicenow-itom, + .servicenow-sir, + .slack, + .swimlane, + .teams, + .tines, + .torq, + .xmatters, + .gen-ai, + .bedrock, + .gemini, + .d3security, and + .webhook. An empty list + [] + will disable all action types.' Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in Kibana and will not function.
      • +
      +
      +
      + Important +
      +
      +

      <<pre-configured-connectors,Preconfigured connectors>> are not affected by this setting.

      +
      +
      +
      +
      xpack.actions.microsoftExchangeUrl +
      +
      +

      The URL for the Microsoft Azure Active Directory endpoint to use for MS Exchange email authentication.

      +

      + Default: + https://login.microsoftonline.com +

      +
      +
      xpack.actions.microsoftGraphApiUrl +
      +
      +

      The URL for the Microsoft Graph API endpoint to use for MS Exchange email authentication.

      +

      + Default: + https://graph.microsoft.com/v1.0 +

      +
      +
      xpack.actions.microsoftGraphApiScope +
      +
      +

      The URL for the Microsoft Graph API scope endpoint to use for MS Exchange email authentication.

      +

      + Default: + https://graph.microsoft.com/.default +

      +
      +
      xpack.actions.proxyUrl +
      +
      +

      Specifies the proxy URL to use, if using a proxy for actions. By default, no proxy is used.

      +
        +
      • 'Proxies may be used to proxy http or https requests through a proxy using the http or https protocol. Kibana only uses proxies in "CONNECT" mode (sometimes referred to as "tunneling" TCP mode, compared to HTTP mode). That is, Kibana will always make requests through a proxy using the HTTP + CONNECT + method.' If your proxy is using the https protocol (vs the http protocol), the setting + xpack.actions.ssl.proxyVerificationMode: none + will likely be needed, unless your proxy's certificates are signed using a publicly available certificate authority. There is currently no support for using basic authentication with a proxy (authentication for the proxy itself, not the URL being requested through the proxy).
      • +
      +

      To help diagnose problems using a proxy, you can use the + curl + command with options to use your proxy, and log debug information, with the following command, replacing the proxy and target URLs as appropriate. This will force the request to be made to the proxy in tunneling mode, and display some of the interaction between the client and the proxy.

      +
      +
      +
      +					curl --verbose --proxytunnel --proxy http://localhost:8080 http://example.com
      +					
      +				
      +
      +
      +
      +
      xpack.actions.proxyBypassHosts +
      +
      +

      Specifies hostnames which should not use the proxy, if using a proxy for actions. The value is an array of hostnames as strings. By default, all hosts will use the proxy, but if an action's hostname is in this list, the proxy will not be used. The settings + xpack.actions.proxyBypassHosts + and + xpack.actions.proxyOnlyHosts + cannot be used at the same time.

      +

      For example:

      +
      +
      +
      +					xpack.actions.proxyBypassHosts: [ "events.pagerduty.com" ]
      +					
      +				
      +
      +
      +

      If applicable, include the subdomain in the hostname

      +
      +
      xpack.actions.proxyOnlyHosts +
      +
      +

      Specifies hostnames which should only use the proxy, if using a proxy for actions. The value is an array of hostnames as strings. By default, no hosts will use the proxy, but if an action's hostname is in this list, the proxy will be used. The settings + xpack.actions.proxyBypassHosts + and + xpack.actions.proxyOnlyHosts + cannot be used at the same time.

      +

      For example:

      +
      +
      +
      +					xpack.actions.proxyOnlyHosts: [ "events.pagerduty.com" ]
      +					
      +				
      +
      +
      +

      If applicable, include the subdomain in the hostname

      +
      +
      xpack.actions.proxyHeaders +
      +
      +

      Specifies HTTP headers for the proxy, if using a proxy for actions.

      +

      + Default: + {} +

      +
      +
      xpack.actions.ssl.proxyVerificationMode +
      +
      +

      Controls the verification for the proxy server certificate that Kibana receives when making an outbound SSL/TLS connection to the proxy server. Use + full + to perform hostname verification, + certificate + to skip hostname verification, and + none + to skip verification. <<elasticsearch-ssl-verificationMode,Equivalent Kibana setting>>

      +

      + Default: + full +

      +

      + Options: +

      +
        +
      • + full +
      • +
      • + certificate +
      • +
      • + none +
      • +
      +
      +
      xpack.actions.ssl.verificationMode +
      +
      +

      Controls the verification for the server certificate that Elastic Maps Server receives when making an outbound SSL/TLS connection for actions. Valid values are + full, + certificate, and + none. Use + full + to perform hostname verification, + certificate + to skip hostname verification, and + none + to skip verification. <<elasticsearch-ssl-verificationMode,Equivalent Kibana setting>> This setting can be overridden for specific URLs by using the setting + xpack.actions.customHostSettings[n].ssl.verificationMode + (described above) to a different value.

      +

      + Default: + full +

      +

      + Options: +

      +
        +
      • + full +
      • +
      • + certificate +
      • +
      • + none +
      • +
      +
      +
      xpack.actions.maxResponseContentLength +
      +
      +

      Specifies the max number of bytes of the http response for requests to external resources.

      +

      + Default: + 1000000 (1MB) +

      +
      +
      xpack.actions.responseTimeout +
      +
      +

      Specifies the time allowed for requests to external resources. Requests that take longer are canceled. The time is formatted as a number and a time unit (ms, + s, + m, + h, + d, + w, + M, or + Y). For example, + 20m, + 24h, + 7d, + 1w. Default: + 60s.

      +
      +
      xpack.actions.run.maxAttempts +
      +
      +

      Specifies the maximum number of times an action can be attempted to run.

      +

      + Options: +

      +
        +
      • + minimum 1 and maximum 10 +
      • +
      +
      +
      xpack.actions.run.connectorTypeOverrides +
      +
      +

      Overrides the configs under + xpack.actions.run + for the connector type with the given ID. List the connector type identifier and its settings in an array of objects.

      +

      For example:

      +
      +
      +
      +					xpack.actions.run:     maxAttempts: 1     connectorTypeOverrides:         - id: '.server-log'           maxAttempts: 5
      +					
      +				
      +
      +
      +
      +
      xpack.actions.queued.max +
      +
      +

      Specifies the maximum number of actions that can be queued.

      +

      + Default: + 1000000 +

      +
      +
      +

      Preconfigured connector settings

      +

      These settings vary depending on which type of preconfigured connector you're adding.

      +

      For example:

      +
      +

      + [source,yaml] +

      +
      + +

      For more examples, go to <<pre-configured-connectors>>.

      +
      +
      xpack.actions.preconfiguredAlertHistoryEsIndex +
      +
      +

      Enables a preconfigured alert history Elasticsearch <<index-action-type, Index>> connector.

      +

      + Default: + false +

      +
      +
      xpack.actions.preconfigured +
      +
      +

      Specifies configuration details that are specific to the type of preconfigured connector.

      +
      +
      xpack.actions.preconfigured.<connector-id>.actionTypeId +
      +
      +

      The type of preconfigured connector.

      +

      + Options: +

      +
        +
      • + .email +
      • +
      • + .index +
      • +
      • + .opsgenie +
      • +
      • + .server-log +
      • +
      • + .resilient +
      • +
      • + .slack +
      • +
      • + .webhook +
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config +
      +
      +

      The configuration details, which are specific to the type of preconfigured connector.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.apiProvider +
      +
      +

      For a <<openai-action-type,OpenAI connector>>, specifies the OpenAI API provider.

      +

      + Options: +

      +
        +
      • + OpenAI +
      • +
      • + Azure OpenAI +
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.apiUrl +
      +
      +

      A configuration URL that varies by connector:

      +
        +
      • For an <<bedrock-action-type,{bedrock} connector>>, specifies the {bedrock} request URL.
      • +
      • For an <<gemini-action-type,{gemini} connector>>, specifies the {gemini} request URL.
      • +
      • For a <<openai-action-type,OpenAI connector>>, specifies the OpenAI request URL.
      • +
      • For a <<resilient-action-type,{ibm-r} connector>>, specifies the {ibm-r} instance URL.
      • +
      • For a <<jira-action-type,Jira connector>>, specifies the Jira instance URL.
      • +
      • For an <<opsgenie-action-type,{opsgenie} connector>>, specifies the {opsgenie} URL. For example, + https://api.opsgenie.com + or + https://api.eu.opsgenie.com.
      • +
      • For a <<pagerduty-action-type,PagerDuty connector>>, specifies the PagerDuty event URL. Defaults to + https://events.pagerduty.com/v2/enqueue.
      • +
      • For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>> specifies the ServiceNow instance URL.
      • +
      • For a <<swimlane-action-type,{swimlane} connector>>, specifies the {swimlane} instance URL.
      • +
      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure the hostname in the URL is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.appId +
      +
      +

      An application ID that varies by connector:

      +
        +
      • For a <<swimlane-action-type,{swimlane} connector>>, specifies a {swimlane} application identifier.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.clientId +
      +
      +

      A client identifier that varies by connector:

      +
        +
      • For an <<email-action-type,email connector>>, specifies a GUID format value that corresponds to the client ID, which is a part of OAuth 2.0 client credentials authentication.
      • +
      • For a <<servicenow-itom-action-type,{sn-itom}>>, <<servicenow-action-type,{sn-itsm}>>, or <<servicenow-sir-action-type,{sn-sir} connector>> specifies the client identifier assigned to the OAuth application.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.configUrl +
      +
      +

      For an <<xmatters-action-type,xMatters connector>> with basic authentication, specifies the request URL for the Elastic Alerts trigger in xMatters.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createCommentJson +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a stringified JSON payload with Mustache variables that is sent to the create comment URL to create a case comment. The required variable is + case.description.

      +
      +
      + Note +
      +
      +

      The JSON is validated after the Mustache variables have been placed when the REST method runs. You should manually ensure that the JSON is valid, disregarding the Mustache variables, so the later validation will pass.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createCommentMethod +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies the REST API HTTP request method to create a case comment in the third-party system.

      +

      + Default: + put +

      +

      + Options: +

      +
        +
      • + post +
      • +
      • + put +
      • +
      • + patch +
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createCommentUrl +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a REST API URL string to create a case comment by ID in the third-party system.

      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure the hostname in the URL is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createIncidentJson +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a stringified JSON payload with Mustache variables that is sent to the create case URL to create a case. Required variables are + case.title + and + case.description.

      +
      +
      + Note +
      +
      +

      The JSON is validated after the Mustache variables have been placed when the REST method runs. You should manually ensure that the JSON is valid, disregarding the Mustache variables, so the later validation will pass.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createIncidentMethod +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies the REST API HTTP request method to create a case in the third-party system

      +

      + Default: + post +

      +

      + Options: +

      +
        +
      • + post +
      • +
      • + put +
      • +
      • + patch +
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createIncidentUrl +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a REST API URL string to create a case in the third-party system.

      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure the hostname in the URL is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.createIncidentResponseKey +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a string from the response body of the create case method that corresponds to the external service identifier.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.defaultModel +
      +
      +

      The default model to use for requests, which varies by connector:

      +
        +
      • For an <<bedrock-action-type,{bedrock} connector>>, current support is for the Anthropic Claude models. Defaults to + anthropic.claude-3-5-sonnet-20240620-v1:0.
      • +
      • For a <<gemini-action-type,{gemini} connector>>, current support is for the Gemini models. Defaults to + gemini-1.5-pro-002.
      • +
      • For a <<openai-action-type,OpenAI connector>>, it is optional and applicable only when + xpack.actions.preconfigured.<connector-id>.config.apiProvider + is + OpenAI.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.executionTimeField +
      +
      +

      For an <<index-action-type,index connector>>, a field that indicates when the document was indexed.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.from +
      +
      +

      For an <<email-action-type,email connector>>, specifies the from address for all emails sent by the connector. It must be specified in + user@host-name + format.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.getIncidentResponseExternalTitleKey +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a string from the response body of the get case method that corresponds to the external service title.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.getIncidentUrl +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a REST API URL string with an external service ID Mustache variable to get the case from the third-party system.

      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure the hostname in the URL is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.hasAuth +
      +
      +

      For an <<email-action-type,email>>, <<webhook-action-type,webhook>>, or <<cases-webhook-action-type,{webhook-cm} connector>>, specifies whether a user and password are required inside the secrets configuration.

      +

      + Default: + true +

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.headers +
      +
      +

      For a <<webhook-action-type,webhook>> or <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a set of key-value pairs sent as headers with the request.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.host +
      +
      +

      For an <<email-action-type,email connector>>, specifies the host name of the service provider.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.index +
      +
      +

      For an <<index-action-type,index connector>>, specifies the Elasticsearch index.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.isOAuth +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies whether to use basic or OAuth authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.jwtKeyId +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies the key ID assigned to the JWT verifier map of your OAuth application. It is required when + xpack.actions.preconfigured.<connector-id>.config.isOAuth + is + true.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, specifies field mappings.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.alertIdConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, field mapping for the alert identifier. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.caseIdConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, field mapping for the case identifier. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.caseNameConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, field mapping for the case name. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.commentsConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, field mapping for the case comments. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.descriptionConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, field mapping for the case description. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.ruleNameConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, field mapping for the rule name. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.mappings.severityConfig +
      +
      +

      For a <<swimlane-action-type,Swimlane connector>>, specifies a field mapping for the severity. You must provide + fieldtype, + id, + key, and + name + values.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.method +
      +
      +

      For a <<webhook-action-type,webhook connector>>, specifies the HTTP request method, either + post + or + put. Defaults to + post.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.orgId +
      +
      +

      For an <<resilient-action-type,{ibm-r} connector>>, specifies the {ibm-r} organization identifier.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.port +
      +
      +

      For an <<email-action-type,email connector>>, specifies the port to connect to on the service provider.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.projectKey +
      +
      +

      For a <<jira-action-type,Jira connector>>, specifies the Jira project key.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.secure +
      +
      +

      For an <<email-action-type,email connector>>, specifies whether the connection will use TLS when connecting to the service provider. If not true, the connection will initially connect over TCP then attempt to switch to TLS via the SMTP STARTTLS command.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.service +
      +
      +

      For an <<email-action-type,email connector>>, specifies the name of the email service. For example, + elastic_cloud, + exchange_server, + gmail, + other, + outlook365, or + ses.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.tenantId +
      +
      +

      For an <<email-action-type,email connector>>, specifies a GUID format value that corresponds to a tenant ID, which is a part of OAuth 2.0 client credentials authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.updateIncidentJson +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a stringified JSON payload with Mustache variables that is sent to the update case URL to update a case. Required variables are + case.title + and + case.description.

      +
      +
      + Note +
      +
      +

      The JSON is validated after the Mustache variables have been placed when the REST method runs. You should manually ensure that the JSON is valid, disregarding the Mustache variables, so the later validation will pass.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.updateIncidentMethod +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies the REST API HTTP request method to update the case in the third-party system.

      +

      + Default: + put +

      +

      + Options: +

      +
        +
      • + post +
      • +
      • + put +
      • +
      • + patch +
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.updateIncidentUrl +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies the REST API URL to update the case by ID in the third-party system.

      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure the hostname in the URL is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.url +
      +
      +

      A configuration URL that varies by connector:

      +
        +
      • For a <<d3security-action-type,D3 Security connector>>, specifies the D3 Security API request URL.
      • +
      • For a <<tines-action-type,Tines connector>>, specifies the Tines tenant URL.
      • +
      • For a <<webhook-action-type,webhook connector>>, specifies the web service request URL.
      • +
      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure this hostname is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.config.userIdentifierValue +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies the user identifier. It is required when required when + xpack.actions.preconfigured.<connector-id>.config.isOAuth + is + true.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.usesBasic +
      +
      +

      For an <<xmatters-action-type,xMatters connector>>, specifies whether it uses HTTP basic authentication.

      +

      + Default: + true +

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.usesTableApi +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>> or <<servicenow-sir-action-type,{sn-sir} connector>>, specifies whether the connector uses the Table API or the Import Set API. If set to + false, the Elastic application should be installed in ServiceNow.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.viewIncidentUrl +
      +
      +

      For a <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a URL string with either the external service ID or external service title Mustache variable to view a case in the external system.

      +
      +
      xpack.actions.preconfigured.<connector-id>.config.webhookIntegrationUrl +
      +
      +

      For a <<torq-action-type,Torq connector>>, specifies the endpoint URL of the Elastic Security integration in Torq.

      +
      +
      xpack.actions.preconfigured.<connector-id>.name +
      +
      +

      The name of the preconfigured connector.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets +
      +
      +

      Sensitive configuration details, such as username, password, and keys, which are specific to the connector type.

      +
      +
      + Tip +
      +
      +

      Sensitive properties, such as passwords, should be stored in the <<creating-keystore,Kibana keystore>>.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.accessKey +
      +
      +

      For an <<bedrock-action-type,{bedrock} connector>>, specifies the AWS access key for authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.apikey +
      +
      +

      An API key secret that varies by connector.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.credentialsJson +
      +
      +

      For an <<gemini-action-type,{gemini} connector>>, specifies the GCP service account credentials JSON file for authentication.

      +
        +
      • For a <<openai-action-type,OpenAI connector>>, specifies the OpenAI or Azure OpenAI API key for authentication.
      • +
      • For an <<opsgenie-action-type,{opsgenie} connector>>, specifies the {opsgenie} API authentication key for HTTP basic authentication.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.apiKeyId +
      +
      +

      For an <<resilient-action-type,{ibm-r} connector>>, specifies the authentication key ID for HTTP basic authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.apiKeySecret +
      +
      +

      For an <<resilient-action-type,{ibm-r} connector>>, specifies the authentication key secret for HTTP basic authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.apiToken +
      +
      +

      For a <<jira-action-type,Jira>> or <<swimlane-action-type,{swimlane} connector>>, specifies the API authentication token for HTTP basic authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.clientSecret +
      +
      +

      A client secret that varies by connector:

      +
        +
      • For an <<email-action-type,email connector>>, specifies the client secret that you generated for your app in the app registration portal. It is required when the email service is + exchange_server, which uses OAuth 2.0 client credentials authentication.
      • +
      • For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies the client secret assigned to the OAuth application. It is required when + xpack.actions.preconfigured.<connector-id>.config.isOAuth + is + true.
      • +
      +
      +
      + Note +
      +
      +

      The client secret must be URL-encoded.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.email +
      +
      +

      An email address that varies by connector:

      +
        +
      • For a <<jira-action-type,Jira connector>>, specifies the account email for HTTP basic authentication.
      • +
      • For a <<tines-action-type,Tines connector>>, specifies the email used to sign in to Tines.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.password +
      +
      +

      A password secret that varies by connector:

      +
        +
      • For an <<email-action-type,email>>, <<webhook-action-type,webhook>>, or <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a password that is required when + xpack.actions.preconfigured.<connector-id>.config.hasAuth + is + true.
      • +
      • For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies a password that is required when + xpack.actions.preconfigured.<connector-id>.config.isOAuth + is + false.
      • +
      • For an <<xmatters-action-type,xMatters connector>>, specifies a password that is required when + xpack.actions.preconfigured.<connector-id>.config.usesBasic + is + true.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.privateKey +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies the RSA private key. It is required when + xpack.actions.preconfigured.<connector-id>.config.isOAuth + is + true.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.privateKeyPassword +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies the password for the RSA private key.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.routingKey +
      +
      +

      For a <<pagerduty-action-type,PagerDuty connector>>, specifies the 32 character PagerDuty Integration Key for an integration on a service, also referred to as the routing key.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.secret +
      +
      +

      For an <<bedrock-action-type,{bedrock} connector>>, specifies the AWS secret for authentication.

      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.secretsUrl +
      +
      +

      For an <<xmatters-action-type,xMatters connector>> with URL authentication, specifies the request URL for the Elastic Alerts trigger in xMatters with the API key included in the URL. It is used only when + xpack.actions.preconfigured.<connector-id>.config.usesBasic + is + false.

      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure this hostname is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.token +
      +
      +

      A token secret that varies by connector:

      +
        +
      • For a <<d3security-action-type,D3 Security conector>>, specifies the D3 Security token.
      • +
      • For a <<slack-action-type,Slack connector>>, specifies the Slack bot user OAuth token.
      • +
      • For a <<tines-action-type,Tines connector>>, specifies the Tines API token.
      • +
      • For a <<torq-action-type,Torq connector>>, specifies the secret of the webhook authentication header.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.user +
      +
      +

      A user name secret that varies by connector:

      +
        +
      • For an <<email-action-type,email>>, <<webhook-action-type,webhook>>, or <<cases-webhook-action-type,{webhook-cm} connector>>, specifies a user name that is required when + xpack.actions.preconfigured.<connector-id>.config.hasAuth + is + true.
      • +
      • For an <<xmatters-action-type,xMatters connector>>, specifies a user name that is required when + xpack.actions.preconfigured.<connector-id>.config.usesBasic + is + true.
      • +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.webhookUrl +
      +
      +

      A URL that varies by connector:

      +
        +
      • For a <<teams-action-type,Microsoft Teams>>, specifies the URL of the incoming webhook.
      • +
      • For a <<slack-action-type,Slack connector>>, specifies the Slack webhook URL.
      • +
      +
      +
      + Note +
      +
      +

      If you are using the + xpack.actions.allowedHosts + setting, make sure the hostname is added to the allowed hosts.

      +
      +
      +
      +
      xpack.actions.preconfigured.<connector-id>.secrets.username +
      +
      +

      For a <<servicenow-action-type,{sn-itsm}>>, <<servicenow-sir-action-type,{sn-sir}>>, or <<servicenow-itom-action-type,{sn-itom} connector>>, specifies a user name that is required when + xpack.actions.preconfigured.<connector-id>.config.isOAuth + is + false.

      +
      +
      +

      Alerting settings

      +
      +
      xpack.alerting.cancelAlertsOnRuleTimeout +
      +
      +

      Specifies whether to skip writing alerts and scheduling actions if rule processing was cancelled due to a timeout. This setting can be overridden by individual rule types.

      +

      + Default: + true +

      +
      +
      xpack.alerting.rules.maxScheduledPerMinute +
      +
      +

      Specifies the maximum number of rules to run per minute.

      +

      + Default: + 10000 +

      +
      +
      xpack.alerting.rules.minimumScheduleInterval.value +
      +
      +

      Specifies the minimum schedule interval for rules. This minimum is applied to all rules created or updated after you set this value. The time is formatted as a number and a time unit (s, + m, + h, or + d). For example, + 20m, + 24h, + 7d. This duration cannot exceed + 1d.

      +

      + Default: + 1m +

      +
      +
      xpack.alerting.rules.minimumScheduleInterval.enforce +
      +
      +

      Specifies the behavior when a new or changed rule has a schedule interval less than the value defined in + xpack.alerting.rules.minimumScheduleInterval.value. If + false, rules with schedules less than the interval will be created but warnings will be logged. If + true, rules with schedules less than the interval cannot be created.

      +

      + Default: + false +

      +
      +
      xpack.alerting.rules.run.actions.max +
      +
      +

      Specifies the maximum number of actions that a rule can generate each time detection checks run.

      +
      +
      xpack.alerting.rules.run.alerts.max +
      +
      +

      Specifies the maximum number of alerts that a rule can generate each time detection checks run.

      +

      + Default: + 1000 +

      +
      +
      + Warning +
      +
      +

      The exact number of alerts your cluster can safely handle depends on your cluster configuration and workload, however setting a value higher than the default (1000) is not recommended or supported. Doing so could strain system resources and lead to performance issues, delays in alert processing, and potential disruptions during high alert activity periods.

      +
      +
      +
      +
      xpack.alerting.rules.run.timeout +
      +
      +

      Specifies the default timeout for tasks associated with all types of rules. The time is formatted as a number and a time unit (ms, + s, + m, + h, + d, + w, + M, or + Y). For example, + 20m, + 24h, + 7d, + 1w. Default: + 5m.

      +
      +
      xpack.alerting.rules.run.ruleTypeOverrides +
      +
      +

      Overrides the configs under + xpack.alerting.rules.run + for the rule type with the given ID. List the rule identifier and its settings in an array of objects.

      +

      For example:

      +
      +
      +
      +					xpack.alerting.rules.run:     timeout: '5m'     ruleTypeOverrides:         - id: '.index-threshold'           timeout: '15m'
      +					
      +				
      +
      +
      +
      +
      xpack.alerting.rules.run.actions.connectorTypeOverrides +
      +
      +

      Overrides the configs under + xpack.alerting.rules.run.actions + for the connector type with the given ID. List the connector type identifier and its settings in an array of objects.

      +

      For example:

      +
      +
      +
      +					xpack.alerting.rules.run:     actions:         max: 10         connectorTypeOverrides:             - id: '.server-log'               max: 5
      +					
      +				
      +
      +
      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/NewSchemaRendersMetadataAndNestedSettings/GeneratesHtml.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/NewSchemaRendersMetadataAndNestedSettings/GeneratesHtml.verified.html new file mode 100644 index 0000000000..4de6cc05ef --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/NewSchemaRendersMetadataAndNestedSettings/GeneratesHtml.verified.html @@ -0,0 +1,54 @@ +

      General settings

      +
      +
      xpack.actions.customHostSettings + + + +
      +
      +

      + Supported on: + + + +

      +

      Parent setting description.

      +

      + Datatype: + object +

      +

      + Default: + [] +

      +

      + Options: +

      +
        +
      • + strict + - Strict mode. +
      • +
      +
      +
      xpack.actions.customHostSettings[n].url + + + +
      +
      +

      + Supported on: + + + +

      +

      Child setting description.

      +

      + Datatype: + string +

      +
      +
      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingReusesAnAnchoredAppliesToBlock/RendersBothSettings.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingReusesAnAnchoredAppliesToBlock/RendersBothSettings.verified.html new file mode 100644 index 0000000000..5aabb8364d --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingReusesAnAnchoredAppliesToBlock/RendersBothSettings.verified.html @@ -0,0 +1,31 @@ +

      Example

      +
      +
      xpack.sample.one + + + +
      +
      +

      + Supported on: + + + +

      +

      First setting.

      +
      +
      xpack.sample.two + + + +
      +
      +

      + Supported on: + + + +

      +

      Second setting, reuses the same applies_to block as the one above.

      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingsTopMatterAndTitlesRender/RendersPageDescriptionNotesAndInterpolatedGroupTitle.verified.html b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingsTopMatterAndTitlesRender/RendersPageDescriptionNotesAndInterpolatedGroupTitle.verified.html new file mode 100644 index 0000000000..e975475abc --- /dev/null +++ b/tests/Elastic.Markdown.Tests/.snapshots/SettingsInclusion/SettingsTopMatterAndTitlesRender/RendersPageDescriptionNotesAndInterpolatedGroupTitle.verified.html @@ -0,0 +1,27 @@ +

      Read the + preconfigured connectors + guide.

      +
      +
      + Note +
      +
      +

      Top-level note for Kibana.

      +
      +
      +

      General Kibana settings

      +
      +
      + Note +
      +
      +

      Group-level note for Kibana.

      +
      +
      +
      +
      xpack.sample.enabled +
      +
      +

      Enables sample behavior.

      +
      +
      diff --git a/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs index f08701fe27..c89d4e4498 100644 --- a/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs @@ -6,8 +6,11 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.SettingsInclusion; @@ -43,7 +46,7 @@ public void HasNoErrors() } [Test] - public void IncludesInclusionHtml() => Html.Should().Contain("xpack.encryptedSavedObjects.encryptionKey"); + public void IncludesInclusionHtml() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } [InheritsTests] @@ -104,27 +107,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void RendersAppliesToAndMetadata() - { - Html.Should().Contain("applies-to-popover"); - Html.Should().Contain("settings-supported-on"); - Html.Should().Contain("Datatype:"); - Html.Should().Contain("Default:"); - Html.Should().Contain("Options:"); - } - - [Test] - public void RendersNestedSettingName() => Html.Should().Contain("xpack.actions.customHostSettings[n].url"); - - [Test] - public void DotsInSettingNamesAreHyphensInAnchors() - { - Html.Should().Contain("id=\"xpack-actions-customhostsettings\""); - Html.Should().NotContain("id=\"xpack.actions.customhostsettings\""); - } - - [Test] - public void NestedSettingAnchorIncludesParentPrefix() => Html.Should().Contain("id=\"xpack-actions-customhostsettingsn-url\""); + public void GeneratesHtml() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } [InheritsTests] @@ -156,11 +139,28 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void RendersAsFencedCodeBlock() - { - Html.Should().Contain("language-yaml"); - Html.Should().NotContain("[source,yaml]"); - } + public void RendersAsFencedCodeBlock() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Example

      +
      +
      xpack.legacy.example +
      +
      +

      Example code:

      +
      +
      +
      +							xpack.legacy.example:   enabled: true
      +							
      +						
      +
      +
      +
      +
      + """, + DocsHtml.Body() + ); } [InheritsTests] @@ -193,13 +193,7 @@ Read the [preconfigured connectors](/reference/connectors-kibana/pre-configured- } [Test] - public void RendersPageDescriptionNotesAndInterpolatedGroupTitle() - { - Html.Should().Contain("General Kibana settings"); - Html.Should().Contain("Top-level note for Kibana."); - Html.Should().Contain("Group-level note for Kibana."); - Html.Should().Contain("https://www.elastic.co/docs/reference/kibana/connectors-kibana/pre-configured-connectors"); - } + public void RendersPageDescriptionNotesAndInterpolatedGroupTitle() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } [InheritsTests] @@ -286,10 +280,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void ShowsEchGaSetting() => Html.Should().Contain("execution_context.enabled"); - - [Test] - public void HidesEchUnavailableSetting() => Html.Should().NotContain("console.ui.enabled"); + public void GeneratesHtml() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } /// @@ -331,13 +322,34 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void HidesSettingWhenEchIsMissing() => Html.Should().NotContain("self.only.setting"); - - [Test] - public void ShowsSettingWithExplicitEchGa() => Html.Should().Contain("ech.explicit.setting"); - - [Test] - public void ShowsSettingWithNoAppliesTo() => Html.Should().Contain("no.applies.to.setting"); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Example

      +
      +
      ech.explicit.setting + + +
      +
      +

      + Supported on: + + + + +

      +

      ECH is explicitly listed as ga.

      +
      +
      no.applies.to.setting +
      +
      +

      No applies_to at all — universally available.

      +
      +
      + """, + DocsHtml.Body() + ); } [InheritsTests] @@ -360,10 +372,151 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) [Test] public void EmitsWarning() => - Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && d.Message.Contains("invalid-deployment")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + """ + warning {repo}/docs/index.md:3 Unknown deployment filter 'invalid-deployment'. Valid values are: ech, ece, eck, self. + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:3 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:3 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:3 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:3 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {hosted-ems} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:3 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:9 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:6 Substitution key {ecloud} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {kib} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:1 Substitution key {es} is undefined + error {repo}/docs/testing/kibana-general-settings.yml:2 Substitution key {ecloud} is undefined + """ + ); [Test] - public void StillRendersAllSettingsWhenFilterIsInvalid() => Html.Should().Contain("execution_context.enabled"); + public void StillRendersAllSettingsWhenFilterIsInvalid() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } [InheritsTests] @@ -394,12 +547,34 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void RendersAppliesToRoleAsBadgeNotLiteralText() - { - Html.Should().Contain("applies-to-popover"); - Html.Should().NotContain("Applies to (stack: ga 9.2)"); - Html.Should().NotContain("Applies to (stack: ga 9.1)"); - } + public void RendersAppliesToRoleAsBadgeNotLiteralText() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Example

      +
      +
      xpack.sample.versioned +
      +
      +

      Behavior depends on version:

      +
        +
      • + + + + Defaults to + model-a.
      • +
      • + + + + Defaults to + model-b.
      • +
      +
      +
      + """, + DocsHtml.Body() + ); } /// @@ -437,21 +612,23 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void RendersPlannedStackBadge() => Html.Should().Contain("badge-key=\"Stack\"").And.Contain("Planned"); - - [Test] - public void DoesNotRenderSupportedOnLine() - { - Html.Should().NotContain("settings-supported-on"); - Html.Should().NotContain("Supported on:"); - } - - [Test] - public void DoesNotRenderEchOrSelfManagedBadges() - { - Html.Should().NotContain("badge-key=\"ECH\""); - Html.Should().NotContain("badge-key=\"Self-managed\""); - } + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Example

      +
      +
      i18n.defaultLocale + + + +
      +
      +

      Locale setting planned for a future stack version.

      +
      +
      + """, + DocsHtml.Body() + ); } /// @@ -486,12 +663,30 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void RendersSupportedOnLineWithBothBadges() - { - Html.Should().Contain("settings-supported-on"); - Html.Should().Contain("badge-key=\"ECH\""); - Html.Should().Contain("badge-key=\"Self-managed\""); - } + public void RendersSupportedOnLineWithBothBadges() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Example

      +
      +
      xpack.sample.released + + + +
      +
      +

      + Supported on: + + + + +

      +

      A setting that has been GA since 7.0.

      +
      +
      + """, + DocsHtml.Body() + ); } /// @@ -527,12 +722,30 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void RendersSupportedOnLine() - { - Html.Should().Contain("settings-supported-on"); - Html.Should().Contain("badge-key=\"ECH\""); - Html.Should().Contain("badge-key=\"Self-managed\""); - } + public void RendersSupportedOnLine() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Example

      +
      +
      xpack.sample.mixed + + + +
      +
      +

      + Supported on: + + + + +

      +

      GA since an existing version, scheduled for deprecation in a future version.

      +
      +
      + """, + DocsHtml.Body() + ); } /// @@ -569,12 +782,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); [Test] - public void RendersBothSettings() - { - Html.Should().Contain("xpack.sample.one"); - Html.Should().Contain("xpack.sample.two"); - } + public void RendersBothSettings() => Html.Should().MatchHtmlSnapshot(DocsHtml.Body()); } From 4e38d141f5e6878571164be9bedd37c54c46786e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:45:21 +0100 Subject: [PATCH 09/18] Markdown tests (TaskList, Assembler, Codex): whole-output snapshots The Assembler and Codex tests are about the htmx attributes and the link target on cross links, so their snapshots keep those attributes (no DocsHtml.Body() normalisation). Fragment checks became whole-output snapshots, so the tests became stricter. Co-Authored-By: Claude Sonnet 5.5 --- .../AssemblerHtmxMarkdownLinkTests.cs | 106 ++++++++++++------ .../Codex/CodexHtmxCrossLinkTests.cs | 45 ++++---- .../TaskList/BasicTaskListTests.cs | 18 ++- 3 files changed, 108 insertions(+), 61 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index e75b388cdc..55d3ecad17 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -7,8 +7,11 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Assembler; @@ -31,14 +34,14 @@ IConfigurationContext configurationContext }; [Test] - public void CrossLink_HasNoSelectOobButKeepsPreload() - { - Html.Should().NotContain("hx-select-oob"); - Html.Should().Contain("preload=\"mousedown\""); - } - - [Test] - public void CrossLink_NoTargetBlank() => Html.Should().NotContain("target=\"_blank\""); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + test +

      + """ + ); [Test] public void EmitsCrossLink() @@ -48,7 +51,7 @@ public void EmitsCrossLink() } [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -67,13 +70,20 @@ IConfigurationContext configurationContext }; [Test] - public void InternalLink_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); + public void InternalLink_HasNoPerLinkHtmxAttributes() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Requirements +

      + """ + ); [Test] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -94,14 +104,17 @@ IConfigurationContext configurationContext }; [Test] - public void AbsolutePathLink_HasNoSelectOobButKeepsPreload() - { - Html.Should().NotContain("hx-select-oob"); - Html.Should().Contain("preload=\"mousedown\""); - } + public void AbsolutePathLink_HasNoSelectOobButKeepsPreload() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Elasticsearch +

      + """ + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -124,13 +137,20 @@ IConfigurationContext configurationContext }; [Test] - public void ReferenceLink_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); + public void ReferenceLink_HasNoPerLinkHtmxAttributes() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + test +

      + """ + ); [Test] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -152,14 +172,24 @@ IConfigurationContext configurationContext }; [Test] - public void EmptyTextCrossLink_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); - - [Test] - public void EmptyTextCrossLink_NoTargetBlank() => Html.Should().NotContain("target=\"_blank\""); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + +

      + """ + ); [Test] public void HasError() => - Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("empty link text")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {repo}/docs/index.md:4 Crosslink 'kibana://index.md' has empty link text. Please provide explicit link text." + ); [Test] public void EmitsCrossLink() @@ -187,13 +217,20 @@ IConfigurationContext configurationContext }; [Test] - public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); + public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + Special Requirements +

      + """ + ); [Test] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -214,13 +251,16 @@ IConfigurationContext configurationContext }; [Test] - public void ExternalLink_DoesNotGetHtmxAttributes_ForAssembler() - { - // HTTP links get target="_blank", not hx-select-oob - Html.Should().Contain("target=\"_blank\""); - Html.Should().NotContain("hx-select-oob"); - } + // HTTP links get target="_blank", not hx-select-oob + public void ExternalLink_DoesNotGetHtmxAttributes_ForAssembler() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      + link to app +

      + """ + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs index 75a6cbd3ae..0435d308e5 100644 --- a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -7,8 +7,11 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Testing; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; +using Nullean.Snapshots; +using Nullean.Snapshots.Html; namespace Elastic.Markdown.Tests.Codex; @@ -30,24 +33,17 @@ IConfigurationContext configurationContext protected override ICrossLinkResolver CreateCrossLinkResolver() => new TestCodexCrossLinkResolver(useRelativePaths: true); [Test] - public void CrossLink_ProducesPathOnlyHref() - { - Html.Should().Contain("href=\"/r/kibana/\""); - Html.Should().NotContain("https://codex.elastic.dev"); - } + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + test +

      + """ + ); [Test] - public void CrossLink_HasNoSelectOobButKeepsPreload() - { - Html.Should().NotContain("hx-select-oob"); - Html.Should().Contain("preload=\"mousedown\""); - } - - [Test] - public void CrossLink_NoTargetBlank() => Html.Should().NotContain("target=\"_blank\""); - - [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } [InheritsTests] @@ -68,14 +64,15 @@ IConfigurationContext configurationContext protected override ICrossLinkResolver CreateCrossLinkResolver() => new TestCodexCrossLinkResolver(useRelativePaths: false); [Test] - public void IsolatedCrossLink_HasAbsoluteHref() => Html.Should().Contain("https://codex.elastic.dev/r/kibana/"); - - [Test] - public void IsolatedCrossLink_HasTargetBlank() => Html.Should().Contain("target=\"_blank\""); - - [Test] - public void IsolatedCrossLink_NoHtmx() => Html.Should().NotContain("hx-select-oob"); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +

      Go to + test +

      + """ + ); [Test] - public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + public void HasNoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } diff --git a/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs b/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs index 9da44bab31..e7482918fd 100644 --- a/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs +++ b/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs @@ -15,10 +15,20 @@ public class BasicTaskListTests() : InlineTest(""" """) { [Test] - public void RendersTaskListContainer() => Html.Should().Contain("class=\"contains-task-list\""); - - [Test] - public void RendersTaskListItem() => Html.Should().Contain("class=\"task-list-item\""); + public void GeneratesHtml() => + Html.Should().MatchInlineHtmlSnapshot( + """ +
        +
      • + + A pending task
      • +
      • + + A completed task
      • +
      + """, + DocsHtml.Body() + ); [Test] public void RendersUncheckedCheckbox() => From 4b6dad9d916e88713599788da6203b0b6a149dde Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:49:23 +0100 Subject: [PATCH 10/18] Markdown tests (AppliesTo, DocSet): JSON snapshots of the serialised output The nine Should().Be("""...""") JSON comparisons (AppliesTo serialisation, RepositoryLinks) become MatchInlineJsonSnapshot with their literals unchanged, and they pass as they were. SerializeAllLifecycles, SerializeComplex and SerializeValidatesJsonStructure checked fragments or walked the parsed JSON; they are whole-output snapshots now, so they became stricter (every old fragment and property value appears in the snapshots). Co-Authored-By: Claude Sonnet 5.5 --- ...icableToJsonConverterSerializationTests.cs | 141 ++++++++++++------ .../DocSet/RepositoryLinksTests.cs | 3 +- 2 files changed, 96 insertions(+), 48 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs index 2b0967e78c..85ac54c242 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation; using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Versions; +using Nullean.Snapshots.Json; namespace Elastic.Markdown.Tests.AppliesTo; @@ -23,7 +24,7 @@ public void SerializeStackProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -48,7 +49,7 @@ public void SerializeStackWithVersionProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -76,7 +77,7 @@ public void SerializeMultipleApplicabilitiesProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -113,7 +114,7 @@ public void SerializeDeploymentProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -150,7 +151,7 @@ public void SerializeServerlessProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -181,7 +182,7 @@ public void Serialize_ServerlessVectorDatabase_ProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -208,7 +209,7 @@ public void SerializeProductProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -239,7 +240,7 @@ public void SerializeProductApplicabilityProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ [ { @@ -276,12 +277,48 @@ public void SerializeAllLifecyclesProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Contain("\"lifecycle\": \"preview\""); - json.Should().Contain("\"lifecycle\": \"experimental\""); - json.Should().Contain("\"lifecycle\": \"beta\""); - json.Should().Contain("\"lifecycle\": \"ga\""); - json.Should().Contain("\"lifecycle\": \"deprecated\""); - json.Should().Contain("\"lifecycle\": \"removed\""); + json.Should().MatchInlineJsonSnapshot( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "preview", + "version": "1.0+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "experimental", + "version": "1.0+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "beta", + "version": "1.0+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "1.0+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "deprecated", + "version": "1.0+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "removed", + "version": "1.0+" + } + ] + """ + ); } [Test] @@ -298,19 +335,30 @@ public void SerializeComplexProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); - // Verify it's an array with 3 items - var jsonDoc = JsonDocument.Parse(json); - jsonDoc.RootElement.GetArrayLength().Should().Be(3); - - // Verify each type is present - json.Should().Contain("\"type\": \"stack\""); - json.Should().Contain("\"type\": \"deployment\""); - json.Should().Contain("\"type\": \"product\""); - - // Verify sub_types - json.Should().Contain("\"sub_type\": \"stack\""); - json.Should().Contain("\"sub_type\": \"ece\""); - json.Should().Contain("\"sub_type\": \"product\""); + json.Should().MatchInlineJsonSnapshot( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "8.0+" + }, + { + "type": "deployment", + "sub_type": "ece", + "lifecycle": "ga", + "version": "all" + }, + { + "type": "product", + "sub_type": "product", + "lifecycle": "ga", + "version": "all" + } + ] + """ + ); } [Test] @@ -320,7 +368,7 @@ public void SerializeEmptyProducesEmptyArray() var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be("[]"); + json.Should().MatchInlineJsonSnapshot("[]"); } [Test] @@ -336,24 +384,23 @@ public void SerializeValidatesJsonStructure() }; var json = JsonSerializer.Serialize(original, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.ValueKind.Should().Be(JsonValueKind.Array); - var array = root.EnumerateArray().ToList(); - - array.Should().HaveCount(2); // Stack + Deployment.Ece - - var stackEntry = array[0]; - stackEntry.GetProperty("type").GetString().Should().Be("stack"); - stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); - stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - stackEntry.GetProperty("version").GetString().Should().Be("all"); - - var deploymentEntry = array[1]; - deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); - deploymentEntry.GetProperty("sub_type").GetString().Should().Be("ece"); - deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - deploymentEntry.GetProperty("version").GetString().Should().Be("3.0+"); + json.Should().MatchInlineJsonSnapshot( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "all" + }, + { + "type": "deployment", + "sub_type": "ece", + "lifecycle": "beta", + "version": "3.0+" + } + ] + """ + ); } } diff --git a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs index 565693f736..aaf653ff11 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs @@ -6,6 +6,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Links; +using Nullean.Snapshots.Json; namespace Elastic.Markdown.Tests.DocSet; @@ -59,7 +60,7 @@ public void SerializesCurrent() }; var json = RepositoryLinks.Serialize(linkReference); // language=json - json.Should().Be( + json.Should().MatchInlineJsonSnapshot( """ { "origin": { From f1908ebbc8b2f1496990c8b70e0b626ec1bbf304 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:49:24 +0100 Subject: [PATCH 11/18] Markdown tests (Search): snapshot the serialised DocumentationDocument The applies_to tests walked the parsed JSON property by property; they are one JSON snapshot of the whole serialised document each, so they became stricter. Every property the old tests asserted appears in the snapshots. SerializeDocumentWithNullAppliesToOmitsField was vacuous: its else branch asserted true.Should().BeTrue(), so it passed whether the field was omitted or null. The snapshot pins that the field is omitted. Co-Authored-By: Claude Sonnet 5.5 --- ...DocumentationDocumentSerializationTests.cs | 509 +++++++++++++----- 1 file changed, 360 insertions(+), 149 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs index d39a0e8e66..7d13103c2f 100644 --- a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Search; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Versions; +using Nullean.Snapshots.Json; namespace Elastic.Markdown.Tests.Search; @@ -30,22 +31,37 @@ public void SerializeDocumentWithStackAppliesToProducesCorrectJson() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - // Verify applies_to existing - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - appliesTo.ValueKind.Should().Be(JsonValueKind.Array); - - // Verify structure - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(1); - - var stackEntry = appliesArray[0]; - stackEntry.GetProperty("type").GetString().Should().Be("stack"); - stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); - stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - stackEntry.GetProperty("version").GetString().Should().Be("all"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "all" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Test Page", + "search_title": "Test Page", + "content_type": "doc", + "path": "/test/page", + "url": "/test/page", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -68,26 +84,43 @@ public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(2); - - // Verify ESS entry - var essEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ess"); - essEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - essEntry.GetProperty("type").GetString().Should().Be("deployment"); - essEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - essEntry.GetProperty("version").GetString().Should().Be("all"); - - // Verify ECE entry - var eceEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ece"); - eceEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - eceEntry.GetProperty("type").GetString().Should().Be("deployment"); - eceEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - eceEntry.GetProperty("version").GetString().Should().Be("3.5+"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "deployment", + "sub_type": "ece", + "lifecycle": "beta", + "version": "3.5+" + }, + { + "type": "deployment", + "sub_type": "ess", + "lifecycle": "ga", + "version": "all" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Deployment Test", + "search_title": "Deployment Test", + "content_type": "doc", + "path": "/test/deployment", + "url": "/test/deployment", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -115,33 +148,49 @@ public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(3); - - // Verify elasticsearch entry - var esEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "elasticsearch"); - esEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - esEntry.GetProperty("type").GetString().Should().Be("serverless"); - esEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - esEntry.GetProperty("version").GetString().Should().Be("8.0+"); - - // Verify security entry - var secEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "security"); - secEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - secEntry.GetProperty("type").GetString().Should().Be("serverless"); - secEntry.GetProperty("lifecycle").GetString().Should().Be("preview"); - secEntry.GetProperty("version").GetString().Should().Be("1.0+"); - - // Verify Vector Database entry - var vectorDatabaseEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "vectordb"); - vectorDatabaseEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - vectorDatabaseEntry.GetProperty("type").GetString().Should().Be("serverless"); - vectorDatabaseEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - vectorDatabaseEntry.GetProperty("version").GetString().Should().Be("all"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "serverless", + "sub_type": "elasticsearch", + "lifecycle": "ga", + "version": "8.0+" + }, + { + "type": "serverless", + "sub_type": "security", + "lifecycle": "preview", + "version": "1.0+" + }, + { + "type": "serverless", + "sub_type": "vectordb", + "lifecycle": "ga", + "version": "all" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Serverless Test", + "search_title": "Serverless Test", + "content_type": "doc", + "path": "/test/serverless", + "url": "/test/serverless", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -160,18 +209,37 @@ public void SerializeDocumentWithProductAppliesToProducesCorrectJson() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(1); - - var productEntry = appliesArray[0]; - productEntry.GetProperty("type").GetString().Should().Be("product"); - productEntry.GetProperty("sub_type").GetString().Should().Be("product"); - productEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - productEntry.GetProperty("version").GetString().Should().Be("2.0+"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "product", + "sub_type": "product", + "lifecycle": "beta", + "version": "2.0+" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Product Test", + "search_title": "Product Test", + "content_type": "doc", + "path": "/test/product", + "url": "/test/product", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -198,26 +266,43 @@ public void SerializeDocumentWithProductApplicabilityProducesCorrectJson() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(2); - - // Verify apm-agent-dotnet entry - var dotnetEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-dotnet"); - dotnetEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - dotnetEntry.GetProperty("type").GetString().Should().Be("product"); - dotnetEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - dotnetEntry.GetProperty("version").GetString().Should().Be("1.5+"); - - // Verify apm-agent-node entry - var nodeEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-node"); - nodeEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); - nodeEntry.GetProperty("type").GetString().Should().Be("product"); - nodeEntry.GetProperty("lifecycle").GetString().Should().Be("deprecated"); - nodeEntry.GetProperty("version").GetString().Should().Be("2.0+"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "product", + "sub_type": "apm-agent-dotnet", + "lifecycle": "ga", + "version": "1.5+" + }, + { + "type": "product", + "sub_type": "apm-agent-node", + "lifecycle": "deprecated", + "version": "2.0+" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "APM Test", + "search_title": "APM Test", + "content_type": "doc", + "path": "/test/apm", + "url": "/test/apm", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -240,17 +325,49 @@ public void SerializeDocumentWithComplexAppliesToProducesCorrectJson() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(3); - - // Verify we have all three types - appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "stack"); - appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "deployment"); - appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "serverless"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "8.0+" + }, + { + "type": "deployment", + "sub_type": "ess", + "lifecycle": "ga", + "version": "all" + }, + { + "type": "serverless", + "sub_type": "elasticsearch", + "lifecycle": "ga", + "version": "all" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Complex Test", + "search_title": "Complex Test", + "content_type": "doc", + "path": "/test/complex", + "url": "/test/complex", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -266,18 +383,29 @@ public void SerializeDocumentWithNullAppliesToOmitsField() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - // With default JSON options, null values might be omitted or serialized as null - // Let's check both possibilities - if (root.TryGetProperty("applies_to", out var appliesTo)) - appliesTo.ValueKind.Should().Be(JsonValueKind.Null); - else - { - // Field is omitted, which is also acceptable - true.Should().BeTrue(); - } + json.Should().MatchInlineJsonSnapshot( + """ + { + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "No Applies Test", + "search_title": "No Applies Test", + "content_type": "doc", + "path": "/test/no-applies", + "url": "/test/no-applies", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -293,12 +421,30 @@ public void SerializeDocumentWithEmptyAppliesToProducesEmptyArray() }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - appliesTo.ValueKind.Should().Be(JsonValueKind.Array); - appliesTo.GetArrayLength().Should().Be(0); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Empty Applies Test", + "search_title": "Empty Applies Test", + "content_type": "doc", + "path": "/test/empty-applies", + "url": "/test/empty-applies", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -411,22 +557,49 @@ public void SerializeDocumentWithMultipleApplicabilitiesPerTypeProducesMultipleA }; var json = JsonSerializer.Serialize(doc, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); - var appliesArray = appliesTo.EnumerateArray().ToList(); - appliesArray.Should().HaveCount(3); - - // All should be stack type - appliesArray.Should().OnlyContain(e => e.GetProperty("type").GetString() == "stack"); - appliesArray.Should().OnlyContain(e => e.GetProperty("sub_type").GetString() == "stack"); - - // Verify different lifecycle values - var lifecycles = appliesArray.Select(e => e.GetProperty("lifecycle").GetString()).ToList(); - lifecycles.Should().Contain("ga"); - lifecycles.Should().Contain("beta"); - lifecycles.Should().Contain("deprecated"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "applies_to": [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "8.0+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "beta", + "version": "7.17+" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "deprecated", + "version": "7.0+" + } + ], + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Multiple Test", + "search_title": "Multiple Test", + "content_type": "doc", + "path": "/test/multiple", + "url": "/test/multiple", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -441,13 +614,30 @@ public void SerializeDocument_IncludesSourceUrlAsSnakeCaseKeyword() }; var json = JsonSerializer.Serialize(doc, _options); - using var jsonDoc = JsonDocument.Parse(json); - jsonDoc - .RootElement - .GetProperty("source_url") - .GetString() - .Should() - .Be("https://github.com/elastic/docs-content/blob/main/docs/some-page.md"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "source_url": "https://github.com/elastic/docs-content/blob/main/docs/some-page.md", + "title": "Some Page", + "search_title": "Some Page", + "content_type": "docs", + "path": "/docs/some-page", + "url": "/docs/some-page", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } [Test] @@ -456,7 +646,28 @@ public void SerializeDocument_OmitsSourceUrlWhenNull() var doc = new DocumentationDocument { Path = "/docs/some-page", Title = "Some Page", SearchTitle = "Some Page", SourceUrl = null }; var json = JsonSerializer.Serialize(doc, _options); - using var jsonDoc = JsonDocument.Parse(json); - jsonDoc.RootElement.TryGetProperty("source_url", out _).Should().BeFalse(); + json.Should().MatchInlineJsonSnapshot( + """ + { + "content_hash": "", + "content_last_updated": "0001-01-01T00:00:00+00:00", + "title": "Some Page", + "search_title": "Some Page", + "content_type": "docs", + "path": "/docs/some-page", + "url": "/docs/some-page", + "hash": "", + "batch_index_date": "0001-01-01T00:00:00+00:00", + "last_updated": "0001-01-01T00:00:00+00:00", + "headings": [], + "parents": [], + "content_tier": "reference", + "navigation": { + "depth": 50, + "table_of_contents": 50 + } + } + """ + ); } } From 37496d969529d8179b00c0c130099e429d82e108 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:49:26 +0100 Subject: [PATCH 12/18] Markdown tests (Exporters): snapshot OKF index content The four RenderIndexContent tests checked Contain/StartWith fragments of a few lines of text; they are inline text snapshots now and became stricter. Every old fragment appears in the snapshots. The pure-function tests and the YAML round-trip tests stay as they are. Co-Authored-By: Claude Sonnet 5.5 --- .../Exporters/OkfMarkdownExporterTests.cs | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs b/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs index 3168193a80..f45996ba24 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Navigation; using Elastic.Markdown; using Elastic.Markdown.Exporters; +using Nullean.Snapshots; using SharpYaml; namespace Elastic.Markdown.Tests.Exporters; @@ -233,7 +234,11 @@ public void RenderIndexContent_RootDirectory_DeclaresOkfVersionAndNoOtherFrontma { var content = OkfMarkdownExporter.RenderIndexContent(directory: "", concepts: [], subdirectories: []); - content.Should().StartWith("---\nokf_version: \"0.1\"\n---"); + content.Should().MatchInlineSnapshot(""" + --- + okf_version: "0.1" + --- + """); } [Test] @@ -241,8 +246,7 @@ public void RenderIndexContent_NonRootDirectory_HasNoFrontmatter() { var content = OkfMarkdownExporter.RenderIndexContent(directory: "reference", concepts: [], subdirectories: []); - content.Should().NotContain("---"); - content.Should().NotContain("okf_version"); + content.Should().MatchInlineSnapshot(""); } [Test] @@ -257,11 +261,18 @@ public void RenderIndexContent_WithConceptsAndSubdirectories_GroupsThemUnderSepa var content = OkfMarkdownExporter.RenderIndexContent(directory: "reference", concepts: concepts, subdirectories: ["reference/foo"]); - content.Should().Contain("# Documents"); - content.Should().Contain("* [Bar](bar.md) - Bar description"); - content.Should().Contain("* [Foo](foo.md) - Foo description"); - content.Should().Contain("# Subdirectories"); - content.Should().Contain("* [foo](foo/) - Foo description"); + content.Should().MatchInlineSnapshot( + """ + # Documents + + * [Bar](bar.md) - Bar description + * [Foo](foo.md) - Foo description + + # Subdirectories + + * [foo](foo/) - Foo description + """ + ); } [Test] @@ -269,8 +280,11 @@ public void RenderIndexContent_SubdirectoryWithoutSiblingLandingPage_OmitsDescri { var content = OkfMarkdownExporter.RenderIndexContent(directory: "reference", concepts: [], subdirectories: ["reference/foo"]); - content.Should().Contain("* [foo](foo/)"); - content.Should().NotContain("* [foo](foo/) -"); + content.Should().MatchInlineSnapshot(""" + # Subdirectories + + * [foo](foo/) + """); } [Test] From c227f505f0d54a5eb4f4bc5695c83917760777bd Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:49:27 +0100 Subject: [PATCH 13/18] Markdown tests (FrontMatter and root): diagnostics snapshots Count and message-substring assertions on the collector become snapshots of the diagnostics text, so they pin the severity, location and whole message and became stricter. The four products-suggestion tests keep their old assertions: their diagnostic embeds an exception stack trace with source line numbers, which a snapshot should not pin. Co-Authored-By: Claude Sonnet 5.5 --- .../FrontMatter/YamlFrontMatterTests.cs | 106 +++++++----------- .../MissingTocFileTests.cs | 11 +- .../RootIndexValidationTests.cs | 9 +- 3 files changed, 58 insertions(+), 68 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs b/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs index e3d3608dd3..559b339dab 100644 --- a/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs +++ b/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs @@ -5,10 +5,12 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.IO; using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.FrontMatter; using Elastic.Markdown.Tests.Directives; +using Nullean.Snapshots; namespace Elastic.Markdown.Tests.FrontMatter; @@ -191,18 +193,17 @@ public class AllUnknownFrontMatterKeysAreHints() : DirectiveTest( ) { [Test] - public void ReportsOneHintPerKeyAndNoErrors() - { - Collector.Errors.Should().Be(0); - Collector.Diagnostics.Should().HaveCount(2).And.OnlyContain(d => d.Severity == Severity.Hint); + public void ReportsOneHintPerKeyAndNoErrors() => Collector .Diagnostics - .Select(d => d.Message) + .ToSnapshotText() .Should() - .Contain(m => m.Contains("Unknown key 'type'")) - .And - .Contain(m => m.Contains("Unknown key 'comment'")); - } + .MatchInlineSnapshot( + """ + hint {repo}/docs/index.md:2 Unknown key 'type'. Valid keys: applies_to, cta, description, layout, listing, mapped_pages, meta_title, navigation_title, noindex, products, sub, title. It is ignored. + hint {repo}/docs/index.md:3 Unknown key 'comment'. Valid keys: applies_to, cta, description, layout, listing, mapped_pages, meta_title, navigation_title, noindex, products, sub, title. It is ignored. + """ + ); } public class YamlFrontMatterTests() : DirectiveTest( @@ -245,7 +246,11 @@ public class EmptyFileWarnsNeedingATitle() : DirectiveTest("") [Test] public void WarnsOfNoTitle() => - Collector.Diagnostics.Should().NotBeEmpty().And.Contain(d => d.Message.Contains("Document has no title, using file name as title.")); + Collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot("warning {repo}/docs/index.md Document has no title, using file name as title."); } public class NavigationTitleSupportReplacements() : DirectiveTest( @@ -408,7 +413,7 @@ public class MappedPagesValidUrl() : DirectiveTest( ) { [Test] - public void NoErrors() => Collector.Diagnostics.Should().BeEmpty(); + public void NoErrors() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class MappedPagesInvalidUrl() : DirectiveTest( @@ -423,18 +428,14 @@ public class MappedPagesInvalidUrl() : DirectiveTest( ) { [Test] - public void HasErrors() - { - Collector.Diagnostics.Should().HaveCount(1); + public void HasErrors() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Message.Contains( - "Invalid mapped_pages URL: \"https://www.elastic.co/docs/get-started/deployment-options\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." - ) + .MatchInlineSnapshot( + "error {repo}/docs/index.md Invalid mapped_pages URL: \"https://www.elastic.co/docs/get-started/deployment-options\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." ); - } } public class MappedPagesMixedUrls() : DirectiveTest( @@ -451,18 +452,14 @@ public class MappedPagesMixedUrls() : DirectiveTest( ) { [Test] - public void HasErrorsForInvalidUrl() - { - Collector.Diagnostics.Should().HaveCount(1); + public void HasErrorsForInvalidUrl() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Message.Contains( - "Invalid mapped_pages URL: \"https://www.elastic.co/docs/invalid-url\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" - ) + .MatchInlineSnapshot( + "error {repo}/docs/index.md Invalid mapped_pages URL: \"https://www.elastic.co/docs/invalid-url\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." ); - } } public class MappedPagesEmptyUrl() : DirectiveTest(""" @@ -475,11 +472,8 @@ public class MappedPagesEmptyUrl() : DirectiveTest(""" """) { [Test] - public void NoErrorsForEmptyUrl() - { - // Empty URLs are ignored, no validation error should occur - Collector.Diagnostics.Should().BeEmpty(); - } + // Empty URLs are ignored, no validation error should occur + public void NoErrorsForEmptyUrl() => Collector.Diagnostics.ToSnapshotText().Should().MatchInlineSnapshot(""); } public class MappedPagesExternalUrl() : DirectiveTest( @@ -494,18 +488,14 @@ public class MappedPagesExternalUrl() : DirectiveTest( ) { [Test] - public void HasErrorsForExternalUrl() - { - Collector.Diagnostics.Should().HaveCount(1); + public void HasErrorsForExternalUrl() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Message.Contains( - "Invalid mapped_pages URL: \"https://github.com/elastic/docs-builder\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" - ) + .MatchInlineSnapshot( + "error {repo}/docs/index.md Invalid mapped_pages URL: \"https://github.com/elastic/docs-builder\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." ); - } } public class MappedPagesMalformedUri() : DirectiveTest( @@ -520,18 +510,14 @@ public class MappedPagesMalformedUri() : DirectiveTest( ) { [Test] - public void HasErrorsForMalformedUri() - { - Collector.Diagnostics.Should().HaveCount(1); + public void HasErrorsForMalformedUri() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Message.Contains( - "Invalid mapped_pages URL: \"https://www.elastic.co/guide/[invalid-characters]\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" - ) + .MatchInlineSnapshot( + "error {repo}/docs/index.md Invalid mapped_pages URL: \"https://www.elastic.co/guide/[invalid-characters]\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." ); - } } public class MappedPagesInvalidScheme() : DirectiveTest( @@ -546,18 +532,14 @@ public class MappedPagesInvalidScheme() : DirectiveTest( ) { [Test] - public void HasErrorsForInvalidScheme() - { - Collector.Diagnostics.Should().HaveCount(1); + public void HasErrorsForInvalidScheme() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Message.Contains( - "Invalid mapped_pages URL: \"https://www.elastic.co/guide/invalid uri with spaces\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" - ) + .MatchInlineSnapshot( + "error {repo}/docs/index.md Invalid mapped_pages URL: \"https://www.elastic.co/guide/invalid uri with spaces\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." ); - } } public class MappedPagesNotAbsoluteUri() : DirectiveTest(""" @@ -570,16 +552,12 @@ public class MappedPagesNotAbsoluteUri() : DirectiveTest(""" """) { [Test] - public void HasErrorsForNotAbsoluteUri() - { - Collector.Diagnostics.Should().HaveCount(1); + public void HasErrorsForNotAbsoluteUri() => Collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Message.Contains( - "Invalid mapped_pages URL: \"not-a-uri-at-all\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" - ) + .MatchInlineSnapshot( + "error {repo}/docs/index.md Invalid mapped_pages URL: \"not-a-uri-at-all\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." ); - } } diff --git a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs index d3393b0b2d..aadd5f114c 100644 --- a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs +++ b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs @@ -6,7 +6,9 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.IO; +using Nullean.Snapshots; namespace Elastic.Markdown.Tests; @@ -47,10 +49,15 @@ public void TocReferencesMissingFile_DoesNotThrow_AndEmitsClearError() act.Should().NotThrow("a missing toc file must surface a validation error, not crash the build"); - collector.Errors.Should().BeGreaterThan(0); collector .Diagnostics + .ToSnapshotText() .Should() - .Contain(d => d.Severity == Severity.Error && d.Message.Contains("missing.md") && d.Message.Contains("does not exist")); + .MatchInlineSnapshot( + """ + error {repo}/docs/docset.yml Table of contents references 'missing.md' but the file does not exist on disk. + error {repo}/docs/docset.yml Documentation set 'test' ({repo}/docs/docset.yml) table of contents has items defined but none could be created + """ + ); } } diff --git a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs index 5470eac332..3e401cf490 100644 --- a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs +++ b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs @@ -7,7 +7,9 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Testing; using Elastic.Markdown.IO; +using Nullean.Snapshots; namespace Elastic.Markdown.Tests; @@ -41,8 +43,11 @@ public void InternalRegistry_MissingIndexMd_EmitsError() var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); - collector.Errors.Should().BeGreaterThan(0); - collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("index.md")); + collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot("error {repo}/docs/docset.yml Non-public documentation sets require a root index.md file"); } [Test] From ee5fdab82e3c01657988cabda2bffa991b7c02d8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:50:21 +0100 Subject: [PATCH 14/18] Markdown tests (Tracking): diagnostics snapshots for the redirect validation errors The three tests that checked an error by substring now snapshot the diagnostics text (the temp path is scrubbed to {temp}), so they pin the severity and the whole message and became stricter. Co-Authored-By: Claude Sonnet 5.5 --- .../LocalChangeTrackingServiceTests.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Tracking/LocalChangeTrackingServiceTests.cs b/tests/Elastic.Markdown.Tests/Tracking/LocalChangeTrackingServiceTests.cs index 83b37fdad6..fe66b6bd6c 100644 --- a/tests/Elastic.Markdown.Tests/Tracking/LocalChangeTrackingServiceTests.cs +++ b/tests/Elastic.Markdown.Tests/Tracking/LocalChangeTrackingServiceTests.cs @@ -8,7 +8,9 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.Refactor.Tracking; +using Elastic.Documentation.Testing; using Nullean.ScopedFileSystem; +using Nullean.Snapshots; namespace Elastic.Markdown.Tests.Tracking; @@ -51,14 +53,12 @@ public async Task DocsetAtRepoRoot_DeletedMarkdownWithoutRedirect_EmitsError() var ok = await service.ValidateRedirects(collector, source, fs); ok.Should().BeFalse(); - collector.Errors.Should().BeGreaterThan(0); collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Severity == Severity.Error && d.Message.Contains("troubleshoot/deployments/serverless.md") && d.Message.Contains( - "redirects.yml" - ) + .MatchInlineSnapshot( + "error {temp} Missing 'troubleshoot/deployments/serverless.md' in redirects.yml. 'troubleshoot/deployments/serverless.md' was deleted but it has no redirect targets. This will lead to broken links." ); } @@ -93,7 +93,13 @@ public async Task DocsetInSubfolder_DeletedMarkdownWithoutRedirect_EmitsError() var ok = await service.ValidateRedirects(collector, source, fs); ok.Should().BeFalse(); - collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("reference/old-page.md")); + collector + .Diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + "error {temp} Missing 'reference/old-page.md' in redirects.yml. 'docs/reference/old-page.md' was deleted but it has no redirect targets. This will lead to broken links." + ); } [Test] @@ -137,9 +143,10 @@ public async Task RenamedMarkdownWithoutRedirect_EmitsError() ok.Should().BeFalse(); collector .Diagnostics + .ToSnapshotText() .Should() - .Contain( - d => d.Severity == Severity.Error && d.Message.Contains("troubleshoot/old.md") && d.Message.Contains("troubleshoot/new.md") + .MatchInlineSnapshot( + "error {temp} Missing 'troubleshoot/old.md' in redirects.yml. 'troubleshoot/old.md' was renamed to 'troubleshoot/new.md' but it has no redirect configuration set." ); } From e3ff2669b3ae29739d5ece8b4ae66ebed0ace856 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:09:32 +0100 Subject: [PATCH 15/18] Scrub paths the same way on every operating system The repository and temp folder scrubbers replaced only the root, so a Windows run printed {repo}\docs\index.md where the verified text says {repo}/docs/index.md, and about 160 diagnostics snapshots failed there. The path that follows the root is now written with forward slashes. The temp scrubber also swallowed everything up to the next whitespace, including the :line after a file name. A path now ends at a colon too, and the repository root is tried before the temp root because the repository can sit under the temp folder. The doc comment claimed it scrubbed git SHAs; it does not, and no longer says so. Tested with a Windows-style root, which also runs on Linux and macOS. Removing the separator replacement makes that test fail. Co-Authored-By: Claude Sonnet 5.5 --- .../Linked/SnapshotScrubbing.cs | 29 ++++++++++++------- .../Testing/SnapshotHelpersTests.cs | 15 ++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs index 6a80bb71fc..44132cd7bd 100644 --- a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs +++ b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs @@ -9,8 +9,9 @@ namespace Elastic.Documentation.Testing.Linked; /// -/// Scrubbers every snapshot in docs-builder's tests gets: machine-specific paths and git SHAs never belong in a -/// verified file. Runs when any test project referencing this assembly loads it. +/// Scrubbers every snapshot in docs-builder's tests gets: the repository root and the temp folder are replaced by +/// {repo} and {temp}, and the rest of such a path is written with forward slashes, so a verified file +/// reads the same on every machine and operating system. Compiled into each test project. /// internal static class SnapshotScrubbing { @@ -19,14 +20,22 @@ internal static class SnapshotScrubbing #pragma warning restore CA2255 internal static void Register() { - SnapshotDefaults.AddScrubber( - Scrubbers.Regex( - new Regex(Regex.Escape(Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar)) + @"[^\s""'<>]*", RegexOptions.Compiled), - "{temp}" - ) - ); + // the more specific root first: the repository may sit under the temp folder if (FindSolutionRoot() is { } root) - SnapshotDefaults.AddScrubber(Scrubbers.Regex(new Regex(Regex.Escape(root), RegexOptions.Compiled), "{repo}")); + SnapshotDefaults.AddScrubber(Under(root, "{repo}")); + SnapshotDefaults.AddScrubber(Under(Path.GetTempPath(), "{temp}")); + } + + /// + /// Replaces with and writes the path that follows it with + /// forward slashes. The path ends at whitespace, a quote, an angle bracket or a colon, so file.md:3 keeps its line. + /// + internal static Func Under(string root, string token) + { + root = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var roots = new[] { root, root.Replace('\\', '/') }.Distinct().Select(Regex.Escape); + var pattern = new Regex($"(?:{string.Join('|', roots)})(?[^\\s\"'<>:*?|]*)", RegexOptions.Compiled); + return text => pattern.Replace(text, m => token + m.Groups["rest"].Value.Replace('\\', '/')); } private static string? FindSolutionRoot() @@ -34,6 +43,6 @@ internal static void Register() var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "docs-builder.slnx"))) dir = dir.Parent; - return dir?.FullName.TrimEnd(Path.DirectorySeparatorChar); + return dir?.FullName; } } diff --git a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs index 5897c67b4a..bef3f63064 100644 --- a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs +++ b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Testing; +using Elastic.Documentation.Testing.Linked; using Nullean.Snapshots; using Nullean.Snapshots.Html; @@ -67,4 +68,18 @@ public void DocsHtmlSection_DropsFixtureHeadingAndHtmxAttributes() => """, DocsHtml.Section() ); + + [Test] + public void PathScrubber_WindowsRoot_WritesTheRestWithForwardSlashesAndKeepsTheLine() => + SnapshotScrubbing + .Under(@"D:\a\docs-builder\docs-builder", "{repo}")(@"error D:\a\docs-builder\docs-builder\docs\index.md:3 bad link") + .Should() + .MatchInlineSnapshot("error {repo}/docs/index.md:3 bad link"); + + [Test] + public void PathScrubber_UnixRootInsideText_LeavesOtherPathsAlone() => + SnapshotScrubbing + .Under("/work/repo/", "{repo}")("see /work/repo/docs/a.md and /elsewhere/b.md") + .Should() + .MatchInlineSnapshot("see {repo}/docs/a.md and /elsewhere/b.md"); } From 12c95f57cc576994e27cfe2084ffb1e61dc50507 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:12:40 +0100 Subject: [PATCH 16/18] Scrub random folder names under the temp directory A test that works in a Guid-named folder in the temp directory printed that name in its diagnostics, so a snapshot of them could not be stable. The temp scrubber now replaces a run of 32 hex digits in the path with {id}. Co-Authored-By: Claude Sonnet 5.5 --- .../Linked/SnapshotScrubbing.cs | 14 +++++++++++--- .../Testing/SnapshotHelpersTests.cs | 9 +++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs index 44132cd7bd..7c97d0d9b1 100644 --- a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs +++ b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs @@ -23,21 +23,29 @@ internal static void Register() // the more specific root first: the repository may sit under the temp folder if (FindSolutionRoot() is { } root) SnapshotDefaults.AddScrubber(Under(root, "{repo}")); - SnapshotDefaults.AddScrubber(Under(Path.GetTempPath(), "{temp}")); + // a test folder in the temp directory is usually named after a fresh Guid; that name is not part of what is verified + SnapshotDefaults.AddScrubber(Under(Path.GetTempPath(), "{temp}", RandomName)); } /// /// Replaces with and writes the path that follows it with /// forward slashes. The path ends at whitespace, a quote, an angle bracket or a colon, so file.md:3 keeps its line. /// - internal static Func Under(string root, string token) + internal static Func Under(string root, string token, Regex? randomName = null) { root = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var roots = new[] { root, root.Replace('\\', '/') }.Distinct().Select(Regex.Escape); var pattern = new Regex($"(?:{string.Join('|', roots)})(?[^\\s\"'<>:*?|]*)", RegexOptions.Compiled); - return text => pattern.Replace(text, m => token + m.Groups["rest"].Value.Replace('\\', '/')); + return text => pattern.Replace(text, m => + { + var rest = m.Groups["rest"].Value.Replace('\\', '/'); + return token + (randomName is null ? rest : randomName.Replace(rest, "{id}")); + }); } + /// A run of 32 hex digits: a Guid.ToString("N") used as a folder name. + private static readonly Regex RandomName = new("[0-9a-fA-F]{32}", RegexOptions.Compiled); + private static string? FindSolutionRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs index bef3f63064..21b3ae758a 100644 --- a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs +++ b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs @@ -82,4 +82,13 @@ public void PathScrubber_UnixRootInsideText_LeavesOtherPathsAlone() => .Under("/work/repo/", "{repo}")("see /work/repo/docs/a.md and /elsewhere/b.md") .Should() .MatchInlineSnapshot("see {repo}/docs/a.md and /elsewhere/b.md"); + + [Test] + public void TempScrubber_GuidFolderName_IsReplacedSoTheSnapshotIsStable() => + SnapshotScrubbing + .Under("/tmp", "{temp}", new System.Text.RegularExpressions.Regex("[0-9a-fA-F]{32}"))( + "error /tmp/diff-validate-test-1d248de3468f4d58849398ff8b320802/docs/redirects.yml Missing" + ) + .Should() + .MatchInlineSnapshot("error {temp}/diff-validate-test-{id}/docs/redirects.yml Missing"); } From 021c3c0f9588533105b2572ba69aacd38ce1360f Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:28:53 +0100 Subject: [PATCH 17/18] Make diagnostics snapshots and snapshot paths the same on every operating system CI on Windows failed five of the stacked PRs for reasons a laptop never shows: - DiagnosticsSnapshot now writes paths with forward slashes and drops a leading drive letter, so `C:\docs\toc.yml`, `C:/docs/toc.yml` and `/docs/toc.yml` read the same, and `testing\req.md` inside a message reads `testing/req.md`. - ToSnapshotText(firstLineOnly: true) keeps only the first line of each message, for diagnostics that carry an exception: its stack trace has source line numbers and the exception type differs per platform. - SnapshotPathLengthTests fails when a verified snapshot's path, with the received file's longer folder and the CI checkout root, would pass Windows' 260 characters. git on the runner cannot check such a file out, so the whole job failed before any test ran. Each has a test. Lowering the length limit makes the guard fail. Co-Authored-By: Claude Sonnet 5.5 --- .../DiagnosticsSnapshot.cs | 29 +++++++-- .../Testing/SnapshotHelpersTests.cs | 43 +++++++++++++ .../Testing/SnapshotPathLengthTests.cs | 60 +++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 tests/Elastic.Documentation.Tests/Testing/SnapshotPathLengthTests.cs diff --git a/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs b/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs index 0e8f94f1f2..f53161eea3 100644 --- a/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs +++ b/tests-shared/Elastic.Documentation.Testing/DiagnosticsSnapshot.cs @@ -3,17 +3,28 @@ // See the LICENSE file in the project root for more information using System.Text; +using System.Text.RegularExpressions; using Elastic.Documentation.Diagnostics; namespace Elastic.Documentation.Testing; -public static class DiagnosticsSnapshot +public static partial class DiagnosticsSnapshot { /// /// One line per diagnostic, error|warning|hint file:line message, in the order they were written. Empty when /// there are none, so a clean run reads as MatchInlineSnapshot(""). + /// + /// Paths read the same on every operating system: backslashes become forward slashes and a leading drive letter is + /// dropped, so C:\docs\toc.yml, C:/docs/toc.yml and /docs/toc.yml are the same text. A message that + /// really contains a backslash reads with a slash, on Linux too. + /// /// - public static string ToSnapshotText(this IEnumerable diagnostics) + /// What a test's collector holds. + /// + /// Keep only the first line of each message. For diagnostics that carry an exception, whose stack trace (frames and + /// source line numbers) is no part of what a test verifies, and whose type and text can differ per platform. + /// + public static string ToSnapshotText(this IEnumerable diagnostics, bool firstLineOnly = false) { var sb = new StringBuilder(); foreach (var d in diagnostics) @@ -24,9 +35,19 @@ public static string ToSnapshotText(this IEnumerable diagnostics) Severity.Warning => "warning", _ => "hint" }; - var position = d.Line is { } line ? $"{d.File}:{line}" : d.File; - sb.Append(severity).Append(' ').Append(position).Append(' ').Append(d.Message).Append('\n'); + var file = Normalise(d.File); + var position = d.Line is { } line ? $"{file}:{line}" : file; + var message = d.Message ?? string.Empty; + if (firstLineOnly) + message = message.Split('\n')[0].TrimEnd('\r'); + _ = sb.Append(severity).Append(' ').Append(position).Append(' ').Append(Normalise(message)).Append('\n'); } return sb.ToString(); } + + private static string Normalise(string text) => DriveLetter().Replace(text.Replace('\\', '/'), string.Empty); + + // "C:/docs/..." at the start of a path; not the "s:" of "https://", which a letter or digit precedes + [GeneratedRegex("(? ) .Should() .MatchInlineSnapshot("error {temp}/diff-validate-test-{id}/docs/redirects.yml Missing"); + + [Test] + public void ToSnapshotText_WindowsPaths_ReadLikeUnixPaths() + { + var diagnostics = new List + { + new() + { + Severity = Severity.Error, + File = @"C:\docs\section-b\toc.yml", + Message = @"`sub-requirements2` does not exist in testing\req.md." + }, + new() { Severity = Severity.Error, File = "C:/docs/docset.yml", Line = 4, Message = "see https://elastic.co/docs and D:/x" }, + new() { Severity = Severity.Warning, File = @"/docs\docset.yml", Message = "mixed" } + }; + diagnostics + .ToSnapshotText() + .Should() + .MatchInlineSnapshot( + """ + error /docs/section-b/toc.yml `sub-requirements2` does not exist in testing/req.md. + error /docs/docset.yml:4 see https://elastic.co/docs and /x + warning /docs/docset.yml mixed + """ + ); + } + + [Test] + public void ToSnapshotText_FirstLineOnly_DropsTheStackTrace() + { + var diagnostics = new List + { + new() + { + Severity = Severity.Error, + File = "index.md", + Line = 3, + Message = + "registry could not be read\nSystem.IO.FileNotFoundException: Could not find file 'x'.\n at Some.Method() in /src/File.cs:line 209" + } + }; + diagnostics.ToSnapshotText(firstLineOnly: true).Should().MatchInlineSnapshot("error index.md:3 registry could not be read"); + } } diff --git a/tests/Elastic.Documentation.Tests/Testing/SnapshotPathLengthTests.cs b/tests/Elastic.Documentation.Tests/Testing/SnapshotPathLengthTests.cs new file mode 100644 index 0000000000..647cddfb22 --- /dev/null +++ b/tests/Elastic.Documentation.Tests/Testing/SnapshotPathLengthTests.cs @@ -0,0 +1,60 @@ +// 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 AwesomeAssertions; + +namespace Elastic.Documentation.Tests.Testing; + +/// +/// A verified snapshot is named after its test class and method, and a failed run writes the received file under +/// .artifacts/.snapshots/received/{Project}/, a longer path. Windows stops at 260 characters, and git on the CI runner +/// cannot even check out a file that is too long, so the whole job fails before any test runs. +/// +public class SnapshotPathLengthTests +{ + // "D:\a\docs-builder\docs-builder\" is where a GitHub runner puts the checkout. + private const int CheckoutRootLength = 31; + private const string ReceivedFolder = ".artifacts/.snapshots/received/"; + // Windows' MAX_PATH is 260 including the terminating character; keep a margin. + private const int MaxLength = 250; + + [Test] + public void VerifiedSnapshots_FitInAWindowsPath_EvenWhenTheReceivedFileIsWritten() + { + var root = FindRepositoryRoot(); + var tooLong = new List(); + foreach (var testsFolder in new[] { "tests", "tests-integration" }) + { + var folder = Path.Combine(root, testsFolder); + if (!Directory.Exists(folder)) + continue; + foreach (var project in Directory.EnumerateDirectories(folder)) + { + var snapshots = Path.Combine(project, ".snapshots"); + if (!Directory.Exists(snapshots)) + continue; + foreach (var file in Directory.EnumerateFiles(snapshots, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(snapshots, file).Replace('\\', '/'); + var length = CheckoutRootLength + ReceivedFolder.Length + Path.GetFileName(project).Length + 1 + relative.Length; + if (length > MaxLength) + tooLong.Add($"{length} {Path.GetRelativePath(root, file).Replace('\\', '/')}"); + } + } + } + + tooLong.Should().BeEmpty( + "a snapshot path over {0} characters cannot be checked out on Windows; shorten the test's name or its class", + MaxLength + ); + } + + private static string FindRepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "docs-builder.slnx"))) + dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("docs-builder.slnx not found above the test binaries"); + } +} From 2b0c0d96482ca8657090d80cb9c58fe6556356eb Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:50:06 +0100 Subject: [PATCH 18/18] Match the repository root in the form DiagnosticsSnapshot writes it DiagnosticsSnapshot drops a leading drive letter and writes forward slashes, so on Windows D:\a\docs-builder\docs-builder\docs\index.md reaches the {repo} scrubber as /a/docs-builder/docs-builder/docs/index.md, which it no longer recognised. Every diagnostics snapshot that names a file failed on the Windows runner. The scrubber now also matches the root without its drive letter. Removing that variant makes the new test fail. Co-Authored-By: Claude Sonnet 5.5 --- .../Linked/SnapshotScrubbing.cs | 7 ++++++- .../Testing/SnapshotHelpersTests.cs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs index 7c97d0d9b1..2a0413a362 100644 --- a/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs +++ b/tests-shared/Elastic.Documentation.Testing/Linked/SnapshotScrubbing.cs @@ -34,7 +34,10 @@ internal static void Register() internal static Func Under(string root, string token, Regex? randomName = null) { root = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var roots = new[] { root, root.Replace('\\', '/') }.Distinct().Select(Regex.Escape); + // DiagnosticsSnapshot writes a path with forward slashes and without its drive letter, so the root has to match that way too + var forward = root.Replace('\\', '/'); + var withoutDrive = DriveLetter.Replace(forward, string.Empty); + var roots = new[] { root, forward, withoutDrive }.Where(r => r.Length > 1).Distinct().Select(Regex.Escape); var pattern = new Regex($"(?:{string.Join('|', roots)})(?[^\\s\"'<>:*?|]*)", RegexOptions.Compiled); return text => pattern.Replace(text, m => { @@ -43,6 +46,8 @@ internal static Func Under(string root, string token, Regex? ran }); } + private static readonly Regex DriveLetter = new("^[A-Za-z]:", RegexOptions.Compiled); + /// A run of 32 hex digits: a Guid.ToString("N") used as a folder name. private static readonly Regex RandomName = new("[0-9a-fA-F]{32}", RegexOptions.Compiled); diff --git a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs index ca2e42c247..c89933d40b 100644 --- a/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs +++ b/tests/Elastic.Documentation.Tests/Testing/SnapshotHelpersTests.cs @@ -134,4 +134,13 @@ public void ToSnapshotText_FirstLineOnly_DropsTheStackTrace() }; diagnostics.ToSnapshotText(firstLineOnly: true).Should().MatchInlineSnapshot("error index.md:3 registry could not be read"); } + + [Test] + public void PathScrubber_RootAfterDiagnosticsSnapshotNormalisedIt_StillReadsAsRepo() + { + // what a Windows run writes for D:\a\docs-builder\docs-builder\docs\index.md once the drive letter and backslashes are gone + var scrub = SnapshotScrubbing.Under(@"D:\a\docs-builder\docs-builder", "{repo}"); + scrub("error /a/docs-builder/docs-builder/docs/index.md:3 bad").Should().MatchInlineSnapshot("error {repo}/docs/index.md:3 bad"); + scrub(@"error D:\a\docs-builder\docs-builder\docs\index.md:3 bad").Should().MatchInlineSnapshot("error {repo}/docs/index.md:3 bad"); + } }