From c0832b646754ae600627221acfcbe95e45a8d8ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:05:16 +0000 Subject: [PATCH 1/4] test: add 25 unit tests for HtmlNode.ToString and HtmlDocument.ToString serialization Covers: - Leaf nodes: NewText, NewComment, NewCData - Void elements: all 16 standard HTML void elements serialise as self-closing - Non-void elements: empty, text-only (inline), element children (indented) - Indentation: per-level 2-space indent; element vs. text-only sibling behaviour - Attributes: single, multiple, name lower-casing - HtmlDocument: with/without DOCTYPE, multiple root elements, empty element list - Round-trip: manually-constructed node serialises then parses back correctly 25 tests pass (net8.0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FSharp.Data.Core.Tests.fsproj | 1 + .../HtmlNodeSerialization.fs | 225 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 tests/FSharp.Data.Core.Tests/HtmlNodeSerialization.fs diff --git a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj index fee4b9d26..8947b3d4f 100644 --- a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj +++ b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj @@ -40,6 +40,7 @@ + diff --git a/tests/FSharp.Data.Core.Tests/HtmlNodeSerialization.fs b/tests/FSharp.Data.Core.Tests/HtmlNodeSerialization.fs new file mode 100644 index 000000000..8c9288165 --- /dev/null +++ b/tests/FSharp.Data.Core.Tests/HtmlNodeSerialization.fs @@ -0,0 +1,225 @@ +module FSharp.Data.Tests.HtmlNodeSerialization + +open NUnit.Framework +open FsUnit +open FSharp.Data + +// ───────────────────────────────────────────────────────────────────────────── +// HtmlNode.ToString() — leaf nodes +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``HtmlNode.NewText serializes to its content`` () = + HtmlNode.NewText "hello world" |> string |> should equal "hello world" + +[] +let ``HtmlNode.NewText with empty string serializes to empty`` () = + HtmlNode.NewText "" |> string |> should equal "" + +[] +let ``HtmlNode.NewComment serializes to HTML comment`` () = + HtmlNode.NewComment " a comment " |> string |> should equal "" + +[] +let ``HtmlNode.NewCData serializes to CDATA section`` () = + HtmlNode.NewCData "some content" |> string |> should equal " content]]>" + +// ───────────────────────────────────────────────────────────────────────────── +// HtmlNode.ToString() — void elements (should self-close with " />") +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``Void element 'area' serializes as self-closing`` () = + HtmlNode.NewElement "area" |> string |> should equal "" + +[] +let ``Void element 'br' serializes as self-closing`` () = + HtmlNode.NewElement "br" |> string |> should equal "
" + +[] +let ``Void element 'img' with attribute serializes as self-closing`` () = + HtmlNode.NewElement("img", [ "src", "logo.png"; "alt", "Logo" ]) |> string + |> should equal """Logo""" + +[] +let ``All standard void elements serialize as self-closing`` () = + let voidNames = + [ "area" + "base" + "br" + "col" + "command" + "embed" + "hr" + "img" + "input" + "keygen" + "link" + "meta" + "param" + "source" + "track" + "wbr" ] + + for name in voidNames do + HtmlNode.NewElement name |> string |> should equal $"<{name} />" + +// ───────────────────────────────────────────────────────────────────────────── +// HtmlNode.ToString() — non-void elements +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``Non-void element with no children uses opening and closing tags`` () = + HtmlNode.NewElement "div" |> string |> should equal "
" + +[] +let ``Non-void element with text child serializes inline`` () = + HtmlNode.NewElement("p", [], [ HtmlNode.NewText "Hello" ]) + |> string + |> should equal "

Hello

" + +[] +let ``Non-void element with multiple text children serializes inline`` () = + HtmlNode.NewElement("p", [], [ HtmlNode.NewText "foo"; HtmlNode.NewText "bar" ]) + |> string + |> should equal "

foobar

" + +[] +let ``Non-void element with element children serializes with indentation`` () = + let node = + HtmlNode.NewElement("ul", [], [ HtmlNode.NewElement("li", [], [ HtmlNode.NewText "item" ]) ]) + + let result = node |> string |> fun s -> s.Replace("\r\n", "\n").Replace("\r", "\n") + result |> should equal "
    \n
  • item
  • \n
" + +[] +let ``Nested element children are indented at each level`` () = + let inner = HtmlNode.NewElement("span", [], [ HtmlNode.NewText "text" ]) + let middle = HtmlNode.NewElement("p", [], [ inner ]) + let outer = HtmlNode.NewElement("div", [], [ middle ]) + let result = outer |> string |> fun s -> s.Replace("\r\n", "\n").Replace("\r", "\n") + result |> should equal "
\n

\n text\n

\n
" + +[] +let ``Element with sibling element children each get their own indented line when not text-only`` () = + // Elements that themselves have element children (not text-only) get their own line. + let node = + HtmlNode.NewElement( + "div", + [], + [ HtmlNode.NewElement("ul", [], [ HtmlNode.NewElement("li", [], [ HtmlNode.NewText "a" ]) ]) + HtmlNode.NewElement("ul", [], [ HtmlNode.NewElement("li", [], [ HtmlNode.NewText "b" ]) ]) ] + ) + + let result = node |> string |> fun s -> s.Replace("\r\n", "\n").Replace("\r", "\n") + + // Each
    has element children so each gets its own line with indentation. + result |> should startWith "
    \n
      " + result |> should contain "\n
        " + +[] +let ``Text-only sibling elements are serialized inline without separating newlines`` () = + //

        elements whose children are all text nodes are considered inline by the serializer. + let node = + HtmlNode.NewElement( + "div", + [], + [ HtmlNode.NewElement("p", [], [ HtmlNode.NewText "first" ]) + HtmlNode.NewElement("p", [], [ HtmlNode.NewText "second" ]) ] + ) + + let result = node |> string |> fun s -> s.Replace("\r\n", "\n").Replace("\r", "\n") + // Text-only siblings appear on the same line (no newline inserted between them). + result |> should equal "

        \n

        first

        second

        \n
        " + +// ───────────────────────────────────────────────────────────────────────────── +// HtmlNode.ToString() — attributes +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``Element with a single attribute serializes the attribute`` () = + HtmlNode.NewElement("a", [ "href", "https://example.com" ]) + |> string + |> should equal """""" + +[] +let ``Element with multiple attributes serializes all attributes in order`` () = + HtmlNode.NewElement("input", [ "type", "text"; "name", "q"; "value", "" ]) + |> string + |> should equal """""" + +[] +let ``Attribute name is lowercased when created via HtmlAttribute.New`` () = + HtmlNode.NewElement("div", [ "Class", "main" ]) + |> string + |> should equal """
        """ + +[] +let ``Element name is lowercased when created via NewElement`` () = + HtmlNode.NewElement("DIV") |> string |> should equal "
        " + +// ───────────────────────────────────────────────────────────────────────────── +// HtmlDocument.ToString() +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``HtmlDocument with empty doctype serializes without DOCTYPE declaration`` () = + let doc = HtmlDocument.New([ HtmlNode.NewElement "html" ]) + let result = doc.ToString() + result |> should equal "" + +[] +let ``HtmlDocument with doctype includes DOCTYPE declaration and newline`` () = + let doc = + HtmlDocument.New("html", [ HtmlNode.NewElement("html", [], [ HtmlNode.NewText "content" ]) ]) + + let result = doc.ToString() |> fun s -> s.Replace("\r\n", "\n").Replace("\r", "\n") + result |> should startWith "\n" + result |> should contain "content" + +[] +let ``HtmlDocument with multiple root elements serializes all of them`` () = + let doc = + HtmlDocument.New([ HtmlNode.NewElement "head"; HtmlNode.NewElement "body" ]) + + let result = doc.ToString() + result |> should contain "" + result |> should contain "" + +[] +let ``HtmlDocument with empty element list produces empty string`` () = + let doc = HtmlDocument.New([]) + doc.ToString() |> should equal "" + +[] +let ``HtmlDocument with void element inside body serializes self-closing`` () = + let doc = + HtmlDocument.New([ HtmlNode.NewElement("body", [], [ HtmlNode.NewElement "br" ]) ]) + + let result = doc.ToString() |> fun s -> s.Replace("\r\n", "\n").Replace("\r", "\n") + result |> should contain "
        " + +// ───────────────────────────────────────────────────────────────────────────── +// Round-trip: HtmlNode constructed manually then parsed back +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``HtmlNode constructed with NewElement round-trips to same string`` () = + let node = + HtmlNode.NewElement( + "ul", + [], + [ HtmlNode.NewElement("li", [], [ HtmlNode.NewText "alpha" ]) + HtmlNode.NewElement("li", [], [ HtmlNode.NewText "beta" ]) ] + ) + + let serialized = node.ToString() + let reparsed = HtmlDocument.Parse("" + serialized + "") + + let uls = + reparsed.Descendants() |> Seq.filter (fun n -> n.Name() = "ul") |> Seq.toList + + uls.Length |> should equal 1 + let lis = uls.[0].Descendants() |> Seq.filter (fun n -> n.Name() = "li") |> Seq.toList + lis.Length |> should equal 2 + From 6a23e1300069cf60654f478bdd72d5f4b5c5ae46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 05:05:18 +0000 Subject: [PATCH 2/4] ci: trigger checks From 55bf18ea1e7a06163b142fa35b21591d25ae0adf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 05:06:39 +0000 Subject: [PATCH 3/4] fix: pin OpenTelemetry.Api >= 1.15.1 and GitHubActionsTestLogger 3.0.3 to resolve GHSA-g94r-2vxg-569j Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- paket.dependencies | 3 ++- paket.lock | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/paket.dependencies b/paket.dependencies index 7eccdc0ed..5a8cce897 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -55,7 +55,8 @@ group Test nuget NUnit3TestAdapter nuget FsUnit 4.2.0 nuget FsCheck 2.16.6 - nuget GitHubActionsTestLogger + nuget GitHubActionsTestLogger 3.0.3 + nuget OpenTelemetry.Api >= 1.15.1 group Benchmarks frameworks: net8.0 diff --git a/paket.lock b/paket.lock index 982d944ef..e180d2a28 100644 --- a/paket.lock +++ b/paket.lock @@ -440,7 +440,7 @@ NUGET FSharp.Core (>= 5.0.2) NETStandard.Library (>= 2.0.3) NUnit (>= 3.13.2 < 3.14) - GitHubActionsTestLogger (3.0.1) + GitHubActionsTestLogger (3.0.3) Microsoft.ApplicationInsights (3.0) Azure.Monitor.OpenTelemetry.Exporter (>= 1.6) Microsoft.Bcl.AsyncInterfaces (10.0.3) @@ -528,7 +528,7 @@ NUGET Microsoft.Extensions.Diagnostics.Abstractions (>= 8.0) Microsoft.Extensions.Logging.Configuration (>= 8.0) OpenTelemetry.Api.ProviderBuilderExtensions (>= 1.15) - OpenTelemetry.Api (1.15) + OpenTelemetry.Api (1.15.3) System.Diagnostics.DiagnosticSource (>= 10.0) OpenTelemetry.Api.ProviderBuilderExtensions (1.15) Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0) From c5bf2aa68d5b58799c0977de4d19e83a1494890e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 05:06:41 +0000 Subject: [PATCH 4/4] ci: trigger checks