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 """"""
+
+[]
+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