From 62867c6e28548157ab8c60318591d3e26838dda8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:26:24 +0100 Subject: [PATCH 01/16] 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/16] 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 b143d3c1c1e21d43d2228830a05fcf5d03b5e1b6 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:32:59 +0100 Subject: [PATCH 03/16] LegacyDocs.Migration.Tests: inline snapshots of emitted markdown EmitterTests checked ~70 Contain/NotContain fragments per output. Each test now snapshots the whole emitted markdown inline. The tests became stricter: every old fact (no raw << , no include::/ifeval::, fences, links) is visible in the literals, and any other output change now fails too. Co-Authored-By: Claude Sonnet 5.5 --- .../EmitterTests.cs | 266 ++++++++++++++---- 1 file changed, 208 insertions(+), 58 deletions(-) diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs index 0062672ae6..2a1a76ddf1 100644 --- a/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs +++ b/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Elastic.LegacyDocs.Migration.Asciidoc; +using Nullean.Snapshots; namespace Elastic.LegacyDocs.Migration.Tests; @@ -23,8 +24,11 @@ public void UndefinedAttribute_EmittedAsSingleBraces_NotDouble() { var md = Emit("= T\n\n{undefined-attr} text\n"); // Should be {undefined-attr}, NOT {{undefined-attr}} - md.Should().Contain("{undefined-attr}"); - md.Should().NotContain("{{undefined-attr}}"); + md.Should().MatchInlineSnapshot(""" + # T + + {undefined-attr} text + """); } [Test] @@ -32,15 +36,22 @@ public void ProductNameAttribute_EmittedAsDoubleBraces() { // {es} is a ProductNames key — should remain {{es}} for docs-builder substitution var md = Emit("= T\n\n{es} cluster\n"); - md.Should().Contain("{{es}}"); + md.Should().MatchInlineSnapshot(""" + # T + + {{es}} cluster + """); } [Test] public void DefinedAttribute_IsSubstituted_NotEmittedAsRef() { var md = Emit("= T\n\n:myattr: hello world\n\n{myattr}\n"); - md.Should().Contain("hello world"); - md.Should().NotContain("{myattr}"); + md.Should().MatchInlineSnapshot(""" + # T + + hello world + """); } // ── Step 5: callout list in output ─────────────────────────────────────── @@ -49,8 +60,19 @@ public void DefinedAttribute_IsSubstituted_NotEmittedAsRef() public void CodeBlock_WithCallouts_EmitsOrderedList() { var md = Emit("= T\n\n[source,python]\n----\nfoo() # <1>\nbar() # <2>\n----\n<1> Call foo\n<2> Call bar\n"); - md.Should().Contain("1. Call foo"); - md.Should().Contain("2. Call bar"); + md.Should().MatchInlineSnapshot( + """ + # T + + ```python + foo() # <1> + bar() # <2> + ``` + + 1. Call foo + 2. Call bar + """ + ); } // ── Step 7: xref with > in text ────────────────────────────────────────── @@ -59,24 +81,35 @@ public void CodeBlock_WithCallouts_EmitsOrderedList() public void CrossRef_Simple_IsEmittedCorrectly() { var md = Emit("= T\n\nSee <>.\n"); - md.Should().NotContain("<<"); - md.Should().Contain("[my-anchor](#my-anchor)"); + md.Should().MatchInlineSnapshot(""" + # T + + See [my-anchor](#my-anchor). + """); } [Test] public void CrossRef_WithText_IsEmittedCorrectly() { var md = Emit("= T\n\nSee <>.\n"); - md.Should().NotContain("<<"); - md.Should().Contain("[Click here](#my-anchor)"); + md.Should().MatchInlineSnapshot(""" + # T + + See [Click here](#my-anchor). + """); } [Test] public void CrossRef_WithBacktickInText_IsEmittedCorrectly() { var md = Emit("= T\n\nfilters like <> to normalise.\n"); - md.Should().NotContain("<<"); - md.Should().Contain("[`lowercase`]"); + md.Should().MatchInlineSnapshot( + """ + # T + + filters like [`lowercase`](#analysis-lowercase-tokenfilter) to normalise. + """ + ); } [Test] @@ -86,8 +119,15 @@ public void CrossRef_InMultiLineParagraph_WithBacktick_IsEmittedCorrectly() var asciidoc = "= T\n\nIt can be combined\nwith token filters like <> to\nnormalise the analysed terms.\n"; var md = Emit(asciidoc); - md.Should().NotContain("<<"); - md.Should().Contain("[`lowercase`]"); + md.Should().MatchInlineSnapshot( + """ + # T + + It can be combined + with token filters like [`lowercase`](#analysis-lowercase-tokenfilter) to + normalise the analysed terms. + """ + ); } [Test] @@ -97,8 +137,17 @@ public void CrossRef_AfterDLItemWithBlankLineSeparator_IsEmittedCorrectly() var asciidoc = "= T\n\n<>::\n\nIt can be combined with token filters like <> to\nnormalise the analysed terms.\n"; var md = Emit(asciidoc); - md.Should().NotContain("<<"); - md.Should().Contain("[`lowercase`]"); + md.Should().MatchInlineSnapshot( + """ + # T + + [Term](#term-anchor) + + + It can be combined with token filters like [`lowercase`](#analysis-lowercase-tokenfilter) to + normalise the analysed terms. + """ + ); } [Test] @@ -108,8 +157,15 @@ public void CrossRef_InMultiLineParagraph_WithCurlyQuotes_IsEmittedCorrectly() var asciidoc = "= T\n\nIn order to use scrolling, the initial search request should specify the\n`scroll` parameter in the query string, which tells Elasticsearch how long it\nshould keep the ``search context'' alive (see <>), eg `?scroll=1m`.\n"; var md = Emit(asciidoc); - md.Should().NotContain("<<"); - md.Should().Contain("[scroll-search-context]"); + md.Should().MatchInlineSnapshot( + """ + # T + + In order to use scrolling, the initial search request should specify the + `scroll` parameter in the query string, which tells Elasticsearch how long it + should keep the "search context" alive (see [scroll-search-context](#scroll-search-context)), eg `?scroll=1m`. + """ + ); } [Test] @@ -117,8 +173,13 @@ public void CrossRef_InOrderedListItem_WithBacktickText_IsEmittedCorrectly() { // Ordered list item with xref that has backtick-wrapped text (space after comma) var md = Emit("= T\n\n. Define a runtime field using the <> queries.\n"); - md.Should().NotContain("<<"); - md.Should().Contain("[`term`]"); + md.Should().MatchInlineSnapshot( + """ + # T + + 1. Define a runtime field using the [`term`](#query-dsl-term-query) queries. + """ + ); } [Test] @@ -126,8 +187,13 @@ public void CrossRef_InOrderedListItem_WithPrecedingCode_IsEmittedCorrectly() { // Ordered list item where a `code` span precedes the xref var md = Emit("= T\n\n. A type of `lookup` that uses the <> query.\n"); - md.Should().NotContain("<<"); - md.Should().Contain("[`term`]"); + md.Should().MatchInlineSnapshot( + """ + # T + + 1. A type of `lookup` that uses the [`term`](#query-dsl-term-query) query. + """ + ); } [Test] @@ -135,8 +201,13 @@ public void CrossRef_WithAsteriskInLinkText_IsEmittedCorrectly() { // Bold marker * between text and xref text must not consume the << var md = Emit("= T\n\nSemantic*text field can be target of <>.\n"); - md.Should().NotContain("<<"); - md.Should().Contain("[copy*to fields]"); + md.Should().MatchInlineSnapshot( + """ + # T + + Semantic*text field can be target of [copy*to fields](#copy-to). + """ + ); } [Test] @@ -144,7 +215,11 @@ public void CrossRef_WithAngleBracketInText_IsEmittedCorrectly() { // < b>> — text contains a literal > var md = Emit("= T\n\nSee < 7.0>>\n"); - md.Should().Contain("[version > 7.0]"); + md.Should().MatchInlineSnapshot(""" + # T + + See [version > 7.0](#setup) + """); } // ── Passthrough inline (constrained +..+) ───────────────────────────────── @@ -153,7 +228,11 @@ public void CrossRef_WithAngleBracketInText_IsEmittedCorrectly() public void PassthroughInline_BasicCase_EmitsCodeSpan() { var md = Emit("= T\n\nDefaults to +max(1, 10)+.\n"); - md.Should().Contain("`max(1, 10)`"); + md.Should().MatchInlineSnapshot(""" + # T + + Defaults to `max(1, 10)`. + """); } [Test] @@ -161,9 +240,13 @@ public void PassthroughInline_VersionSuffix_NotTreatedAsPassthrough() { // "8.0+" is a version indicator, NOT a passthrough delimiter — must not swallow following text var md = Emit("= T\n\nES 8.0+ does not (see <>). ES 8.0+ returns error.\n"); - md.Should().NotContain("<>"); - md.Should().Contain("[removal-of-types](#removal-of-types)"); - md.Should().Contain("8.0+"); + md.Should().MatchInlineSnapshot( + """ + # T + + ES 8.0+ does not (see [removal-of-types](#removal-of-types)). ES 8.0+ returns error. + """ + ); } // ── Verbatim include resolution (Step 6) ────────────────────────────────── @@ -183,9 +266,16 @@ public void VerbatimBlock_StandardTaggedInclude_IsResolved() }); var doc = parser.Parse("= T\n\n[source,esql]\n----\ninclude::{specs}/string.csv-spec[tag=my-example]\n----\n", ""); var md = new MarkdownEmitter(new MarkdownEmitterOptions()).Emit(doc); - md.Should().Contain("row1"); - md.Should().Contain("row2"); - md.Should().NotContain("include::"); + md.Should().MatchInlineSnapshot( + """ + # T + + ```esql + row1 + row2 + ``` + """ + ); } [Test] @@ -194,9 +284,17 @@ public void VerbatimBlock_IfevalConditionMarkers_AreStripped() // ifeval/endif markers inside verbatim blocks should be stripped; content is kept var adoc = "= T\n\n----\n{\nifeval::[\"{trust}\"==\"api-key\"]\n \"credentials\": \"secret\",\nendif::[]\n}\n----\n"; var md = Emit(adoc); - md.Should().NotContain("ifeval::"); - md.Should().NotContain("endif::"); - md.Should().Contain("\"credentials\": \"secret\","); + md.Should().MatchInlineSnapshot( + """ + # T + + ``` + { + "credentials": "secret", + } + ``` + """ + ); } // ── Conditional blocks (ifeval) ──────────────────────────────────────────── @@ -207,9 +305,13 @@ public void Ifeval_FalseCondition_ExcludesContent() // ifeval::[expr] — condition false, block content must be excluded var adoc = "= T\n\nBefore.\n\nifeval::[\"{stack}\"==\"something-else\"]\nHidden text.\nendif::[]\n\nAfter.\n"; var md = Emit(adoc, new Dictionary { ["stack"] = "elastic" }); - md.Should().Contain("Before."); - md.Should().Contain("After."); - md.Should().NotContain("Hidden text."); + md.Should().MatchInlineSnapshot(""" + # T + + Before. + + After. + """); } [Test] @@ -218,9 +320,15 @@ public void Ifeval_TrueCondition_IncludesContent() // ifeval::[expr] — condition true, block content must be included var adoc = "= T\n\nBefore.\n\nifeval::[\"{stack}\"==\"elastic\"]\nVisible text.\nendif::[]\n\nAfter.\n"; var md = Emit(adoc, new Dictionary { ["stack"] = "elastic" }); - md.Should().Contain("Before."); - md.Should().Contain("After."); - md.Should().Contain("Visible text."); + md.Should().MatchInlineSnapshot(""" + # T + + Before. + + Visible text. + + After. + """); } [Test] @@ -230,10 +338,18 @@ public void CodeBlock_TrailingSpaceOnClosingDelimiter_StillClosesBlock() // The lexer must treat this as a valid closing delimiter. var adoc = "= T\n\n[source,json]\n----\n{\"hello\":\"world\"}\n---- \n\n==== Next section\n"; var md = Emit(adoc); - md.Should().Contain("```json"); - md.Should().Contain(/*lang=json,strict*/ "{\"hello\":\"world\"}"); // If the block closes, Next section becomes a real heading; if not, it's inside code block - md.Should().NotContain("==== Next section"); // raw AsciiDoc must not appear + md.Should().MatchInlineSnapshot( + """ + # T + + ```json + {"hello":"world"} + ``` + + #### Next section + """ + ); } [Test] @@ -243,8 +359,11 @@ public void Ifeval_ContentAfterBlock_NotLeaked_WhenConditionFalse() // causing all content after the ifeval block to leak through unconditionally. var adoc = "= T\n\nifeval::[\"{trust}\"==\"api-key\"]\nSecret\nendif::[]\n\nPublic paragraph.\n"; var md = Emit(adoc, new Dictionary { ["trust"] = "cert" }); - md.Should().NotContain("Secret"); - md.Should().Contain("Public paragraph."); + md.Should().MatchInlineSnapshot(""" + # T + + Public paragraph. + """); } // ── Long dash delimiter (50 dashes) ────────────────────────────────────── @@ -256,10 +375,17 @@ public void CodeBlock_LongDashDelimiter_IsTreatedAsCodeFence() var adoc = "= T\n\n[source,js]\n--------------------------------------------------\n\"input\": {}\n--------------------------------------------------\n\nNormal paragraph.\n"; var md = Emit(adoc); - md.Should().Contain("```"); - md.Should().Contain("\"input\": {}"); - md.Should().NotContain("--------------------------------------------------"); - md.Should().Contain("Normal paragraph."); + md.Should().MatchInlineSnapshot( + """ + # T + + ```js + "input": {} + ``` + + Normal paragraph. + """ + ); } [Test] @@ -290,10 +416,25 @@ public void CodeBlock_LongDashDelimiter_InNestedSection_IsTreatedAsCodeFence() "" ); var md = Emit(adoc); - md.Should().Contain("```"); - md.Should().Contain("\"key\": \"value\""); - md.Should().NotContain("--------------------------------------------------"); - md.Should().Contain("Normal paragraph."); + md.Should().MatchInlineSnapshot( + """ + # Doc + + ## Level1 [#top] + + ### Level2 [#sec2] + + #### Level3 [#sec3] + + Some text: + + ```js + "key": "value" + ``` + + Normal paragraph. + """ + ); } // ── Open block delimiter trailing whitespace ─────────────────────────────── @@ -305,9 +446,18 @@ public void OpenBlock_TrailingSpaceOnOpeningDelimiter_StillClosesBlock() // The parser must not leave the block unclosed because openingDelim = "-- " ≠ "--". var adoc = "= T\n\n.Requirements\n[sidebar]\n-- \n* req one\n--\n\nNormal paragraph.\n"; var md = Emit(adoc); - md.Should().Contain("req one"); - md.Should().Contain("Normal paragraph."); // The trailing `--` must NOT appear raw in the output - md.Should().NotContain("\n--\n"); + md.Should().MatchInlineSnapshot( + """ + # T + + :::{admonition} Sidebar + - req one + + ::: + + Normal paragraph. + """ + ); } } From 4bb638a86941cbed67ebd0e6ff15a56d235fd96f Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:36:11 +0100 Subject: [PATCH 04/16] SiteSearch.Tests: JSON snapshots of mapped documents and ES mappings ContentStackMappingTests asserted a handful of properties per fixture; each fixture test now snapshots the whole mapped SiteDocument (the two clock-stamped fields are removed first). MappingStructureTests walked the mapping JSON property by property; the five document mappings are now snapshotted whole. The tests became stricter. Dropped the old tautology Body.Should().Be(Body) and if-guarded checks that passed vacuously when the field was absent. Random AutoBogus roundtrips and the small inline-JSON mapper cases stay. Co-Authored-By: Claude Sonnet 5.5 --- ...gLegacy_Maps_BodyAndMetadata.verified.json | 34 ++ ...odern_Maps_ModularBlocksBody.verified.json | 31 + ...NestedRichTextBodyAndSummary.verified.json | 31 + ...stomerTile_Maps_ShortContent.verified.json | 27 + ...Maps_PlainDescription_NoBody.verified.json | 28 + .../FaqPage_Maps_NestedTopics.verified.json | 31 + ...SubheadingDescription_NoBody.verified.json | 28 + ...rkdownAsHeadingsAndPlainBody.verified.json | 32 + ...MinimalPage_Maps_TitleUrlSeo.verified.json | 31 + ...rPage_Maps_FlatModularBlocks.verified.json | 31 + ...aragraphPage_Maps_SingleBody.verified.json | 33 + ...ease_Maps_CombinedParagraphs.verified.json | 28 + ...uctRelease_Maps_ReleaseNotes.verified.json | 31 + ..._Maps_PageDescription_NoBody.verified.json | 30 + ...roseMirrorBodyAndDescription.verified.json | 32 + ...SubheadingDescription_NoBody.verified.json | 30 + ...Case_Maps_IntroAndChallenges.verified.json | 30 + ...o_Maps_ParagraphAndPresenter.verified.json | 32 + ...entationDocument_MappingJson.verified.json | 474 +++++++++++++++ .../GuideDocument_MappingJson.verified.json | 312 ++++++++++ ...DocumentSemantic_MappingJson.verified.json | 396 ++++++++++++ .../SiteDocument_MappingJson.verified.json | 381 ++++++++++++ ...teSearchDocument_MappingJson.verified.json | 569 ++++++++++++++++++ .../ContentStackMappingTests.cs | 179 +----- .../MappingStructureTests.cs | 331 +--------- 25 files changed, 2729 insertions(+), 463 deletions(-) create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogLegacy_Maps_BodyAndMetadata.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogModern_Maps_ModularBlocksBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogV3_Maps_NestedRichTextBodyAndSummary.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/CustomerTile_Maps_ShortContent.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/DescriptionOnly_Maps_PlainDescription_NoBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/FaqPage_Maps_NestedTopics.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/LandingPage_Maps_SubheadingDescription_NoBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MarkdownBodyPage_Maps_MarkdownAsHeadingsAndPlainBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MinimalPage_Maps_TitleUrlSeo.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ModularPage_Maps_FlatModularBlocks.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ParagraphPage_Maps_SingleBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PressRelease_Maps_CombinedParagraphs.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ProductRelease_Maps_ReleaseNotes.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PropertyHomepage_Maps_PageDescription_NoBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/RichTextPage_Maps_ProseMirrorBodyAndDescription.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/TopLevelSubheadingLandingPage_Maps_SubheadingDescription_NoBody.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/UseCase_Maps_IntroAndChallenges.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/Video_Maps_ParagraphAndPresenter.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/GuideDocument_MappingJson.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocumentSemantic_MappingJson.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocument_MappingJson.verified.json create mode 100644 tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogLegacy_Maps_BodyAndMetadata.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogLegacy_Maps_BodyAndMetadata.verified.json new file mode 100644 index 0000000000..4157131a88 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogLegacy_Maps_BodyAndMetadata.verified.json @@ -0,0 +1,34 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2023-01-16T09:00:00+00:00", + "modified_date": "2023-06-20T14:45:00+00:00", + "og": { + "title": "Getting Started with Kibana Dashboards | Elastic Blog", + "description": "Learn how to create interactive dashboards in Kibana." + }, + "twitter": {}, + "http": {}, + "title": "Getting Started with Kibana Dashboards", + "search_title": "Blog: Getting Started with Kibana Dashboards", + "content_type": "site", + "path": "/blog/getting-started-with-kibana-dashboards", + "url": "/blog/getting-started-with-kibana-dashboards", + "hash": "1fbad85a92ea0c15", + "last_updated": "2023-06-20T14:45:00+00:00", + "description": "Learn how to create interactive dashboards in Kibana to visualize your Elasticsearch data effectively.", + "headings": [ + "Creating Your First Dashboard", + "Adding Visualizations" + ], + "body": "Kibana is a powerful visualization tool that lets you explore your Elasticsearch data. Creating Your First Dashboard Start by navigating to the Dashboard section in Kibana. You can add visualizations, saved searches, and maps to build comprehensive views of your data. Adding Visualizations Click the Add panel button to select from existing visualizations or create new ones directly within the dashboard.", + "summary": "Learn how to create interactive dashboards in Kibana to visualize your Elasticsearch data effectively.", + "parents": [], + "translated": true, + "section": "blog", + "content_tier": "reference", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogModern_Maps_ModularBlocksBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogModern_Maps_ModularBlocksBody.verified.json new file mode 100644 index 0000000000..e5d9bf2126 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogModern_Maps_ModularBlocksBody.verified.json @@ -0,0 +1,31 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2024-03-12T10:00:00+00:00", + "modified_date": "2024-04-15T16:30:00+00:00", + "og": { + "title": "Building Search Applications | Elastic Blog", + "description": "A practical guide to building search applications with Elasticsearch." + }, + "twitter": {}, + "http": {}, + "title": "Building Search Applications with Elasticsearch", + "search_title": "Blog: Building Search Applications with Elasticsearch", + "content_type": "site", + "path": "/blog/building-search-applications-with-elasticsearch", + "url": "/blog/building-search-applications-with-elasticsearch", + "hash": "a1702fc1327b9904", + "last_updated": "2024-04-15T16:30:00+00:00", + "description": "Explore how to build production-ready search applications using Elasticsearch and its ecosystem of tools.", + "headings": [], + "body": "Search is at the heart of modern applications. In this guide, we walk through building a search application from scratch. First, define your index mappings to match your data model. Consider using dynamic templates for flexible schemas. The Query DSL provides a rich set of operators for full-text search, filtering, and aggregations.", + "summary": "Explore how to build production-ready search applications using Elasticsearch and its ecosystem of tools.", + "parents": [], + "translated": true, + "section": "blog", + "content_tier": "reference", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogV3_Maps_NestedRichTextBodyAndSummary.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogV3_Maps_NestedRichTextBodyAndSummary.verified.json new file mode 100644 index 0000000000..6edd5841c0 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/BlogV3_Maps_NestedRichTextBodyAndSummary.verified.json @@ -0,0 +1,31 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2026-06-01T00:00:00+00:00", + "og": { + "title": "Hybrid Search With Elasticsearch", + "description": "" + }, + "twitter": {}, + "http": {}, + "title": "Hybrid Search With Elasticsearch", + "search_title": "Hybrid Search With Elasticsearch", + "content_type": "site", + "path": "/search-labs/blog/hybrid-search-with-elasticsearch", + "url": "/search-labs/blog/hybrid-search-with-elasticsearch", + "hash": "c04874ba5dc0d16d", + "description": "Combine lexical and semantic search for better relevance.", + "headings": [ + "Why hybrid search" + ], + "body": "Why hybrid search Hybrid search combines BM25 lexical scoring with dense vector search to improve relevance.", + "summary": "Combine lexical and semantic search for better relevance.", + "parents": [], + "translated": true, + "section": "search-labs", + "content_tier": "reference", + "navigation": { + "depth": 4, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/CustomerTile_Maps_ShortContent.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/CustomerTile_Maps_ShortContent.verified.json new file mode 100644 index 0000000000..b2eb62b05e --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/CustomerTile_Maps_ShortContent.verified.json @@ -0,0 +1,27 @@ +{ + "$type": "site", + "locale": "ko", + "modified_date": "2022-10-01T08:00:00+00:00", + "og": {}, + "twitter": {}, + "http": {}, + "title": "TechCo Industries", + "search_title": "Customer Story: TechCo Industries", + "content_type": "site", + "path": "/kr/videos/techco-elasticsearch-case-study", + "url": "/kr/videos/techco-elasticsearch-case-study", + "hash": "9f3e869e9b215109", + "last_updated": "2022-10-01T08:00:00+00:00", + "description": null, + "headings": [], + "body": "TechCo uses Elasticsearch to power search across their global e-commerce platform.", + "summary": "TechCo uses Elasticsearch to power search across their global e-commerce platform.", + "parents": [], + "translated": true, + "section": "customer-story", + "content_tier": "supplementary", + "navigation": { + "depth": 4, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/DescriptionOnly_Maps_PlainDescription_NoBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/DescriptionOnly_Maps_PlainDescription_NoBody.verified.json new file mode 100644 index 0000000000..797c6296ba --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/DescriptionOnly_Maps_PlainDescription_NoBody.verified.json @@ -0,0 +1,28 @@ +{ + "$type": "site", + "locale": "en", + "og": { + "title": "Cohere RAG and Rerank with Elasticsearch", + "description": "Learn how to build a RAG system using the Cohere Inference API." + }, + "twitter": {}, + "http": {}, + "title": "Using Cohere for RAG and Rerank in Elasticsearch", + "search_title": "Using Cohere for RAG and Rerank in Elasticsearch", + "content_type": "site", + "path": "/search-labs/tutorials/examples/cohere-rag-rerank-elasticsearch-tutorial", + "url": "/search-labs/tutorials/examples/cohere-rag-rerank-elasticsearch-tutorial", + "hash": "1ead94c577f4f8eb", + "description": "Learn how to build a RAG system using the Cohere Inference API and Elasticsearch with embeddings, hybrid search, and advanced reranking.", + "headings": [], + "body": "Learn how to build a RAG system using the Cohere Inference API and Elasticsearch with embeddings, hybrid search, and advanced reranking.", + "summary": "Learn how to build a RAG system using the Cohere Inference API and Elasticsearch with embeddings, hybrid search, and advanced reranking.", + "parents": [], + "translated": true, + "section": "search-labs", + "content_tier": "reference", + "navigation": { + "depth": 5, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/FaqPage_Maps_NestedTopics.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/FaqPage_Maps_NestedTopics.verified.json new file mode 100644 index 0000000000..1ab3229cf1 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/FaqPage_Maps_NestedTopics.verified.json @@ -0,0 +1,31 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2024-05-20T16:00:00+00:00", + "og": { + "title": "What is Cloud Security? | Elastic", + "description": "Learn about cloud security fundamentals and best practices.", + "image": "https://images.contentstack.io/v3/assets/sample/cloud-security.png" + }, + "twitter": {}, + "http": {}, + "title": "What is Cloud Security?", + "search_title": "What is: What is Cloud Security?", + "content_type": "site", + "path": "/what-is/cloud-security", + "url": "/what-is/cloud-security", + "hash": "ffa05ad64187f8b4", + "last_updated": "2024-05-20T16:00:00+00:00", + "description": "Learn about cloud security fundamentals and best practices.", + "headings": [], + "body": "Cloud security encompasses the technologies, policies, and controls used to protect data, applications, and infrastructure in cloud computing environments. As organizations migrate workloads to the cloud, ensuring the security of cloud resources becomes critical to protecting sensitive data and maintaining compliance.", + "summary": "Learn about cloud security fundamentals and best practices.", + "parents": [], + "translated": true, + "section": "concept", + "content_tier": "primary", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/LandingPage_Maps_SubheadingDescription_NoBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/LandingPage_Maps_SubheadingDescription_NoBody.verified.json new file mode 100644 index 0000000000..905e74d3fc --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/LandingPage_Maps_SubheadingDescription_NoBody.verified.json @@ -0,0 +1,28 @@ +{ + "$type": "site", + "locale": "en", + "og": { + "title": "Elasticsearch tutorials", + "description": "" + }, + "twitter": {}, + "http": {}, + "title": "Tutorials", + "search_title": "Tutorials", + "content_type": "site", + "path": "/search-labs/tutorials", + "url": "/search-labs/tutorials", + "hash": "c27e667d2a905da9", + "description": "Long-form guides to get you started", + "headings": [], + "body": "Long-form guides to get you started", + "summary": "Long-form guides to get you started", + "parents": [], + "translated": true, + "section": "search-labs", + "content_tier": "reference", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MarkdownBodyPage_Maps_MarkdownAsHeadingsAndPlainBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MarkdownBodyPage_Maps_MarkdownAsHeadingsAndPlainBody.verified.json new file mode 100644 index 0000000000..a7f8ba6bdd --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MarkdownBodyPage_Maps_MarkdownAsHeadingsAndPlainBody.verified.json @@ -0,0 +1,32 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2024-01-08T00:00:00+00:00", + "modified_date": "2024-01-10T11:00:00+00:00", + "og": {}, + "twitter": {}, + "http": {}, + "title": "Tracking a novel loader: PIPEDANCE", + "search_title": "Tracking a novel loader: PIPEDANCE", + "content_type": "site", + "path": "/security-labs/threat-command/tracking-a-novel-loader-pipedance", + "url": "/security-labs/threat-command/tracking-a-novel-loader-pipedance", + "hash": "b4c258bda492ff4e", + "last_updated": "2024-01-10T11:00:00+00:00", + "description": "An analysis of the PIPEDANCE loader observed in recent intrusions.", + "headings": [ + "Key takeaways", + "Analysis", + "API resolution" + ], + "body": "Key takeaways PIPEDANCE is a novel loader used to stage secondary payloads. It uses process injection to evade detection. Analysis The loader begins by resolving kernel32.dll exports via a custom PEB walk. API resolution Once resolved, the loader calls VirtualAlloc to stage the payload in memory.", + "summary": "An analysis of the PIPEDANCE loader observed in recent intrusions.", + "parents": [], + "translated": true, + "section": "security-labs", + "content_tier": "reference", + "navigation": { + "depth": 4, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MinimalPage_Maps_TitleUrlSeo.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MinimalPage_Maps_TitleUrlSeo.verified.json new file mode 100644 index 0000000000..666bd56f23 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/MinimalPage_Maps_TitleUrlSeo.verified.json @@ -0,0 +1,31 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2024-06-01T12:00:00+00:00", + "og": { + "title": "Elastic Blog | News, Updates & Technical Content", + "description": "Read the latest from Elastic on search, observability, and security.", + "image": "https://images.contentstack.io/v3/assets/sample/blog-og.png" + }, + "twitter": {}, + "http": {}, + "title": "Elastic Blog", + "search_title": "Blog: Elastic Blog", + "content_type": "site", + "path": "/blog", + "url": "/blog", + "hash": "5e69b553831e39de", + "last_updated": "2024-06-01T12:00:00+00:00", + "description": "Read the latest from Elastic on search, observability, and security.", + "headings": [], + "body": "Read the latest from Elastic on search, observability, and security.", + "summary": "Read the latest from Elastic on search, observability, and security.", + "parents": [], + "translated": true, + "section": "blog", + "content_tier": "reference", + "navigation": { + "depth": 2, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ModularPage_Maps_FlatModularBlocks.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ModularPage_Maps_FlatModularBlocks.verified.json new file mode 100644 index 0000000000..d15edbcbad --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ModularPage_Maps_FlatModularBlocks.verified.json @@ -0,0 +1,31 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2024-02-10T11:20:00+00:00", + "og": { + "title": "Cloud Workload Protection | Elastic Security", + "description": "Protect cloud workloads with Elastic Security.", + "image": "https://images.contentstack.io/v3/assets/sample/security-og.png" + }, + "twitter": {}, + "http": {}, + "title": "Elastic Security for Cloud Workloads", + "search_title": "Product: Elastic Security for Cloud Workloads", + "content_type": "site", + "path": "/security/cloud-workload-protection", + "url": "/security/cloud-workload-protection", + "hash": "55f67df05646caf7", + "last_updated": "2024-02-10T11:20:00+00:00", + "description": "Protect cloud workloads with Elastic Security.", + "headings": [], + "body": "Elastic Security provides comprehensive protection for your cloud workloads across AWS, Azure, and GCP. Detect threats in real-time with pre-built detection rules and custom analytics powered by machine learning.", + "summary": "Protect cloud workloads with Elastic Security.", + "parents": [], + "translated": true, + "section": "product", + "content_tier": "primary", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ParagraphPage_Maps_SingleBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ParagraphPage_Maps_SingleBody.verified.json new file mode 100644 index 0000000000..15116a17d3 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ParagraphPage_Maps_SingleBody.verified.json @@ -0,0 +1,33 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2024-01-15T08:30:00+00:00", + "og": { + "title": "Cloud Terms of Service | Elastic", + "description": "Review the terms of service for Elastic Cloud." + }, + "twitter": {}, + "http": {}, + "title": "Elastic Cloud Terms of Service", + "search_title": "Legal: Elastic Cloud Terms of Service", + "content_type": "site", + "path": "/agreements/cloud-terms-of-service", + "url": "/agreements/cloud-terms-of-service", + "hash": "38ecdbc3e2f4198c", + "last_updated": "2024-01-15T08:30:00+00:00", + "description": "Review the terms of service for Elastic Cloud.", + "headings": [ + "1. Definitions", + "2. Account Registration" + ], + "body": "These Terms of Service govern your use of Elastic Cloud services. 1. Definitions \"Service\" means the Elastic Cloud hosted service provided by Elastic. 2. Account Registration You must register for an account to use the Service. You agree to provide accurate information.", + "summary": "Review the terms of service for Elastic Cloud.", + "parents": [], + "translated": true, + "section": "legal", + "content_tier": "peripheral", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PressRelease_Maps_CombinedParagraphs.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PressRelease_Maps_CombinedParagraphs.verified.json new file mode 100644 index 0000000000..605437a880 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PressRelease_Maps_CombinedParagraphs.verified.json @@ -0,0 +1,28 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2023-09-01T00:00:00+02:00", + "modified_date": "2023-09-05T10:30:00+00:00", + "og": {}, + "twitter": {}, + "http": {}, + "title": "Elastic Announces New Security Analytics Features", + "search_title": "Press: Elastic Announces New Security Analytics Features", + "content_type": "site", + "path": "/about/press/elastic-announces-new-security-analytics", + "url": "/about/press/elastic-announces-new-security-analytics", + "hash": "1d2f23a5e401ef66", + "last_updated": "2023-09-05T10:30:00+00:00", + "description": null, + "headings": [], + "body": "Elastic today announced new security analytics features designed to help organizations detect and respond to cyber threats more effectively. The new features include advanced threat detection rules, improved investigation workflows, and enhanced integration with cloud security tools. These capabilities build on Elastic Security's unified approach to SIEM, endpoint security, and cloud security.", + "summary": "Elastic today announced new security analytics features designed to help organizations detect and respond to cyber threats more effectively. The new features include advanced threat detection rules, improved investigation workflows, and enhanced integration with cloud security tools. These capabilities build on Elastic Security's unified approach to SIEM, endpoint security, and cloud security.", + "parents": [], + "translated": true, + "section": "press", + "content_tier": "peripheral", + "navigation": { + "depth": 4, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ProductRelease_Maps_ReleaseNotes.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ProductRelease_Maps_ReleaseNotes.verified.json new file mode 100644 index 0000000000..d1bf58ec91 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/ProductRelease_Maps_ReleaseNotes.verified.json @@ -0,0 +1,31 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2024-01-10T00:00:00+01:00", + "modified_date": "2024-01-10T12:00:00+00:00", + "og": { + "title": "Download Elasticsearch 8.12.0", + "description": "Download Elasticsearch 8.12.0 for your platform." + }, + "twitter": {}, + "http": {}, + "title": "Elasticsearch 8.12.0", + "search_title": "Download: Elasticsearch 8.12.0", + "content_type": "site", + "path": "/downloads/past-releases/elasticsearch-8-12-0", + "url": "/downloads/past-releases/elasticsearch-8-12-0", + "hash": "f3b0ce116110a2f0", + "last_updated": "2024-01-10T12:00:00+00:00", + "description": "Download Elasticsearch 8.12.0 for your platform.", + "headings": [], + "body": "See the release notes for details.", + "summary": "Download Elasticsearch 8.12.0 for your platform.", + "parents": [], + "translated": true, + "section": "download", + "content_tier": "peripheral", + "navigation": { + "depth": 4, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PropertyHomepage_Maps_PageDescription_NoBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PropertyHomepage_Maps_PageDescription_NoBody.verified.json new file mode 100644 index 0000000000..d2bf52d1af --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/PropertyHomepage_Maps_PageDescription_NoBody.verified.json @@ -0,0 +1,30 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2022-05-02T10:00:00+00:00", + "og": { + "title": "Elastic Security Labs", + "description": "Research, tools, and insights from the Elastic Security Labs team." + }, + "twitter": {}, + "http": {}, + "title": "Security Labs", + "search_title": "Security Labs", + "content_type": "site", + "path": "/security-labs", + "url": "/security-labs", + "hash": "49957c356fce64d9", + "last_updated": "2022-05-02T10:00:00+00:00", + "description": "Research, tools, and insights from the Elastic Security Labs team.", + "headings": [], + "body": "Research, tools, and insights from the Elastic Security Labs team.", + "summary": "Research, tools, and insights from the Elastic Security Labs team.", + "parents": [], + "translated": true, + "section": "security-labs", + "content_tier": "reference", + "navigation": { + "depth": 2, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/RichTextPage_Maps_ProseMirrorBodyAndDescription.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/RichTextPage_Maps_ProseMirrorBodyAndDescription.verified.json new file mode 100644 index 0000000000..f48af0468f --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/RichTextPage_Maps_ProseMirrorBodyAndDescription.verified.json @@ -0,0 +1,32 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2026-07-01T00:00:00+00:00", + "og": { + "title": "Amazon Bedrock Integration", + "description": "Connect Amazon Bedrock to Elasticsearch." + }, + "twitter": {}, + "http": {}, + "title": "Amazon Bedrock", + "search_title": "Amazon Bedrock", + "content_type": "site", + "path": "/search-labs/integrations/amazon-bedrock", + "url": "/search-labs/integrations/amazon-bedrock", + "hash": "72b0323fe0a9b693", + "last_updated": "2026-07-01T00:00:00+00:00", + "description": "Amazon Bedrock is a fully managed service offering foundation models from leading AI companies.", + "headings": [ + "Blogs to get started" + ], + "body": "Amazon Bedrock makes leading foundation models available through a single API. Blogs to get started RAG using Bedrock", + "summary": "Amazon Bedrock is a fully managed service offering foundation models from leading AI companies.", + "parents": [], + "translated": true, + "section": "search-labs", + "content_tier": "reference", + "navigation": { + "depth": 4, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/TopLevelSubheadingLandingPage_Maps_SubheadingDescription_NoBody.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/TopLevelSubheadingLandingPage_Maps_SubheadingDescription_NoBody.verified.json new file mode 100644 index 0000000000..2a908e19b5 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/TopLevelSubheadingLandingPage_Maps_SubheadingDescription_NoBody.verified.json @@ -0,0 +1,30 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2023-11-02T10:00:00+00:00", + "og": { + "title": "", + "description": "" + }, + "twitter": {}, + "http": {}, + "title": "Reports", + "search_title": "Reports", + "content_type": "site", + "path": "/security-labs/reports", + "url": "/security-labs/reports", + "hash": "f7cfede52f5f3ab7", + "last_updated": "2023-11-02T10:00:00+00:00", + "description": "In-depth research reports from the Elastic Security Labs team.", + "headings": [], + "body": "In-depth research reports from the Elastic Security Labs team.", + "summary": "In-depth research reports from the Elastic Security Labs team.", + "parents": [], + "translated": true, + "section": "security-labs", + "content_tier": "reference", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/UseCase_Maps_IntroAndChallenges.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/UseCase_Maps_IntroAndChallenges.verified.json new file mode 100644 index 0000000000..6a39e6d4ed --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/UseCase_Maps_IntroAndChallenges.verified.json @@ -0,0 +1,30 @@ +{ + "$type": "site", + "locale": "en", + "modified_date": "2023-08-15T14:30:00+00:00", + "og": { + "title": "Acme Corp Customer Story | Elastic", + "description": "Learn how Acme Corp uses Elastic for real-time analytics." + }, + "twitter": {}, + "http": {}, + "title": "Acme Corp", + "search_title": "Customer Story: Acme Corp", + "content_type": "site", + "path": "/customers/acme-corp", + "url": "/customers/acme-corp", + "hash": "4f1ea839a4d6cdea", + "last_updated": "2023-08-15T14:30:00+00:00", + "description": "Acme Corp processes millions of events per day and needed a scalable solution for real-time analytics and monitoring.", + "headings": [], + "body": "Acme Corp processes millions of events per day and needed a scalable solution for real-time analytics and monitoring. The existing solution could not handle the growing data volume and lacked real-time capabilities. By deploying Elastic, Acme Corp achieved sub-second query times across petabytes of data. The engineering team integrated Elasticsearch into their data pipeline within weeks. Elastic transformed how we approach observability and analytics.", + "summary": "Acme Corp processes millions of events per day and needed a scalable solution for real-time analytics and monitoring.", + "parents": [], + "translated": true, + "section": "customer-story", + "content_tier": "supplementary", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/Video_Maps_ParagraphAndPresenter.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/Video_Maps_ParagraphAndPresenter.verified.json new file mode 100644 index 0000000000..0fd4b40028 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/ContentStackMappingTests/Video_Maps_ParagraphAndPresenter.verified.json @@ -0,0 +1,32 @@ +{ + "$type": "site", + "locale": "en", + "published_date": "2023-06-15T17:00:00+00:00", + "modified_date": "2023-07-10T09:15:00+00:00", + "og": { + "title": "Ingesting Data into Elasticsearch | Elastic Webinar", + "description": "Learn about data ingestion patterns with Elasticsearch.", + "image": "https://images.contentstack.io/v3/assets/sample/seo-image.png" + }, + "twitter": {}, + "http": {}, + "title": "Ingesting Data into Elasticsearch", + "search_title": "Webinar: Ingesting Data into Elasticsearch", + "content_type": "site", + "path": "/webinars/ingesting-data-into-elasticsearch", + "url": "/webinars/ingesting-data-into-elasticsearch", + "hash": "4d31971fe7ddc1f9", + "last_updated": "2023-07-10T09:15:00+00:00", + "description": "Learn about data ingestion patterns with Elasticsearch.", + "headings": [], + "body": "Join us for a deep dive into data ingestion patterns with Elasticsearch. We cover Logstash, Beats, and the Elasticsearch ingest pipeline.", + "summary": "Learn about data ingestion patterns with Elasticsearch.", + "parents": [], + "translated": true, + "section": "webinar", + "content_tier": "supplementary", + "navigation": { + "depth": 3, + "table_of_contents": 100 + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json new file mode 100644 index 0000000000..b786502c59 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json @@ -0,0 +1,474 @@ +{ + "properties": { + "api": { + "type": "object", + "properties": { + "properties": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + } + } + }, + "path_words": { + "type": "search_as_you_type", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "type": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "anchor": { + "type": "keyword" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "parent": { + "type": "keyword" + } + } + }, + "routes": { + "type": "object", + "properties": { + "path": { + "type": "keyword", + "index": false + }, + "methods": { + "type": "keyword", + "index": false + } + } + }, + "endpoints": { + "type": "keyword" + }, + "endpoints_bare": { + "type": "keyword" + }, + "method": { + "type": "keyword" + }, + "path": { + "type": "keyword" + }, + "operation_id": { + "type": "keyword" + }, + "words": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "group": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "id_words": { + "type": "rank_feature", + "positive_score_impact": false + }, + "deprecated": { + "type": "boolean" + } + } + }, + "product": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "related_products": { + "type": "object", + "properties": { + "id": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "repository": { + "type": "keyword", + "normalizer": "keyword_normalizer" + } + } + }, + "applies_to": { + "type": "nested", + "properties": { + "type": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "normalizer": "keyword_normalizer" + }, + "sub_type": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "normalizer": "keyword_normalizer" + }, + "lifecycle": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "normalizer": "keyword_normalizer" + }, + "version": { + "type": "version", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "links": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "content_hash": { + "type": "keyword" + }, + "content_last_updated": { + "type": "date" + }, + "source_url": { + "type": "keyword" + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "starts_with": { + "type": "text", + "analyzer": "starts_with_analyzer", + "search_analyzer": "starts_with_analyzer_search" + }, + "completion": { + "type": "search_as_you_type", + "search_analyzer": "synonyms_analyzer" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "search_title": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "content_type": { + "type": "keyword", + "copy_to": "tags" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "keyword" + }, + "hash": { + "type": "keyword" + }, + "batch_index_date": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "headings": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "body": { + "type": "text", + "fields": { + "en": { + "type": "text", + "analyzer": "english" + }, + "de": { + "type": "text", + "analyzer": "german" + }, + "fr": { + "type": "text", + "analyzer": "french" + }, + "ja": { + "type": "text", + "analyzer": "cjk" + }, + "ko": { + "type": "text", + "analyzer": "cjk" + }, + "zh": { + "type": "text", + "analyzer": "cjk" + }, + "es": { + "type": "text", + "analyzer": "spanish" + }, + "pt": { + "type": "text", + "analyzer": "portuguese" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "term_vector": "with_positions_offsets" + }, + "summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "parents": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "alias", + "path": "parents.path" + } + } + }, + "hidden": { + "type": "boolean" + }, + "translated": { + "type": "boolean" + }, + "ai_rag_optimized_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_short_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "ai_search_query": { + "type": "keyword", + "fields": { + "completion": { + "type": "search_as_you_type" + } + } + }, + "ai_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_use_cases": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "section": { + "type": "keyword", + "normalizer": "keyword_normalizer", + "copy_to": "tags" + }, + "content_tier": { + "type": "keyword" + }, + "navigation": { + "type": "object", + "properties": { + "depth": { + "type": "rank_feature", + "positive_score_impact": false + }, + "table_of_contents": { + "type": "rank_feature", + "positive_score_impact": false + } + } + }, + "ai_autocomplete_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + }, + "suggest": { + "type": "completion" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "tags": { + "type": "text", + "analyzer": "content_tags_analyzer" + }, + "abstract": { + "type": "alias", + "path": "summary" + }, + "navigation_section": { + "type": "alias", + "path": "section" + }, + "content_tags": { + "type": "alias", + "path": "tags" + }, + "stripped_body": { + "type": "alias", + "path": "body" + }, + "navigation_depth": { + "type": "alias", + "path": "navigation.depth" + }, + "navigation_table_of_contents": { + "type": "alias", + "path": "navigation.table_of_contents" + } + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/GuideDocument_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/GuideDocument_MappingJson.verified.json new file mode 100644 index 0000000000..eeafe72c82 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/GuideDocument_MappingJson.verified.json @@ -0,0 +1,312 @@ +{ + "properties": { + "product": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "http": { + "type": "object", + "properties": { + "etag": { + "type": "keyword", + "index": false + }, + "last_modified": { + "type": "date" + } + } + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "starts_with": { + "type": "text", + "analyzer": "starts_with_analyzer", + "search_analyzer": "starts_with_analyzer_search" + }, + "completion": { + "type": "search_as_you_type", + "search_analyzer": "synonyms_analyzer" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "search_title": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "content_type": { + "type": "keyword", + "copy_to": "tags" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "keyword" + }, + "hash": { + "type": "keyword" + }, + "batch_index_date": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "headings": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "body": { + "type": "text", + "fields": { + "en": { + "type": "text", + "analyzer": "english" + }, + "de": { + "type": "text", + "analyzer": "german" + }, + "fr": { + "type": "text", + "analyzer": "french" + }, + "ja": { + "type": "text", + "analyzer": "cjk" + }, + "ko": { + "type": "text", + "analyzer": "cjk" + }, + "zh": { + "type": "text", + "analyzer": "cjk" + }, + "es": { + "type": "text", + "analyzer": "spanish" + }, + "pt": { + "type": "text", + "analyzer": "portuguese" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "term_vector": "with_positions_offsets" + }, + "summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "parents": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "alias", + "path": "parents.path" + } + } + }, + "hidden": { + "type": "boolean" + }, + "translated": { + "type": "boolean" + }, + "ai_rag_optimized_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_short_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "ai_search_query": { + "type": "keyword", + "fields": { + "completion": { + "type": "search_as_you_type" + } + } + }, + "ai_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_use_cases": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "section": { + "type": "keyword", + "normalizer": "keyword_normalizer", + "copy_to": "tags" + }, + "content_tier": { + "type": "keyword" + }, + "navigation": { + "type": "object", + "properties": { + "depth": { + "type": "rank_feature", + "positive_score_impact": false + }, + "table_of_contents": { + "type": "rank_feature", + "positive_score_impact": false + } + } + }, + "ai_autocomplete_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + }, + "suggest": { + "type": "completion" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "tags": { + "type": "text", + "analyzer": "content_tags_analyzer" + }, + "abstract": { + "type": "alias", + "path": "summary" + }, + "navigation_section": { + "type": "alias", + "path": "section" + }, + "content_tags": { + "type": "alias", + "path": "tags" + }, + "stripped_body": { + "type": "alias", + "path": "body" + }, + "navigation_depth": { + "type": "alias", + "path": "navigation.depth" + }, + "navigation_table_of_contents": { + "type": "alias", + "path": "navigation.table_of_contents" + }, + "http_etag": { + "type": "alias", + "path": "http.etag" + }, + "http_last_modified": { + "type": "alias", + "path": "http.last_modified" + } + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocumentSemantic_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocumentSemantic_MappingJson.verified.json new file mode 100644 index 0000000000..021a52ba40 --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocumentSemantic_MappingJson.verified.json @@ -0,0 +1,396 @@ +{ + "properties": { + "locale": { + "type": "keyword" + }, + "author": { + "type": "keyword" + }, + "published_date": { + "type": "date" + }, + "modified_date": { + "type": "date" + }, + "og": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "image": { + "type": "keyword", + "index": false + } + } + }, + "twitter": { + "type": "object", + "properties": { + "image": { + "type": "keyword", + "index": false + }, + "card": { + "type": "keyword" + } + } + }, + "http": { + "type": "object", + "properties": { + "etag": { + "type": "keyword", + "index": false + }, + "last_modified": { + "type": "date" + } + } + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "starts_with": { + "type": "text", + "analyzer": "starts_with_analyzer", + "search_analyzer": "starts_with_analyzer_search" + }, + "completion": { + "type": "search_as_you_type", + "search_analyzer": "synonyms_analyzer" + }, + "semantic_text": { + "type": "semantic_text" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "search_title": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "content_type": { + "type": "keyword", + "copy_to": "tags" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "keyword" + }, + "hash": { + "type": "keyword" + }, + "batch_index_date": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "headings": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "body": { + "type": "text", + "fields": { + "en": { + "type": "text", + "analyzer": "english" + }, + "de": { + "type": "text", + "analyzer": "german" + }, + "fr": { + "type": "text", + "analyzer": "french" + }, + "ja": { + "type": "text", + "analyzer": "cjk" + }, + "ko": { + "type": "text", + "analyzer": "cjk" + }, + "zh": { + "type": "text", + "analyzer": "cjk" + }, + "es": { + "type": "text", + "analyzer": "spanish" + }, + "pt": { + "type": "text", + "analyzer": "portuguese" + }, + "semantic_text": { + "type": "semantic_text" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "term_vector": "with_positions_offsets" + }, + "summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + }, + "semantic_text": { + "type": "semantic_text" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "parents": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "alias", + "path": "parents.path" + } + } + }, + "hidden": { + "type": "boolean" + }, + "translated": { + "type": "boolean" + }, + "ai_rag_optimized_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + }, + "semantic_text": { + "type": "semantic_text" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_short_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "ai_search_query": { + "type": "keyword", + "fields": { + "completion": { + "type": "search_as_you_type" + } + } + }, + "ai_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + }, + "semantic_text": { + "type": "semantic_text" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_use_cases": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "section": { + "type": "keyword", + "normalizer": "keyword_normalizer", + "copy_to": "tags" + }, + "content_tier": { + "type": "keyword" + }, + "navigation": { + "type": "object", + "properties": { + "depth": { + "type": "rank_feature", + "positive_score_impact": false + }, + "table_of_contents": { + "type": "rank_feature", + "positive_score_impact": false + } + } + }, + "ai_autocomplete_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + }, + "suggest": { + "type": "completion" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "tags": { + "type": "text", + "analyzer": "content_tags_analyzer" + }, + "abstract": { + "type": "alias", + "path": "summary" + }, + "navigation_section": { + "type": "alias", + "path": "section" + }, + "content_tags": { + "type": "alias", + "path": "tags" + }, + "stripped_body": { + "type": "alias", + "path": "body" + }, + "navigation_depth": { + "type": "alias", + "path": "navigation.depth" + }, + "navigation_table_of_contents": { + "type": "alias", + "path": "navigation.table_of_contents" + }, + "language": { + "type": "alias", + "path": "locale" + }, + "og_title": { + "type": "alias", + "path": "og.title" + }, + "og_description": { + "type": "alias", + "path": "og.description" + }, + "og_image": { + "type": "alias", + "path": "og.image" + }, + "twitter_image": { + "type": "alias", + "path": "twitter.image" + }, + "twitter_card": { + "type": "alias", + "path": "twitter.card" + }, + "http_etag": { + "type": "alias", + "path": "http.etag" + }, + "http_last_modified": { + "type": "alias", + "path": "http.last_modified" + } + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocument_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocument_MappingJson.verified.json new file mode 100644 index 0000000000..07b68b390b --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/SiteDocument_MappingJson.verified.json @@ -0,0 +1,381 @@ +{ + "properties": { + "locale": { + "type": "keyword" + }, + "author": { + "type": "keyword" + }, + "published_date": { + "type": "date" + }, + "modified_date": { + "type": "date" + }, + "og": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "image": { + "type": "keyword", + "index": false + } + } + }, + "twitter": { + "type": "object", + "properties": { + "image": { + "type": "keyword", + "index": false + }, + "card": { + "type": "keyword" + } + } + }, + "http": { + "type": "object", + "properties": { + "etag": { + "type": "keyword", + "index": false + }, + "last_modified": { + "type": "date" + } + } + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "starts_with": { + "type": "text", + "analyzer": "starts_with_analyzer", + "search_analyzer": "starts_with_analyzer_search" + }, + "completion": { + "type": "search_as_you_type", + "search_analyzer": "synonyms_analyzer" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "search_title": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "content_type": { + "type": "keyword", + "copy_to": "tags" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "keyword" + }, + "hash": { + "type": "keyword" + }, + "batch_index_date": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "headings": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "body": { + "type": "text", + "fields": { + "en": { + "type": "text", + "analyzer": "english" + }, + "de": { + "type": "text", + "analyzer": "german" + }, + "fr": { + "type": "text", + "analyzer": "french" + }, + "ja": { + "type": "text", + "analyzer": "cjk" + }, + "ko": { + "type": "text", + "analyzer": "cjk" + }, + "zh": { + "type": "text", + "analyzer": "cjk" + }, + "es": { + "type": "text", + "analyzer": "spanish" + }, + "pt": { + "type": "text", + "analyzer": "portuguese" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "term_vector": "with_positions_offsets" + }, + "summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "parents": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "alias", + "path": "parents.path" + } + } + }, + "hidden": { + "type": "boolean" + }, + "translated": { + "type": "boolean" + }, + "ai_rag_optimized_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_short_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "ai_search_query": { + "type": "keyword", + "fields": { + "completion": { + "type": "search_as_you_type" + } + } + }, + "ai_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_use_cases": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "section": { + "type": "keyword", + "normalizer": "keyword_normalizer", + "copy_to": "tags" + }, + "content_tier": { + "type": "keyword" + }, + "navigation": { + "type": "object", + "properties": { + "depth": { + "type": "rank_feature", + "positive_score_impact": false + }, + "table_of_contents": { + "type": "rank_feature", + "positive_score_impact": false + } + } + }, + "ai_autocomplete_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + }, + "suggest": { + "type": "completion" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "tags": { + "type": "text", + "analyzer": "content_tags_analyzer" + }, + "abstract": { + "type": "alias", + "path": "summary" + }, + "navigation_section": { + "type": "alias", + "path": "section" + }, + "content_tags": { + "type": "alias", + "path": "tags" + }, + "stripped_body": { + "type": "alias", + "path": "body" + }, + "navigation_depth": { + "type": "alias", + "path": "navigation.depth" + }, + "navigation_table_of_contents": { + "type": "alias", + "path": "navigation.table_of_contents" + }, + "language": { + "type": "alias", + "path": "locale" + }, + "og_title": { + "type": "alias", + "path": "og.title" + }, + "og_description": { + "type": "alias", + "path": "og.description" + }, + "og_image": { + "type": "alias", + "path": "og.image" + }, + "twitter_image": { + "type": "alias", + "path": "twitter.image" + }, + "twitter_card": { + "type": "alias", + "path": "twitter.card" + }, + "http_etag": { + "type": "alias", + "path": "http.etag" + }, + "http_last_modified": { + "type": "alias", + "path": "http.last_modified" + } + } +} diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json new file mode 100644 index 0000000000..943037a64a --- /dev/null +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json @@ -0,0 +1,569 @@ +{ + "properties": { + "locale": { + "type": "keyword" + }, + "author": { + "type": "keyword" + }, + "published_date": { + "type": "date" + }, + "modified_date": { + "type": "date" + }, + "og": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "image": { + "type": "keyword", + "index": false + } + } + }, + "twitter": { + "type": "object", + "properties": { + "image": { + "type": "keyword", + "index": false + }, + "card": { + "type": "keyword" + } + } + }, + "http": { + "type": "object", + "properties": { + "etag": { + "type": "keyword", + "index": false + }, + "last_modified": { + "type": "date" + } + } + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "starts_with": { + "type": "text", + "analyzer": "starts_with_analyzer", + "search_analyzer": "starts_with_analyzer_search" + }, + "completion": { + "type": "search_as_you_type", + "search_analyzer": "synonyms_analyzer" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "search_title": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "content_type": { + "type": "keyword", + "copy_to": "tags" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "keyword" + }, + "hash": { + "type": "keyword" + }, + "batch_index_date": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "headings": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "body": { + "type": "text", + "fields": { + "en": { + "type": "text", + "analyzer": "english" + }, + "de": { + "type": "text", + "analyzer": "german" + }, + "fr": { + "type": "text", + "analyzer": "french" + }, + "ja": { + "type": "text", + "analyzer": "cjk" + }, + "ko": { + "type": "text", + "analyzer": "cjk" + }, + "zh": { + "type": "text", + "analyzer": "cjk" + }, + "es": { + "type": "text", + "analyzer": "spanish" + }, + "pt": { + "type": "text", + "analyzer": "portuguese" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "term_vector": "with_positions_offsets" + }, + "summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "parents": { + "type": "object", + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + }, + "search_analyzer": "synonyms_analyzer" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + }, + "prefix": { + "type": "text", + "analyzer": "hierarchy_analyzer" + } + } + }, + "url": { + "type": "alias", + "path": "parents.path" + } + } + }, + "hidden": { + "type": "boolean" + }, + "translated": { + "type": "boolean" + }, + "ai_rag_optimized_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_short_summary": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "ai_search_query": { + "type": "keyword", + "fields": { + "completion": { + "type": "search_as_you_type" + } + } + }, + "ai_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "ai_use_cases": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "section": { + "type": "keyword", + "normalizer": "keyword_normalizer", + "copy_to": "tags" + }, + "content_tier": { + "type": "keyword" + }, + "navigation": { + "type": "object", + "properties": { + "depth": { + "type": "rank_feature", + "positive_score_impact": false + }, + "table_of_contents": { + "type": "rank_feature", + "positive_score_impact": false + } + } + }, + "ai_autocomplete_questions": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer", + "index_options": "offsets" + }, + "suggest": { + "type": "completion" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "tags": { + "type": "text", + "analyzer": "content_tags_analyzer" + }, + "abstract": { + "type": "alias", + "path": "summary" + }, + "navigation_section": { + "type": "alias", + "path": "section" + }, + "content_tags": { + "type": "alias", + "path": "tags" + }, + "stripped_body": { + "type": "alias", + "path": "body" + }, + "navigation_depth": { + "type": "alias", + "path": "navigation.depth" + }, + "navigation_table_of_contents": { + "type": "alias", + "path": "navigation.table_of_contents" + }, + "language": { + "type": "alias", + "path": "locale" + }, + "og_title": { + "type": "alias", + "path": "og.title" + }, + "og_description": { + "type": "alias", + "path": "og.description" + }, + "og_image": { + "type": "alias", + "path": "og.image" + }, + "twitter_image": { + "type": "alias", + "path": "twitter.image" + }, + "twitter_card": { + "type": "alias", + "path": "twitter.card" + }, + "http_etag": { + "type": "alias", + "path": "http.etag" + }, + "http_last_modified": { + "type": "alias", + "path": "http.last_modified" + }, + "api": { + "type": "object", + "properties": { + "properties": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + } + } + }, + "path_words": { + "type": "search_as_you_type", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "type": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "anchor": { + "type": "keyword" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "parent": { + "type": "keyword" + } + } + }, + "routes": { + "type": "object", + "properties": { + "path": { + "type": "keyword", + "index": false + }, + "methods": { + "type": "keyword", + "index": false + } + } + }, + "endpoints": { + "type": "keyword" + }, + "endpoints_bare": { + "type": "keyword" + }, + "method": { + "type": "keyword" + }, + "path": { + "type": "keyword" + }, + "operation_id": { + "type": "keyword" + }, + "words": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "group": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "id_words": { + "type": "rank_feature", + "positive_score_impact": false + }, + "deprecated": { + "type": "boolean" + } + } + }, + "product": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "related_products": { + "type": "object", + "properties": { + "id": { + "type": "keyword", + "normalizer": "keyword_normalizer" + }, + "repository": { + "type": "keyword", + "normalizer": "keyword_normalizer" + } + } + }, + "applies_to": { + "type": "nested", + "properties": { + "type": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "normalizer": "keyword_normalizer" + }, + "sub_type": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "normalizer": "keyword_normalizer" + }, + "lifecycle": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "normalizer": "keyword_normalizer" + }, + "version": { + "type": "version", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "links": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "content_hash": { + "type": "keyword" + }, + "content_last_updated": { + "type": "date" + }, + "source_url": { + "type": "keyword" + } + } +} diff --git a/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs b/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs index b189c17a98..edfb3a655e 100644 --- a/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs +++ b/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs @@ -3,8 +3,11 @@ // See the LICENSE file in the project root for more information using System.Text.Json; +using System.Text.Json.Nodes; using AwesomeAssertions; +using Elastic.Documentation.Search.Contract; using Elastic.SiteSearch.Cli.ContentStack; +using Nullean.Snapshots.Json; namespace Elastic.SiteSearch.Tests; @@ -14,6 +17,18 @@ namespace Elastic.SiteSearch.Tests; /// public class ContentStackMappingTests { + private static readonly JsonSerializerOptions DocumentOptions = new() { TypeInfoResolver = SourceGenerationContext.Default }; + + /// The mapped document as JSON, without the fields stamped with the current time (index date, and last_updated when the entry has no modified date). + private static string ToJson(SiteDocument doc) + { + var node = JsonNode.Parse(JsonSerializer.Serialize(doc, DocumentOptions))!.AsObject(); + _ = node.Remove("batch_index_date"); + if (doc.ModifiedDate is null) + _ = node.Remove("last_updated"); + return node.ToJsonString(); + } + private static SyncItem LoadFixture(string fixtureName, string contentTypeUid) { var path = Path.Combine("Fixtures", "ContentStack", fixtureName); @@ -30,21 +45,7 @@ public void BlogLegacy_Maps_BodyAndMetadata() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/blog/getting-started-with-kibana-dashboards"); - doc.Title.Should().Be("Getting Started with Kibana Dashboards"); - doc.SearchTitle.Should().StartWith("Blog:"); - doc.Section.Should().Be("blog"); - doc.Locale.Should().Be("en"); - doc.Body.Should().Contain("Kibana is a powerful visualization tool"); - doc.Body.Should().NotContain("

    "); - doc.Body.Should().Be(doc.Body); - doc.Headings.Should().Contain("Creating Your First Dashboard"); - doc.Description.Should().Contain("Learn how to create interactive dashboards"); - doc.PublishedDate.Should().NotBeNull(); - doc.ModifiedDate.Should().NotBeNull(); - doc.Og.Title.Should().Contain("Kibana Dashboards"); - doc.Og.Description.Should().NotBeNullOrEmpty(); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: blog_v2 (modular blocks with nested title_text arrays) @@ -55,16 +56,7 @@ public void BlogModern_Maps_ModularBlocksBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/blog/building-search-applications-with-elasticsearch"); - doc.Title.Should().Be("Building Search Applications with Elasticsearch"); - doc.SearchTitle.Should().StartWith("Blog:"); - doc.Section.Should().Be("blog"); - doc.Body.Should().Contain("Search is at the heart of modern applications"); - doc.Body.Should().Contain("Query DSL"); - doc.Body.Should().NotContain("

    "); - doc.Description.Should().Contain("Explore how to build production-ready"); - doc.PublishedDate.Should().NotBeNull(); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: videos (paragraph_l10n body, presentation_date) @@ -75,15 +67,7 @@ public void Video_Maps_ParagraphAndPresenter() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/webinars/ingesting-data-into-elasticsearch"); - doc.Title.Should().Be("Ingesting Data into Elasticsearch"); - doc.SearchTitle.Should().StartWith("Webinar:"); - doc.Section.Should().Be("webinar"); - doc.Body.Should().Contain("data ingestion patterns"); - doc.Body.Should().NotContain("

    "); - doc.PublishedDate.Should().NotBeNull(); - doc.Og.Image.Should().NotBeNullOrEmpty(); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: press (intro_paragraph_l10n + paragraph_l10n, date field) @@ -94,15 +78,7 @@ public void PressRelease_Maps_CombinedParagraphs() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Contain("/about/press/"); - doc.Title.Should().Contain("Security Analytics"); - doc.SearchTitle.Should().StartWith("Press:"); - doc.Section.Should().Be("press"); - doc.Body.Should().Contain("Elastic today announced"); - doc.Body.Should().Contain("advanced threat detection"); - doc.PublishedDate.Should().NotBeNull(); - doc.PublishedDate.Value.Year.Should().Be(2023); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: product_versions (release_notes body, date, version_number) @@ -113,14 +89,7 @@ public void ProductRelease_Maps_ReleaseNotes() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Contain("/downloads/past-releases/"); - doc.Title.Should().Contain("Elasticsearch 8.12.0"); - doc.SearchTitle.Should().StartWith("Download:"); - doc.Section.Should().Be("download"); - doc.Body.Should().Contain("release notes"); - doc.PublishedDate.Should().NotBeNull(); - doc.Og.Title.Should().Contain("Download"); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: agreements, forms (paragraph_l10n as single body) @@ -131,15 +100,7 @@ public void ParagraphPage_Maps_SingleBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Contain("/agreements/"); - doc.Title.Should().Contain("Terms of Service"); - doc.SearchTitle.Should().StartWith("Legal:"); - doc.Section.Should().Be("legal"); - doc.Body.Should().Contain("Terms of Service govern"); - doc.Body.Should().NotContain("

    "); - doc.Headings.Should().Contain("1. Definitions"); - doc.Og.Title.Should().NotBeNullOrEmpty(); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: default_detail, account_based_marketing, product_detail (modular_blocks with flat title_text) @@ -150,15 +111,7 @@ public void ModularPage_Maps_FlatModularBlocks() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/security/cloud-workload-protection"); - doc.Title.Should().Contain("Cloud Workloads"); - doc.SearchTitle.Should().StartWith("Product:"); - doc.Section.Should().Be("product"); - doc.Body.Should().Contain("comprehensive protection"); - doc.Body.Should().Contain("pre-built detection rules"); - doc.Body.Should().NotContain("

    "); - doc.Og.Image.Should().NotBeNullOrEmpty(); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: use_cases (introduction + challenge_solution + modular_blocks) @@ -169,15 +122,7 @@ public void UseCase_Maps_IntroAndChallenges() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/customers/acme-corp"); - doc.Title.Should().Be("Acme Corp"); - doc.SearchTitle.Should().StartWith("Customer Story:"); - doc.Section.Should().Be("customer-story"); - doc.Body.Should().Contain("millions of events per day"); - doc.Body.Should().Contain("could not handle the growing data volume"); - doc.Body.Should().Contain("sub-second query times"); - doc.Description.Should().Contain("scalable solution"); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: faq (topic[].subtopic[].paragraph_l10n) @@ -188,14 +133,7 @@ public void FaqPage_Maps_NestedTopics() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/what-is/cloud-security"); - doc.Title.Should().Contain("Cloud Security"); - doc.SearchTitle.Should().StartWith("What is:"); - doc.Section.Should().Be("concept"); - doc.Body.Should().Contain("technologies, policies, and controls"); - doc.Body.Should().Contain("organizations migrate workloads"); - doc.Body.Should().NotContain("

    "); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: customer_tile (short paragraph_l10n, partial URL coverage) @@ -206,12 +144,8 @@ public void CustomerTile_Maps_ShortContent() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Contain("/kr/videos/"); - doc.Title.Should().Be("TechCo Industries"); // URL prefix wins over the entry's own (en-us) locale — see GetLanguageFromUrl. - doc.Locale.Should().Be("ko"); - doc.Body.Should().Contain("global e-commerce platform"); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// @@ -230,19 +164,9 @@ public void MinimalPage_Maps_TitleUrlSeo() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/blog"); - doc.Title.Should().Be("Elastic Blog"); // ContentTypeUid "blog_overview" is now mapped to "blog" via GetSectionFromContentType, // even though the URL alone ("/blog", no trailing slash) doesn't match the URL heuristic. - doc.Section.Should().Be("blog"); - doc.Locale.Should().Be("en"); - doc.Body.Should().Contain("latest from Elastic"); - doc.Body.Should().Be(doc.Body); - doc.Description.Should().Contain("latest from Elastic"); - doc.Og.Title.Should().NotBeNullOrEmpty(); - doc.Og.Description.Should().NotBeNullOrEmpty(); - doc.Og.Image.Should().NotBeNullOrEmpty(); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: tutorial, tutorial_page, tutorial_chapter, labs_integration (rich-text JSON AST body + description) @@ -253,15 +177,7 @@ public void RichTextPage_Maps_ProseMirrorBodyAndDescription() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/search-labs/integrations/amazon-bedrock"); - doc.Section.Should().Be("search-labs"); - doc.Body.Should().Contain("Amazon Bedrock makes leading foundation models available"); - doc.Body.Should().Contain("RAG using Bedrock"); - doc.Body.Should().NotContain("

    "); - doc.Body.Should().NotContain("

    "); - doc.Headings.Should().Contain("Blogs to get started"); - doc.Description.Should().Contain("fully managed service offering foundation models"); - doc.Hash.Should().NotBeNullOrEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: blog_v3 (rich-text body nested under main_content.body.content_l10n + summary_l10n description) @@ -272,13 +188,7 @@ public void BlogV3_Maps_NestedRichTextBodyAndSummary() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/search-labs/blog/hybrid-search-with-elasticsearch"); - doc.Section.Should().Be("search-labs"); - doc.Body.Should().Contain("Hybrid search combines BM25 lexical scoring"); - doc.Body.Should().NotContain("

    "); - doc.Headings.Should().Contain("Why hybrid search"); - doc.Description.Should().Be("Combine lexical and semantic search for better relevance."); - doc.PublishedDate.Should().NotBeNull(); + ToJson(doc!).Should().MatchJsonSnapshot(); } ///

    Covers: tutorials_landing, integrations_landing, blog_landing (page_info.subheading_l10n description, no body) @@ -289,11 +199,8 @@ public void LandingPage_Maps_SubheadingDescription_NoBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/search-labs/tutorials"); - doc.Section.Should().Be("search-labs"); - doc.Description.Should().Be("Long-form guides to get you started"); // No main_content on landing pages — body falls back to the description. - doc.Body.Should().Be(doc.Description); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: notebook, series, labs_category, labs_homepage, glossary, examples_landing (plain description_l10n, no body) @@ -304,10 +211,7 @@ public void DescriptionOnly_Maps_PlainDescription_NoBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Section.Should().Be("search-labs"); - doc.Description.Should().Contain("Learn how to build a RAG system"); - doc.Body.Should().Be(doc.Description); - doc.Headings.Should().BeEmpty(); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: reports, threat_command (main_content.markdown_l10n plain-Markdown body + summary_l10n description) @@ -318,21 +222,10 @@ public void MarkdownBodyPage_Maps_MarkdownAsHeadingsAndPlainBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/security-labs/threat-command/tracking-a-novel-loader-pipedance"); - doc.Section.Should().Be("security-labs"); - doc.Headings.Should().BeEquivalentTo(["Key takeaways", "Analysis", "API resolution"]); - doc.Body.Should().Contain("PIPEDANCE is a novel loader used to stage secondary payloads"); // Links keep their visible text, drop the URL. - doc.Body.Should().Contain("process injection"); - doc.Body.Should().NotContain("example.com/injection"); // Bold/inline-code markers are stripped but the wrapped text is kept. - doc.Body.Should().Contain("kernel32.dll"); - doc.Body.Should().Contain("PEB"); - doc.Body.Should().NotContain("**"); - doc.Body.Should().NotContain("`"); // Fenced code blocks are dropped entirely. - doc.Body.Should().NotContain("walk_peb"); - doc.Description.Should().Be("An analysis of the PIPEDANCE loader observed in recent intrusions."); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: reports_landing, threat_command_landing (top-level subheading_l10n description, no body) @@ -343,10 +236,7 @@ public void TopLevelSubheadingLandingPage_Maps_SubheadingDescription_NoBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/security-labs/reports"); - doc.Section.Should().Be("security-labs"); - doc.Description.Should().Be("In-depth research reports from the Elastic Security Labs team."); - doc.Body.Should().Be(doc.Description); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// Covers: security_labs_homepage, observability_labs_homepage (page_description_l10n description, no body) @@ -357,10 +247,7 @@ public void PropertyHomepage_Maps_PageDescription_NoBody() var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); - doc.Path.Should().Be("/security-labs"); - doc.Section.Should().Be("security-labs"); - doc.Description.Should().Be("Research, tools, and insights from the Elastic Security Labs team."); - doc.Body.Should().Be(doc.Description); + ToJson(doc!).Should().MatchJsonSnapshot(); } /// blog_description_l10n is used when page_description_l10n is absent. diff --git a/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs b/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs index 7b11afb319..577078c9a1 100644 --- a/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs +++ b/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs @@ -2,9 +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 System.Text.Json; using AwesomeAssertions; using Elastic.Documentation.Search.Contract; +using Nullean.Snapshots.Json; namespace Elastic.SiteSearch.Tests; @@ -15,8 +15,6 @@ namespace Elastic.SiteSearch.Tests; ///

    public class MappingStructureTests { - // ── [Id] resolves to Url for every document type ───────────────────────── - [Test] public void SiteDocument_GetId_ReturnsUrl() { @@ -38,109 +36,6 @@ public void GuideDocument_GetId_ReturnsUrl() ctx.GetId!(doc).Should().Be("https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html"); } - // ── Base fields from SearchDocumentBase present in SiteDocument mapping ── - - [Test] - public void SiteDocument_MappingJson_ContainsBaseFields() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var props = doc.RootElement.GetProperty("properties"); - - props.TryGetProperty("path", out _).Should().BeTrue("path is declared on SearchDocumentBase"); - props.TryGetProperty("title", out _).Should().BeTrue("title is declared on SearchDocumentBase"); - props.TryGetProperty("content_type", out _).Should().BeTrue("content_type is declared on SearchDocumentBase"); - props.TryGetProperty("content_tier", out _).Should().BeTrue("content_tier is declared on SearchDocumentBase"); - props.TryGetProperty("hash", out _).Should().BeTrue("hash is declared on SearchDocumentBase"); - props.TryGetProperty("body", out _).Should().BeTrue("body is declared on SearchDocumentBase"); - props.TryGetProperty("headings", out _).Should().BeTrue("headings is declared on SearchDocumentBase"); - props.TryGetProperty("navigation", out _).Should().BeTrue("navigation is declared on SearchDocumentBase"); - } - - // ── Base fields from SearchDocumentBase present in GuideDocument mapping ─ - - [Test] - public void GuideDocument_MappingJson_ContainsBaseFields() - { - var json = GuideMappingContext.GuideDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var props = doc.RootElement.GetProperty("properties"); - - props.TryGetProperty("path", out _).Should().BeTrue("path is declared on SearchDocumentBase"); - props.TryGetProperty("title", out _).Should().BeTrue("title is declared on SearchDocumentBase"); - props.TryGetProperty("content_type", out _).Should().BeTrue("content_type is declared on SearchDocumentBase"); - props.TryGetProperty("content_tier", out _).Should().BeTrue("content_tier is declared on SearchDocumentBase"); - props.TryGetProperty("hash", out _).Should().BeTrue("hash is declared on SearchDocumentBase"); - props.TryGetProperty("body", out _).Should().BeTrue("body is declared on SearchDocumentBase"); - props.TryGetProperty("navigation", out _).Should().BeTrue("navigation is declared on SearchDocumentBase"); - } - - // ── Url: [Id][Keyword] must map as keyword ──────────────────────────────── - - [Test] - public void SiteDocument_UrlField_IsKeyword() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("path").GetProperty("type").GetString().Should().Be("keyword"); - } - - [Test] - public void GuideDocument_UrlField_IsKeyword() - { - var json = GuideMappingContext.GuideDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("path").GetProperty("type").GetString().Should().Be("keyword"); - } - - // ── content_type: [Keyword] on base ────────────────────────────────────── - - [Test] - public void SiteDocument_ContentTypeField_IsKeyword() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("content_type").GetProperty("type").GetString().Should().Be("keyword"); - } - - // ── tags: copy_to target for content_type/section ───── - - [Test] - public void SiteDocument_ContentTypeField_CopiesToContentTags() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("content_type").GetProperty("copy_to").GetString().Should().Be("tags"); - } - - [Test] - public void SiteDocument_NavigationSectionField_CopiesToContentTags() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("section").GetProperty("copy_to").GetString().Should().Be("tags"); - } - - [Test] - public void SiteDocument_ContentTagsField_IsTextWithContentTagsAnalyzer() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var contentTags = doc.RootElement.GetProperty("properties").GetProperty("tags"); - contentTags.GetProperty("type").GetString().Should().Be("text"); - contentTags.GetProperty("analyzer").GetString().Should().Be("content_tags_analyzer"); - } - - // ── content_tier: [Keyword] on base, neutral default ────────────────────── - - [Test] - public void SiteDocument_ContentTierField_IsKeyword() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("content_tier").GetProperty("type").GetString().Should().Be("keyword"); - } - [Test] public void SiteDocument_ContentTier_DefaultsToNeutralReference() { @@ -148,169 +43,6 @@ public void SiteDocument_ContentTier_DefaultsToNeutralReference() document.ContentTier.Should().Be(ContentTiers.Reference, "content_tier must default to a neutral tier, not a penalty"); } - // ── hash: [Keyword] on base ─────────────────────────────────────────────── - - [Test] - public void SiteDocument_HashField_IsKeyword() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("hash").GetProperty("type").GetString().Should().Be("keyword"); - } - - // ── Title multi-fields from AddSearchDocumentMappings ──────────────────── - - [Test] - public void SiteDocument_TitleField_HasKeywordNormalizedMultiField() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var titleFields = doc.RootElement.GetProperty("properties").GetProperty("title").GetProperty("fields"); - - var keyword = titleFields.GetProperty("keyword"); - keyword.GetProperty("type").GetString().Should().Be("keyword"); - keyword.GetProperty("normalizer").GetString().Should().Be("keyword_normalizer"); - } - - [Test] - public void SiteDocument_TitleField_HasStartsWithMultiField() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var titleFields = doc.RootElement.GetProperty("properties").GetProperty("title").GetProperty("fields"); - - titleFields.TryGetProperty("starts_with", out _).Should().BeTrue("starts_with is configured in AddSearchDocumentMappings"); - } - - [Test] - public void SiteDocument_TitleField_HasCompletionMultiField() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var titleFields = doc.RootElement.GetProperty("properties").GetProperty("title").GetProperty("fields"); - - titleFields.TryGetProperty("completion", out _).Should().BeTrue("completion is configured in AddSearchDocumentMappings"); - } - - // ── ai_search_query: search_as_you_type completion sub-field, no semantic_text ── - - [Test] - public void SiteDocument_AiSearchQueryField_IsKeywordWithCompletionMultiField() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var aiSearchQuery = doc.RootElement.GetProperty("properties").GetProperty("ai_search_query"); - aiSearchQuery.GetProperty("type").GetString().Should().Be("keyword"); - - var completion = aiSearchQuery.GetProperty("fields").GetProperty("completion"); - completion.GetProperty("type").GetString().Should().Be("search_as_you_type"); - } - - [Test] - public void SiteDocument_SemanticVariant_AiSearchQueryHasNoSemanticTextField() - { - var json = SiteMappingContext.SiteDocumentSemantic.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var aiSearchQuery = doc.RootElement.GetProperty("properties").GetProperty("ai_search_query"); - if (aiSearchQuery.TryGetProperty("fields", out var fields)) - fields.TryGetProperty("semantic_text", out _).Should().BeFalse("ai_search_query is typeahead-only — never semantic_text"); - } - - // ── Path multi-fields from AddCommonTitleMappings ────────────────────────── - - [Test] - public void SiteDocument_UrlField_HasMatchAndPrefixMultiFields() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var urlFields = doc.RootElement.GetProperty("properties").GetProperty("path").GetProperty("fields"); - - urlFields.TryGetProperty("match", out _).Should().BeTrue("path.match is configured in AddCommonTitleMappings"); - urlFields - .TryGetProperty("prefix", out _) - .Should() - .BeTrue("path.prefix (hierarchy_analyzer) is configured in AddCommonTitleMappings"); - urlFields.GetProperty("prefix").GetProperty("analyzer").GetString().Should().Be("hierarchy_analyzer"); - } - - // ── Navigation fields must be rank_feature with negative score impact ───── - - [Test] - public void SiteDocument_NavigationDepth_IsRankFeatureWithNegativeImpact() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var depth = doc.RootElement.GetProperty("properties").GetProperty("navigation").GetProperty("properties").GetProperty("depth"); - depth.GetProperty("type").GetString().Should().Be("rank_feature"); - depth.GetProperty("positive_score_impact").GetBoolean().Should().BeFalse(); - } - - [Test] - public void SiteDocument_NavigationTableOfContents_IsRankFeatureWithNegativeImpact() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var toc = doc - .RootElement - .GetProperty("properties") - .GetProperty("navigation") - .GetProperty("properties") - .GetProperty("table_of_contents"); - toc.GetProperty("type").GetString().Should().Be("rank_feature"); - toc.GetProperty("positive_score_impact").GetBoolean().Should().BeFalse(); - } - - // ── Body multi-language fields from AddSearchDocumentMappings ──────────── - - [Test] - public void SiteDocument_BodyField_HasLanguageMultiFields() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var bodyFields = doc.RootElement.GetProperty("properties").GetProperty("body").GetProperty("fields"); - - bodyFields.GetProperty("en").GetProperty("analyzer").GetString().Should().Be("english"); - bodyFields.GetProperty("de").GetProperty("analyzer").GetString().Should().Be("german"); - bodyFields.GetProperty("fr").GetProperty("analyzer").GetString().Should().Be("french"); - bodyFields.TryGetProperty("ja", out _).Should().BeTrue("ja (cjk) body multi-field is configured"); - bodyFields.TryGetProperty("ko", out _).Should().BeTrue("ko (cjk) body multi-field is configured"); - bodyFields.TryGetProperty("zh", out _).Should().BeTrue("zh (cjk) body multi-field is configured"); - bodyFields.TryGetProperty("es", out _).Should().BeTrue("es (spanish) body multi-field is configured"); - bodyFields.TryGetProperty("pt", out _).Should().BeTrue("pt (portuguese) body multi-field is configured"); - } - - // ── Semantic variant adds semantic_text multi-fields ───────────────────── - - [Test] - public void SiteDocument_SemanticVariant_TitleHasSemanticTextField() - { - var json = SiteMappingContext.SiteDocumentSemantic.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var titleFields = doc.RootElement.GetProperty("properties").GetProperty("title").GetProperty("fields"); - titleFields.GetProperty("semantic_text").GetProperty("type").GetString().Should().Be("semantic_text"); - } - - [Test] - public void SiteDocument_SemanticVariant_StrippedBodyHasSemanticTextField() - { - var json = SiteMappingContext.SiteDocumentSemantic.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var bodyFields = doc.RootElement.GetProperty("properties").GetProperty("body").GetProperty("fields"); - bodyFields.GetProperty("semantic_text").GetProperty("type").GetString().Should().Be("semantic_text"); - } - - [Test] - public void SiteDocument_LexicalVariant_DoesNotHaveSemanticTextField() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var title = doc.RootElement.GetProperty("properties").GetProperty("title"); - if (title.TryGetProperty("fields", out var titleFields)) - titleFields.TryGetProperty("semantic_text", out _).Should().BeFalse("lexical variant has no semantic_text"); - } - - // ── Field name constants match JSON property names ──────────────────────── - [Test] public void SiteDocument_Fields_UrlMatchesJsonPropertyName() => SiteMappingContext.SiteDocument.Fields.Path.Should().Be("path"); @@ -323,61 +55,26 @@ public void SiteDocument_LexicalVariant_DoesNotHaveSemanticTextField() [Test] public void GuideDocument_Fields_UrlMatchesJsonPropertyName() => GuideMappingContext.GuideDocument.Fields.Path.Should().Be("path"); - // ── parents: shared topology declared once in SharedMappingConfig ──────── + // ── Whole mapping per document type ────────────────────────────────────── + // The snapshots pin everything the old per-field checks did: the SearchDocumentBase fields, keyword and + // copy_to settings, the title/path/body/parents multi-fields, rank_feature navigation fields, the + // semantic_text sub-fields that only the semantic variant has, and the applies_to / api topology. [Test] - public void SiteDocument_ParentsPath_IsKeywordWithMatchAndPrefixMultiFields() - { - var json = SiteMappingContext.SiteDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var path = doc.RootElement.GetProperty("properties").GetProperty("parents").GetProperty("properties").GetProperty("path"); - - path.GetProperty("type").GetString().Should().Be("keyword"); - var fields = path.GetProperty("fields"); - fields.TryGetProperty("match", out _).Should().BeTrue(); - fields.GetProperty("prefix").GetProperty("analyzer").GetString().Should().Be("hierarchy_analyzer"); - } - - // ── Unified index (WebsiteSearchDocument) merges in DocumentationDocument's - // applies_to topology, which it has no C# property for ───────────────────── + public void SiteDocument_MappingJson() => SiteMappingContext.SiteDocument.GetMappingJson().Should().MatchJsonSnapshot(); [Test] - public void WebsiteSearchDocument_MergesDocumentationAppliesTo() - { - var json = WebsiteSearchMappingContext.WebsiteSearchDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var appliesTo = doc.RootElement.GetProperty("properties").GetProperty("applies_to"); - - appliesTo.GetProperty("type").GetString().Should().Be("nested"); - var props = appliesTo.GetProperty("properties"); - props.GetProperty("type").GetProperty("type").GetString().Should().Be("keyword"); - props.GetProperty("type").GetProperty("normalizer").GetString().Should().Be("keyword_normalizer"); - props.GetProperty("version").GetProperty("type").GetString().Should().Be("version"); - } + public void SiteDocumentSemantic_MappingJson() => SiteMappingContext.SiteDocumentSemantic.GetMappingJson().Should().MatchJsonSnapshot(); [Test] - public void WebsiteSearchDocument_ParentsPath_IsKeywordWithMatchAndPrefixMultiFields() - { - var json = WebsiteSearchMappingContext.WebsiteSearchDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var path = doc.RootElement.GetProperty("properties").GetProperty("parents").GetProperty("properties").GetProperty("path"); + public void GuideDocument_MappingJson() => GuideMappingContext.GuideDocument.GetMappingJson().Should().MatchJsonSnapshot(); - path.GetProperty("type").GetString().Should().Be("keyword"); - var fields = path.GetProperty("fields"); - fields.TryGetProperty("match", out _).Should().BeTrue(); - fields.GetProperty("prefix").GetProperty("analyzer").GetString().Should().Be("hierarchy_analyzer"); - } + /// The unified index merges in DocumentationDocument's applies_to topology, which it has no C# property for. [Test] - public void DocumentationDocument_MappingJson_ApiEndpointsIsPlainKeyword() - { - var json = DocumentationMappingContext.DocumentationDocument.GetMappingJson(); - using var doc = JsonDocument.Parse(json); - var api = doc.RootElement.GetProperty("properties").GetProperty("api").GetProperty("properties"); + public void WebsiteSearchDocument_MappingJson() => + WebsiteSearchMappingContext.WebsiteSearchDocument.GetMappingJson().Should().MatchJsonSnapshot(); - foreach (var name in new[] { "endpoints", "method", "path", "operation_id" }) - { - api.GetProperty(name).GetProperty("type").GetString().Should().Be("keyword"); - api.GetProperty(name).TryGetProperty("normalizer", out _).Should().BeFalse(); - } - } + [Test] + public void DocumentationDocument_MappingJson() => + DocumentationMappingContext.DocumentationDocument.GetMappingJson().Should().MatchJsonSnapshot(); } From 364e6ef1250f85c11854904a825d6e39b33d26e8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:38:27 +0100 Subject: [PATCH 05/16] Api.Tests: snapshot the built ES queries and API mappings SearchQueryBuilderTests and ApiPropertySearchTests walked the query JSON with GetProperty chains and Contain/NotContain. The queries and the api.* mapping (lexical and semantic) are now JSON snapshots, so the tests became stricter. The existing one-line expectations are kept as inline JSON snapshots. Dropped an unused 'prefix' argument that both rows set to false. Co-Authored-By: Claude Sonnet 5.5 --- ...BeforeEndpointBoost.c624ce26.verified.json | 89 ++++++ ...BeforeEndpointBoost.d0302c76.verified.json | 89 ++++++ ...dingsWithUniqueInnerHitNames.verified.json | 204 ++++++++++++ ...y_DocsWide_HasNoChildrenLift.verified.json | 95 ++++++ ...edAfterItsInnerHits.c624ce26.verified.json | 89 ++++++ ...edAfterItsInnerHits.edaac49a.verified.json | 71 ++++ ...quiredForTheSurvivingReading.verified.json | 115 +++++++ ...DocsWide_HasNoChildrenOption.verified.json | 57 ++++ ...urnsNestedInnerHits.3dd983dc.verified.json | 33 ++ ...urnsNestedInnerHits.ae232dbe.verified.json | 33 ++ ...ingleWord_MatchesTheBareName.verified.json | 33 ++ ...heApiFilteredLookup.1b7c3761.verified.json | 33 ++ ...heApiFilteredLookup.36054997.verified.json | 33 ++ ...ingLeafAndPathWords.1a97d7d5.verified.json | 57 ++++ ...ingLeafAndPathWords.9d305c6d.verified.json | 57 ++++ ...ingLeafAndPathWords.9f803851.verified.json | 57 ++++ ...ingLeafAndPathWords.f466369f.verified.json | 57 ++++ .../Mapping_Api.2f0e22ee.verified.json | 106 ++++++ .../Mapping_Api.54b63aa5.verified.json | 106 ++++++ ...ry_IncludesApiEndpointsTerms.verified.json | 139 ++++++++ .../ApiPropertySearchTests.cs | 302 ++---------------- .../SearchQueryBuilderTests.cs | 132 +++++--- 22 files changed, 1680 insertions(+), 307 deletions(-) create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_DocsWide_HasNoChildrenOption.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.3dd983dc.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.ae232dbe.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_MarkedSingleWord_MatchesTheBareName.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.1b7c3761.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.36054997.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.1a97d7d5.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9d305c6d.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9f803851.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.f466369f.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json create mode 100644 tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json new file mode 100644 index 0000000000..36bdaa362d --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json @@ -0,0 +1,89 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "must": { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + "_name": "api_properties", + "should": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_search" + ] + } + } + } + } + ] + } + } + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json new file mode 100644 index 0000000000..36bdaa362d --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json @@ -0,0 +1,89 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "must": { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + "_name": "api_properties", + "should": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_search" + ] + } + } + } + } + ] + } + } + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json new file mode 100644 index 0000000000..819700aa32 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json @@ -0,0 +1,204 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "minimum_should_match": 1, + "should": [ + { + "bool": { + "must": [ + { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + { + "dis_max": { + "queries": [ + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "knn_field" + ] + } + } + } + } + ] + } + }, + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_knn_field" + ] + } + } + } + } + ] + } + } + ] + } + } + ], + "_name": "api_properties" + } + }, + { + "bool": { + "must": [ + { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties_1", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "knn_field" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + { + "dis_max": { + "queries": [ + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "track_total" + ] + } + } + } + } + ] + } + }, + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_track_total" + ] + } + } + } + } + ] + } + } + ] + } + } + ], + "_name": "api_properties_1" + } + } + ] + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json new file mode 100644 index 0000000000..59964eca21 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json @@ -0,0 +1,95 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "must": { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "retriever.standard" + } + } + } + } + }, + { + "bool": { + "filter": { + "match_phrase_prefix": { + "api.properties.path_words": { + "query": "retriever standard" + } + } + }, + "must": { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "standard" + } + } + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + "_name": "api_properties" + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json new file mode 100644 index 0000000000..36bdaa362d --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json @@ -0,0 +1,89 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "must": { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + "_name": "api_properties", + "should": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_search" + ] + } + } + } + } + ] + } + } + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json new file mode 100644 index 0000000000..196433fb95 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json @@ -0,0 +1,71 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "must": { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + "_name": "api_properties" + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json new file mode 100644 index 0000000000..c5eff12680 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json @@ -0,0 +1,115 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "minimum_should_match": 1, + "should": [ + { + "match_none": {} + }, + { + "bool": { + "must": [ + { + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total_hits" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } + }, + { + "dis_max": { + "queries": [ + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "bulk" + ] + } + } + } + } + ] + } + }, + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_bulk" + ] + } + } + } + } + ] + } + } + ] + } + } + ], + "_name": "api_properties" + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_DocsWide_HasNoChildrenOption.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_DocsWide_HasNoChildrenOption.verified.json new file mode 100644 index 0000000000..2495016d87 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_DocsWide_HasNoChildrenOption.verified.json @@ -0,0 +1,57 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "retriever.standard" + } + } + } + } + }, + { + "bool": { + "filter": { + "match_phrase_prefix": { + "api.properties.path_words": { + "query": "retriever standard" + } + } + }, + "must": { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "standard" + } + } + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.3dd983dc.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.3dd983dc.verified.json new file mode 100644 index 0000000000..31bb0a8c53 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.3dd983dc.verified.json @@ -0,0 +1,33 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.ae232dbe.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.ae232dbe.verified.json new file mode 100644 index 0000000000..05ebb55d4d --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits.ae232dbe.verified.json @@ -0,0 +1,33 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "knn_field" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_MarkedSingleWord_MatchesTheBareName.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_MarkedSingleWord_MatchesTheBareName.verified.json new file mode 100644 index 0000000000..21b7ab17ad --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_MarkedSingleWord_MatchesTheBareName.verified.json @@ -0,0 +1,33 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "retriever" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.1b7c3761.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.1b7c3761.verified.json new file mode 100644 index 0000000000..31bb0a8c53 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.1b7c3761.verified.json @@ -0,0 +1,33 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.36054997.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.36054997.verified.json new file mode 100644 index 0000000000..31bb0a8c53 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup.36054997.verified.json @@ -0,0 +1,33 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "track_total" + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.1a97d7d5.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.1a97d7d5.verified.json new file mode 100644 index 0000000000..856112c032 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.1a97d7d5.verified.json @@ -0,0 +1,57 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "rrf.min_score" + } + } + } + } + }, + { + "bool": { + "filter": { + "match_phrase_prefix": { + "api.properties.path_words": { + "query": "rrf min_score" + } + } + }, + "must": { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "min_score" + } + } + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9d305c6d.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9d305c6d.verified.json new file mode 100644 index 0000000000..7671dc160d --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9d305c6d.verified.json @@ -0,0 +1,57 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "retriever.rrf.min_score" + } + } + } + } + }, + { + "bool": { + "filter": { + "match_phrase_prefix": { + "api.properties.path_words": { + "query": "retriever rrf min_score" + } + } + }, + "must": { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "min_score" + } + } + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9f803851.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9f803851.verified.json new file mode 100644 index 0000000000..b546e4f8a2 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.9f803851.verified.json @@ -0,0 +1,57 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "labels..value" + } + } + } + } + }, + { + "bool": { + "filter": { + "match_phrase_prefix": { + "api.properties.path_words": { + "query": "labels value" + } + } + }, + "must": { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "value" + } + } + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.f466369f.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.f466369f.verified.json new file mode 100644 index 0000000000..f99af68ffb --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords.f466369f.verified.json @@ -0,0 +1,57 @@ +{ + "nested": { + "ignore_unmapped": true, + "inner_hits": { + "name": "api_properties", + "size": 5, + "_source": { + "includes": "api.properties.*" + } + }, + "path": "api.properties", + "query": { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "[].retriever.standard" + } + } + } + } + }, + { + "bool": { + "filter": { + "match_phrase_prefix": { + "api.properties.path_words": { + "query": "retriever standard" + } + } + }, + "must": { + "constant_score": { + "boost": 1, + "filter": { + "term": { + "api.properties.name": { + "case_insensitive": true, + "value": "standard" + } + } + } + } + } + } + } + ] + } + }, + "score_mode": "max" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json new file mode 100644 index 0000000000..cadfebc3f3 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json @@ -0,0 +1,106 @@ +{ + "properties": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + } + } + }, + "path_words": { + "type": "search_as_you_type", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "type": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "anchor": { + "type": "keyword" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "parent": { + "type": "keyword" + } + } + }, + "routes": { + "type": "object", + "properties": { + "path": { + "type": "keyword", + "index": false + }, + "methods": { + "type": "keyword", + "index": false + } + } + }, + "endpoints": { + "type": "keyword" + }, + "endpoints_bare": { + "type": "keyword" + }, + "method": { + "type": "keyword" + }, + "path": { + "type": "keyword" + }, + "operation_id": { + "type": "keyword" + }, + "words": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "group": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "id_words": { + "type": "rank_feature", + "positive_score_impact": false + }, + "deprecated": { + "type": "boolean" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json new file mode 100644 index 0000000000..cadfebc3f3 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json @@ -0,0 +1,106 @@ +{ + "properties": { + "type": "nested", + "properties": { + "name": { + "type": "keyword" + }, + "path": { + "type": "keyword", + "fields": { + "match": { + "type": "text" + } + } + }, + "path_words": { + "type": "search_as_you_type", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "type": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "anchor": { + "type": "keyword" + }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "parent": { + "type": "keyword" + } + } + }, + "routes": { + "type": "object", + "properties": { + "path": { + "type": "keyword", + "index": false + }, + "methods": { + "type": "keyword", + "index": false + } + } + }, + "endpoints": { + "type": "keyword" + }, + "endpoints_bare": { + "type": "keyword" + }, + "method": { + "type": "keyword" + }, + "path": { + "type": "keyword" + }, + "operation_id": { + "type": "keyword" + }, + "words": { + "type": "text", + "fields": { + "completion": { + "type": "search_as_you_type", + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "group": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + }, + "analyzer": "synonyms_fixed_analyzer", + "search_analyzer": "synonyms_analyzer" + }, + "id_words": { + "type": "rank_feature", + "positive_score_impact": false + }, + "deprecated": { + "type": "boolean" + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json new file mode 100644 index 0000000000..3bfd329537 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json @@ -0,0 +1,139 @@ +{ + "bool": { + "filter": { + "bool": { + "must_not": [ + { + "terms": { + "path.keyword": [ + "/docs", + "/docs/", + "/docs/404", + "/docs/404/" + ] + } + }, + { + "term": { + "hidden": { + "value": true + } + } + } + ] + } + }, + "must": { + "bool": { + "should": [ + { + "constant_score": { + "boost": 3, + "filter": { + "multi_match": { + "analyzer": "synonyms_analyzer", + "fields": [ + "search_title.completion", + "search_title.completion._2gram", + "search_title.completion._3gram" + ], + "operator": "and", + "query": "_search", + "type": "bool_prefix" + } + } + } + }, + { + "multi_match": { + "analyzer": "synonyms_analyzer", + "boost": 0.1, + "fields": "body", + "operator": "and", + "query": "_search", + "type": "best_fields" + } + }, + { + "term": { + "title.keyword": { + "value": "_search" + } + } + }, + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_search" + ] + } + } + } + } + ] + } + }, + { + "constant_score": { + "filter": { + "term": { + "title.starts_with": { + "value": "_search" + } + } + } + } + }, + { + "constant_score": { + "boost": 0.3, + "filter": { + "match": { + "path.match": { + "query": "_search" + } + } + } + } + } + ] + } + }, + "should": [ + { + "rank_feature": { + "boost": 0.8, + "field": "navigation.depth" + } + }, + { + "rank_feature": { + "boost": 0.8, + "field": "navigation.table_of_contents" + } + }, + { + "term": { + "section": { + "boost": 0.15, + "value": "reference" + } + } + }, + { + "term": { + "section": { + "boost": 0.1, + "value": "getting-started" + } + } + } + ] + } +} diff --git a/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs b/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs index f045e6da91..5c372114b7 100644 --- a/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.Search.Highlighting; using Elastic.Transport; using Elastic.Transport.Extensions; +using Nullean.Snapshots.Json; namespace Elastic.Documentation.Api.Tests; @@ -21,171 +22,55 @@ public class ApiPropertySearchTests { private static readonly ElasticsearchClient Client = new(new ElasticsearchClientSettings(new InMemoryRequestInvoker())); - private static JsonDocument Json(Query? query) => JsonDocument.Parse(Client.RequestResponseSerializer.SerializeToString(query)); + private static string Json(Query? query) => Client.RequestResponseSerializer.SerializeToString(query); [Test] - [Arguments("track_total", null, 5)] - [Arguments("knn_field search", null, 5)] - public void BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits(string input, string? filter, int size) - { - using var json = Json(SearchQueryBuilder.BuildApiPropertyQuery(input, filter)); - var nested = json.RootElement.GetProperty("nested"); - nested.GetProperty("path").GetString().Should().Be("api.properties"); - nested.GetProperty("inner_hits").GetProperty("size").GetInt32().Should().Be(size); - } + [Arguments("track_total", null)] + [Arguments("knn_field search", null)] + public void BuildApiPropertyQuery_Identifier_ReturnsNestedInnerHits(string input, string? filter) => + Json(SearchQueryBuilder.BuildApiPropertyQuery(input, filter)).Should().MatchJsonSnapshot(); [Test] - [Arguments("retriever.rrf.min_score", "min_score", "retriever rrf min_score")] - [Arguments("rrf.min_score", "min_score", "rrf min_score")] - [Arguments("[].retriever.standard", "standard", "retriever standard")] - [Arguments("labels..value", "value", "labels value")] - public void BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords(string input, string leaf, string phrase) - { - using var json = Json(SearchQueryBuilder.BuildApiPropertyQuery(input, null)); - var qualified = json - .RootElement - .GetProperty("nested") - .GetProperty("query") - .GetProperty("dis_max") - .GetProperty("queries")[1] - .GetProperty("bool"); - qualified - .GetProperty("must") - .GetProperty("constant_score") - .GetProperty("filter") - .GetProperty("term") - .GetProperty("api.properties.name") - .GetProperty("value") - .GetString() - .Should() - .Be(leaf); - qualified - .GetProperty("filter") - .GetProperty("match_phrase_prefix") - .GetProperty("api.properties.path_words") - .GetProperty("query") - .GetString() - .Should() - .Be(phrase); - } + [Arguments("retriever.rrf.min_score")] + [Arguments("rrf.min_score")] + [Arguments("[].retriever.standard")] + [Arguments("labels..value")] + public void BuildApiPropertyQuery_QualifiedPath_RequiresMatchingLeafAndPathWords(string input) => + Json(SearchQueryBuilder.BuildApiPropertyQuery(input, null)).Should().MatchJsonSnapshot(); [Test] - public void BuildApiPropertyQuery_MarkedSingleWord_MatchesTheBareName() - { - using var json = Json(SearchQueryBuilder.BuildApiPropertyQuery("[].retriever", null)); - var options = json.RootElement.GetProperty("nested").GetProperty("query").GetProperty("dis_max").GetProperty("queries"); - options.GetArrayLength().Should().Be(1); - options[0] - .GetProperty("constant_score") - .GetProperty("filter") - .GetProperty("term") - .GetProperty("api.properties.name") - .GetProperty("value") - .GetString() - .Should() - .Be("retriever"); - } + public void BuildApiPropertyQuery_MarkedSingleWord_MatchesTheBareName() => + Json(SearchQueryBuilder.BuildApiPropertyQuery("[].retriever", null)).Should().MatchJsonSnapshot(); [Test] - [Arguments(null, false)] - [Arguments("all", false)] - public void BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup(string? filter, bool prefix) - { - using var json = Json(SearchQueryBuilder.BuildApiPropertyQuery("track_total", filter)); - var options = json.RootElement.GetProperty("nested").GetProperty("query").GetProperty("dis_max").GetProperty("queries"); - options.GetArrayLength().Should().Be(1); - var name = options[0].TryGetProperty("dis_max", out var names) - ? names.GetProperty("queries").EnumerateArray().ToArray() - : [options[0]]; - name.Should().HaveCount(prefix ? 2 : 1); - name[0].GetRawText().Should().Contain("\"term\""); - } + [Arguments(null)] + [Arguments("all")] + public void BuildApiPropertyQuery_PrefixMatch_OnlyInTheApiFilteredLookup(string? filter) => + Json(SearchQueryBuilder.BuildApiPropertyQuery("track_total", filter)).Should().MatchJsonSnapshot(); [Test] [Arguments("track_total")] [Arguments("/_search track_total")] - public void ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits(string input) - { - using var json = Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), input, null)); - var reading = json.RootElement.GetProperty("bool").GetProperty("should")[1].GetProperty("bool"); - reading.GetProperty("_name").GetString().Should().Be("api_properties"); - reading - .GetProperty("must") - .GetProperty("nested") - .GetProperty("inner_hits") - .GetProperty("name") - .GetString() - .Should() - .Be("api_properties"); - } + public void ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits(string input) => + Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), input, null)).Should().MatchJsonSnapshot(); [Test] - public void ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading() - { - using var json = Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), "bulk track_total_hits", null)); - var reading = json.RootElement.GetProperty("bool").GetProperty("should")[1].GetProperty("bool"); - var must = reading.GetProperty("must"); - must.GetArrayLength().Should().Be(2); - must[0].GetProperty("nested").GetProperty("query").GetRawText().Should().Contain("\"value\":\"track_total_hits\""); - must[1].GetRawText().Should().Contain("_bulk"); - reading - .TryGetProperty("should", out _) - .Should() - .BeFalse("the plain word names no field docs-wide, and the reading left must still hold its endpoint"); - } + public void ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading() => + Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), "bulk track_total_hits", null)).Should().MatchJsonSnapshot(); [Test] [Arguments("GET /_search track_total")] [Arguments("/_search track_total")] - public void ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost(string input) - { - using var json = Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), input, null)); - var fieldBranch = json.RootElement.GetProperty("bool").GetProperty("should")[1].GetProperty("bool"); - fieldBranch - .GetProperty("must") - .GetProperty("nested") - .GetProperty("query") - .GetRawText() - .Should() - .Contain("\"value\":\"track_total\""); - var endpoint = fieldBranch.GetProperty("should").GetProperty("dis_max").GetProperty("queries")[0].GetProperty("constant_score"); - endpoint.GetProperty("filter").GetProperty("terms").GetProperty("api.endpoints")[0].GetString().Should().Be("_search"); - } + public void ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost(string input) => + Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), input, null)).Should().MatchJsonSnapshot(); [Test] - public void ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames() - { - using var json = Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), "knn_field track_total", null)); - var readings = json.RootElement.GetProperty("bool").GetProperty("should")[1].GetProperty("bool").GetProperty("should"); - readings.GetArrayLength().Should().Be(2); - var trackTotal = readings.EnumerateArray().Single(r => r.GetRawText().Contains("\"value\":\"track_total\"")).GetProperty("bool"); - trackTotal.GetProperty("must")[1].GetRawText().Should().Contain("\"api.endpoints\":[\"_knn_field\""); - var readingNames = readings.EnumerateArray().Select(r => r.GetProperty("bool").GetProperty("_name").GetString()).ToArray(); - var innerHitNames = readings - .EnumerateArray() - .Select( - r => r - .GetProperty("bool") - .GetProperty("must")[0] - .GetProperty("nested") - .GetProperty("inner_hits") - .GetProperty("name") - .GetString() - ) - .ToArray(); - readingNames.Should().OnlyHaveUniqueItems(); - innerHitNames.Should().OnlyHaveUniqueItems(); - readingNames.Concat(innerHitNames).Should().AllSatisfy(n => SearchQueryBuilder.IsApiPropertyHits(n).Should().BeTrue()); - } + public void ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames() => + Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), "knn_field track_total", null)).Should().MatchJsonSnapshot(); [Test] - public void ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift() - { - var json = Client.RequestResponseSerializer.SerializeToString( - SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), "retriever.standard", null) - ); - json.Should().Contain("\"api.properties.name\"").And.NotContain("api.properties.parent"); - } + public void ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift() => + Json(SearchQueryBuilder.ApplyApiPropertyQuery(new MatchNoneQuery(), "retriever.standard", null)).Should().MatchJsonSnapshot(); [Test] public void ProcessHit_InnerHitsFromBothReadings_AreMerged() @@ -234,105 +119,15 @@ public void ProcessHit_HitThatMatchedNoReading_KeepsNoProperties() public void BuildApiPropertyQuery_ExcludedQuery_ReturnsNull(string input, string? filter) => SearchQueryBuilder.BuildApiPropertyQuery(input, filter).Should().BeNull(); + /// + /// The api.* mapping, for the lexical and the semantic variant: boolean deprecated, synonym text words and group, + /// negative-impact rank_feature id_words, plain keyword endpoints, nested properties with keyword names and parent + /// (and no children field), and unindexed keyword routes. + /// [Test] [Arguments(false)] [Arguments(true)] - public void Mapping_ApiDeprecated_IsBoolean(bool semantic) - { - var api = ApiMapping(semantic); - api.GetProperty("deprecated").GetProperty("type").GetString().Should().Be("boolean"); - } - - [Test] - [Arguments(false)] - [Arguments(true)] - public void Mapping_ApiWordsAndGroup_AreSynonymText(bool semantic) - { - var api = ApiMapping(semantic); - var words = api.GetProperty("words"); - words.GetProperty("type").GetString().Should().Be("text"); - words.GetProperty("analyzer").GetString().Should().Be("synonyms_fixed_analyzer"); - words.GetProperty("search_analyzer").GetString().Should().Be("synonyms_analyzer"); - var completion = words.GetProperty("fields").GetProperty("completion"); - completion.GetProperty("type").GetString().Should().Be("search_as_you_type"); - completion.GetProperty("analyzer").GetString().Should().Be("synonyms_fixed_analyzer"); - completion.GetProperty("search_analyzer").GetString().Should().Be("synonyms_analyzer"); - completion.TryGetProperty("index_options", out _).Should().BeFalse(); - - var group = api.GetProperty("group"); - group.GetProperty("type").GetString().Should().Be("text"); - group.GetProperty("analyzer").GetString().Should().Be("synonyms_fixed_analyzer"); - group.GetProperty("search_analyzer").GetString().Should().Be("synonyms_analyzer"); - } - - [Test] - [Arguments(false)] - [Arguments(true)] - public void Mapping_ApiIdWords_IsRankFeatureWithNegativeImpact(bool semantic) - { - var idWords = ApiMapping(semantic).GetProperty("id_words"); - idWords.GetProperty("type").GetString().Should().Be("rank_feature"); - idWords.GetProperty("positive_score_impact").GetBoolean().Should().BeFalse(); - } - - [Test] - [Arguments(false)] - [Arguments(true)] - public void Mapping_ApiEndpointsBare_IsKeyword(bool semantic) - { - var api = ApiMapping(semantic); - var endpoints = api.GetProperty("endpoints"); - endpoints.GetProperty("type").GetString().Should().Be("keyword"); - endpoints.TryGetProperty("fields", out _).Should().BeFalse(); - api.GetProperty("endpoints_bare").GetProperty("type").GetString().Should().Be("keyword"); - } - - [Test] - [Arguments(false)] - [Arguments(true)] - public void Mapping_ApiProperties_IsNestedWithKeywordNames(bool semantic) - { - var context = semantic - ? DocumentationMappingContext.DocumentationDocumentSemantic.CreateContext(type: "assembler", env: "test") - : DocumentationMappingContext.DocumentationDocument.CreateContext(type: "assembler", env: "test"); - using var json = JsonDocument.Parse(context.GetMappingsJson()); - var field = json.RootElement.GetProperty("properties").GetProperty("api").GetProperty("properties").GetProperty("properties"); - field.GetProperty("type").GetString().Should().Be("nested"); - var fields = field.GetProperty("properties"); - fields.GetProperty("name").GetProperty("type").GetString().Should().Be("keyword"); - fields.GetProperty("anchor").GetProperty("type").GetString().Should().Be("keyword"); - fields.GetProperty("path_words").GetProperty("type").GetString().Should().Be("search_as_you_type"); - - var routes = json - .RootElement - .GetProperty("properties") - .GetProperty("api") - .GetProperty("properties") - .GetProperty("routes") - .GetProperty("properties"); - routes.GetProperty("path").GetProperty("type").GetString().Should().Be("keyword"); - routes.GetProperty("path").GetProperty("index").GetBoolean().Should().BeFalse(); - routes.GetProperty("methods").GetProperty("type").GetString().Should().Be("keyword"); - } - - [Test] - [Arguments(false)] - [Arguments(true)] - public void Mapping_ApiProperties_HasNoChildrenField(bool semantic) - { - var context = semantic - ? DocumentationMappingContext.DocumentationDocumentSemantic.CreateContext(type: "assembler", env: "test") - : DocumentationMappingContext.DocumentationDocument.CreateContext(type: "assembler", env: "test"); - using var json = JsonDocument.Parse(context.GetMappingsJson()); - var fields = json - .RootElement - .GetProperty("properties") - .GetProperty("api") - .GetProperty("properties") - .GetProperty("properties") - .GetProperty("properties"); - fields.TryGetProperty("children", out _).Should().BeFalse(); - } + public void Mapping_Api(bool semantic) => ApiMapping(semantic).GetRawText().Should().MatchJsonSnapshot(); [Test] public void BuildApiPropertyQuery_OnlyADot_ReturnsNull() => SearchQueryBuilder.BuildApiPropertyQuery(".", null).Should().BeNull(); @@ -360,13 +155,7 @@ public void ProcessHit_ManyInnerHits_KeepsTheThreeBestDistinctAnchorsInScoreOrde [Test] public void BuildApiPropertyQuery_DocsWide_HasNoChildrenOption() => - Client - .RequestResponseSerializer - .SerializeToString(SearchQueryBuilder.BuildApiPropertyQuery("retriever.standard", null)) - .Should() - .NotContain("api.properties.parent") - .And - .NotContain("_name"); + Json(SearchQueryBuilder.BuildApiPropertyQuery("retriever.standard", null)).Should().MatchJsonSnapshot(); [Test] public void ProcessHit_InnerHitsWithoutMatchedQueries_AreAllNameMatches() @@ -382,27 +171,6 @@ public void ProcessHit_InnerHitsWithoutMatchedQueries_AreAllNameMatches() result.Properties.Select(p => p.Path).Should().Equal("a", "b", "c"); } - [Test] - [Arguments(false)] - [Arguments(true)] - public void Mapping_ApiPropertiesParent_IsKeyword(bool semantic) - { - var context = semantic - ? DocumentationMappingContext.DocumentationDocumentSemantic.CreateContext(type: "assembler", env: "test") - : DocumentationMappingContext.DocumentationDocument.CreateContext(type: "assembler", env: "test"); - using var json = JsonDocument.Parse(context.GetMappingsJson()); - var parent = json - .RootElement - .GetProperty("properties") - .GetProperty("api") - .GetProperty("properties") - .GetProperty("properties") - .GetProperty("properties") - .GetProperty("parent"); - parent.GetProperty("type").GetString().Should().Be("keyword"); - parent.TryGetProperty("index", out _).Should().BeFalse(); - } - private static string SearchResponseJson( string innerHits, string moreInnerHitGroups = "", diff --git a/tests/Elastic.Documentation.Api.Tests/SearchQueryBuilderTests.cs b/tests/Elastic.Documentation.Api.Tests/SearchQueryBuilderTests.cs index 6ed3b86ef6..e40209c699 100644 --- a/tests/Elastic.Documentation.Api.Tests/SearchQueryBuilderTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/SearchQueryBuilderTests.cs @@ -2,12 +2,13 @@ // 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.Json; using AwesomeAssertions; using Elastic.Clients.Elasticsearch; using Elastic.Documentation.Search; using Elastic.Transport; using Elastic.Transport.Extensions; +using Nullean.Snapshots; +using Nullean.Snapshots.Json; namespace Elastic.Documentation.Api.Tests; @@ -24,7 +25,9 @@ public void BuildApiEndpointQuery_SingleSegment_ReturnsDisMaxOfOneFullBoostTerms .RequestResponseSerializer .SerializeToString(SearchQueryBuilder.BuildApiEndpointQuery(input)) .Should() - .Be("""{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["_search"]}}}}]}}"""); + .MatchInlineJsonSnapshot( + """{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["_search"]}}}}]}}""" + ); [Test] public void BuildApiEndpointQuery_ValueThenEndpoint_ScoresFullTermAboveWildcardTemplates() => @@ -32,38 +35,85 @@ public void BuildApiEndpointQuery_ValueThenEndpoint_ScoresFullTermAboveWildcardT .RequestResponseSerializer .SerializeToString(SearchQueryBuilder.BuildApiEndpointQuery("my-index/_search")) .Should() - .Be( + .MatchInlineJsonSnapshot( """{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["my-index/_search"]}}}},{"constant_score":{"boost":3.3333333,"filter":{"terms":{"api.endpoints":["*/_search","my-index/*"]}}}}]}}""" ); [Test] - public void BuildApiEndpointQuery_InteriorValue_MatchesTemplateAndNeverTheParentPrefix() - { - var json = Client.RequestResponseSerializer.SerializeToString( - SearchQueryBuilder.BuildApiEndpointQuery("_security/user/alice/_password") - ); - using var doc = JsonDocument.Parse(json); - var clauses = doc - .RootElement - .GetProperty("dis_max") - .GetProperty("queries") - .EnumerateArray() - .Select(q => q.GetProperty("constant_score")) - .ToArray(); - var terms = clauses.Select( - c => c - .GetProperty("filter") - .GetProperty("terms") - .GetProperty("api.endpoints") - .EnumerateArray() - .Select(t => t.GetString()) - .ToArray() - ).ToArray(); - - clauses.Select(c => c.GetProperty("boost").GetSingle()).Should().Equal(5f, 4f, 3f, 2f); - terms[1].Should().Contain("_security/user/*/_password"); - terms.SelectMany(t => t).Should().NotContain("_security/user"); - } + public void BuildApiEndpointQuery_InteriorValue_MatchesTemplateAndNeverTheParentPrefix() => + Client + .RequestResponseSerializer + .SerializeToString(SearchQueryBuilder.BuildApiEndpointQuery("_security/user/alice/_password")) + .Should() + .MatchInlineJsonSnapshot( + """ + { + "dis_max": { + "queries": [ + { + "constant_score": { + "boost": 5, + "filter": { + "terms": { + "api.endpoints": [ + "_security/user/alice/_password" + ] + } + } + } + }, + { + "constant_score": { + "boost": 4, + "filter": { + "terms": { + "api.endpoints": [ + "*/user/alice/_password", + "_security/*/alice/_password", + "_security/user/*/_password", + "_security/user/alice/*" + ] + } + } + } + }, + { + "constant_score": { + "boost": 3, + "filter": { + "terms": { + "api.endpoints": [ + "*/*/alice/_password", + "*/user/*/_password", + "*/user/alice/*", + "_security/*/*/_password", + "_security/*/alice/*", + "_security/user/*/*" + ] + } + } + } + }, + { + "constant_score": { + "boost": 2, + "filter": { + "terms": { + "api.endpoints": [ + "*/*/*/_password", + "*/*/alice/*", + "*/user/*/*", + "_security/*/*/*" + ] + } + } + } + } + ] + } + } + """ + ); [Test] [Arguments(" ")] @@ -80,7 +130,9 @@ public void BuildApiEndpointQuery_RootRoute_ReturnsTheRootTerm(string input) => .RequestResponseSerializer .SerializeToString(SearchQueryBuilder.BuildApiEndpointQuery(input)) .Should() - .Be("""{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["/"]}}}}]}}"""); + .MatchInlineJsonSnapshot( + """{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["/"]}}}}]}}""" + ); [Test] [Arguments("search")] @@ -90,7 +142,9 @@ public void BuildBareWordEndpointQuery_Word_TargetsUnderscoreEndpoint(string inp .RequestResponseSerializer .SerializeToString(SearchQueryBuilder.BuildBareWordEndpointQuery(input)) .Should() - .Be("""{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["_search"]}}}}]}}"""); + .MatchInlineJsonSnapshot( + """{"dis_max":{"queries":[{"constant_score":{"boost":5,"filter":{"terms":{"api.endpoints":["_search"]}}}}]}}""" + ); [Test] [Arguments("_search")] @@ -102,12 +156,10 @@ public void BuildBareWordEndpointQuery_NotABareWord_ReturnsNull(string input) => SearchQueryBuilder.BuildBareWordEndpointQuery(input).Should().BeNull(); [Test] - public void BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms() - { - var query = SearchQueryBuilder.BuildLexicalQuery("_search", new Dictionary(), [], null); - - var json = Client.RequestResponseSerializer.SerializeToString(query); - - json.Should().Contain("""{"terms":{"api.endpoints":["_search"]}}"""); - } + public void BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms() => + Client + .RequestResponseSerializer + .SerializeToString(SearchQueryBuilder.BuildLexicalQuery("_search", new Dictionary(), [], null)) + .Should() + .MatchJsonSnapshot(); } From 0453e4608900f932da8ca2515f1a8df2b4b4c56c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:40:29 +0100 Subject: [PATCH 06/16] Api.Infrastructure.Tests: snapshot the SSE output of the stream transformers StreamTransformerTests parsed the transformer output back into events and asserted counts and a few fields. They now snapshot the SSE text the client receives, with generated ids and clock timestamps scrubbed. The tests became stricter: every event, its order and its content is pinned. The three parameterised ConversationStart checks (event present, id, timestamp) collapse into one snapshot per transformer. The multi-line data test asserted HaveCountGreaterThanOrEqualTo(0), which cannot fail; it now pins the output. Co-Authored-By: Claude Sonnet 5.5 --- ...derStreamTransformer.084fb69f.verified.txt | 3 + ...wayStreamTransformer.5331b6a1.verified.txt | 3 + .../Adapters/AskAi/StreamTransformerTests.cs | 270 +++++++----------- 3 files changed, 108 insertions(+), 168 deletions(-) create mode 100644 tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt create mode 100644 tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt b/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt new file mode 100644 index 0000000000..30a469ae59 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt @@ -0,0 +1,3 @@ +data: {"type":"conversation_start","conversationId":"Guid_1","id":"Guid_2","timestamp":{now}} + +data: {"type":"message_chunk","content":"test","id":"Guid_3","timestamp":{now}} diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt b/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt new file mode 100644 index 0000000000..391bc0933b --- /dev/null +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt @@ -0,0 +1,3 @@ +data: {"type":"conversation_start","conversationId":"Guid_1","id":"Guid_2","timestamp":{now}} + +data: {"type":"message_chunk","content":"test","id":"1","timestamp":1234567890} diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index 743b267d9f..97e2e267d2 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -5,10 +5,12 @@ using System.IO.Pipelines; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using AwesomeAssertions; using Elastic.Documentation.Api.Adapters.AskAi; using Elastic.Documentation.Api.AskAi; using Microsoft.Extensions.Logging.Abstractions; +using Nullean.Snapshots; namespace Elastic.Documentation.Api.Infrastructure.Tests.Adapters.AskAi; @@ -33,6 +35,17 @@ public static async Task> ParseAskAiEventsAsync(Stream stream) } return events; } + + /// The transformer output exactly as the client receives it: SSE text. + public static async Task ReadSseAsync(Stream stream) + { + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + /// Replaces what changes per run: generated conversation ids and millisecond timestamps taken from the clock. + public static SnapshotOptions Scrub() => + new SnapshotOptions().Scrub(Scrubbers.Guids).Scrub(new Regex(@"""timestamp"":\d{13}"), "\"timestamp\":{now}"); } public class AgentBuilderStreamTransformerTests @@ -80,45 +93,30 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() // Act - No generated ID (Agent Builder handles IDs in stream) var outputStream = await _transformer.TransformAsync(inputStream, generatedConversationId: null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); - - // Assert - // Note: Due to async streaming, the final event might not be written before the input stream closes - // In production, real SSE streams stay open, so this isn't an issue - events.Should().HaveCountGreaterThanOrEqualTo(7); - - // Verify we got the key events (ConversationStart comes from stream for Agent Builder) - events.Should().ContainSingle(e => e is AskAiEvent.ConversationStart); - events.Should().ContainSingle(e => e is AskAiEvent.Reasoning); - events.Should().ContainSingle(e => e is AskAiEvent.SearchToolCall); - events.Should().ContainSingle(e => e is AskAiEvent.ToolResult); - events.Should().Contain(e => e is AskAiEvent.MessageChunk); - events.Should().ContainSingle(e => e is AskAiEvent.MessageComplete); - - // Verify specific content - var convStart = events.OfType().First(); - convStart.ConversationId.Should().Be("360222c5-76aa-405a-8316-703e1061b621"); - - var reasoning = events.OfType().First(); - reasoning.Message.Should().Contain("Searching"); - - // Tool call should be SearchToolCall type with extracted query - var searchToolCall = events.OfType().FirstOrDefault(); - searchToolCall.Should().NotBeNull(); - searchToolCall.ToolCallId.Should().Be("tooluse_abc123"); - searchToolCall.SearchQuery.Should().Be("semantic search"); - - var toolResult = events.OfType().First(); - toolResult.ToolCallId.Should().Be("tooluse_abc123"); - toolResult.Result.Should().Contain("semantic-docs-prod-latest"); - - var chunks = events.OfType().ToList(); - chunks.Should().HaveCount(2); - chunks[0].Content.Should().Be("Hello"); - chunks[1].Content.Should().Be(" world"); - - var complete = events.OfType().First(); - complete.FullContent.Should().Be("Hello world"); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); + + // Assert - every event type of the Agent Builder stream, in order, with its content + // (ConversationStart comes from the stream for Agent Builder) + output.Should().MatchInlineSnapshot( + options: StreamTransformerTestHelpers.Scrub(), + expected: """ + data: {"type":"conversation_start","conversationId":"Guid_1","id":"Guid_2","timestamp":{now}} + + data: {"type":"reasoning","message":"Searching for relevant documents...","id":"Guid_3","timestamp":{now}} + + data: {"type":"search_tool_call","toolCallId":"tooluse_abc123","searchQuery":"semantic search","id":"Guid_4","timestamp":{now}} + + data: {"type":"tool_result","toolCallId":"tooluse_abc123","result":"[{\u0022type\u0022:\u0022query\u0022,\u0022data\u0022:{\u0022esql\u0022:\u0022FROM semantic-docs-prod-latest | WHERE MATCH(title.semantic_text, \\\u0022semantic search\\\u0022)\u0022},\u0022tool_result_id\u0022:\u0022result1\u0022}]","id":"Guid_5","timestamp":{now}} + + data: {"type":"message_chunk","content":"Hello","id":"Guid_6","timestamp":{now}} + + data: {"type":"message_chunk","content":" world","id":"Guid_7","timestamp":{now}} + + data: {"type":"message_complete","fullContent":"Hello world","id":"Guid_8","timestamp":{now}} + + data: {"type":"conversation_end","id":"Guid_9","timestamp":{now}} + """ + ); } [Test] @@ -143,11 +141,17 @@ public async Task TransformAsyncWithKeepAliveCommentsSkipsThem() // Act var outputStream = await _transformer.TransformAsync(inputStream, generatedConversationId: null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); - // Assert - Should have at least 1 event (round_complete might not be written in time) - events.Should().HaveCountGreaterThanOrEqualTo(1); - events[0].Should().BeOfType(); + // Assert - the keepalive comments are dropped + output.Should().MatchInlineSnapshot( + options: StreamTransformerTestHelpers.Scrub(), + expected: """ + data: {"type":"message_chunk","content":"test","id":"Guid_1","timestamp":{now}} + + data: {"type":"conversation_end","id":"Guid_2","timestamp":{now}} + """ + ); } [Test] @@ -167,12 +171,14 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() // Act var outputStream = await _transformer.TransformAsync(inputStream, generatedConversationId: null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); - // Assert - This test has malformed SSE data (missing proper blank line terminator) - // In a real scenario with proper SSE formatting, this would work - // For now, skip this test or mark as known limitation - events.Should().HaveCountGreaterThanOrEqualTo(0); + // This test has malformed SSE data (missing proper blank line terminator). The old assertion + // (HaveCountGreaterThanOrEqualTo(0)) could not fail; the snapshot pins what is actually emitted. + output.Should().MatchInlineSnapshot( + options: StreamTransformerTestHelpers.Scrub(), + expected: "data: {\"type\":\"message_chunk\",\"content\":\"multiline\",\"id\":\"Guid_1\",\"timestamp\":{now}}" + ); } } @@ -217,46 +223,28 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() // Act - Simulate new conversation to get ConversationStart event var testConversationId = Guid.NewGuid().ToString(); var outputStream = await _transformer.TransformAsync(inputStream, Guid.Parse(testConversationId), null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); - - // Assert - events.Should().HaveCount(7); - - // Event 1: ConversationStart (emitted by transformer for new conversation) - events[0].Should().BeOfType(); - var convStart = events[0] as AskAiEvent.ConversationStart; - convStart!.ConversationId.Should().Be(testConversationId); - - // Event 2: ai_message_chunk (first) - events[1].Should().BeOfType(); - var chunk1 = events[1] as AskAiEvent.MessageChunk; - chunk1!.Content.Should().Be("Hello"); - - // Event 3: ai_message_chunk (second) - events[2].Should().BeOfType(); - var chunk2 = events[2] as AskAiEvent.MessageChunk; - chunk2!.Content.Should().Be(" world"); - - // Event 4: tool_call -> Should be SearchToolCall with extracted query - events[3].Should().BeOfType(); - var searchToolCall = events[3] as AskAiEvent.SearchToolCall; - searchToolCall!.ToolCallId.Should().Be("tool1"); - searchToolCall.SearchQuery.Should().Be("Index Lifecycle Management (ILM) Elasticsearch documentation"); - - // Event 5: tool_message - events[4].Should().BeOfType(); - var toolResult = events[4] as AskAiEvent.ToolResult; - toolResult!.ToolCallId.Should().Be("tool1"); - // Result is intentionally empty - payload stripped to prevent CloudFront OAC buffering issues - toolResult.Result.Should().BeEmpty(); - - // Event 6: ai_message - events[5].Should().BeOfType(); - var complete = events[5] as AskAiEvent.MessageComplete; - complete!.FullContent.Should().Be("Hello world"); - - // Event 7: agent_end - events[6].Should().BeOfType(); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); + + // Assert - ConversationStart (generated by the transformer), both chunks, the search tool call with its extracted query, + // the tool result with the payload stripped on purpose (CloudFront OAC buffering), the complete message and the end + output.Should().MatchInlineSnapshot( + options: StreamTransformerTestHelpers.Scrub(), + expected: """ + data: {"type":"conversation_start","conversationId":"Guid_1","id":"Guid_2","timestamp":{now}} + + data: {"type":"message_chunk","content":"Hello","id":"2","timestamp":1234567891} + + data: {"type":"message_chunk","content":" world","id":"3","timestamp":1234567892} + + data: {"type":"search_tool_call","toolCallId":"tool1","searchQuery":"Index Lifecycle Management (ILM) Elasticsearch documentation","id":"4","timestamp":1234567893} + + data: {"type":"tool_result","toolCallId":"tool1","result":"","id":"5","timestamp":1234567894} + + data: {"type":"message_complete","fullContent":"Hello world","id":"6","timestamp":1234567895} + + data: {"type":"conversation_end","id":"7","timestamp":1234567896} + """ + ); } [Test] @@ -284,12 +272,17 @@ public async Task TransformAsyncWithEmptyDataLinesSkipsThem() // Act - Simulate new conversation var testConversationId = Guid.NewGuid().ToString(); var outputStream = await _transformer.TransformAsync(inputStream, Guid.Parse(testConversationId), null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); + + // Assert - the empty data lines are skipped: only ConversationStart and ConversationEnd remain + output.Should().MatchInlineSnapshot( + options: StreamTransformerTestHelpers.Scrub(), + expected: """ + data: {"type":"conversation_start","conversationId":"Guid_1","id":"Guid_2","timestamp":{now}} - // Assert - Should only have 2 events (ConversationStart + ConversationEnd) - events.Should().HaveCount(2); - events[0].Should().BeOfType(); - events[1].Should().BeOfType(); + data: {"type":"conversation_end","id":"2","timestamp":1234567891} + """ + ); } [Test] @@ -311,12 +304,17 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() // Act - Simulate new conversation var testConversationId = Guid.NewGuid().ToString(); var outputStream = await _transformer.TransformAsync(inputStream, Guid.Parse(testConversationId), null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); + + // Assert - the model lifecycle events are skipped: only ConversationStart and the message chunk remain + output.Should().MatchInlineSnapshot( + options: StreamTransformerTestHelpers.Scrub(), + expected: """ + data: {"type":"conversation_start","conversationId":"Guid_1","id":"Guid_2","timestamp":{now}} - // Assert - Should only have the ConversationStart and message chunk, model events skipped - events.Should().HaveCount(2); - events[0].Should().BeOfType(); - events[1].Should().BeOfType(); + data: {"type":"message_chunk","content":"test","id":"2","timestamp":1234567891} + """ + ); } } @@ -350,6 +348,10 @@ public class StreamTransformerCommonBehaviorTests """); } + /// + /// A new conversation starts with a ConversationStart event that has a conversation id, an event id and a clock + /// timestamp (scrubbed to {now}, which also fails on a zero timestamp), whatever the transformer. + /// [Test] [MethodDataSource(nameof(StreamTransformerTestCases))] public async Task TransformAsyncWhenIsNewConversationEmitsConversationStartEvent( @@ -358,80 +360,12 @@ public async Task TransformAsyncWhenIsNewConversationEmitsConversationStartEvent string sseData ) { - // Arrange - var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); - var testConversationId = Guid.NewGuid().ToString(); - - // Act - Pass isNewConversation: true to simulate new conversation - var outputStream = await transformer.TransformAsync(inputStream, Guid.Parse(testConversationId), null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); - - // Assert - Should have ConversationStart event - events.Should().ContainSingle( - e => e is AskAiEvent.ConversationStart, - $"{transformerName} should emit ConversationStart when isNewConversation is true" - ); - - var conversationStart = events.OfType().First(); - conversationStart - .ConversationId - .Should() - .NotBeNullOrEmpty($"{transformerName} should have a non-empty conversation ID in ConversationStart event"); - } - - [Test] - [MethodDataSource(nameof(StreamTransformerTestCases))] - public async Task TransformAsyncConversationStartEventHasValidTimestamp( - string transformerName, - IStreamTransformer transformer, - string sseData - ) - { - // Arrange var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); var testConversationId = Guid.NewGuid().ToString(); - // Act var outputStream = await transformer.TransformAsync(inputStream, Guid.Parse(testConversationId), null, CancellationToken.None); - var events = new List(); - var reader = PipeReader.Create(outputStream); - await foreach (var sseEvent in SseParser.ParseAsync(reader, CancellationToken.None)) - { - var evt = JsonSerializer.Deserialize(sseEvent.Data, AskAiEventJsonContext.Default.AskAiEvent); - if (evt != null) - events.Add(evt); - } - - // Assert - var conversationStart = events.OfType().FirstOrDefault(); - conversationStart.Should().NotBeNull($"{transformerName} should emit ConversationStart event"); - - conversationStart.Timestamp.Should().BeGreaterThan(0, $"{transformerName} ConversationStart should have a valid timestamp"); - } - - [Test] - [MethodDataSource(nameof(StreamTransformerTestCases))] - public async Task TransformAsyncConversationStartEventHasValidId(string transformerName, IStreamTransformer transformer, string sseData) - { - // Arrange - var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); - var testConversationId = Guid.NewGuid().ToString(); - - // Act - var outputStream = await transformer.TransformAsync(inputStream, Guid.Parse(testConversationId), null, CancellationToken.None); - var events = new List(); - var reader = PipeReader.Create(outputStream); - await foreach (var sseEvent in SseParser.ParseAsync(reader, CancellationToken.None)) - { - var evt = JsonSerializer.Deserialize(sseEvent.Data, AskAiEventJsonContext.Default.AskAiEvent); - if (evt != null) - events.Add(evt); - } - - // Assert - var conversationStart = events.OfType().FirstOrDefault(); - conversationStart.Should().NotBeNull($"{transformerName} should emit ConversationStart event"); + var output = await StreamTransformerTestHelpers.ReadSseAsync(outputStream); - conversationStart.Id.Should().NotBeNullOrEmpty($"{transformerName} ConversationStart should have a non-empty event ID"); + output.Should().MatchSnapshot(name: transformerName, options: StreamTransformerTestHelpers.Scrub()); } } From bb4e647b43c669370cec79fb500197712caeca5c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:41:40 +0100 Subject: [PATCH 07/16] Build.Tests: snapshot the sitemap XML and the sitemap search body SitemapTests parsed the XML and checked element by element, and checked the PIT search body with Contain. The generated sitemap.xml and the search body are now inline snapshots, so the tests became stricter (namespace, declaration, lastmod format, size, sort and all). The ordering and API-exclusion tests used the current time for lastmod; they use a fixed date now. Co-Authored-By: Claude Sonnet 5.5 --- .../SitemapTests.cs | 212 ++++++++++++++---- 1 file changed, 165 insertions(+), 47 deletions(-) diff --git a/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs b/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs index 12eaba7b39..bba6cecd59 100644 --- a/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs @@ -3,10 +3,10 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions.TestingHelpers; -using System.Text.Json; -using System.Xml.Linq; using AwesomeAssertions; using Elastic.Documentation.Assembler.Building; +using Nullean.Snapshots; +using Nullean.Snapshots.Json; namespace Elastic.Documentation.Build.Tests; @@ -28,23 +28,27 @@ public void Generate_WritesValidSitemapXml_WithCorrectLastModDates() // Act SitemapBuilder.Generate(entries, fs, outputDir); - // Assert - var sitemapPath = fs.Path.Join("/output", "sitemap.xml"); - fs.File.Exists(sitemapPath).Should().BeTrue(); - - var content = fs.File.ReadAllText(sitemapPath); - var doc = XDocument.Parse(content); - XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; - - var urls = doc.Descendants(ns + "url").ToList(); - urls.Should().HaveCount(2); - - // Entries are ordered by URL - urls[0].Element(ns + "loc")!.Value.Should().Be("https://www.elastic.co/docs/elasticsearch/getting-started"); - urls[0].Element(ns + "lastmod")!.Value.Should().Contain("2025-06-15"); - - urls[1].Element(ns + "loc")!.Value.Should().Be("https://www.elastic.co/docs/kibana/dashboard"); - urls[1].Element(ns + "lastmod")!.Value.Should().Contain("2025-07-20"); + // Assert - entries are ordered by URL, lastmod comes from the entry + fs.File.Exists(fs.Path.Join("/output", "sitemap.xml")).Should().BeTrue(); + fs + .File + .ReadAllText(fs.Path.Join("/output", "sitemap.xml")) + .Should() + .MatchInlineSnapshot( + """ + + + + https://www.elastic.co/docs/elasticsearch/getting-started + 2025-06-15T10:30:00.0000000+00:00 + + + https://www.elastic.co/docs/kibana/dashboard + 2025-07-20T14:00:00.0000000+00:00 + + + """ + ); } [Test] @@ -71,19 +75,36 @@ public void Generate_OrdersUrlsAlphabetically() var outputDir = fs.DirectoryInfo.New("/output"); fs.Directory.CreateDirectory("/output"); - var now = DateTimeOffset.UtcNow; + var now = new DateTimeOffset(2025, 6, 15, 10, 30, 0, TimeSpan.Zero); var entries = new Dictionary { ["/docs/z-last"] = now, ["/docs/a-first"] = now, ["/docs/m-middle"] = now, }; // Act SitemapBuilder.Generate(entries, fs, outputDir); // Assert - var content = fs.File.ReadAllText(fs.Path.Join("/output", "sitemap.xml")); - var doc = XDocument.Parse(content); - XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; - - var locs = doc.Descendants(ns + "loc").Select(e => e.Value).ToList(); - locs.Should().BeInAscendingOrder(); + fs + .File + .ReadAllText(fs.Path.Join("/output", "sitemap.xml")) + .Should() + .MatchInlineSnapshot( + """ + + + + https://www.elastic.co/docs/a-first + 2025-06-15T10:30:00.0000000+00:00 + + + https://www.elastic.co/docs/m-middle + 2025-06-15T10:30:00.0000000+00:00 + + + https://www.elastic.co/docs/z-last + 2025-06-15T10:30:00.0000000+00:00 + + + """ + ); } [Test] @@ -142,7 +163,7 @@ public void Generate_ExcludesApiDocsFromSitemap() var fs = new MockFileSystem(); var outputDir = fs.DirectoryInfo.New("/output"); - var now = DateTimeOffset.UtcNow; + var now = new DateTimeOffset(2025, 6, 15, 10, 30, 0, TimeSpan.Zero); var entries = new Dictionary { ["/docs/elasticsearch/getting-started"] = now, @@ -154,16 +175,26 @@ public void Generate_ExcludesApiDocsFromSitemap() // Act SitemapBuilder.Generate(entries, fs, outputDir); - // Assert - var content = fs.File.ReadAllText(fs.Path.Join("/output", "sitemap.xml")); - var doc = XDocument.Parse(content); - XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; - - var locs = doc.Descendants(ns + "loc").Select(e => e.Value).ToList(); - locs.Should().HaveCount(2); - locs.Should().NotContain(l => l.Contains("/docs/api/")); - locs.Should().Contain("https://www.elastic.co/docs/elasticsearch/getting-started"); - locs.Should().Contain("https://www.elastic.co/docs/kibana/dashboard"); + // Assert - the /docs/api/ entries are left out + fs + .File + .ReadAllText(fs.Path.Join("/output", "sitemap.xml")) + .Should() + .MatchInlineSnapshot( + """ + + + + https://www.elastic.co/docs/elasticsearch/getting-started + 2025-06-15T10:30:00.0000000+00:00 + + + https://www.elastic.co/docs/kibana/dashboard + 2025-06-15T10:30:00.0000000+00:00 + + + """ + ); } [Test] @@ -173,11 +204,37 @@ public void BuildSearchBody_FirstPage_HasPitButNoSearchAfter() var json = EsSitemapReader.BuildSearchBody("test-pit-id", null); // Assert - json.Should().Contain("\"pit\""); - json.Should().Contain("\"test-pit-id\""); - json.Should().Contain("\"must_not\""); - json.Should().Contain("\"hidden\":true"); - json.Should().NotContain("search_after"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "size": 1000, + "_source": [ + "url", + "content_last_updated" + ], + "query": { + "bool": { + "must_not": [ + { + "term": { + "hidden": true + } + } + ] + } + }, + "pit": { + "id": "test-pit-id", + "keep_alive": "2m" + }, + "sort": [ + { + "url": "asc" + } + ] + } + """ + ); } [Test] @@ -187,8 +244,40 @@ public void BuildSearchBody_SubsequentPage_IncludesSearchAfter() var json = EsSitemapReader.BuildSearchBody("test-pit-id", ["/docs/last-url"]); // Assert - json.Should().Contain("\"search_after\""); - json.Should().Contain("/docs/last-url"); + json.Should().MatchInlineJsonSnapshot( + """ + { + "size": 1000, + "_source": [ + "url", + "content_last_updated" + ], + "query": { + "bool": { + "must_not": [ + { + "term": { + "hidden": true + } + } + ] + } + }, + "pit": { + "id": "test-pit-id", + "keep_alive": "2m" + }, + "sort": [ + { + "url": "asc" + } + ], + "search_after": [ + "/docs/last-url" + ] + } + """ + ); } [Test] @@ -196,9 +285,38 @@ public void BuildSearchBody_EscapesSpecialCharactersInPitId() { // Act var json = EsSitemapReader.BuildSearchBody("pit-with-\"quotes\"", null); - var doc = JsonDocument.Parse(json); - // Assert — verify the value round-trips correctly through serialization - doc.RootElement.GetProperty("pit").GetProperty("id").GetString().Should().Be("pit-with-\"quotes\""); + // Assert - the quotes are escaped, so the value round-trips through serialization + json.Should().MatchInlineJsonSnapshot( + """ + { + "size": 1000, + "_source": [ + "url", + "content_last_updated" + ], + "query": { + "bool": { + "must_not": [ + { + "term": { + "hidden": true + } + } + ] + } + }, + "pit": { + "id": "pit-with-\"quotes\"", + "keep_alive": "2m" + }, + "sort": [ + { + "url": "asc" + } + ] + } + """ + ); } } From f0ebbb9f17eb45bc973b8ed1d24bb71432356bdc Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 12:42:35 +0100 Subject: [PATCH 08/16] Mcp.Remote.Tests: one inline snapshot per profile's server instructions McpServerInstructionTests had ~30 Contain/NotContain checks on the composed instruction text, plus two tests that already compared the whole text with Be. The Contain tests are subsumed by the whole-text comparison, so they are removed and the two exact tests became inline snapshots with the same literals (every fact the Contain/NotContain checks asserted is visible in them). Co-Authored-By: Claude Sonnet 5.5 --- .../McpServerInstructionTests.cs | 63 +++---------------- 1 file changed, 7 insertions(+), 56 deletions(-) diff --git a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs index f559f8183e..2ceba051ad 100644 --- a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs +++ b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs @@ -4,59 +4,12 @@ using AwesomeAssertions; using Elastic.Documentation.Mcp.Remote; +using Nullean.Snapshots; namespace Mcp.Remote.Tests; public class McpServerInstructionTests { - [Test] - public void PublicProfile_ContainsAllModuleGuidance() - { - var instructions = McpServerProfile.Public.ComposeServerInstructions(); - - instructions.Should().Contain("Use this server to search, retrieve, and analyze"); - instructions.Should().Contain("Elastic product documentation published at elastic.co/docs"); - instructions.Should().Contain(""); - instructions.Should().Contain("Use the server when the user:"); - instructions.Should().Contain(""); - instructions.Should().Contain("Prefer search_docs over a general web search"); - instructions.Should().Contain("Use get_document_by_url to retrieve a specific page"); - instructions.Should().Contain("Use find_related_docs when exploring what documentation exists"); - instructions.Should().Contain("Use check_docs_coherence or find_docs_inconsistencies when reviewing or auditing"); - } - - [Test] - public void InternalProfile_ContainsSearchAndDocumentGuidanceOnly() - { - var instructions = McpServerProfile.Internal.ComposeServerInstructions(); - - instructions.Should().Contain("Use this server to search and retrieve"); - instructions.Should().Contain("Elastic internal documentation: team processes, run books, architecture"); - instructions.Should().Contain("Prefer search_internal_docs over a general web search"); - instructions.Should().Contain("Use get_internal_document_by_url to retrieve a specific page"); - instructions.Should().Contain("Use find_related_internal_docs when exploring what documentation exists"); - instructions.Should().NotContain("check_coherence"); - instructions.Should().NotContain("find_inconsistencies"); - instructions.Should().NotContain("resolve_cross_link"); - instructions.Should().NotContain("list_content_types"); - instructions.Should().NotContain("generate_template"); - } - - [Test] - public void Triggers_AreProfileSpecific() - { - var publicInstructions = McpServerProfile.Public.ComposeServerInstructions(); - var internalInstructions = McpServerProfile.Internal.ComposeServerInstructions(); - - publicInstructions.Should().Contain("Elastic documentation pages"); - publicInstructions.Should().Contain("References Elastic product names"); - publicInstructions.Should().NotContain("internal team processes"); - - internalInstructions.Should().Contain("Elastic internal documentation pages"); - internalInstructions.Should().Contain("internal team processes"); - internalInstructions.Should().NotContain("Elastic product names"); - } - [Test] public void PublicProfile_ServiceName_IsDocsMcp() => McpServerProfile.Public.ServiceName.Should().Be("docs-mcp"); @@ -102,7 +55,7 @@ public void PublicProfile_ComposesExactInstructions() { var instructions = McpServerProfile.Public.ComposeServerInstructions(); - var expected = + instructions.Should().MatchInlineSnapshot( """ Use this server to search, retrieve, and analyze Elastic product documentation published at elastic.co/docs. All content covers version 9 and later. @@ -120,9 +73,8 @@ public void PublicProfile_ComposesExactInstructions() - Use get_document_by_url to retrieve a specific page when the user provides or you already know the URL. - Use check_docs_coherence or find_docs_inconsistencies when reviewing or auditing documentation quality. - """; - - instructions.Should().Be(expected.ReplaceLineEndings("\n")); + """ + ); } [Test] @@ -130,7 +82,7 @@ public void InternalProfile_ComposesExactInstructions() { var instructions = McpServerProfile.Internal.ComposeServerInstructions(); - var expected = + instructions.Should().MatchInlineSnapshot( """ Use this server to search and retrieve Elastic internal documentation: team processes, run books, architecture, and other internal knowledge. @@ -146,9 +98,8 @@ public void InternalProfile_ComposesExactInstructions() - Use find_related_internal_docs when exploring what documentation exists around a topic. - Use get_internal_document_by_url to retrieve a specific page when the user provides or you already know the URL. - """; - - instructions.Should().Be(expected.ReplaceLineEndings("\n")); + """ + ); } private static List ExtractBullets(string instructions) => From e3ff2669b3ae29739d5ece8b4ae66ebed0ace856 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:09:32 +0100 Subject: [PATCH 09/16] 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 10/16] 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 11/16] 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 388a445602c2183e0e137078820786ca2dae7867 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:31:52 +0100 Subject: [PATCH 12/16] Shorten the stream transformer test so its snapshots fit a Windows path The common-behaviour test's two verified files were 230 characters from the repository root. With the runner's checkout folder in front, and the longer received path under .artifacts when a run fails, they passed Windows' 260 characters, and git on the runner could not check the pull request out at all. The method is now NewConversationEmitsConversationStart. The files are renamed, with the same content. Co-Authored-By: Claude Sonnet 5.5 --- ...art.AgentBuilderStreamTransformer.084fb69f.verified.txt} | 0 ...Start.LlmGatewayStreamTransformer.5331b6a1.verified.txt} | 0 .../Adapters/AskAi/StreamTransformerTests.cs | 6 +----- 3 files changed, 1 insertion(+), 5 deletions(-) rename tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/{TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt => NewConversationEmitsConversationStart.AgentBuilderStreamTransformer.084fb69f.verified.txt} (100%) rename tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/{TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt => NewConversationEmitsConversationStart.LlmGatewayStreamTransformer.5331b6a1.verified.txt} (100%) diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt b/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/NewConversationEmitsConversationStart.AgentBuilderStreamTransformer.084fb69f.verified.txt similarity index 100% rename from tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.AgentBuilderStreamTransformer.084fb69f.verified.txt rename to tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/NewConversationEmitsConversationStart.AgentBuilderStreamTransformer.084fb69f.verified.txt diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt b/tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/NewConversationEmitsConversationStart.LlmGatewayStreamTransformer.5331b6a1.verified.txt similarity index 100% rename from tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/TransformAsyncWhenIsNewConversationEmitsConversationStartEvent.LlmGatewayStreamTransformer.5331b6a1.verified.txt rename to tests/Elastic.Documentation.Api.Infrastructure.Tests/.snapshots/Adapters/AskAi/StreamTransformerCommonBehaviorTests/NewConversationEmitsConversationStart.LlmGatewayStreamTransformer.5331b6a1.verified.txt diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index 97e2e267d2..f76d65bbef 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -354,11 +354,7 @@ public class StreamTransformerCommonBehaviorTests /// [Test] [MethodDataSource(nameof(StreamTransformerTestCases))] - public async Task TransformAsyncWhenIsNewConversationEmitsConversationStartEvent( - string transformerName, - IStreamTransformer transformer, - string sseData - ) + public async Task NewConversationEmitsConversationStart(string transformerName, IStreamTransformer transformer, string sseData) { var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); var testConversationId = Guid.NewGuid().ToString(); From 2b0c0d96482ca8657090d80cb9c58fe6556356eb Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:50:06 +0100 Subject: [PATCH 13/16] 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"); + } } From b76f898c5c519bd56c89d91d6a36377b865f40f4 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 14:51:01 +0100 Subject: [PATCH 14/16] Give the stream transformer test cases the same line endings on every platform The SSE text is an argument of NewConversationEmitsConversationStart, and the name of its snapshot files carries a hash of the arguments. A checkout on Windows turns this file's line endings into "\r\n", so the text, and with it the hash, differed there: the runner looked for `...AgentBuilderStreamTransformer.ea024bf4.verified.txt` where macOS and Linux write `.084fb69f`. The text is now normalised to "\n". Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/AskAi/StreamTransformerTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index f76d65bbef..da96d7a1a4 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -325,6 +325,8 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() /// public class StreamTransformerCommonBehaviorTests { + // The SSE text is an argument of the test, and the snapshot file name carries a hash of the arguments. A checkout on Windows turns the + // line endings of this file into "\r\n", so the text is normalised, or the hash and the file name would differ per platform. public static IEnumerable> StreamTransformerTestCases() { yield return () => @@ -337,7 +339,9 @@ public class StreamTransformerCommonBehaviorTests event: message_chunk data: {"data":{"text_chunk":"test"}} - """); + """.ReplaceLineEndings( + "\n" + )); yield return () => ("LlmGatewayStreamTransformer", new LlmGatewayStreamTransformer(NullLogger.Instance), // LLM Gateway SSE format - minimal events @@ -345,7 +349,9 @@ public class StreamTransformerCommonBehaviorTests event: agent_stream_output data: [null, {"type":"ai_message_chunk","id":"1","timestamp":1234567890,"data":{"content":"test"}}] - """); + """.ReplaceLineEndings( + "\n" + )); } /// From e3b6643a07e11eeb75d00c40d494e0c1ca7a8c3d Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 15:45:23 +0100 Subject: [PATCH 15/16] Update the API search snapshots for the version-tree scoping #4732 scoped API navigation search to the reader's version tree. It added the `key` and `trees` fields to the API property mapping and moved the filter that excludes bare /docs roots and hidden documents to wrap the whole query, lexical and semantic. These snapshots caught both, which the old Contain checks would not have. Ten snapshots changed and nothing else in them: two mapping snapshots gain the `key` and `trees` fields (15 lines each), and eight query snapshots lose the same 23-line filter block, which is now applied around the whole query. Co-Authored-By: Claude Sonnet 5.5 --- ...BeforeEndpointBoost.c624ce26.verified.json | 23 ------------------- ...BeforeEndpointBoost.d0302c76.verified.json | 23 ------------------- ...dingsWithUniqueInnerHitNames.verified.json | 23 ------------------- ...y_DocsWide_HasNoChildrenLift.verified.json | 23 ------------------- ...edAfterItsInnerHits.c624ce26.verified.json | 23 ------------------- ...edAfterItsInnerHits.edaac49a.verified.json | 23 ------------------- ...quiredForTheSurvivingReading.verified.json | 23 ------------------- .../Mapping_Api.2f0e22ee.verified.json | 15 ++++++++++++ .../Mapping_Api.54b63aa5.verified.json | 15 ++++++++++++ ...ry_IncludesApiEndpointsTerms.verified.json | 23 ------------------- 10 files changed, 30 insertions(+), 184 deletions(-) diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json index 36bdaa362d..9f677dc756 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.c624ce26.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json index 36bdaa362d..9f677dc756 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideMarkedEndpointAndField_RequiresFieldMatchBeforeEndpointBoost.d0302c76.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json index 819700aa32..b16777a44e 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWideTwoIdentifierWords_TriesBothReadingsWithUniqueInnerHitNames.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json index 59964eca21..db79f54ebf 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_DocsWide_HasNoChildrenLift.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json index 36bdaa362d..9f677dc756 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.c624ce26.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json index 196433fb95..36830e6af6 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_SingleReading_IsNamedAfterItsInnerHits.edaac49a.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json index c5eff12680..68a0053676 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/ApplyApiPropertyQuery_TwoPlainWordsDocsWide_KeepsTheEndpointRequiredForTheSurvivingReading.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "minimum_should_match": 1, "should": [ { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json index cadfebc3f3..09282a0c34 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.2f0e22ee.verified.json @@ -1,4 +1,19 @@ { + "key": { + "type": "keyword" + }, + "trees": { + "type": "object", + "properties": { + "tree": { + "type": "keyword" + }, + "root": { + "type": "keyword", + "index": false + } + } + }, "properties": { "type": "nested", "properties": { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json index cadfebc3f3..09282a0c34 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/ApiPropertySearchTests/Mapping_Api.54b63aa5.verified.json @@ -1,4 +1,19 @@ { + "key": { + "type": "keyword" + }, + "trees": { + "type": "object", + "properties": { + "tree": { + "type": "keyword" + }, + "root": { + "type": "keyword", + "index": false + } + } + }, "properties": { "type": "nested", "properties": { diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json b/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json index 3bfd329537..6671182316 100644 --- a/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json +++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/SearchQueryBuilderTests/BuildLexicalQuery_EndpointQuery_IncludesApiEndpointsTerms.verified.json @@ -1,28 +1,5 @@ { "bool": { - "filter": { - "bool": { - "must_not": [ - { - "terms": { - "path.keyword": [ - "/docs", - "/docs/", - "/docs/404", - "/docs/404/" - ] - } - }, - { - "term": { - "hidden": { - "value": true - } - } - } - ] - } - }, "must": { "bool": { "should": [ From 14cf8de1a8bc0bf8647e20b3b6266154384517bf Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 5 Nov 2026 15:46:06 +0100 Subject: [PATCH 16/16] Update the SiteSearch mapping snapshots for the version-tree scoping The two whole-mapping snapshots (DocumentationDocument and WebsiteSearchDocument) hold the API property mapping too, so #4732's new `key` and `trees` fields show up there as well: the only change in either file. Co-Authored-By: Claude Sonnet 5.5 --- ...ocumentationDocument_MappingJson.verified.json | 15 +++++++++++++++ ...ebsiteSearchDocument_MappingJson.verified.json | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json index b786502c59..38cf5b4569 100644 --- a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/DocumentationDocument_MappingJson.verified.json @@ -3,6 +3,21 @@ "api": { "type": "object", "properties": { + "key": { + "type": "keyword" + }, + "trees": { + "type": "object", + "properties": { + "tree": { + "type": "keyword" + }, + "root": { + "type": "keyword", + "index": false + } + } + }, "properties": { "type": "nested", "properties": { diff --git a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json index 943037a64a..1130a577ac 100644 --- a/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json +++ b/tests/Elastic.SiteSearch.Tests/.snapshots/MappingStructureTests/WebsiteSearchDocument_MappingJson.verified.json @@ -380,6 +380,21 @@ "api": { "type": "object", "properties": { + "key": { + "type": "keyword" + }, + "trees": { + "type": "object", + "properties": { + "tree": { + "type": "keyword" + }, + "root": { + "type": "keyword", + "index": false + } + } + }, "properties": { "type": "nested", "properties": {