diff --git a/.github/workflows/powershell-tests.yml b/.github/workflows/powershell-tests.yml
new file mode 100644
index 000000000..3cf950d48
--- /dev/null
+++ b/.github/workflows/powershell-tests.yml
@@ -0,0 +1,25 @@
+# CI Validation
+
+name: PowerShell Tests
+
+on:
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'helpers/software-report-base/**'
+
+jobs:
+ powershell-tests:
+ name: PowerShell tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Run Software Report module tests
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+ Invoke-Pester -Output Detailed "helpers/software-report-base/tests"
+
\ No newline at end of file
diff --git a/helpers/software-report-base/Calculate-ImagesDiff.ps1 b/helpers/software-report-base/Calculate-ImagesDifference.ps1
similarity index 62%
rename from helpers/software-report-base/Calculate-ImagesDiff.ps1
rename to helpers/software-report-base/Calculate-ImagesDifference.ps1
index 91a05c86b..153621709 100644
--- a/helpers/software-report-base/Calculate-ImagesDiff.ps1
+++ b/helpers/software-report-base/Calculate-ImagesDifference.ps1
@@ -1,5 +1,5 @@
using module ./SoftwareReport.psm1
-using module ./SoftwareReport.Comparer.psm1
+using module ./SoftwareReport.DifferenceCalculator.psm1
<#
.SYNOPSIS
@@ -10,6 +10,10 @@ using module ./SoftwareReport.Comparer.psm1
Path to the current software report.
.PARAMETER OutputFile
Path to the file where the difference will be saved.
+.PARAMETER ReleaseBranchName
+ Name of the release branch to build image docs URL.
+.PARAMETER ReadmePath
+ Path to the README file in repository to build image docs URL.
#>
Param (
@@ -20,7 +24,9 @@ Param (
[Parameter(Mandatory=$true)]
[string] $OutputFile,
[Parameter(Mandatory=$false)]
- [string] $ImageDocsUrl
+ [string] $ReleaseBranchName,
+ [Parameter(Mandatory=$false)]
+ [string] $ReadmePath
)
$ErrorActionPreference = "Stop"
@@ -44,12 +50,14 @@ function Read-SoftwareReport {
$previousReport = Read-SoftwareReport -JsonReportPath $PreviousJsonReportPath
$currentReport = Read-SoftwareReport -JsonReportPath $CurrentJsonReportPath
-$comparer = [SoftwareReportComparer]::new($previousReport, $currentReport)
+$comparer = [SoftwareReportDifferenceCalculator]::new($previousReport, $currentReport)
$comparer.CompareReports()
$diff = $comparer.GetMarkdownReport()
-if ($ImageDocsUrl) {
- $diff += "`n`n`n For comprehensive list of software installed on this image please click [here]($ImageDocsUrl)."
+if ($ReleaseBranch -and $ReadmePath) {
+ # https://github.com/actions/runner-images/blob/releases/macOS-12/20221215/images/macos/macos-12-Readme.md
+ $ImageDocsUrl = "https://github.com/actions/runner-images/blob/${ReleaseBranchName}/${ReadmePath}"
+ $diff += "`n`n`nFor comprehensive list of software installed on this image please click [here]($ImageDocsUrl)."
}
$diff | Out-File -Path $OutputFile -Encoding utf8NoBOM
diff --git a/helpers/software-report-base/SoftwareReport.BaseNodes.psm1 b/helpers/software-report-base/SoftwareReport.BaseNodes.psm1
index db7916ae9..8bfb01c98 100644
--- a/helpers/software-report-base/SoftwareReport.BaseNodes.psm1
+++ b/helpers/software-report-base/SoftwareReport.BaseNodes.psm1
@@ -8,6 +8,14 @@ class BaseNode {
return $false
}
+ [String] ToMarkdown() {
+ return $this.ToMarkdown(1)
+ }
+
+ [String] ToMarkdown([Int32] $Level) {
+ throw "Abtract method 'ToMarkdown(level)' is not implemented for '$($this.GetType().Name)'"
+ }
+
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
throw "Abtract method 'IsSimilarTo' is not implemented for '$($this.GetType().Name)'"
}
@@ -19,6 +27,7 @@ class BaseNode {
# Abstract base class for all nodes that describe a tool and should be rendered inside diff table
class BaseToolNode: BaseNode {
+ [ValidateNotNullOrEmpty()]
[String] $ToolName
BaseToolNode([String] $ToolName) {
diff --git a/helpers/software-report-base/SoftwareReport.Comparer.psm1 b/helpers/software-report-base/SoftwareReport.Comparer.psm1
deleted file mode 100644
index bcce515c3..000000000
--- a/helpers/software-report-base/SoftwareReport.Comparer.psm1
+++ /dev/null
@@ -1,342 +0,0 @@
-using module ./SoftwareReport.psm1
-using module ./SoftwareReport.BaseNodes.psm1
-using module ./SoftwareReport.Nodes.psm1
-
-
-# SoftwareReportComparer is used to calculate differences between two SoftwareReport objects
-class SoftwareReportComparer {
- hidden [SoftwareReport] $PreviousReport
- hidden [SoftwareReport] $CurrentReport
-
- hidden [Collections.Generic.List[ReportDifferenceItem]] $AddedItems
- hidden [Collections.Generic.List[ReportDifferenceItem]] $ChangedItems
- hidden [Collections.Generic.List[ReportDifferenceItem]] $DeletedItems
-
- SoftwareReportComparer([SoftwareReport] $PreviousReport, [SoftwareReport] $CurrentReport) {
- $this.PreviousReport = $PreviousReport
- $this.CurrentReport = $CurrentReport
- }
-
- [void] CompareReports() {
- $this.AddedItems = @()
- $this.ChangedItems = @()
- $this.DeletedItems = @()
-
- $this.CompareInternal($this.PreviousReport.Root, $this.CurrentReport.Root, @())
- }
-
- hidden [void] CompareInternal([HeaderNode] $previousReportPointer, [HeaderNode] $currentReportPointer, [Array] $Headers) {
- $currentReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
- $currentReportNode = $_
- $sameNodeInPreviousReport = $previousReportPointer ? $previousReportPointer.FindSimilarChildNode($currentReportNode) : $null
-
- if ($currentReportNode -is [HeaderNode]) {
- $this.CompareInternal($sameNodeInPreviousReport, $currentReportNode, $Headers + $currentReportNode.Title)
- } else {
- if ($sameNodeInPreviousReport -and ($currentReportNode.IsIdenticalTo($sameNodeInPreviousReport))) {
- # Nodes are identical, nothing changed, just ignore it
- } elseif ($sameNodeInPreviousReport) {
- # Nodes are equal but not identical, so something was changed
- if ($currentReportNode -is [TableNode]) {
- $this.CompareSimilarTableNodes($sameNodeInPreviousReport, $currentReportNode, $Headers)
- } elseif ($currentReportNode -is [ToolVersionsListNode]) {
- $this.CompareSimilarToolVersionsListNodes($sameNodeInPreviousReport, $currentReportNode, $Headers)
- } else {
- $this.ChangedItems.Add([ReportDifferenceItem]::new($sameNodeInPreviousReport, $currentReportNode, $Headers))
- }
- } else {
- # Node was not found in previous report, new node was added
- $this.AddedItems.Add([ReportDifferenceItem]::new($null, $currentReportNode, $Headers))
- }
- }
- }
-
- # Detecting nodes that were removed
- $previousReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
- $previousReportNode = $_
- $sameNodeInCurrentReport = $currentReportPointer ? $currentReportPointer.FindSimilarChildNode($previousReportNode) : $null
-
- if (-not $sameNodeInCurrentReport) {
- if ($previousReportNode -is [HeaderNode]) {
- $this.CompareInternal($previousReportNode, $null, $Headers + $previousReportNode.Title)
- } else {
- $this.DeletedItems.Add([ReportDifferenceItem]::new($previousReportNode, $null, $Headers))
- }
- }
- }
- }
-
- hidden [void] CompareSimilarTableNodes([TableNode] $PreviousReportNode, [TableNode] $CurrentReportNode, [Array] $Headers) {
- $addedRows = $CurrentReportNode.Rows | Where-Object { $_ -notin $PreviousReportNode.Rows }
- $deletedRows = $PreviousReportNode.Rows | Where-Object { $_ -notin $CurrentReportNode.Rows }
-
- if (($addedRows.Count -gt 0) -and ($deletedRows.Count -eq 0)) {
- $this.AddedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $CurrentReportNode, $Headers))
- } elseif (($deletedRows.Count -gt 0) -and ($addedRows.Count -eq 0)) {
- $this.DeletedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $CurrentReportNode, $Headers))
- } else {
- $this.ChangedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $CurrentReportNode, $Headers))
- }
- }
-
- hidden [void] CompareSimilarToolVersionsListNodes([ToolVersionsListNode] $PreviousReportNode, [ToolVersionsListNode] $CurrentReportNode, [Array] $Headers) {
- $previousReportMajorVersions = $PreviousReportNode.Versions | ForEach-Object { $PreviousReportNode.ExtractMajorVersion($_) }
- $currentReportMajorVersion = $CurrentReportNode.Versions | ForEach-Object { $CurrentReportNode.ExtractMajorVersion($_) }
-
- $addedVersions = $CurrentReportNode.Versions | Where-Object { $CurrentReportNode.ExtractMajorVersion($_) -notin $previousReportMajorVersions }
- $deletedVersions = $PreviousReportNode.Versions | Where-Object { $PreviousReportNode.ExtractMajorVersion($_) -notin $currentReportMajorVersion }
- $changedPreviousVersions = $PreviousReportNode.Versions | Where-Object { ($PreviousReportNode.ExtractMajorVersion($_) -in $currentReportMajorVersion) -and ($_ -notin $CurrentReportNode.Versions) }
- $changedCurrentVersions = $CurrentReportNode.Versions | Where-Object { ($CurrentReportNode.ExtractMajorVersion($_) -in $previousReportMajorVersions) -and ($_ -notin $PreviousReportNode.Versions) }
-
- if ($addedVersions.Count -gt 0) {
- $this.AddedItems.Add([ReportDifferenceItem]::new($null, [ToolVersionsListNode]::new($CurrentReportNode.ToolName, $addedVersions, $CurrentReportNode.MajorVersionRegex, $true), $Headers))
- }
-
- if ($deletedVersions.Count -gt 0) {
- $this.DeletedItems.Add([ReportDifferenceItem]::new([ToolVersionsListNode]::new($PreviousReportNode.ToolName, $deletedVersions, $PreviousReportNode.MajorVersionRegex, $true), $null, $Headers))
- }
-
- $previousChangedNode = ($changedPreviousVersions.Count -gt 0) ? [ToolVersionsListNode]::new($PreviousReportNode.ToolName, $changedPreviousVersions, $PreviousReportNode.MajorVersionRegex, $true) : $null
- $currentChangedNode = ($changedCurrentVersions.Count -gt 0) ? [ToolVersionsListNode]::new($CurrentReportNode.ToolName, $changedCurrentVersions, $CurrentReportNode.MajorVersionRegex, $true) : $null
- if ($previousChangedNode -and $currentChangedNode) {
- $this.ChangedItems.Add([ReportDifferenceItem]::new($previousChangedNode, $currentChangedNode, $Headers))
- }
- }
-
- [String] GetMarkdownReport() {
- $reporter = [SoftwareReportComparerReport]::new()
- $report = $reporter.GenerateMarkdownReport($this.CurrentReport, $this.PreviousReport, $this.AddedItems, $this.ChangedItems, $this.DeletedItems)
- return $report
- }
-
- hidden [Boolean] FilterExcludedNodes([BaseNode] $Node) {
- # We shouldn't show "Image Version" diff because it is already shown in report header
- if (($Node -is [ToolVersionNode]) -and ($Node.ToolName -eq "Image Version:")) {
- return $false
- }
-
- return $true
- }
-}
-
-# SoftwareReportComparerReport is used to render results of SoftwareReportComparer in markdown format
-class SoftwareReportComparerReport {
- [String] GenerateMarkdownReport([SoftwareReport] $CurrentReport, [SoftwareReport] $PreviousReport, [ReportDifferenceItem[]] $AddedItems, [ReportDifferenceItem[]] $ChangedItems, [ReportDifferenceItem[]] $DeletedItems) {
- $sb = [System.Text.StringBuilder]::new()
-
- $rootNode = $CurrentReport.Root
- $imageVersion = $this.GetImageVersion($CurrentReport)
- $previousImageVersion = $this.GetImageVersion($PreviousReport)
-
- #############################
- ### Render report header ####
- #############################
-
- $sb.AppendLine("# :desktop_computer: Actions Runner Image: $($rootNode.Title)")
-
- # ToolNodes on root level contains main image description so just copy-paste them to final report
- $rootNode.Children | Where-Object { $_ -is [BaseToolNode] } | ForEach-Object {
- $sb.AppendLine($_.ToMarkdown(0))
- }
- $sb.AppendLine()
-
- $sb.AppendLine("## :mega: What's changed?")
-
- ###########################
- ### Render added items ####
- ###########################
-
- [ReportDifferenceItem[]] $addedItemsExcludeTables = $AddedItems | Where-Object { $_.IsBaseToolNode() }
- if ($addedItemsExcludeTables.Count -gt 0) {
- $tableItems = $addedItemsExcludeTables | ForEach-Object {
- [PSCustomObject]@{
- "Category" = $this.RenderCategory($_.Headers, $true);
- "Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
- "Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
- }
- }
-
- $sb.AppendLine("### Added :heavy_plus_sign:")
- $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
- }
-
- # Render added tables separately
- $AddedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
- $sb.AppendLine($this.RenderTableNodesDiff($_))
- }
-
- #############################
- ### Render deleted items ####
- #############################
-
- [ReportDifferenceItem[]] $deletedItemsExcludeTables = $DeletedItems | Where-Object { $_.IsBaseToolNode() }
- if ($deletedItemsExcludeTables.Count -gt 0) {
- $tableItems = $deletedItemsExcludeTables | ForEach-Object {
- [PSCustomObject]@{
- "Category" = $this.RenderCategory($_.Headers, $true);
- "Tool name" = $this.RenderToolName($_.PreviousReportNode.ToolName);
- "Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
- }
- }
-
- $sb.AppendLine("### Deleted :heavy_minus_sign:")
- $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
- }
-
- # Render deleted tables separately
- $DeletedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
- $sb.AppendLine($this.RenderTableNodesDiff($_))
- }
-
- #############################
- ### Render updated items ####
- #############################
-
- [ReportDifferenceItem[]] $changedItemsExcludeTables = $ChangedItems | Where-Object { $_.IsBaseToolNode() }
- if ($changedItemsExcludeTables.Count -gt 0) {
- $tableItems = $changedItemsExcludeTables | ForEach-Object {
- [PSCustomObject]@{
- "Category" = $this.RenderCategory($_.Headers, $true);
- "Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
- "Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
- "Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
- }
- }
-
- $sb.AppendLine("### Updated")
- $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
- }
-
- # Render updated tables separately
- $ChangedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
- $sb.AppendLine($this.RenderTableNodesDiff($_))
- }
-
- return $sb.ToString()
- }
-
- [String] RenderHtmlTable([PSCustomObject[]] $Table, $RowSpanColumnName) {
- $headers = $Table[0].PSObject.Properties.Name
-
- $sb = [System.Text.StringBuilder]::new()
- $sb.AppendLine("
")
- $sb.AppendLine(" ")
- $headers | ForEach-Object {
- $sb.AppendLine(" | $_ | ")
- }
- $sb.AppendLine(" ")
- $sb.AppendLine(" ")
-
- $tableRowSpans = $this.CalculateHtmlTableRowSpan($Table, $RowSpanColumnName)
- for ($rowIndex = 0; $rowIndex -lt $Table.Count; $rowIndex++) {
- $row = $Table[$rowIndex]
-
- $sb.AppendLine(" ")
- $headers | ForEach-Object {
- if ($_ -eq $RowSpanColumnName) {
- if ($tableRowSpans[$rowIndex] -gt 0) {
- $sb.AppendLine(" | $($row.$_) | ")
- } else {
- # Skip rendering this cell at all
- }
- } else {
- $sb.AppendLine(" $($row.$_) | ")
- }
- }
- $sb.AppendLine("
")
- }
- $sb.AppendLine(" ")
- $sb.AppendLine("
")
-
- return $sb.ToString()
- }
-
- [int[]] CalculateHtmlTableRowSpan([PSCustomObject[]] $Table, $keyColumn) {
- $result = @(0) * $Table.Count
-
- for ($rowIndex = $Table.Count - 1; $rowIndex -ge 0; $rowIndex--) {
- if (($rowIndex -lt ($Table.Count - 1)) -and ($Table[$rowIndex].$keyColumn -eq $Table[$rowIndex + 1].$keyColumn)) {
- # If the current row is the same as the next row
- # Then rowspan of current row should be equal to rowspan of the next row + 1
- # And rowspan of the next row should be 0 because it is already included in the rowspan of the current row
- $result[$rowIndex] = $result[$rowIndex + 1] + 1
- $result[$rowIndex + 1] = 0
- } else {
- $result[$rowIndex] = 1
- }
- }
-
- return $result
- }
-
- [String] RenderTableNodesDiff([ReportDifferenceItem] $DiffItem) {
- # Use the simplest approach for now: first, print all removed lines. Then print added lines
- # It will work well for most cases like changing existing rows, adding new rows and removing rows
- # But can produce not so pretty results for cases when some rows are changed and some rows are added at the same time
- # Let's see how it works in practice and improve it later if needed
-
- [String] $tableHeaders = ($DiffItem.CurrentReportNode ?? $DiffItem.PreviousReportNode).Headers
- [System.Collections.ArrayList] $tableRows = @()
- $DiffItem.PreviousReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.CurrentReportNode.Rows } | ForEach-Object {
- $tableRows.Add($this.StrikeTableRow($_))
- }
- $DiffItem.CurrentReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.PreviousReportNode.Rows } | ForEach-Object {
- $tableRows.Add($_)
- }
-
- $sb = [System.Text.StringBuilder]::new()
- $sb.AppendLine("#### $($this.RenderCategory($DiffItem.Headers, $false))")
- $sb.AppendLine([TableNode]::new($tableHeaders, $tableRows).ToMarkdown(0))
- return $sb.ToString()
- }
-
- [String] RenderCategory([Array] $Headers, [Boolean] $AddLineSeparator) {
- # Always skip the first header because it is "Installed Software"
- [Array] $takeHeaders = $Headers | Select-Object -Skip 1
- if ($takeHeaders.Count -eq 0) {
- return ""
- }
-
- $lineSeparator = $AddLineSeparator ? "
": ""
- return [String]::Join(" >$lineSeparator ", $takeHeaders)
- }
-
- [String] RenderToolName([String] $ToolName) {
- return $ToolName.TrimEnd(":")
- }
-
- [String] StrikeTableRow([String] $Row) {
- # Convert "a|b|c" to "~~a~~|~~b~~|~~c~~
- $cells = $Row.Split("|")
- $strikedCells = $cells | ForEach-Object { "~~$($_)~~"}
- return [String]::Join("|", $strikedCells)
- }
-
- [String] GetImageVersion([SoftwareReport] $Report) {
- $imageVersionNode = $Report.Root.Children ?? @() | Where-Object { ($_ -is [ToolVersionNode]) -and ($_.ToolName -eq "Image Version:") } | Select-Object -First 1
- return $imageVersionNode.Version ?? "Unknown version"
- }
-}
-
-# Temporary structure to store the single difference between two reports
-class ReportDifferenceItem {
- [BaseNode] $PreviousReportNode
- [BaseNode] $CurrentReportNode
- [Array] $Headers
-
- ReportDifferenceItem([BaseNode] $PreviousReportNode, [BaseNode] $CurrentReportNode, [Array] $Headers) {
- $this.PreviousReportNode = $PreviousReportNode
- $this.CurrentReportNode = $CurrentReportNode
- $this.Headers = $Headers
- }
-
- [Boolean] IsBaseToolNode() {
- $node = $this.CurrentReportNode ?? $this.PreviousReportNode
- return $node -is [BaseToolNode]
- }
-
- [Boolean] IsTableNode() {
- $node = $this.CurrentReportNode ?? $this.PreviousReportNode
- return $node -is [TableNode]
- }
-}
\ No newline at end of file
diff --git a/helpers/software-report-base/SoftwareReport.DifferenceCalculator.psm1 b/helpers/software-report-base/SoftwareReport.DifferenceCalculator.psm1
new file mode 100644
index 000000000..38a1df6f8
--- /dev/null
+++ b/helpers/software-report-base/SoftwareReport.DifferenceCalculator.psm1
@@ -0,0 +1,136 @@
+using module ./SoftwareReport.psm1
+using module ./SoftwareReport.BaseNodes.psm1
+using module ./SoftwareReport.Nodes.psm1
+using module ./SoftwareReport.DifferenceRender.psm1
+
+class SoftwareReportDifferenceCalculator {
+ [ValidateNotNullOrEmpty()]
+ hidden [SoftwareReport] $PreviousReport
+ [ValidateNotNullOrEmpty()]
+ hidden [SoftwareReport] $CurrentReport
+
+ hidden [Collections.Generic.List[ReportDifferenceItem]] $AddedItems
+ hidden [Collections.Generic.List[ReportDifferenceItem]] $ChangedItems
+ hidden [Collections.Generic.List[ReportDifferenceItem]] $DeletedItems
+
+ SoftwareReportDifferenceCalculator([SoftwareReport] $PreviousReport, [SoftwareReport] $CurrentReport) {
+ $this.PreviousReport = $PreviousReport
+ $this.CurrentReport = $CurrentReport
+ }
+
+ [void] CompareReports() {
+ $this.AddedItems = @()
+ $this.ChangedItems = @()
+ $this.DeletedItems = @()
+
+ $this.CompareInternal($this.PreviousReport.Root, $this.CurrentReport.Root, @())
+ }
+
+ [String] GetMarkdownReport() {
+ $reporter = [SoftwareReportDifferenceRender]::new()
+ $report = $reporter.GenerateMarkdownReport($this.CurrentReport, $this.PreviousReport, $this.AddedItems, $this.ChangedItems, $this.DeletedItems)
+ return $report
+ }
+
+ hidden [void] CompareInternal([HeaderNode] $previousReportPointer, [HeaderNode] $currentReportPointer, [String[]] $Headers) {
+ $currentReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
+ $currentReportNode = $_
+ $sameNodeInPreviousReport = $previousReportPointer ? $previousReportPointer.FindSimilarChildNode($currentReportNode) : $null
+
+ if ($currentReportNode -is [HeaderNode]) {
+ # Compare HeaderNode recursively
+ $this.CompareInternal($sameNodeInPreviousReport, $currentReportNode, $Headers + $currentReportNode.Title)
+ } else {
+ if ($sameNodeInPreviousReport -and ($currentReportNode.IsIdenticalTo($sameNodeInPreviousReport))) {
+ # Nodes are identical, nothing changed, just ignore it
+ } elseif ($sameNodeInPreviousReport) {
+ # Nodes are equal but not identical, something was changed
+ if ($currentReportNode -is [TableNode]) {
+ $this.CompareSimilarTableNodes($sameNodeInPreviousReport, $currentReportNode, $Headers)
+ } elseif ($currentReportNode -is [ToolVersionsListNode]) {
+ $this.CompareSimilarToolVersionsListNodes($sameNodeInPreviousReport, $currentReportNode, $Headers)
+ } else {
+ $this.ChangedItems.Add([ReportDifferenceItem]::new($sameNodeInPreviousReport, $currentReportNode, $Headers))
+ }
+ } else {
+ # Node was not found in previous report, new node was added
+ $this.AddedItems.Add([ReportDifferenceItem]::new($null, $currentReportNode, $Headers))
+ }
+ }
+ }
+
+ # Detecting nodes that were removed
+ $previousReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
+ $previousReportNode = $_
+ $sameNodeInCurrentReport = $currentReportPointer ? $currentReportPointer.FindSimilarChildNode($previousReportNode) : $null
+
+ if (-not $sameNodeInCurrentReport) {
+ if ($previousReportNode -is [HeaderNode]) {
+ # Compare removed HeaderNode recursively
+ $this.CompareInternal($previousReportNode, $null, $Headers + $previousReportNode.Title)
+ } else {
+ # Node was not found in current report, node was removed
+ $this.DeletedItems.Add([ReportDifferenceItem]::new($previousReportNode, $null, $Headers))
+ }
+ }
+ }
+ }
+
+ hidden [void] CompareSimilarTableNodes([TableNode] $PreviousReportNode, [TableNode] $CurrentReportNode, [String[]] $Headers) {
+ $addedRows = $CurrentReportNode.Rows | Where-Object { $_ -notin $PreviousReportNode.Rows }
+ $deletedRows = $PreviousReportNode.Rows | Where-Object { $_ -notin $CurrentReportNode.Rows }
+
+ if (($addedRows.Count -eq 0) -and ($deletedRows.Count -eq 0)) {
+ # Unexpected state: TableNodes are identical
+ return
+ }
+
+ if ($PreviousReportNode.Headers -ne $CurrentReportNode.Headers) {
+ # If headers are changed and rows are changed at the same time, we should track it as removing table and adding new one
+ $this.DeletedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $null, $Headers))
+ $this.AddedItems.Add([ReportDifferenceItem]::new($null, $CurrentReportNode, $Headers))
+ } elseif (($addedRows.Count -gt 0) -and ($deletedRows.Count -eq 0)) {
+ # If new rows were added and no rows were deleted, then it is AddedItem
+ $this.AddedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $CurrentReportNode, $Headers))
+ } elseif (($deletedRows.Count -gt 0) -and ($addedRows.Count -eq 0)) {
+ # If no rows were added and some rows were deleted, then it is DeletedItem
+ $this.DeletedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $CurrentReportNode, $Headers))
+ } else {
+ # If some rows were added and some rows were removed, then it is UpdatedItem
+ $this.ChangedItems.Add([ReportDifferenceItem]::new($PreviousReportNode, $CurrentReportNode, $Headers))
+ }
+ }
+
+ hidden [void] CompareSimilarToolVersionsListNodes([ToolVersionsListNode] $PreviousReportNode, [ToolVersionsListNode] $CurrentReportNode, [String[]] $Headers) {
+ $previousReportMajorVersions = $PreviousReportNode.Versions | ForEach-Object { $PreviousReportNode.ExtractMajorVersion($_) }
+ $currentReportMajorVersion = $CurrentReportNode.Versions | ForEach-Object { $CurrentReportNode.ExtractMajorVersion($_) }
+
+ $addedVersions = $CurrentReportNode.Versions | Where-Object { $CurrentReportNode.ExtractMajorVersion($_) -notin $previousReportMajorVersions }
+ $deletedVersions = $PreviousReportNode.Versions | Where-Object { $PreviousReportNode.ExtractMajorVersion($_) -notin $currentReportMajorVersion }
+ $changedPreviousVersions = $PreviousReportNode.Versions | Where-Object { ($PreviousReportNode.ExtractMajorVersion($_) -in $currentReportMajorVersion) -and ($_ -notin $CurrentReportNode.Versions) }
+ $changedCurrentVersions = $CurrentReportNode.Versions | Where-Object { ($CurrentReportNode.ExtractMajorVersion($_) -in $previousReportMajorVersions) -and ($_ -notin $PreviousReportNode.Versions) }
+
+ if ($addedVersions.Count -gt 0) {
+ $this.AddedItems.Add([ReportDifferenceItem]::new($null, [ToolVersionsListNode]::new($CurrentReportNode.ToolName, $addedVersions, $CurrentReportNode.MajorVersionRegex, "List"), $Headers))
+ }
+
+ if ($deletedVersions.Count -gt 0) {
+ $this.DeletedItems.Add([ReportDifferenceItem]::new([ToolVersionsListNode]::new($PreviousReportNode.ToolName, $deletedVersions, $PreviousReportNode.MajorVersionRegex, "List"), $null, $Headers))
+ }
+
+ $previousChangedNode = ($changedPreviousVersions.Count -gt 0) ? [ToolVersionsListNode]::new($PreviousReportNode.ToolName, $changedPreviousVersions, $PreviousReportNode.MajorVersionRegex, "List") : $null
+ $currentChangedNode = ($changedCurrentVersions.Count -gt 0) ? [ToolVersionsListNode]::new($CurrentReportNode.ToolName, $changedCurrentVersions, $CurrentReportNode.MajorVersionRegex, "List") : $null
+ if ($previousChangedNode -and $currentChangedNode) {
+ $this.ChangedItems.Add([ReportDifferenceItem]::new($previousChangedNode, $currentChangedNode, $Headers))
+ }
+ }
+
+ hidden [Boolean] FilterExcludedNodes([BaseNode] $Node) {
+ # We shouldn't show "Image Version" diff because it is already shown in report header
+ if (($Node -is [ToolVersionNode]) -and ($Node.ToolName -eq "Image Version:")) {
+ return $false
+ }
+
+ return $true
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/SoftwareReport.DifferenceRender.psm1 b/helpers/software-report-base/SoftwareReport.DifferenceRender.psm1
new file mode 100644
index 000000000..91d51e300
--- /dev/null
+++ b/helpers/software-report-base/SoftwareReport.DifferenceRender.psm1
@@ -0,0 +1,225 @@
+using module ./SoftwareReport.psm1
+using module ./SoftwareReport.BaseNodes.psm1
+using module ./SoftwareReport.Nodes.psm1
+
+class SoftwareReportDifferenceRender {
+ [String] GenerateMarkdownReport([SoftwareReport] $CurrentReport, [SoftwareReport] $PreviousReport, [ReportDifferenceItem[]] $AddedItems, [ReportDifferenceItem[]] $ChangedItems, [ReportDifferenceItem[]] $DeletedItems) {
+ $sb = [System.Text.StringBuilder]::new()
+
+ $rootNode = $CurrentReport.Root
+ $imageVersion = $CurrentReport.GetImageVersion()
+ $previousImageVersion = $PreviousReport.GetImageVersion()
+
+ #############################
+ ### Render report header ####
+ #############################
+
+ $sb.AppendLine("# :desktop_computer: Actions Runner Image: $($rootNode.Title)")
+
+ # ToolVersionNodes on root level contains main image description so just copy-paste them to final report
+ $rootNode.Children | Where-Object { $_ -is [ToolVersionNode] } | ForEach-Object {
+ $sb.AppendLine($_.ToMarkdown())
+ }
+ $sb.AppendLine()
+
+ $sb.AppendLine("## :mega: What's changed?").AppendLine()
+
+ ###########################
+ ### Render added items ####
+ ###########################
+
+ [ReportDifferenceItem[]] $addedItemsBaseTools = $AddedItems | Where-Object { $_.IsBaseToolNode() }
+ [ReportDifferenceItem[]] $addedItemsTables = $AddedItems | Where-Object { $_.IsTableNode() }
+ if ($addedItemsBaseTools.Count + $addedItemsTables.Count -gt 0) {
+ $sb.AppendLine("### Added :heavy_plus_sign:").AppendLine()
+ }
+ if ($addedItemsBaseTools.Count -gt 0) {
+ $tableItems = $addedItemsBaseTools | ForEach-Object {
+ [PSCustomObject]@{
+ "Category" = $this.RenderCategory($_.Headers, $true);
+ "Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
+ "Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
+ }
+ }
+ $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
+ }
+ if ($addedItemsTables.Count -gt 0) {
+ $addedItemsTables | ForEach-Object {
+ $sb.AppendLine($this.RenderTableNodesDiff($_))
+ }
+ }
+
+ #############################
+ ### Render deleted items ####
+ #############################
+
+ [ReportDifferenceItem[]] $deletedItemsBaseTools = $DeletedItems | Where-Object { $_.IsBaseToolNode() }
+ [ReportDifferenceItem[]] $deletedItemsTables = $DeletedItems | Where-Object { $_.IsTableNode() }
+ if ($deletedItemsBaseTools.Count + $deletedItemsTables.Count -gt 0) {
+ $sb.AppendLine("### Deleted :heavy_minus_sign:").AppendLine()
+ }
+ if ($deletedItemsBaseTools.Count -gt 0) {
+ $tableItems = $deletedItemsBaseTools | ForEach-Object {
+ [PSCustomObject]@{
+ "Category" = $this.RenderCategory($_.Headers, $true);
+ "Tool name" = $this.RenderToolName($_.PreviousReportNode.ToolName);
+ "Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
+ }
+ }
+ $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
+ }
+ if ($deletedItemsTables.Count -gt 0) {
+ $deletedItemsTables | ForEach-Object {
+ $sb.AppendLine($this.RenderTableNodesDiff($_))
+ }
+ }
+
+ #############################
+ ### Render updated items ####
+ #############################
+
+ [ReportDifferenceItem[]] $changedItemsBaseTools = $ChangedItems | Where-Object { $_.IsBaseToolNode() }
+ [ReportDifferenceItem[]] $changedItemsTables = $ChangedItems | Where-Object { $_.IsTableNode() }
+ if ($changedItemsBaseTools.Count + $changedItemsTables.Count -gt 0) {
+ $sb.AppendLine("### Updated").AppendLine()
+ }
+ if ($changedItemsBaseTools.Count -gt 0) {
+ $tableItems = $changedItemsBaseTools | ForEach-Object {
+ [PSCustomObject]@{
+ "Category" = $this.RenderCategory($_.Headers, $true);
+ "Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
+ "Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
+ "Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
+ }
+ }
+ $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
+ }
+ if ($changedItemsTables.Count -gt 0) {
+ $changedItemsTables | ForEach-Object {
+ $sb.AppendLine($this.RenderTableNodesDiff($_))
+ }
+ }
+
+ return $sb.ToString()
+ }
+
+ [String] RenderHtmlTable([PSCustomObject[]] $Table, [String] $RowSpanColumnName) {
+ $headers = $Table[0].PSObject.Properties.Name
+
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendLine("")
+ $sb.AppendLine(" ")
+ $headers | ForEach-Object {
+ $sb.AppendLine(" | $_ | ")
+ }
+ $sb.AppendLine(" ")
+ $sb.AppendLine(" ")
+
+ $tableRowSpans = $this.CalculateHtmlTableRowSpan($Table, $RowSpanColumnName)
+ for ($rowIndex = 0; $rowIndex -lt $Table.Count; $rowIndex++) {
+ $row = $Table[$rowIndex]
+
+ $sb.AppendLine(" ")
+ $headers | ForEach-Object {
+ if ($_ -eq $RowSpanColumnName) {
+ if ($tableRowSpans[$rowIndex] -gt 0) {
+ $sb.AppendLine(" | $($row.$_) | ")
+ } else {
+ # Skip rendering this cell at all
+ }
+ } else {
+ $sb.AppendLine(" $($row.$_) | ")
+ }
+ }
+ $sb.AppendLine("
")
+ }
+ $sb.AppendLine(" ")
+ $sb.AppendLine("
")
+
+ return $sb.ToString()
+ }
+
+ [int[]] CalculateHtmlTableRowSpan([PSCustomObject[]] $Table, [String] $keyColumn) {
+ $result = @(0) * $Table.Count
+
+ for ($rowIndex = $Table.Count - 1; $rowIndex -ge 0; $rowIndex--) {
+ if (($rowIndex -lt ($Table.Count - 1)) -and ($Table[$rowIndex].$keyColumn -eq $Table[$rowIndex + 1].$keyColumn)) {
+ # If the current row is the same as the next row
+ # Then rowspan of current row should be equal to rowspan of the next row + 1
+ # And rowspan of the next row should be 0 because it is already included in the rowspan of the current row
+ $result[$rowIndex] = $result[$rowIndex + 1] + 1
+ $result[$rowIndex + 1] = 0
+ } else {
+ $result[$rowIndex] = 1
+ }
+ }
+
+ return $result
+ }
+
+ [String] RenderTableNodesDiff([ReportDifferenceItem] $DiffItem) {
+ # Use the simplest approach for now: first, print all removed lines. Then print added lines
+ # It will work well for most cases like changing existing rows, adding new rows and removing rows
+ # But can produce not so pretty results for cases when some rows are changed and some rows are added at the same time
+ # Let's see how it works in practice and improve it later if needed
+
+ [String] $tableHeaders = ($DiffItem.CurrentReportNode ?? $DiffItem.PreviousReportNode).Headers
+ [Collections.Generic.List[String]] $tableRows = @()
+ $DiffItem.PreviousReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.CurrentReportNode.Rows } | ForEach-Object {
+ $tableRows.Add($this.StrikeTableRow($_))
+ }
+ $DiffItem.CurrentReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.PreviousReportNode.Rows } | ForEach-Object {
+ $tableRows.Add($_)
+ }
+
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendLine("#### $($this.RenderCategory($DiffItem.Headers, $false))")
+ $sb.AppendLine([TableNode]::new($tableHeaders, $tableRows).ToMarkdown())
+ return $sb.ToString()
+ }
+
+ [String] RenderCategory([String[]] $Headers, [Boolean] $AddLineSeparator) {
+ # Always skip the first header because it is "Installed Software"
+ [String[]] $takeHeaders = $Headers | Select-Object -Skip 1
+ if ($takeHeaders.Count -eq 0) {
+ return ""
+ }
+
+ $lineSeparator = $AddLineSeparator ? "
": ""
+ return [String]::Join(" >$lineSeparator ", $takeHeaders)
+ }
+
+ [String] RenderToolName([String] $ToolName) {
+ return $ToolName.TrimEnd(":")
+ }
+
+ [String] StrikeTableRow([String] $Row) {
+ # Convert "a|b|c" to "~~a~~|~~b~~|~~c~~
+ $cells = $Row.Split("|")
+ $strikedCells = $cells | ForEach-Object { "~~$($_)~~"}
+ return [String]::Join("|", $strikedCells)
+ }
+}
+
+# Temporary structure to store the single difference between two reports
+class ReportDifferenceItem {
+ [BaseNode] $PreviousReportNode
+ [BaseNode] $CurrentReportNode
+ [String[]] $Headers
+
+ ReportDifferenceItem([BaseNode] $PreviousReportNode, [BaseNode] $CurrentReportNode, [String[]] $Headers) {
+ $this.PreviousReportNode = $PreviousReportNode
+ $this.CurrentReportNode = $CurrentReportNode
+ $this.Headers = $Headers
+ }
+
+ [Boolean] IsBaseToolNode() {
+ $node = $this.CurrentReportNode ?? $this.PreviousReportNode
+ return $node -is [BaseToolNode]
+ }
+
+ [Boolean] IsTableNode() {
+ $node = $this.CurrentReportNode ?? $this.PreviousReportNode
+ return $node -is [TableNode]
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/SoftwareReport.Nodes.psm1 b/helpers/software-report-base/SoftwareReport.Nodes.psm1
index 5e820dc69..7fcb43ad4 100644
--- a/helpers/software-report-base/SoftwareReport.Nodes.psm1
+++ b/helpers/software-report-base/SoftwareReport.Nodes.psm1
@@ -7,27 +7,27 @@ using module ./SoftwareReport.BaseNodes.psm1
# NodesFactory is used to simplify parsing different types of notes
# Every node has own logic of parsing and this method just invokes "FromJsonObject" of correct node type
class NodesFactory {
- static [BaseNode] ParseNodeFromObject($jsonObj) {
- if ($jsonObj.NodeType -eq [HeaderNode].Name) {
- return [HeaderNode]::FromJsonObject($jsonObj)
- } elseif ($jsonObj.NodeType -eq [ToolVersionNode].Name) {
- return [ToolVersionNode]::FromJsonObject($jsonObj)
- } elseif ($jsonObj.NodeType -eq [ToolVersionsListNode].Name) {
- return [ToolVersionsListNode]::FromJsonObject($jsonObj)
- } elseif ($jsonObj.NodeType -eq [TableNode].Name) {
- return [TableNode]::FromJsonObject($jsonObj)
- } elseif ($jsonObj.NodeType -eq [NoteNode].Name) {
- return [NoteNode]::FromJsonObject($jsonObj)
+ static [BaseNode] ParseNodeFromObject([object] $JsonObj) {
+ if ($JsonObj.NodeType -eq [HeaderNode].Name) {
+ return [HeaderNode]::FromJsonObject($JsonObj)
+ } elseif ($JsonObj.NodeType -eq [ToolVersionNode].Name) {
+ return [ToolVersionNode]::FromJsonObject($JsonObj)
+ } elseif ($JsonObj.NodeType -eq [ToolVersionsListNode].Name) {
+ return [ToolVersionsListNode]::FromJsonObject($JsonObj)
+ } elseif ($JsonObj.NodeType -eq [TableNode].Name) {
+ return [TableNode]::FromJsonObject($JsonObj)
+ } elseif ($JsonObj.NodeType -eq [NoteNode].Name) {
+ return [NoteNode]::FromJsonObject($JsonObj)
}
- throw "Unknown node type in ParseNodeFromObject '$($jsonObj.NodeType)'"
+ throw "Unknown node type in ParseNodeFromObject '$($JsonObj.NodeType)'"
}
}
-# Node type to describe headers: "## Installed software"
class HeaderNode: BaseNode {
+ [ValidateNotNullOrEmpty()]
[String] $Title
- [System.Collections.ArrayList] $Children
+ [Collections.Generic.List[BaseNode]] $Children
HeaderNode([String] $Title) {
$this.Title = $Title
@@ -44,10 +44,15 @@ class HeaderNode: BaseNode {
throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.`nFound node: $($similarNode.ToJsonObject() | ConvertTo-Json)`nNew node: $($node.ToJsonObject() | ConvertTo-Json)"
}
+ [Array] $existingHeaderNodes = $this.Children | Where-Object { $_ -is [HeaderNode] }
+ if (($existingHeaderNodes.Count -gt 0) -and ($node -isnot [HeaderNode])) {
+ throw "It is not allowed to add the node of type '$($node.GetType().Name)' to the HeaderNode that already contains the HeaderNode children."
+ }
+
$this.Children.Add($node)
}
- [void] AddNodes([Array] $nodes) {
+ [void] AddNodes([BaseNode[]] $nodes) {
$nodes | ForEach-Object {
$this.AddNode($_)
}
@@ -63,11 +68,15 @@ class HeaderNode: BaseNode {
$this.AddNode([ToolVersionNode]::new($ToolName, $Version))
}
- [void] AddToolVersionsList([String] $ToolName, [Array] $Version, [String] $MajorVersionRegex, [Boolean] $InlineList) {
- $this.AddNode([ToolVersionsListNode]::new($ToolName, $Version, $MajorVersionRegex, $InlineList))
+ [void] AddToolVersionsList([String] $ToolName, [String[]] $Version, [String] $MajorVersionRegex) {
+ $this.AddNode([ToolVersionsListNode]::new($ToolName, $Version, $MajorVersionRegex, "List"))
+ }
+
+ [void] AddToolVersionsListInline([String] $ToolName, [String[]] $Version, [String] $MajorVersionRegex) {
+ $this.AddNode([ToolVersionsListNode]::new($ToolName, $Version, $MajorVersionRegex, "Inline"))
}
- [void] AddTable([Array] $Table) {
+ [void] AddTable([PSCustomObject[]] $Table) {
$this.AddNode([TableNode]::FromObjectsArray($Table))
}
@@ -75,12 +84,12 @@ class HeaderNode: BaseNode {
$this.AddNode([NoteNode]::new($Content))
}
- [String] ToMarkdown($level) {
+ [String] ToMarkdown([Int32] $Level) {
$sb = [System.Text.StringBuilder]::new()
$sb.AppendLine()
- $sb.AppendLine("$("#" * $level) $($this.Title)")
+ $sb.AppendLine("$("#" * $Level) $($this.Title)")
$this.Children | ForEach-Object {
- $sb.AppendLine($_.ToMarkdown($level + 1))
+ $sb.AppendLine($_.ToMarkdown($Level + 1))
}
return $sb.ToString().TrimEnd()
@@ -94,9 +103,9 @@ class HeaderNode: BaseNode {
}
}
- static [HeaderNode] FromJsonObject($jsonObj) {
- $node = [HeaderNode]::new($jsonObj.Title)
- $jsonObj.Children | Where-Object { $_ } | ForEach-Object { $node.AddNode([NodesFactory]::ParseNodeFromObject($_)) }
+ static [HeaderNode] FromJsonObject([Object] $JsonObj) {
+ $node = [HeaderNode]::new($JsonObj.Title)
+ $JsonObj.Children | Where-Object { $_ } | ForEach-Object { $node.AddNode([NodesFactory]::ParseNodeFromObject($_)) }
return $node
}
@@ -123,15 +132,15 @@ class HeaderNode: BaseNode {
}
}
-# Node type to describe the tool with single version: "Bash 5.1.16"
class ToolVersionNode: BaseToolNode {
+ [ValidateNotNullOrEmpty()]
[String] $Version
ToolVersionNode([String] $ToolName, [String] $Version): base($ToolName) {
$this.Version = $Version
}
- [String] ToMarkdown($level) {
+ [String] ToMarkdown([Int32] $Level) {
return "- $($this.ToolName) $($this.Version)"
}
@@ -147,32 +156,35 @@ class ToolVersionNode: BaseToolNode {
}
}
- static [BaseNode] FromJsonObject($jsonObj) {
- return [ToolVersionNode]::new($jsonObj.ToolName, $jsonObj.Version)
+ static [BaseNode] FromJsonObject([Object] $JsonObj) {
+ return [ToolVersionNode]::new($JsonObj.ToolName, $JsonObj.Version)
}
}
-# Node type to describe the tool with multiple versions "Toolcache Node.js 14.17.6 16.2.0 18.2.3"
class ToolVersionsListNode: BaseToolNode {
- [Array] $Versions
+ [ValidateNotNullOrEmpty()]
+ [String[]] $Versions
+
[Regex] $MajorVersionRegex
+
+ [ValidateSet("List", "Inline")]
[String] $ListType
- ToolVersionsListNode([String] $ToolName, [Array] $Versions, [String] $MajorVersionRegex, [Boolean] $InlineList): base($ToolName) {
+ ToolVersionsListNode([String] $ToolName, [String[]] $Versions, [String] $MajorVersionRegex, [String] $ListType): base($ToolName) {
$this.Versions = $Versions
$this.MajorVersionRegex = [Regex]::new($MajorVersionRegex)
- $this.ListType = $InlineList ? "Inline" : "List"
+ $this.ListType = $ListType
$this.ValidateMajorVersionRegex()
}
- [String] ToMarkdown($level) {
+ [String] ToMarkdown([Int32] $Level) {
if ($this.ListType -eq "Inline") {
return "- $($this.ToolName): $($this.Versions -join ', ')"
}
$sb = [System.Text.StringBuilder]::new()
$sb.AppendLine()
- $sb.AppendLine("$("#" * $level) $($this.ToolName)")
+ $sb.AppendLine("$("#" * $Level) $($this.ToolName)")
$this.Versions | ForEach-Object {
$sb.AppendLine("- $_")
}
@@ -186,7 +198,7 @@ class ToolVersionsListNode: BaseToolNode {
[String] ExtractMajorVersion([String] $Version) {
$match = $this.MajorVersionRegex.Match($Version)
- if ($match.Success -ne $true) {
+ if (($match.Success -ne $true) -or [String]::IsNullOrEmpty($match.Groups[0].Value)) {
throw "Version '$Version' doesn't match regex '$($this.PrimaryVersionRegex)'"
}
@@ -203,57 +215,46 @@ class ToolVersionsListNode: BaseToolNode {
}
}
- static [ToolVersionsListNode] FromJsonObject($jsonObj) {
- return [ToolVersionsListNode]::new($jsonObj.ToolName, $jsonObj.Versions, $jsonObj.MajorVersionRegex, $jsonObj.ListType -eq "Inline")
+ static [ToolVersionsListNode] FromJsonObject([Object] $JsonObj) {
+ return [ToolVersionsListNode]::new($JsonObj.ToolName, $JsonObj.Versions, $JsonObj.MajorVersionRegex, $JsonObj.ListType)
}
hidden [void] ValidateMajorVersionRegex() {
$this.Versions | Group-Object { $this.ExtractMajorVersion($_) } | ForEach-Object {
if ($_.Count -gt 1) {
- throw "Multiple versions from list $($this.GetValue()) return the same result from regex '$($this.MajorVersionRegex)': $($_.Name)"
+ throw "Multiple versions from list '$($this.GetValue())' return the same result from regex '$($this.MajorVersionRegex)': $($_.Name)"
}
}
}
}
-# Node type to describe tables
class TableNode: BaseNode {
# It is easier to store the table as rendered lines because it will simplify finding differences in rows later
+ [ValidateNotNullOrEmpty()]
[String] $Headers
- [System.Collections.ArrayList] $Rows
+ [ValidateNotNullOrEmpty()]
+ [String[]] $Rows
- TableNode($Headers, $Rows) {
+ TableNode([String] $Headers, [String[]] $Rows) {
$this.Headers = $Headers
$this.Rows = $Rows
+
+ $columnsCount = $this.Headers.Split("|").Count
+ $this.Rows | ForEach-Object {
+ if ($_.Split("|").Count -ne $columnsCount) {
+ throw "Table has different number of columns in different rows"
+ }
+ }
}
[Boolean] ShouldBeIncludedToDiff() {
return $true
}
- static [TableNode] FromObjectsArray([Array] $Table) {
- # take column names from the first row in table because we expect all rows to have the same columns
- [String] $tableHeaders = [TableNode]::ArrayToTableRow($Table[0].PSObject.Properties.Name)
- [System.Collections.ArrayList] $tableRows = @()
-
- $Table | ForEach-Object {
- $tableRows.Add([TableNode]::ArrayToTableRow($_.PSObject.Properties.Value))
- }
-
- return [TableNode]::new($tableHeaders, $tableRows)
- }
-
- [String] ToMarkdown($level) {
- $maxColumnWidths = $this.Headers.Split("|") | ForEach-Object { $_.Length }
+ [String] ToMarkdown([Int32] $Level) {
+ $maxColumnWidths = $this.CalculateColumnsWidth()
$columnsCount = $maxColumnWidths.Count
- $this.Rows | ForEach-Object {
- $columnWidths = $_.Split("|") | ForEach-Object { $_.Length }
- for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
- $maxColumnWidths[$colIndex] = [Math]::Max($maxColumnWidths[$colIndex], $columnWidths[$colIndex])
- }
- }
-
$delimeterLine = [String]::Join("|", @("-") * $columnsCount)
$sb = [System.Text.StringBuilder]::new()
@@ -273,6 +274,20 @@ class TableNode: BaseNode {
return $sb.ToString().TrimEnd()
}
+ hidden [Int32[]] CalculateColumnsWidth() {
+ $maxColumnWidths = $this.Headers.Split("|") | ForEach-Object { $_.Length }
+ $columnsCount = $maxColumnWidths.Count
+
+ $this.Rows | ForEach-Object {
+ $columnWidths = $_.Split("|") | ForEach-Object { $_.Length }
+ for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
+ $maxColumnWidths[$colIndex] = [Math]::Max($maxColumnWidths[$colIndex], $columnWidths[$colIndex])
+ }
+ }
+
+ return $maxColumnWidths
+ }
+
[PSCustomObject] ToJsonObject() {
return [PSCustomObject]@{
NodeType = $this.GetType().Name
@@ -281,8 +296,8 @@ class TableNode: BaseNode {
}
}
- static [TableNode] FromJsonObject($jsonObj) {
- return [TableNode]::new($jsonObj.Headers, $jsonObj.Rows)
+ static [TableNode] FromJsonObject([Object] $JsonObj) {
+ return [TableNode]::new($JsonObj.Headers, $JsonObj.Rows)
}
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
@@ -299,9 +314,8 @@ class TableNode: BaseNode {
return $false
}
- if ($this.Headers -ne $OtherNode.Headers) {
- return $false
- }
+ # We don't compare $this.Headers intentionally
+ # It is fine to ignore the tables where headers are changed but rows are not changed
if ($this.Rows.Count -ne $OtherNode.Rows.Count) {
return $false
@@ -316,20 +330,49 @@ class TableNode: BaseNode {
return $true
}
- hidden static [String] ArrayToTableRow([Array] $Values) {
- # TO-DO: Add validation for the case when $Values contains "|"
+ static [TableNode] FromObjectsArray([PSCustomObject[]] $Table) {
+ if ($Table.Count -eq 0) {
+ throw "Failed to create TableNode from empty objects array"
+ }
+
+ [String] $tableHeaders = [TableNode]::ArrayToTableRow($Table[0].PSObject.Properties.Name)
+ [Collections.Generic.List[String]] $tableRows = @()
+
+ $Table | ForEach-Object {
+ $rowHeaders = [TableNode]::ArrayToTableRow($_.PSObject.Properties.Name)
+ if (($rowHeaders -ne $tableHeaders)) {
+ throw "Failed to create TableNode from objects array because objects have different properties"
+ }
+
+ $tableRows.Add([TableNode]::ArrayToTableRow($_.PSObject.Properties.Value))
+ }
+
+ return [TableNode]::new($tableHeaders, $tableRows)
+ }
+
+ hidden static [String] ArrayToTableRow([String[]] $Values) {
+ if ($Values.Count -eq 0) {
+ throw "Failed to create TableNode because some objects are empty"
+ }
+ $Values | ForEach-Object {
+ if ($_.Contains("|")) {
+ throw "Failed to create TableNode because some cells '$_' contains forbidden symbol '|'"
+ }
+ }
+
return [String]::Join("|", $Values)
}
}
class NoteNode: BaseNode {
+ [ValidateNotNullOrEmpty()]
[String] $Content
NoteNode([String] $Content) {
$this.Content = $Content
}
- [String] ToMarkdown($level) {
+ [String] ToMarkdown([Int32] $Level) {
return @(
'```',
$this.Content,
@@ -344,8 +387,8 @@ class NoteNode: BaseNode {
}
}
- static [NoteNode] FromJsonObject($jsonObj) {
- return [NoteNode]::new($jsonObj.Content)
+ static [NoteNode] FromJsonObject([Object] $JsonObj) {
+ return [NoteNode]::new($JsonObj.Content)
}
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
diff --git a/helpers/software-report-base/SoftwareReport.psm1 b/helpers/software-report-base/SoftwareReport.psm1
index 81f3cfd9c..c78757f25 100644
--- a/helpers/software-report-base/SoftwareReport.psm1
+++ b/helpers/software-report-base/SoftwareReport.psm1
@@ -2,6 +2,7 @@ using module ./SoftwareReport.BaseNodes.psm1
using module ./SoftwareReport.Nodes.psm1
class SoftwareReport {
+ [ValidateNotNullOrEmpty()]
[HeaderNode] $Root
SoftwareReport([String] $Title) {
@@ -16,13 +17,18 @@ class SoftwareReport {
return $this.Root.ToJsonObject() | ConvertTo-Json -Depth 10
}
- static [SoftwareReport] FromJson($jsonString) {
- $jsonObj = $jsonString | ConvertFrom-Json
+ static [SoftwareReport] FromJson([String] $JsonString) {
+ $jsonObj = $JsonString | ConvertFrom-Json
$rootNode = [NodesFactory]::ParseNodeFromObject($jsonObj)
return [SoftwareReport]::new($rootNode)
}
[String] ToMarkdown() {
- return $this.Root.ToMarkdown(1).Trim()
+ return $this.Root.ToMarkdown().Trim()
+ }
+
+ [String] GetImageVersion() {
+ $imageVersionNode = $this.Root.Children ?? @() | Where-Object { ($_ -is [ToolVersionNode]) -and ($_.ToolName -eq "Image Version:") } | Select-Object -First 1
+ return $imageVersionNode.Version ?? "Unknown version"
}
}
\ No newline at end of file
diff --git a/helpers/software-report-base/tests/SoftwareReport.Difference.E2E.Tests.ps1 b/helpers/software-report-base/tests/SoftwareReport.Difference.E2E.Tests.ps1
new file mode 100644
index 000000000..8025e4736
--- /dev/null
+++ b/helpers/software-report-base/tests/SoftwareReport.Difference.E2E.Tests.ps1
@@ -0,0 +1,525 @@
+using module ../SoftwareReport.psm1
+using module ../SoftwareReport.DifferenceCalculator.psm1
+
+Describe "Comparer.E2E" {
+ It "Some tools are updated" {
+ # Previous report
+ $prevSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $prevSoftwareReport.Root.AddToolVersion("OS Version:", "macOS 11.7.1 (20G817)")
+ $prevSoftwareReport.Root.AddToolVersion("Image Version:", "20220918.1")
+ $prevInstalledSoftware = $prevSoftwareReport.Root.AddHeader("Installed Software")
+ $prevTools = $prevInstalledSoftware.AddHeader("Tools")
+ $prevTools.AddToolVersion("ToolWillBeUpdated1", "1.0.0")
+ $prevTools.AddToolVersion("ToolWillBeUpdated2", "3.0.1")
+ $prevTools.AddToolVersionsList("ToolWillBeUpdated3", @("14.0.0", "15.5.1"), "^\d+")
+
+ # Next report
+ $nextSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $nextSoftwareReport.Root.AddToolVersion("OS Version:", "macOS 11.7.1 (20G817)")
+ $nextSoftwareReport.Root.AddToolVersion("Image Version:", "20220922.1")
+ $nextInstalledSoftware = $nextSoftwareReport.Root.AddHeader("Installed Software")
+ $nextTools = $nextInstalledSoftware.AddHeader("Tools")
+ $nextTools.AddToolVersion("ToolWillBeUpdated1", "2.5.0")
+ $nextTools.AddToolVersion("ToolWillBeUpdated2", "3.0.2")
+ $nextTools.AddToolVersionsList("ToolWillBeUpdated3", @("14.2.0", "15.5.1"), "^\d+")
+
+ # Compare reports
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevSoftwareReport, $nextSoftwareReport)
+ $comparer.CompareReports()
+ $comparer.GetMarkdownReport() | Should -BeExactly @'
+# :desktop_computer: Actions Runner Image: macOS 11
+- OS Version: macOS 11.7.1 (20G817)
+- Image Version: 20220922.1
+
+## :mega: What's changed?
+
+### Updated
+
+
+
+ | Category |
+ Tool name |
+ Previous (20220918.1) |
+ Current (20220922.1) |
+
+
+
+ | Tools |
+ ToolWillBeUpdated1 |
+ 1.0.0 |
+ 2.5.0 |
+
+
+ | ToolWillBeUpdated2 |
+ 3.0.1 |
+ 3.0.2 |
+
+
+ | ToolWillBeUpdated3 |
+ 14.0.0 |
+ 14.2.0 |
+
+
+
+
+
+'@
+ }
+
+ It "Some tools are updated, added and removed" {
+ # Previous report
+ $prevSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $prevSoftwareReport.Root.AddToolVersion("OS Version:", "macOS 11.7.1 (20G817)")
+ $prevSoftwareReport.Root.AddToolVersion("Image Version:", "20220918.1")
+ $prevInstalledSoftware = $prevSoftwareReport.Root.AddHeader("Installed Software")
+
+ $prevLanguagesAndRuntimes = $prevInstalledSoftware.AddHeader("Language and Runtime")
+ $prevLanguagesAndRuntimes.AddToolVersion("ToolWillBeRemoved", "5.1.16(1)-release")
+ $prevLanguagesAndRuntimes.AddToolVersionsListInline("ToolWithMultipleVersions3", @("1.2.100", "1.2.200", "1.3.500", "1.4.100", "1.4.200"), "^\d+\.\d+\.\d")
+ $prevLanguagesAndRuntimes.AddToolVersion("ToolWithoutChanges", "5.34.0")
+ $prevLanguagesAndRuntimes.AddToolVersion("ToolWillBeUpdated", "8.1.0")
+
+ $prevCachedTools = $prevInstalledSoftware.AddHeader("Cached Tools")
+ $prevCachedTools.AddToolVersionsList("ToolWithMultipleVersions1", @("2.7.3", "2.8.1", "3.1.2"), "^\d+\.\d+")
+ $prevCachedTools.AddToolVersionsList("ToolWithMultipleVersions2", @("14.8.0", "15.1.0", "16.4.2"), "^\d+")
+
+ $prevSQLSection = $prevInstalledSoftware.AddHeader("Databases")
+ $prevSQLSection.AddToolVersion("MineSQL", "6.1.0")
+ $prevSQLSection.AddNote("First Note")
+
+ # Next report
+ $nextSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $nextSoftwareReport.Root.AddToolVersion("OS Version:", "macOS 11.7.2 (20G922)")
+ $nextSoftwareReport.Root.AddToolVersion("Image Version:", "20220922.0")
+ $nextInstalledSoftware = $nextSoftwareReport.Root.AddHeader("Installed Software")
+
+ $nextLanguagesAndRuntimes = $nextInstalledSoftware.AddHeader("Language and Runtime")
+ $nextLanguagesAndRuntimes.AddToolVersion("ToolWillBeAdded", "16.18.0")
+ $nextLanguagesAndRuntimes.AddToolVersionsListInline("ToolWithMultipleVersions3", @("1.2.200", "1.3.515", "1.4.100", "1.4.200", "1.5.800"), "^\d+\.\d+\.\d")
+ $nextLanguagesAndRuntimes.AddToolVersion("ToolWithoutChanges", "5.34.0")
+ $nextLanguagesAndRuntimes.AddToolVersion("ToolWillBeUpdated", "8.3.0")
+
+ $nextCachedTools = $nextInstalledSoftware.AddHeader("Cached Tools")
+ $nextCachedTools.AddToolVersionsList("ToolWithMultipleVersions1", @("2.7.3", "2.8.1", "3.1.2"), "^\d+\.\d+")
+ $nextCachedTools.AddToolVersionsList("ToolWithMultipleVersions2", @("15.1.0", "16.4.2", "17.0.1"), "^\d+")
+
+ $nextSQLSection = $nextInstalledSoftware.AddHeader("Databases")
+ $nextSQLSection.AddToolVersion("MineSQL", "6.1.1")
+ $nextSQLSection.AddNote("Second Note")
+
+ # Compare reports
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevSoftwareReport, $nextSoftwareReport)
+ $comparer.CompareReports()
+ $comparer.GetMarkdownReport() | Should -BeExactly @'
+# :desktop_computer: Actions Runner Image: macOS 11
+- OS Version: macOS 11.7.2 (20G922)
+- Image Version: 20220922.0
+
+## :mega: What's changed?
+
+### Added :heavy_plus_sign:
+
+
+
+ | Category |
+ Tool name |
+ Current (20220922.0) |
+
+
+
+ | Language and Runtime |
+ ToolWillBeAdded |
+ 16.18.0 |
+
+
+ | ToolWithMultipleVersions3 |
+ 1.5.800 |
+
+
+ | Cached Tools |
+ ToolWithMultipleVersions2 |
+ 17.0.1 |
+
+
+
+
+### Deleted :heavy_minus_sign:
+
+
+
+ | Category |
+ Tool name |
+ Previous (20220918.1) |
+
+
+
+ | Language and Runtime |
+ ToolWithMultipleVersions3 |
+ 1.2.100 |
+
+
+ | ToolWillBeRemoved |
+ 5.1.16(1)-release |
+
+
+ | Cached Tools |
+ ToolWithMultipleVersions2 |
+ 14.8.0 |
+
+
+
+
+### Updated
+
+
+
+ | Category |
+ Tool name |
+ Previous (20220918.1) |
+ Current (20220922.0) |
+
+
+
+ |
+ OS Version |
+ macOS 11.7.1 (20G817) |
+ macOS 11.7.2 (20G922) |
+
+
+ | Language and Runtime |
+ ToolWithMultipleVersions3 |
+ 1.3.500 |
+ 1.3.515 |
+
+
+ | ToolWillBeUpdated |
+ 8.1.0 |
+ 8.3.0 |
+
+
+ | Databases |
+ MineSQL |
+ 6.1.0 |
+ 6.1.1 |
+
+
+
+
+
+'@
+ }
+
+ It "Header tree changes" {
+ # Previous report
+ $prevSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $prevSoftwareReport.Root.AddToolVersion("Image Version:", "20220918.1")
+ $prevInstalledSoftware = $prevSoftwareReport.Root.AddHeader("Installed Software")
+ $prevInstalledSoftware.AddToolVersion("ToolWithoutChanges", "5.34.0")
+ $prevInstalledSoftware.AddHeader("HeaderWillBeRemoved").AddHeader("SubheaderWillBeRemoved").AddToolVersion("ToolWillBeRemoved", "1.0.0")
+ $prevInstalledSoftware.AddHeader("Header1").AddToolVersion("ToolWillBeMovedToAnotherHeader", "3.0.0")
+
+ # Next report
+ $nextSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $nextSoftwareReport.Root.AddToolVersion("Image Version:", "20220922.0")
+ $nextInstalledSoftware = $nextSoftwareReport.Root.AddHeader("Installed Software")
+ $nextInstalledSoftware.AddToolVersion("ToolWithoutChanges", "5.34.0")
+ $nextInstalledSoftware.AddHeader("HeaderWillBeAdded").AddHeader("SubheaderWillBeAdded").AddToolVersion("ToolWillBeAdded", "5.0.0")
+ $nextInstalledSoftware.AddHeader("Header2").AddToolVersion("ToolWillBeMovedToAnotherHeader", "3.0.0")
+
+ # Compare reports
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevSoftwareReport, $nextSoftwareReport)
+ $comparer.CompareReports()
+ $comparer.GetMarkdownReport() | Should -BeExactly @'
+# :desktop_computer: Actions Runner Image: macOS 11
+- Image Version: 20220922.0
+
+## :mega: What's changed?
+
+### Added :heavy_plus_sign:
+
+
+
+ | Category |
+ Tool name |
+ Current (20220922.0) |
+
+
+
+ HeaderWillBeAdded > SubheaderWillBeAdded |
+ ToolWillBeAdded |
+ 5.0.0 |
+
+
+ | Header2 |
+ ToolWillBeMovedToAnotherHeader |
+ 3.0.0 |
+
+
+
+
+### Deleted :heavy_minus_sign:
+
+
+
+ | Category |
+ Tool name |
+ Previous (20220918.1) |
+
+
+
+ HeaderWillBeRemoved > SubheaderWillBeRemoved |
+ ToolWillBeRemoved |
+ 1.0.0 |
+
+
+ | Header1 |
+ ToolWillBeMovedToAnotherHeader |
+ 3.0.0 |
+
+
+
+
+
+'@
+ }
+
+ It "Tables are added and removed" {
+ # Previous report
+ $prevSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $prevSoftwareReport.Root.AddToolVersion("Image Version:", "20220918.1")
+ $prevInstalledSoftware = $prevSoftwareReport.Root.AddHeader("Installed Software")
+ $prevInstalledSoftware.AddHeader("HeaderWillExist").AddTable(@(
+ [PSCustomObject]@{TableInExistingHeaderWillBeRemoved = "Q"; Value = "25"},
+ [PSCustomObject]@{TableInExistingHeaderWillBeRemoved = "O"; Value = "24"}
+ ))
+
+ $prevTools = $prevInstalledSoftware.AddHeader("Tools")
+ $prevTools.AddHeader("HeaderWillBeRemoved").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "Z"; Value = "30"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "W"; Value = "29"}
+ ))
+
+ # Next report
+ $nextSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $nextSoftwareReport.Root.AddToolVersion("Image Version:", "20220922.1")
+ $nextInstalledSoftware = $nextSoftwareReport.Root.AddHeader("Installed Software")
+ $nextInstalledSoftware.AddHeader("HeaderWillExist")
+ $nextTools = $nextInstalledSoftware.AddHeader("Tools")
+ $nextTools.AddToolVersion("ToolWillBeAdded", "3.0.1")
+ $nextTools.AddTable(@(
+ [PSCustomObject]@{NewTableInExistingHeader = "A"; Value = "1"},
+ [PSCustomObject]@{NewTableInExistingHeader = "B"; Value = "2"}
+ ))
+ $nextTools.AddHeader("NewHeaderWithTable").AddTable(@(
+ [PSCustomObject]@{NewTableInNewHeader = "C"; Value = "3"},
+ [PSCustomObject]@{NewTableInNewHeader = "D"; Value = "4"}
+ ))
+
+ # Compare reports
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevSoftwareReport, $nextSoftwareReport)
+ $comparer.CompareReports()
+ $comparer.GetMarkdownReport() | Should -BeExactly @'
+# :desktop_computer: Actions Runner Image: macOS 11
+- Image Version: 20220922.1
+
+## :mega: What's changed?
+
+### Added :heavy_plus_sign:
+
+
+
+ | Category |
+ Tool name |
+ Current (20220922.1) |
+
+
+
+ | Tools |
+ ToolWillBeAdded |
+ 3.0.1 |
+
+
+
+
+#### Tools
+| NewTableInExistingHeader | Value |
+| ------------------------ | ----- |
+| A | 1 |
+| B | 2 |
+
+#### Tools > NewHeaderWithTable
+| NewTableInNewHeader | Value |
+| ------------------- | ----- |
+| C | 3 |
+| D | 4 |
+
+### Deleted :heavy_minus_sign:
+
+#### HeaderWillExist
+| TableInExistingHeaderWillBeRemoved | Value |
+| ---------------------------------- | ------ |
+| ~~Q~~ | ~~25~~ |
+| ~~O~~ | ~~24~~ |
+
+#### Tools > HeaderWillBeRemoved
+| TableWillBeRemovedWithHeader | Value |
+| ---------------------------- | ------ |
+| ~~Z~~ | ~~30~~ |
+| ~~W~~ | ~~29~~ |
+
+
+'@
+ }
+
+ It "Tables are changed" {
+ # Previous report
+ $prevSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $prevSoftwareReport.Root.AddToolVersion("Image Version:", "20220918.1")
+ $prevInstalledSoftware = $prevSoftwareReport.Root.AddHeader("Installed Software")
+ $prevTools = $prevInstalledSoftware.AddHeader("Tools")
+ $prevTools.AddHeader("TableWithAddedRows").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "AA"; Value = "10"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "AB"; Value = "11"}
+ ))
+ $prevTools.AddHeader("TableWithRemovedRows").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "BA"; Value = "32"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "BB"; Value = "33"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "BC"; Value = "34"}
+ ))
+ $prevTools.AddHeader("TableWithUpdatedRow").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "CA"; Value = "42"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "CB"; Value = "43"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "CC"; Value = "44"}
+ ))
+ $prevTools.AddHeader("TableWithUpdatedRows").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DA"; Value = "50"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DB"; Value = "51"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DC"; Value = "52"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DD"; Value = "53"}
+ ))
+ $prevTools.AddHeader("TableWithComplexChanges").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "EA"; Value = "62"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "EB"; Value = "63"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "EC"; Value = "64"}
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "ED"; Value = "65"}
+ ))
+
+ $prevTools.AddHeader("TableWithOnlyHeaderChanged").AddTable(@(
+ [PSCustomObject]@{TableWithOnlyHeaderChanged = "FA"; Value = "72"},
+ [PSCustomObject]@{TableWithOnlyHeaderChanged = "FB"; Value = "73"}
+ ))
+
+ $prevTools.AddHeader("TableWithHeaderAndRowsChanges").AddTable(@(
+ [PSCustomObject]@{TableWithHeaderAndRowsChanges = "GA"; Value = "82"},
+ [PSCustomObject]@{TableWithHeaderAndRowsChanges = "GB"; Value = "83"},
+ [PSCustomObject]@{TableWithHeaderAndRowsChanges = "GC"; Value = "84"}
+ ))
+
+ # Next report
+ $nextSoftwareReport = [SoftwareReport]::new("macOS 11")
+ $nextSoftwareReport.Root.AddToolVersion("Image Version:", "20220922.1")
+ $nextInstalledSoftware = $nextSoftwareReport.Root.AddHeader("Installed Software")
+ $nextTools = $nextInstalledSoftware.AddHeader("Tools")
+ $nextTools.AddHeader("TableWithAddedRows").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "AA"; Value = "10"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "AB"; Value = "11"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "AC"; Value = "12"}
+ ))
+ $nextTools.AddHeader("TableWithRemovedRows").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "BB"; Value = "33"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "BC"; Value = "34"}
+ ))
+ $nextTools.AddHeader("TableWithUpdatedRow").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "CA"; Value = "42"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "CB"; Value = "500"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "CC"; Value = "44"}
+ ))
+ $nextTools.AddHeader("TableWithUpdatedRows").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DA"; Value = "50"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DB"; Value = "5100"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DC"; Value = "5200"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "DD"; Value = "53"}
+ ))
+ $nextTools.AddHeader("TableWithComplexChanges").AddTable(@(
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "EB"; Value = "63"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "EC"; Value = "640"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "ED"; Value = "65"},
+ [PSCustomObject]@{TableWillBeRemovedWithHeader = "EE"; Value = "66"}
+ ))
+
+ $nextTools.AddHeader("TableWithOnlyHeaderChanged").AddTable(@(
+ [PSCustomObject]@{TableWithOnlyHeaderChanged2 = "FA"; Value = "72"},
+ [PSCustomObject]@{TableWithOnlyHeaderChanged2 = "FB"; Value = "73"}
+ ))
+
+ $nextTools.AddHeader("TableWithHeaderAndRowsChanges").AddTable(@(
+ [PSCustomObject]@{TableWithHeaderAndRowsChanges2 = "GA"; Value = "82"},
+ [PSCustomObject]@{TableWithHeaderAndRowsChanges2 = "GE"; Value = "850"},
+ [PSCustomObject]@{TableWithHeaderAndRowsChanges2 = "GC"; Value = "840"}
+ ))
+
+ # Compare reports
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevSoftwareReport, $nextSoftwareReport)
+ $comparer.CompareReports()
+ $comparer.GetMarkdownReport() | Should -BeExactly @'
+# :desktop_computer: Actions Runner Image: macOS 11
+- Image Version: 20220922.1
+
+## :mega: What's changed?
+
+### Added :heavy_plus_sign:
+
+#### Tools > TableWithAddedRows
+| TableWillBeRemovedWithHeader | Value |
+| ---------------------------- | ----- |
+| AC | 12 |
+
+#### Tools > TableWithHeaderAndRowsChanges
+| TableWithHeaderAndRowsChanges2 | Value |
+| ------------------------------ | ----- |
+| GA | 82 |
+| GE | 850 |
+| GC | 840 |
+
+### Deleted :heavy_minus_sign:
+
+#### Tools > TableWithRemovedRows
+| TableWillBeRemovedWithHeader | Value |
+| ---------------------------- | ------ |
+| ~~BA~~ | ~~32~~ |
+
+#### Tools > TableWithHeaderAndRowsChanges
+| TableWithHeaderAndRowsChanges | Value |
+| ----------------------------- | ------ |
+| ~~GA~~ | ~~82~~ |
+| ~~GB~~ | ~~83~~ |
+| ~~GC~~ | ~~84~~ |
+
+### Updated
+
+#### Tools > TableWithUpdatedRow
+| TableWillBeRemovedWithHeader | Value |
+| ---------------------------- | ------ |
+| ~~CB~~ | ~~43~~ |
+| CB | 500 |
+
+#### Tools > TableWithUpdatedRows
+| TableWillBeRemovedWithHeader | Value |
+| ---------------------------- | ------ |
+| ~~DB~~ | ~~51~~ |
+| ~~DC~~ | ~~52~~ |
+| DB | 5100 |
+| DC | 5200 |
+
+#### Tools > TableWithComplexChanges
+| TableWillBeRemovedWithHeader | Value |
+| ---------------------------- | ------ |
+| ~~EA~~ | ~~62~~ |
+| ~~EC~~ | ~~64~~ |
+| EC | 640 |
+| EE | 66 |
+
+
+'@
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/tests/SoftwareReport.DifferenceCalculator.Unit.Tests.ps1 b/helpers/software-report-base/tests/SoftwareReport.DifferenceCalculator.Unit.Tests.ps1
new file mode 100644
index 000000000..31e690e27
--- /dev/null
+++ b/helpers/software-report-base/tests/SoftwareReport.DifferenceCalculator.Unit.Tests.ps1
@@ -0,0 +1,603 @@
+using module ../SoftwareReport.Nodes.psm1
+using module ../SoftwareReport.DifferenceCalculator.psm1
+
+BeforeDiscovery {
+ Import-Module $(Join-Path $PSScriptRoot "TestHelpers.psm1") -DisableNameChecking
+}
+
+Describe "Comparer.UnitTests" {
+ Describe "Headers Tree" {
+ It "Add Node to existing header" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersion("MyTool1", "2.1.3")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Version | Should -Be "2.1.3"
+ $comparer.AddedItems[0].Headers | Should -BeArray @("MyHeader")
+ }
+
+ It "Add new header with Node" {
+ $prevReport = [HeaderNode]::new("Version 1")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddHeader("MySubHeader").AddToolVersion("MyTool1", "2.1.3")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Version | Should -Be "2.1.3"
+ $comparer.AddedItems[0].Headers | Should -BeArray @("MyHeader", "MySubHeader")
+ }
+
+ It "Remove Node from existing header" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersion("MyTool1", "2.1.3")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Version | Should -Be "2.1.3"
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ $comparer.DeletedItems[0].Headers | Should -BeArray @("MyHeader")
+ }
+
+ It "Remove header with Node" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddHeader("MySubheader").AddToolVersion("MyTool1", "2.1.3")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Version | Should -Be "2.1.3"
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ $comparer.DeletedItems[0].Headers | Should -BeArray @("MyHeader", "MySubheader")
+ }
+
+ It "Node with minor changes" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddHeader("MySubheader").AddToolVersion("MyTool1", "2.1.3")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddHeader("MySubheader").AddToolVersion("MyTool1", "2.1.4")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Version | Should -Be "2.1.3"
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Version | Should -Be "2.1.4"
+ $comparer.ChangedItems[0].Headers | Should -BeArray @("MyHeader", "MySubHeader")
+ }
+
+ It "Node without changes" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddHeader("MySubheader").AddToolVersion("MyTool1", "2.1.3")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddHeader("MySubheader").AddToolVersion("MyTool1", "2.1.3")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+ }
+
+ It "Node is moved to different header" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddHeader("MySubheader").AddToolVersion("MyTool1", "2.1.3")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddHeader("MySubheader2").AddToolVersion("MyTool1", "2.1.3")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Version | Should -Be "2.1.3"
+ $comparer.AddedItems[0].Headers | Should -BeArray @("MyHeader", "MySubheader2")
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Version | Should -Be "2.1.3"
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ $comparer.DeletedItems[0].Headers | Should -BeArray @("MyHeader", "MySubheader")
+ }
+
+ It "Complex structure" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevSubHeader = $prevReport.AddHeader("MyHeader").AddHeader("MySubheader")
+ $prevSubHeader.AddToolVersion("MyTool1", "2.1.3")
+ $prevSubHeader.AddHeader("MySubSubheader").AddToolVersion("MyTool2", "2.9.1")
+ $prevReport.AddHeader("MyHeader2")
+ $prevReport.AddHeader("MyHeader3").AddHeader("MySubheader3").AddToolVersion("MyTool3", "14.2.1")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextSubHeader = $nextReport.AddHeader("MyHeader").AddHeader("MySubheader")
+ $nextSubHeader.AddToolVersion("MyTool1", "2.1.4")
+ $nextSubSubHeader = $nextSubHeader.AddHeader("MySubSubheader")
+ $nextSubSubHeader.AddToolVersion("MyTool2", "2.9.1")
+ $nextSubSubHeader.AddToolVersion("MyTool4", "2.7.6")
+ $nextReport.AddHeader("MyHeader2")
+ $nextReport.AddHeader("MyHeader3")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool4"
+ $comparer.AddedItems[0].CurrentReportNode.Version | Should -Be "2.7.6"
+ $comparer.AddedItems[0].Headers | Should -BeArray @("MyHeader", "MySubheader", "MySubSubheader")
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Version | Should -Be "2.1.3"
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Version | Should -Be "2.1.4"
+ $comparer.ChangedItems[0].Headers | Should -BeArray @("MyHeader", "MySubheader")
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool3"
+ $comparer.DeletedItems[0].PreviousReportNode.Version | Should -Be "14.2.1"
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ $comparer.DeletedItems[0].Headers | Should -BeArray @("MyHeader3", "MySubheader3")
+ }
+ }
+
+ Describe "ToolVersionNode" {
+ It "ToolVersionNode is updated" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersion("MyTool1", "2.1.3")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersion("MyTool1", "2.1.4")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Version | Should -Be "2.1.3"
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Version | Should -Be "2.1.4"
+ $comparer.ChangedItems[0].Headers | Should -BeArray @("MyHeader")
+ }
+ }
+
+ Describe "ToolVersionsListNode" {
+ It "Single version is not changed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3"), "^.+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3"), "^.+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+ }
+
+ It "Single version is changed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3"), "^\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.4"), "^\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Versions | Should -BeArray @("2.1.3")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Versions | Should -BeArray @("2.1.4")
+ }
+
+ It "Major version is added" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3"), "^\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3", "3.1.4"), "^\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Versions | Should -BeArray @("3.1.4")
+ }
+
+ It "Major version is removed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3", "3.1.4"), "^\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("3.1.4"), "^\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Versions | Should -BeArray @("2.1.3")
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ }
+
+ It "Major version is changed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("3.1.4"), "^\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("3.2.0"), "^\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Versions | Should -BeArray @("3.1.4")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Versions | Should -BeArray @("3.2.0")
+ }
+
+ It "Major version is added, removed and updated at the same time" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("1.0.0", "2.1.3", "3.1.4", "4.0.2"), "^\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.1.3", "3.2.0", "4.0.2", "5.1.0"), "^\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Versions | Should -BeArray @("5.1.0")
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Versions | Should -BeArray @("3.1.4")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Versions | Should -BeArray @("3.2.0")
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Versions | Should -BeArray @("1.0.0")
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ }
+
+ It "Minor version is added, removed and updated at the same time" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.3.8", "2.4.9", "2.5.3", "2.6.0", "2.7.4", "2.8.0"), "^\d+\.\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.5.3", "2.6.2", "2.7.5", "2.8.0", "2.9.2", "2.10.3"), "^\d+\.\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Versions | Should -BeArray @("2.9.2", "2.10.3")
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].PreviousReportNode.Versions | Should -BeArray @("2.6.0", "2.7.4")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.ChangedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.ChangedItems[0].CurrentReportNode.Versions | Should -BeArray @("2.6.2", "2.7.5")
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Versions | Should -BeArray @("2.3.8", "2.4.9")
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ }
+
+ It "Patch version is added, removed and updated at the same time" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.3.8", "2.4.9", "2.5.3", "2.6.0", "2.7.4"), "^\d+\.\d+\.\d+")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddToolVersionsList("MyTool1", @("2.4.9", "2.5.4", "2.6.0", "2.7.5", "2.8.2"), "^\d+\.\d+\.\d+")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.AddedItems[0].CurrentReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.AddedItems[0].CurrentReportNode.Versions | Should -BeArray @("2.5.4", "2.7.5", "2.8.2")
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([ToolVersionsListNode])
+ $comparer.DeletedItems[0].PreviousReportNode.ToolName | Should -Be "MyTool1"
+ $comparer.DeletedItems[0].PreviousReportNode.Versions | Should -BeArray @("2.3.8", "2.5.3", "2.7.4")
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ }
+ }
+
+ Describe "TableNode" {
+ It "Rows are added" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2", "C1|C2", "D1|D2")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeOfType ([TableNode])
+ $comparer.AddedItems[0].PreviousReportNode.Headers | Should -Be "Name|Value"
+ $comparer.AddedItems[0].PreviousReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2")
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([TableNode])
+ $comparer.AddedItems[0].CurrentReportNode.Headers | Should -Be "Name|Value"
+ $comparer.AddedItems[0].CurrentReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2", "C1|C2", "D1|D2")
+ }
+
+ It "Rows are deleted" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2", "C1|C2", "D1|D2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("C1|C2", "D1|D2")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([TableNode])
+ $comparer.DeletedItems[0].PreviousReportNode.Headers | Should -Be "Name|Value"
+ $comparer.DeletedItems[0].PreviousReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2", "C1|C2", "D1|D2")
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeOfType ([TableNode])
+ $comparer.DeletedItems[0].CurrentReportNode.Headers | Should -Be "Name|Value"
+ $comparer.DeletedItems[0].CurrentReportNode.Rows | Should -BeArray @("C1|C2", "D1|D2")
+ }
+
+ It "Rows are changed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B3|B4")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([TableNode])
+ $comparer.ChangedItems[0].PreviousReportNode.Headers | Should -Be "Name|Value"
+ $comparer.ChangedItems[0].PreviousReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([TableNode])
+ $comparer.ChangedItems[0].CurrentReportNode.Headers | Should -Be "Name|Value"
+ $comparer.ChangedItems[0].CurrentReportNode.Rows | Should -BeArray @("A1|A2", "B3|B4")
+ }
+
+ It "Rows are changed and updated at the same time" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B3|B4", "C1|C2")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([TableNode])
+ $comparer.ChangedItems[0].PreviousReportNode.Headers | Should -Be "Name|Value"
+ $comparer.ChangedItems[0].PreviousReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([TableNode])
+ $comparer.ChangedItems[0].CurrentReportNode.Headers | Should -Be "Name|Value"
+ $comparer.ChangedItems[0].CurrentReportNode.Rows | Should -BeArray @("A1|A2", "B3|B4", "C1|C2")
+ }
+
+ It "Rows are changed and removed at the same time" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2", "C1|C2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B3|B4")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 1
+ $comparer.DeletedItems | Should -HaveCount 0
+
+ $comparer.ChangedItems[0].PreviousReportNode | Should -BeOfType ([TableNode])
+ $comparer.ChangedItems[0].PreviousReportNode.Headers | Should -Be "Name|Value"
+ $comparer.ChangedItems[0].PreviousReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2", "C1|C2")
+ $comparer.ChangedItems[0].CurrentReportNode | Should -BeOfType ([TableNode])
+ $comparer.ChangedItems[0].CurrentReportNode.Headers | Should -Be "Name|Value"
+ $comparer.ChangedItems[0].CurrentReportNode.Rows | Should -BeArray @("A1|A2", "B3|B4")
+ }
+
+ It "Rows are not changed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+ }
+
+ It "Rows are not changed but header is changed" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value2", @("A1|A2", "B1|B2")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+ }
+
+ It "Rows are changed and header is changed at the same time" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value", @("A1|A2", "B1|B2")))
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddHeader("MyHeader").AddNode([TableNode]::new("Name|Value2", @("A1|A2", "B1|B2", "C1|C2")))
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 1
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 1
+
+ $comparer.AddedItems[0].PreviousReportNode | Should -BeNullOrEmpty
+ $comparer.AddedItems[0].CurrentReportNode | Should -BeOfType ([TableNode])
+ $comparer.AddedItems[0].CurrentReportNode.Headers | Should -Be "Name|Value2"
+ $comparer.AddedItems[0].CurrentReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2", "C1|C2")
+
+ $comparer.DeletedItems[0].PreviousReportNode | Should -BeOfType ([TableNode])
+ $comparer.DeletedItems[0].PreviousReportNode.Headers | Should -Be "Name|Value"
+ $comparer.DeletedItems[0].PreviousReportNode.Rows | Should -BeArray @("A1|A2", "B1|B2")
+ $comparer.DeletedItems[0].CurrentReportNode | Should -BeNullOrEmpty
+ }
+ }
+
+ Describe "NoteNode" {
+ It "NoteNode is ignored from report" {
+ $prevReport = [HeaderNode]::new("Version 1")
+ $prevReport.AddNote("MyFirstNote")
+ $prevReport.AddHeader("MyFirstHeader").AddNote("MyFirstSubNote")
+
+ $nextReport = [HeaderNode]::new("Version 2")
+ $nextReport.AddNote("MySecondNote")
+ $nextReport.AddHeader("MySecondHeader").AddNote("MySecondSubNote")
+
+ $comparer = [SoftwareReportDifferenceCalculator]::new($prevReport, $nextReport)
+ $comparer.CompareReports()
+
+ $comparer.AddedItems | Should -HaveCount 0
+ $comparer.ChangedItems | Should -HaveCount 0
+ $comparer.DeletedItems | Should -HaveCount 0
+ }
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/tests/SoftwareReport.DifferenceRender.Unit.Tests.ps1 b/helpers/software-report-base/tests/SoftwareReport.DifferenceRender.Unit.Tests.ps1
new file mode 100644
index 000000000..92723f2c1
--- /dev/null
+++ b/helpers/software-report-base/tests/SoftwareReport.DifferenceRender.Unit.Tests.ps1
@@ -0,0 +1,291 @@
+using module ../SoftwareReport.Nodes.psm1
+using module ../SoftwareReport.DifferenceRender.psm1
+
+BeforeDiscovery {
+ Import-Module $(Join-Path $PSScriptRoot "TestHelpers.psm1") -DisableNameChecking
+}
+
+Describe "ComparerReport.UnitTests" {
+ BeforeAll {
+ $script:DifferenceRender = [SoftwareReportDifferenceRender]::new()
+ }
+
+ Context "CalculateHtmlTableRowSpan" {
+ It "Without the equal cells" {
+ $table = @(
+ [PSCustomObject]@{ Key = "A"; Value = "1" }
+ [PSCustomObject]@{ Key = "B"; Value = "2" }
+ [PSCustomObject]@{ Key = "C"; Value = "3" }
+ )
+
+ $actual = $DifferenceRender.CalculateHtmlTableRowSpan($table, "Key")
+ $actual | Should -BeArray @(1, 1, 1)
+ }
+
+ It "Only equal cells" {
+ $table = @(
+ [PSCustomObject]@{ Key = "A"; Value = "D" }
+ [PSCustomObject]@{ Key = "B"; Value = "D" }
+ [PSCustomObject]@{ Key = "C"; Value = "D" }
+ )
+
+ $actual = $DifferenceRender.CalculateHtmlTableRowSpan($table, "Value")
+ $actual | Should -BeArray @(3, 0, 0)
+ }
+
+ It "Single row" {
+ $table = @(
+ [PSCustomObject]@{ Key = "A"; Value = "1" }
+ )
+
+ $actual = $DifferenceRender.CalculateHtmlTableRowSpan($table, "Key")
+ $actual | Should -BeArray @(1)
+ }
+
+ It "Different cells" {
+ $table = @(
+ [PSCustomObject]@{ Key = "A"; Value = "1" }
+ [PSCustomObject]@{ Key = "B"; Value = "2" }
+ [PSCustomObject]@{ Key = "B"; Value = "3" }
+ [PSCustomObject]@{ Key = "C"; Value = "4" }
+ [PSCustomObject]@{ Key = "C"; Value = "5" }
+ [PSCustomObject]@{ Key = "C"; Value = "6" }
+ [PSCustomObject]@{ Key = "D"; Value = "7" }
+ [PSCustomObject]@{ Key = "E"; Value = "8" }
+ [PSCustomObject]@{ Key = "E"; Value = "9" }
+ [PSCustomObject]@{ Key = "F"; Value = "10" }
+ )
+
+ $actual = $DifferenceRender.CalculateHtmlTableRowSpan($table, "Key")
+ $actual | Should -BeArray @(1, 2, 0, 3, 0, 0, 1, 2, 0, 1)
+ }
+ }
+
+ Context "RenderCategory" {
+ It "With line separator" {
+ $actual = $DifferenceRender.RenderCategory(@("Header 1", "Header 2", "Header 3"), $true)
+ $actual | Should -Be "Header 2 >
Header 3"
+ }
+
+ It "Without line separator" {
+ $actual = $DifferenceRender.RenderCategory(@("Header 1", "Header 2", "Header 3"), $false)
+ $actual | Should -Be "Header 2 > Header 3"
+ }
+
+ It "One header" {
+ $actual = $DifferenceRender.RenderCategory(@("Header 1"), $false)
+ $actual | Should -Be ""
+ }
+
+ It "Empty headers" {
+ $actual = $DifferenceRender.RenderCategory(@(), $false)
+ $actual | Should -Be ""
+ }
+ }
+
+ Context "RenderToolName" {
+ It "Clear tool name" {
+ $actual = $DifferenceRender.RenderToolName("My Tool 1")
+ $actual | Should -Be "My Tool 1"
+ }
+
+ It "Name with colon symbol" {
+ $actual = $DifferenceRender.RenderToolName("My Tool 1:")
+ $actual | Should -Be "My Tool 1"
+ }
+ }
+
+ Context "StrikeTableRow" {
+ It "Simple row" {
+ $actual = $DifferenceRender.StrikeTableRow("Test1|Test2|Test3")
+ $actual | Should -Be "~~Test1~~|~~Test2~~|~~Test3~~"
+ }
+
+ It "Row with spaces" {
+ $actual = $DifferenceRender.StrikeTableRow("Test 1|Test 2|Test 3")
+ $actual | Should -Be "~~Test 1~~|~~Test 2~~|~~Test 3~~"
+ }
+ }
+
+ Context "RenderHtmlTable" {
+ It "Simple table" {
+ $table = @(
+ [PSCustomObject]@{ "Category" = "A"; "Tool name" = "My Tool 1"; "Version" = "1.0" },
+ [PSCustomObject]@{ "Category" = "B"; "Tool name" = "My Tool 2"; "Version" = "2.0" },
+ [PSCustomObject]@{ "Category" = "C"; "Tool name" = "My Tool 3"; "Version" = "3.0" }
+ )
+
+ $renderedTable = $DifferenceRender.RenderHtmlTable($table, "Category")
+ $renderedTable | Should -Be @'
+
+
+ | Category |
+ Tool name |
+ Version |
+
+
+
+ | A |
+ My Tool 1 |
+ 1.0 |
+
+
+ | B |
+ My Tool 2 |
+ 2.0 |
+
+
+ | C |
+ My Tool 3 |
+ 3.0 |
+
+
+
+
+'@
+
+ }
+
+ It "Table with the same category" {
+ $table = @(
+ [PSCustomObject]@{ "Category" = "A"; "Tool name" = "My Tool 1"; "Version" = "1.0" },
+ [PSCustomObject]@{ "Category" = "A"; "Tool name" = "My Tool 2"; "Version" = "2.0" },
+ [PSCustomObject]@{ "Category" = "A"; "Tool name" = "My Tool 3"; "Version" = "3.0" },
+ [PSCustomObject]@{ "Category" = "B"; "Tool name" = "My Tool 4"; "Version" = "4.0" }
+ )
+
+ $renderedTable = $DifferenceRender.RenderHtmlTable($table, "Category")
+ $renderedTable | Should -Be @'
+
+
+ | Category |
+ Tool name |
+ Version |
+
+
+
+ | A |
+ My Tool 1 |
+ 1.0 |
+
+
+ | My Tool 2 |
+ 2.0 |
+
+
+ | My Tool 3 |
+ 3.0 |
+
+
+ | B |
+ My Tool 4 |
+ 4.0 |
+
+
+
+
+'@
+
+ }
+ }
+
+ Context "RenderTableNodesDiff" {
+ It "Add new table" {
+ $previousNode = $null
+ $currentNode = [TableNode]::new("Name|Value", @("A|1", "B|2"))
+ $reportItem = [ReportDifferenceItem]::new($previousNode, $currentNode, @("Header 1", "Header 2", "Header 3"))
+
+ $actual = $DifferenceRender.RenderTableNodesDiff($reportItem)
+ $actual | Should -Be @'
+#### Header 2 > Header 3
+| Name | Value |
+| ---- | ----- |
+| A | 1 |
+| B | 2 |
+
+'@
+ }
+
+ It "Remove existing table" {
+ $previousNode = [TableNode]::new("Name|Value", @("A|1", "B|2"))
+ $currentNode = $null
+ $reportItem = [ReportDifferenceItem]::new($previousNode, $currentNode, @("Header 1", "Header 2", "Header 3"))
+
+ $actual = $DifferenceRender.RenderTableNodesDiff($reportItem)
+ $actual | Should -Be @'
+#### Header 2 > Header 3
+| Name | Value |
+| ----- | ----- |
+| ~~A~~ | ~~1~~ |
+| ~~B~~ | ~~2~~ |
+
+'@
+ }
+
+ It "Add new rows to existing table" {
+ $previousNode = [TableNode]::new("Name|Value", @("A|1", "B|2"))
+ $currentNode = [TableNode]::new("Name|Value", @("A|1", "B|2", "C|3", "D|4"))
+ $reportItem = [ReportDifferenceItem]::new($previousNode, $currentNode, @("Header 1", "Header 2", "Header 3"))
+
+ $actual = $DifferenceRender.RenderTableNodesDiff($reportItem)
+ $actual | Should -Be @'
+#### Header 2 > Header 3
+| Name | Value |
+| ---- | ----- |
+| C | 3 |
+| D | 4 |
+
+'@
+ }
+
+ It "Remove rows from existing table" {
+ $previousNode = [TableNode]::new("Name|Value", @("A|1", "B|2", "C|3", "D|4"))
+ $currentNode = [TableNode]::new("Name|Value", @("C|3", "D|4"))
+ $reportItem = [ReportDifferenceItem]::new($previousNode, $currentNode, @("Header 1", "Header 2", "Header 3"))
+
+ $actual = $DifferenceRender.RenderTableNodesDiff($reportItem)
+ $actual | Should -Be @'
+#### Header 2 > Header 3
+| Name | Value |
+| ----- | ----- |
+| ~~A~~ | ~~1~~ |
+| ~~B~~ | ~~2~~ |
+
+'@
+ }
+
+ It "Row is changed in existing table" {
+ $previousNode = [TableNode]::new("Name|Value", @("A|1", "B|2"))
+ $currentNode = [TableNode]::new("Name|Value", @("A|1", "B|3"))
+ $reportItem = [ReportDifferenceItem]::new($previousNode, $currentNode, @("Header 1", "Header 2", "Header 3"))
+
+ $actual = $DifferenceRender.RenderTableNodesDiff($reportItem)
+ $actual | Should -Be @'
+#### Header 2 > Header 3
+| Name | Value |
+| ----- | ----- |
+| ~~B~~ | ~~2~~ |
+| B | 3 |
+
+'@
+ }
+
+ It "Row is changed, added and removed at the same time in existing table" {
+ $previousNode = [TableNode]::new("Name|Value", @("A|1", "B|2", "C|3", "D|4"))
+ $currentNode = [TableNode]::new("Name|Value", @("B|2", "C|4", "D|4", "E|5"))
+ $reportItem = [ReportDifferenceItem]::new($previousNode, $currentNode, @("Header 1", "Header 2", "Header 3"))
+
+ $actual = $DifferenceRender.RenderTableNodesDiff($reportItem)
+ $actual | Should -Be @'
+#### Header 2 > Header 3
+| Name | Value |
+| ----- | ----- |
+| ~~A~~ | ~~1~~ |
+| ~~C~~ | ~~3~~ |
+| C | 4 |
+| E | 5 |
+
+'@
+ }
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/tests/SoftwareReport.E2E.Tests.ps1 b/helpers/software-report-base/tests/SoftwareReport.E2E.Tests.ps1
new file mode 100644
index 000000000..ca8b4626c
--- /dev/null
+++ b/helpers/software-report-base/tests/SoftwareReport.E2E.Tests.ps1
@@ -0,0 +1,93 @@
+using module ../SoftwareReport.psm1
+using module ../SoftwareReport.Nodes.psm1
+
+Describe "SoftwareReport.E2E" {
+ Context "Report example 1" {
+ BeforeEach {
+ $softwareReport = [SoftwareReport]::new("macOS 11")
+ $softwareReport.Root.AddToolVersion("OS Version:", "macOS 11.7 (20G817)")
+ $softwareReport.Root.AddToolVersion("Image Version:", "20220918.1")
+ $installedSoftware = $softwareReport.Root.AddHeader("Installed Software")
+
+ $languagesAndRuntimes = $installedSoftware.AddHeader("Language and Runtime")
+ $languagesAndRuntimes.AddToolVersion("Bash", "5.1.16(1)-release")
+ $languagesAndRuntimes.AddToolVersionsListInline(".NET Core SDK", @("1.2.100", "1.2.200", "3.1.414"), "^\d+\.\d+\.\d")
+ $languagesAndRuntimes.AddNode([ToolVersionNode]::new("Perl", "5.34.0"))
+
+ $cachedTools = $installedSoftware.AddHeader("Cached Tools")
+ $cachedTools.AddToolVersionsList("Ruby", @("2.7.3", "2.8.1", "3.1.2"), "^\d+\.\d+")
+ $cachedTools.AddToolVersionsList("Node.js", @("14.8.0", "15.1.0", "16.4.2"), "^\d+")
+
+ $javaSection = $installedSoftware.AddHeader("Java")
+ $javaSection.AddTable(@(
+ [PSCustomObject] @{ Version = "8.0.125"; Vendor = "My Vendor"; "Environment Variable" = "JAVA_HOME_8_X64" },
+ [PSCustomObject] @{ Version = "11.3.103"; Vendor = "My Vendor"; "Environment Variable" = "JAVA_HOME_11_X64" }
+ ))
+
+ $sqlSection = $installedSoftware.AddHeader("MySQL")
+ $sqlSection.AddToolVersion("MySQL", "6.1.0")
+ $sqlSection.AddNote("MySQL service is disabled by default.`nUse the following command as a part of your job to start the service: 'sudo systemctl start mysql.service'")
+
+ $expectedMarkdown = @'
+# macOS 11
+- OS Version: macOS 11.7 (20G817)
+- Image Version: 20220918.1
+
+## Installed Software
+
+### Language and Runtime
+- Bash 5.1.16(1)-release
+- .NET Core SDK: 1.2.100, 1.2.200, 3.1.414
+- Perl 5.34.0
+
+### Cached Tools
+
+#### Ruby
+- 2.7.3
+- 2.8.1
+- 3.1.2
+
+#### Node.js
+- 14.8.0
+- 15.1.0
+- 16.4.2
+
+### Java
+| Version | Vendor | Environment Variable |
+| -------- | --------- | -------------------- |
+| 8.0.125 | My Vendor | JAVA_HOME_8_X64 |
+| 11.3.103 | My Vendor | JAVA_HOME_11_X64 |
+
+### MySQL
+- MySQL 6.1.0
+```
+MySQL service is disabled by default.
+Use the following command as a part of your job to start the service: 'sudo systemctl start mysql.service'
+```
+'@
+ }
+
+ It "ToMarkdown" {
+ $softwareReport.ToMarkdown() | Should -Be $expectedMarkdown
+ }
+
+ It "Serialization + Deserialization" {
+ $json = $softwareReport.ToJson()
+ $deserializedReport = [SoftwareReport]::FromJson($json)
+ $deserializedReport.ToMarkdown() | Should -Be $expectedMarkdown
+ }
+ }
+
+ Context "GetImageVersion" {
+ It "Image version exists" {
+ $softwareReport = [SoftwareReport]::new("MyReport")
+ $softwareReport.Root.AddToolVersion("Image Version:", "123.4")
+ $softwareReport.GetImageVersion() | Should -Be "123.4"
+ }
+
+ It "Empty report" {
+ $softwareReport = [SoftwareReport]::new("MyReport")
+ $softwareReport.GetImageVersion() | Should -Be "Unknown version"
+ }
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/tests/SoftwareReport.Nodes.Unit.Tests.ps1 b/helpers/software-report-base/tests/SoftwareReport.Nodes.Unit.Tests.ps1
new file mode 100644
index 000000000..58f57d397
--- /dev/null
+++ b/helpers/software-report-base/tests/SoftwareReport.Nodes.Unit.Tests.ps1
@@ -0,0 +1,511 @@
+using module ../SoftwareReport.Nodes.psm1
+
+BeforeDiscovery {
+ Import-Module $(Join-Path $PSScriptRoot "TestHelpers.psm1") -DisableNameChecking
+}
+
+Describe "Nodes.UnitTests" {
+ Context "ToolVersionNode" {
+ It "ToMarkdown" {
+ $node = [ToolVersionNode]::new("MyTool", "2.1.3")
+ $node.ToMarkdown() | Should -Be "- MyTool 2.1.3"
+ }
+
+ It "GetValue" {
+ $node = [ToolVersionNode]::new("MyTool", "2.1.3")
+ $node.GetValue() | Should -Be "2.1.3"
+ }
+
+ It "Serialization" {
+ $node = [ToolVersionNode]::new("MyTool", "2.1.3")
+ $json = $node.ToJsonObject()
+ $json.NodeType | Should -Be "ToolVersionNode"
+ $json.ToolName | Should -Be "MyTool"
+ $json.Version | Should -Be "2.1.3"
+ }
+
+ It "Deserialization" {
+ { [ToolVersionNode]::FromJsonObject(@{ NodeType = "ToolVersionNode"; ToolName = ""; Version = "2.1.3" }) } | Should -Throw '*Exception setting "ToolName": "The argument is null or empty.*'
+ { [ToolVersionNode]::FromJsonObject(@{ NodeType = "ToolVersionNode"; ToolName = "MyTool"; Version = "" }) } | Should -Throw '*Exception setting "Version": "The argument is null or empty.*'
+ { [ToolVersionNode]::FromJsonObject(@{ NodeType = "ToolVersionNode"; ToolName = "MyTool"; Version = "2.1.3" }) } | Should -Not -Throw
+ }
+
+ It "Serialization + Deserialization" {
+ $node = [ToolVersionNode]::new("MyTool", "2.1.3")
+ $json = $node.ToJsonObject()
+ $node2 = [ToolVersionNode]::FromJsonObject($json)
+ $json2 = $node2.ToJsonObject()
+ $($json | ConvertTo-Json) | Should -Be $($json2 | ConvertTo-Json)
+ }
+
+ It "IsSimilarTo" {
+ [ToolVersionNode]::new("MyTool", "2.1.3").IsSimilarTo([ToolVersionNode]::new("MyTool", "2.1.3")) | Should -BeTrue
+ [ToolVersionNode]::new("MyTool", "2.1.3").IsSimilarTo([ToolVersionNode]::new("MyTool", "1.0.0")) | Should -BeTrue
+ [ToolVersionNode]::new("MyTool", "2.1.3").IsSimilarTo([ToolVersionNode]::new("MyTool2", "2.1.3")) | Should -BeFalse
+ }
+
+ It "IsIdenticalTo" {
+ [ToolVersionNode]::new("MyTool", "2.1.3").IsIdenticalTo([ToolVersionNode]::new("MyTool", "2.1.3")) | Should -BeTrue
+ [ToolVersionNode]::new("MyTool", "2.1.3").IsIdenticalTo([ToolVersionNode]::new("MyTool", "1.0.0")) | Should -BeFalse
+ [ToolVersionNode]::new("MyTool", "2.1.3").IsIdenticalTo([ToolVersionNode]::new("MyTool2", "2.1.3")) | Should -BeFalse
+ }
+ }
+
+ Context "ToolVersionsListNode" {
+ It "ToMarkdown - List" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.7.7", "3.0.5", "3.1.3"), "^.+", "List")
+ $expected = @(
+ "",
+ "# MyTool"
+ "- 2.7.7"
+ "- 3.0.5"
+ "- 3.1.3"
+ ) -join "`n"
+ $node.ToMarkdown() | Should -Be $expected
+ }
+
+ It "ToMarkdown - Inline" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.7.7", "3.0.5", "3.1.3"), "^.+", "Inline")
+ $node.ToMarkdown() | Should -Be "- MyTool: 2.7.7, 3.0.5, 3.1.3"
+ }
+
+ It "GetValue" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.7.7", "3.0.5", "3.1.3"), "^.+", "List")
+ $node.GetValue() | Should -Be "2.7.7, 3.0.5, 3.1.3"
+ }
+
+ It "Serialization - List" {
+ $node = [ToolVersionsListNode]::new("Ruby", @("2.7.7", "3.0.5", "3.1.3"), "^.+", "List")
+ $json = $node.ToJsonObject()
+ $json.NodeType | Should -Be "ToolVersionsListNode"
+ $json.ToolName | Should -Be "Ruby"
+ $json.Versions | Should -BeArray @("2.7.7", "3.0.5", "3.1.3")
+ $json.MajorVersionRegex | Should -Be "^.+"
+ $json.ListType | Should -Be "List"
+ }
+
+ It "Serialization - Inline" {
+ $node = [ToolVersionsListNode]::new("Ruby", @("2.7.7", "3.0.5", "3.1.3"), "^.+", "Inline")
+ $json = $node.ToJsonObject()
+ $json.NodeType | Should -Be "ToolVersionsListNode"
+ $json.ToolName | Should -Be "Ruby"
+ $json.Versions | Should -BeArray @("2.7.7", "3.0.5", "3.1.3")
+ $json.MajorVersionRegex | Should -Be "^.+"
+ $json.ListType | Should -Be "Inline"
+ }
+
+ It "Deserialization" {
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = ""; Versions = @("2.1.3", "3.1.4"); MajorVersionRegex = "^\d+"; ListType = "List" }) } | Should -Throw '*Exception setting "ToolName": "The argument is null or empty.*'
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; MajorVersionRegex = "^\d+"; ListType = "List" }) } | Should -Throw '*Exception setting "Versions": "The argument is null or empty.*'
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; Versions = @(); MajorVersionRegex = "^\d+"; ListType = "List" }) } | Should -Throw '*Exception setting "Versions": "The argument is null, empty,*'
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; Versions = @("2.1.3", '2.2.4'); MajorVersionRegex = "^\d+"; ListType = "List" }) } | Should -Throw 'Multiple versions from list * return the same result from regex *'
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; Versions = @("2.1.3", "3.1.4"); MajorVersionRegex = ""; ListType = "List" }) } | Should -Throw 'Version * doesn''t match regex *'
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; Versions = @("2.1.3", "3.1.4"); MajorVersionRegex = "^\d+"; ListType = "Fake" }) } | Should -Throw '*Exception setting "ListType": "The argument * does not belong to the set*'
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; Versions = @("2.1.3", "3.1.4"); MajorVersionRegex = "^\d+"; ListType = "List" }) } | Should -Not -Throw
+ { [ToolVersionsListNode]::FromJsonObject(@{ NodeType = "ToolVersionsListNode"; ToolName = "MyTool"; Versions = @("2.1.3", "3.1.4"); MajorVersionRegex = "^\d+"; ListType = "Inline" }) } | Should -Not -Throw
+ }
+
+ It "Serialization + Deserialization" {
+ $node = [ToolVersionsListNode]::new("Ruby", @("2.7.7", "3.0.5", "3.1.3"), "^.+", "List")
+ $json = $node.ToJsonObject()
+ $node2 = [ToolVersionsListNode]::FromJsonObject($json)
+ $json2 = $node2.ToJsonObject()
+ $($json | ConvertTo-Json) | Should -Be $($json2 | ConvertTo-Json)
+ }
+
+ It "IsSimilarTo" {
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List").IsSimilarTo(
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List")
+ ) | Should -BeTrue
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List").IsSimilarTo(
+ [ToolVersionsListNode]::new("MyTool", @("2.1.5", "5.0.0"), "^.+", "List")
+ ) | Should -BeTrue
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List").IsSimilarTo(
+ [ToolVersionsListNode]::new("MyTool2", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List")
+ ) | Should -BeFalse
+ }
+
+ It "IsIdenticalTo" {
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List").IsIdenticalTo(
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List")
+ ) | Should -BeTrue
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List").IsIdenticalTo(
+ [ToolVersionsListNode]::new("MyTool", @("2.1.5", "5.0.0"), "^.+", "List")
+ ) | Should -BeFalse
+ [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List").IsIdenticalTo(
+ [ToolVersionsListNode]::new("MyTool2", @("2.1.3", "3.1.5", "4.0.0"), "^.+", "List")
+ ) | Should -BeFalse
+ }
+
+ It "ExtractMajorVersion" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^\d+\.\d+", "List")
+ $node.ExtractMajorVersion("2.1.3") | Should -Be "2.1"
+ $node.ExtractMajorVersion("3.1.5") | Should -Be "3.1"
+ $node.ExtractMajorVersion("4.0.0") | Should -Be "4.0"
+ }
+
+ Context "ValidateMajorVersionRegex" {
+ It "Major version regex - unique versions" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "4.0.0"), "^\d+", "List")
+ $node.Versions | Should -BeArray @("2.1.3", "3.1.5", "4.0.0")
+ }
+
+ It "Major version regex - non-unique versions" {
+ { [ToolVersionsListNode]::new("MyTool", @("2.1.3", "3.1.5", "3.2.0", "4.0.0"), "^\d+", "List") } | Should -Throw "Multiple versions from list * return the same result from regex *"
+ }
+
+ It "Minor version regex - unique versions" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.1.3", "2.4.0", "3.1.2"), "^\d+\.\d+", "List")
+ $node.Versions | Should -BeArray @("2.1.3", "2.4.0", "3.1.2")
+ }
+
+ It "Minor version regex - non-unique versions" {
+ { [ToolVersionsListNode]::new("MyTool", @("2.1.3", "2.1.4", "3.1.2"), "^\d+\.\d+", "List") } | Should -Throw "Multiple versions from list * return the same result from regex *"
+ }
+
+ It "Patch version regex - unique versions" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.1.3", "2.1.4", "2.1.5"), "^\d+\.\d+\.\d+", "List")
+ $node.Versions | Should -BeArray @("2.1.3", "2.1.4", "2.1.5")
+ }
+
+ It "Patch version regex - non-unique versions" {
+ { [ToolVersionsListNode]::new("MyTool", @("2.1.3", "2.1.4", "2.1.4"), "^\d+\.\d+\.\d+", "List") } | Should -Throw "Multiple versions from list * return the same result from regex *"
+ }
+
+ It ".NET Core version regex - unique versions" {
+ $node = [ToolVersionsListNode]::new("MyTool", @("2.1.100", "2.1.205", "2.1.303"), "^\d+\.\d+\.\d", "List")
+ $node.Versions | Should -BeArray @("2.1.100", "2.1.205", "2.1.303")
+ }
+
+ It ".NET Core version regex - non-unique versions" {
+ { [ToolVersionsListNode]::new("MyTool", @("2.1.100", "2.1.205", "2.1.230", "3.1.0"), "^\d+\.\d+\.\d", "List") } | Should -Throw "Multiple versions from list * return the same result from regex *"
+ }
+ }
+ }
+
+ Context "TableNode" {
+ Context "ToMarkdown" {
+ It "Simple table" {
+ $node = [TableNode]::new("Name|Value", @("A|B", "C|D"))
+ $node.ToMarkdown() | Should -Be @'
+| Name | Value |
+| ---- | ----- |
+| A | B |
+| C | D |
+'@
+ }
+
+ It "Wide cells" {
+ $node = [TableNode]::new("Name|Value", @("Very long value here|B", "C|And very long value here too"))
+ $node.ToMarkdown() | Should -Be @'
+| Name | Value |
+| -------------------- | ---------------------------- |
+| Very long value here | B |
+| C | And very long value here too |
+'@
+ }
+ }
+
+ It "CalculateColumnsWidth" {
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).CalculateColumnsWidth() | Should -BeArray @(4, 5)
+ [TableNode]::new("Name|Value", @("Very long value here|B", "C|And very long value here too")).CalculateColumnsWidth() | Should -BeArray @(20, 28)
+ }
+
+ It "Serialization" {
+ $node = [TableNode]::new("Name|Value", @("A|B", "C|D"))
+ $json = $node.ToJsonObject()
+ $json.NodeType | Should -Be "TableNode"
+ $json.Headers | Should -Be "Name|Value"
+ $json.Rows | Should -BeArray @("A|B", "C|D")
+ }
+
+ It "Deserialization" {
+ { [TableNode]::FromJsonObject(@{ NodeType = "TableNode"; Headers = ""; Rows = @("A|1", "B|2") }) } | Should -Throw 'Exception setting "Headers": "The argument is null or empty. *'
+ { [TableNode]::FromJsonObject(@{ NodeType = "TableNode"; Headers = "Name|Value"; Rows = @() }) } | Should -Throw 'Exception setting "Rows": "The argument is null, empty, *'
+ { [TableNode]::FromJsonObject(@{ NodeType = "TableNode"; Headers = "Name|Value"; Rows = @("A|1", "B|2|T", "C|3") }) } | Should -Throw 'Table has different number of columns in different rows'
+ { [TableNode]::FromJsonObject(@{ NodeType = "TableNode"; Headers = "Name|Value"; Rows = @("A|1", "B|2") }) } | Should -Not -Throw
+ }
+
+ It "Serialization + Deserialization" {
+ $node = [TableNode]::new("Name|Value", @("A|B", "C|D"))
+ $json = $node.ToJsonObject()
+ $node2 = [TableNode]::FromJsonObject($json)
+ $json2 = $node2.ToJsonObject()
+ $($json | ConvertTo-Json) | Should -Be $($json2 | ConvertTo-Json)
+ }
+
+ It "IsSimilarTo" {
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsSimilarTo([TableNode]::new("Name|Value", @("A|B", "C|D"))) | Should -BeTrue
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsSimilarTo([TableNode]::new("Name|Value", @("A|B", "C|D", "F|W"))) | Should -BeTrue
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsSimilarTo([TableNode]::new("Name|Value", @("A|B", "C|E"))) | Should -BeTrue
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsSimilarTo([TableNode]::new("Name|Key", @("A|B", "C|D"))) | Should -BeTrue
+ }
+
+ It "IsIdenticalTo" {
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsIdenticalTo([TableNode]::new("Name|Value", @("A|B", "C|D"))) | Should -BeTrue
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsIdenticalTo([TableNode]::new("Name|Key", @("A|B", "C|D"))) | Should -BeTrue
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsIdenticalTo([TableNode]::new("Name|Value", @("A|B", "C|D", "F|W"))) | Should -BeFalse
+ [TableNode]::new("Name|Value", @("A|B", "C|D")).IsIdenticalTo([TableNode]::new("Name|Value", @("A|B", "C|E"))) | Should -BeFalse
+ }
+
+ Context "FromObjectsArray" {
+ It "Correct table" {
+ $table = @(
+ [PSCustomObject]@{Name = "A"; Value = "B"}
+ [PSCustomObject]@{Name = "C"; Value = "D"}
+ )
+
+ $tableNode = [TableNode]::FromObjectsArray($table)
+ $tableNode.Headers | Should -Be "Name|Value"
+ $tableNode.Rows | Should -BeArray @("A|B", "C|D")
+ }
+
+ It "Correct table with spaces" {
+ $table = @(
+ [PSCustomObject]@{Name = "A B"; "My Value" = "1 2"}
+ [PSCustomObject]@{Name = "C D"; "My Value" = "3 4"}
+ )
+
+ $tableNode = [TableNode]::FromObjectsArray($table)
+ $tableNode.Headers | Should -Be "Name|My Value"
+ $tableNode.Rows | Should -BeArray @("A B|1 2", "C D|3 4")
+ }
+
+ It "Throw on empty table" {
+ { [TableNode]::FromObjectsArray(@()) } | Should -Throw "Failed to create TableNode from empty objects array"
+ }
+
+ It "Throw on table with different columns" {
+ $table = @(
+ [PSCustomObject]@{Name = "A"; Value = "B"}
+ [PSCustomObject]@{Name = "C"; Value2 = "D"}
+ )
+
+ { [TableNode]::FromObjectsArray($table) } | Should -Throw "Failed to create TableNode from objects array because objects have different properties"
+ }
+
+ It "Throw on empty row" {
+ $table = @(
+ [PSCustomObject]@{Name = "A"; Value = "B"},
+ [PSCustomObject]@{},
+ [PSCustomObject]@{Name = "C"; Value2 = "D"}
+ )
+
+ { [TableNode]::FromObjectsArray($table) } | Should -Throw "Failed to create TableNode because some objects are empty"
+ }
+
+ It "Throw on incorrect symbols in table column names" {
+ $table = @(
+ [PSCustomObject]@{"Name|War" = "A"; Value = "B"}
+ [PSCustomObject]@{"Name|War" = "C"; Value = "D"}
+ )
+
+ { [TableNode]::FromObjectsArray($table) } | Should -Throw "Failed to create TableNode because some cells * contains forbidden symbol*"
+ }
+
+ It "Throw on incorrect symbols in table rows" {
+ $table = @(
+ [PSCustomObject]@{Name = "A"; Value = "B|AA"}
+ [PSCustomObject]@{Name = "C"; Value = "D"}
+ )
+
+ { [TableNode]::FromObjectsArray($table) } | Should -Throw "Failed to create TableNode because some cells * contains forbidden symbol*"
+ }
+ }
+ }
+
+ Context "NoteNode" {
+ It "ToMarkdown" {
+ $node = [NoteNode]::new("Hello world`nGood Bye world")
+ $node.ToMarkdown() | Should -Be @'
+```
+hello world
+Good Bye world
+```
+'@
+ }
+
+ It "Serialization" {
+ $node = [NoteNode]::new("MyContent`nMyContent2")
+ $json = $node.ToJsonObject()
+ $json.NodeType | Should -Be "NoteNode"
+ $json.Content | Should -Be "MyContent`nMyContent2"
+ }
+
+ It "Deserialization" {
+ { [NoteNode]::FromJsonObject(@{ NodeType = "NoteNode" }) } | Should -Throw '*Exception setting "Content": "The argument is null or empty.*'
+ { [NoteNode]::FromJsonObject(@{ NodeType = "NoteNode"; Content = "" }) } | Should -Throw '*Exception setting "Content": "The argument is null or empty.*'
+ { [NoteNode]::FromJsonObject(@{ NodeType = "NoteNode"; Content = "MyTool" }) } | Should -Not -Throw
+ }
+
+ It "Serialization + Deserialization" {
+ $node = [NoteNode]::new("MyContent`nMyContent2")
+ $json = $node.ToJsonObject()
+ $node2 = [NoteNode]::FromJsonObject($json)
+ $json2 = $node2.ToJsonObject()
+ $($json | ConvertTo-Json) | Should -Be $($json2 | ConvertTo-Json)
+ }
+
+ It "IsSimilarTo" {
+ [NoteNode]::new("MyContent").IsSimilarTo([NoteNode]::new("MyContent")) | Should -BeTrue
+ [NoteNode]::new("MyContent").IsSimilarTo([NoteNode]::new("MyContent2")) | Should -BeFalse
+ }
+
+ It "IsIdenticalTo" {
+ [NoteNode]::new("MyContent").IsIdenticalTo([NoteNode]::new("MyContent")) | Should -BeTrue
+ [NoteNode]::new("MyContent").IsIdenticalTo([NoteNode]::new("MyContent2")) | Should -BeFalse
+ }
+ }
+
+ Context "HeaderNode" {
+ It "ToMarkdown" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+ $node.ToMarkdown(1) | Should -Be @'
+
+# MyHeader
+- MyTool 2.1.3
+'@
+ }
+
+ It "ToMarkdown (level 3)" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+ $node.ToMarkdown(3) | Should -Be @'
+
+### MyHeader
+- MyTool 2.1.3
+'@
+ }
+
+ It "ToMarkdown (multiple levels)" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddHeader("MyHeader 2").AddHeader("MyHeader 3").AddHeader("MyHeader 4").AddToolVersion("MyTool", "2.1.3")
+ $node.ToMarkdown(1) | Should -Be @'
+
+# MyHeader
+
+## MyHeader 2
+
+### MyHeader 3
+
+#### MyHeader 4
+- MyTool 2.1.3
+'@
+ }
+
+ It "Serialization" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+ $json = $node.ToJsonObject()
+ $json.NodeType | Should -Be "HeaderNode"
+ $json.Title | Should -Be "MyHeader"
+ $json.Children | Should -HaveCount 1
+ }
+
+ It "Deserialization" {
+ { [HeaderNode]::FromJsonObject(@{ NodeType = "HeaderNode" }) } | Should -Throw '*Exception setting "Title": "The argument is null or empty.*'
+ { [HeaderNode]::FromJsonObject(@{ NodeType = "HeaderNode"; Title = "" }) } | Should -Throw '*Exception setting "Title": "The argument is null or empty.*'
+ { [HeaderNode]::FromJsonObject(@{ NodeType = "HeaderNode"; Title = "MyHeader" }) } | Should -Not -Throw
+ }
+
+ It "Serialization + Deserialization" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+ $json = $node.ToJsonObject()
+ $node2 = [HeaderNode]::FromJsonObject($json)
+ $json2 = $node2.ToJsonObject()
+ $($json | ConvertTo-Json) | Should -Be $($json2 | ConvertTo-Json)
+ }
+
+ It "IsSimilarTo" {
+ [HeaderNode]::new("MyHeader").IsSimilarTo([HeaderNode]::new("MyHeader")) | Should -BeTrue
+ [HeaderNode]::new("MyHeader").IsSimilarTo([HeaderNode]::new("MyHeader2")) | Should -BeFalse
+ }
+
+ It "IsIdenticalTo" {
+ [HeaderNode]::new("MyHeader").IsIdenticalTo([HeaderNode]::new("MyHeader")) | Should -BeTrue
+ [HeaderNode]::new("MyHeader").IsIdenticalTo([HeaderNode]::new("MyHeader2")) | Should -BeFalse
+ }
+
+ It "FindSimilarChildNode" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+
+ $node.FindSimilarChildNode([ToolVersionNode]::new("MyTool", "1.0.0")) | Should -Not -BeNullOrEmpty
+ $node.FindSimilarChildNode([ToolVersionNode]::New("MyTool2", "1.0.0")) | Should -BeNullOrEmpty
+ }
+
+ Context "Detect node duplicates" {
+ It "Similar HeaderNode on the same header" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddHeader("MySubHeader1")
+ $node.AddHeader("MySubHeader2")
+ { $node.AddHeader("MySubHeader1") } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "Similar ToolVersionNode on the same header" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+ $node.AddToolVersion("MyTool2", "2.1.3")
+ { $node.AddToolVersion("MyTool", "2.1.3") } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "Similar ToolVersionsListNode on the same header" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersionsList("MyTool", @("2.1.3", "3.0.0"), "^\d+")
+ $node.AddToolVersionsListInline("MyTool2", @("2.1.3", "3.0.0"), "^\d+")
+ { $node.AddToolVersionsList("MyTool", @("2.1.3", "3.0.0"), "^\d+") } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "Similar TableNode on the same header" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddTable(@(
+ [PSCustomObject]@{Name = "Value1"},
+ [PSCustomObject]@{Name = "Value2"}
+ ))
+ {
+ $node.AddTable(@(
+ [PSCustomObject]@{Name = "Value1"},
+ [PSCustomObject]@{Name = "Value2"}
+ ))
+ } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "Similar NoteNode on the same header" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddNote("MyContent")
+ $node.AddNote("MyContent2")
+ { $node.AddNote("MyContent") } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "AddNode detects duplicates" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddNode([ToolVersionNode]::new("MyTool", "2.1.3"))
+ { $node.AddNode([ToolVersionNode]::new("MyTool", "2.1.3")) } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "AddNodes detects duplicates" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddNodes(@(
+ [ToolVersionNode]::new("MyTool", "2.1.3"),
+ [ToolVersionNode]::new("MyTool2", "2.1.4")
+ ))
+ {
+ $node.AddNodes(@(
+ [ToolVersionNode]::new("MyTool3", "2.1.5"),
+ [ToolVersionNode]::new("MyTool", "2.1.3")
+ ))
+ } | Should -Throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.*"
+ }
+
+ It "Doesn't allow adding non-header nodes after header node" {
+ $node = [HeaderNode]::new("MyHeader")
+ $node.AddToolVersion("MyTool", "2.1.3")
+ $node.AddHeader("MySubHeader")
+ { $node.AddToolVersion("MyTool2", "2.1.4") } | Should -Throw "It is not allowed to add the node of type * to the HeaderNode that already contains the HeaderNode children."
+ { $node.AddHeader("MySubHeader2") } | Should -Not -Throw
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/tests/TestHelpers.psm1 b/helpers/software-report-base/tests/TestHelpers.psm1
new file mode 100644
index 000000000..8918a2942
--- /dev/null
+++ b/helpers/software-report-base/tests/TestHelpers.psm1
@@ -0,0 +1,34 @@
+function ShouldBeArray([Array] $ActualValue, [Array]$ExpectedValue, [Switch] $Negate, [String] $Because) {
+ if ($Negate) {
+ throw "Negation is not supported for Should-BeArray"
+ }
+
+ if ($ExpectedValue.Count -eq 0) {
+ throw "Expected array cannot be empty. Use Should-BeNullOrEmpty instead."
+ }
+
+ $ExpectedValue | ForEach-Object {
+ if ($_.GetType() -notin @([String], [Int32])) {
+ throw "Only string or int arrays are supported in Should-BeArray"
+ }
+ }
+
+ $actualValueJson = $ActualValue | ConvertTo-Json
+ $expectedValueJson = $ExpectedValue | ConvertTo-Json
+
+ $succeeded = ($ActualValue.Count -eq $ExpectedValue.Count) -and ($actualValueJson -eq $expectedValueJson)
+
+ if (-not $succeeded) {
+ $failureMessage = "Expected array '$actualValueJson' to be equal to '$expectedValueJson'"
+ }
+
+ return [PSCustomObject]@{
+ Succeeded = $succeeded
+ FailureMessage = $failureMessage
+ }
+}
+
+Add-ShouldOperator -Name BeArray `
+ -InternalName 'ShouldBeArray' `
+ -Test ${function:ShouldBeArray} `
+ -SupportsArrayInput
\ No newline at end of file
diff --git a/images/macos/software-report/SoftwareReport.Generator.ps1 b/images/macos/software-report/SoftwareReport.Generator.ps1
index 9acb317f1..7065c84b5 100644
--- a/images/macos/software-report/SoftwareReport.Generator.ps1
+++ b/images/macos/software-report/SoftwareReport.Generator.ps1
@@ -33,7 +33,7 @@ $installedSoftware = $softwareReport.Root.AddHeader("Installed Software")
# Language and Runtime
$languageAndRuntime = $installedSoftware.AddHeader("Language and Runtime")
-$languageAndRuntime.AddToolVersionsList(".NET Core SDK", $(Get-DotnetVersionList), '^\d+\.\d+\.\d', $true)
+$languageAndRuntime.AddToolVersionsListInline(".NET Core SDK", $(Get-DotnetVersionList), '^\d+\.\d+\.\d')
$languageAndRuntime.AddToolVersion("Bash", $(Get-BashVersion))
$languageAndRuntime.AddNodes($(Get-ClangLLVMVersions))
$languageAndRuntime.AddNodes($(Get-GccVersions))
@@ -45,7 +45,7 @@ $languageAndRuntime.AddToolVersion("Mono", $(Get-MonoVersion))
$languageAndRuntime.AddToolVersion("MSBuild", $(Get-MSBuildVersion))
$languageAndRuntime.AddToolVersion("Node.js", $(Get-NodeVersion))
$languageAndRuntime.AddToolVersion("NVM", $(Get-NVMVersion))
-$languageAndRuntime.AddToolVersionsList("NVM - Cached node versions", $(Get-NVMNodeVersionList), '^\d+', $true)
+$languageAndRuntime.AddToolVersionsListInline("NVM - Cached node versions", $(Get-NVMNodeVersionList), '^\d+')
$languageAndRuntime.AddToolVersion("Perl", $(Get-PerlVersion))
$languageAndRuntime.AddToolVersion("PHP", $(Get-PHPVersion))
$languageAndRuntime.AddToolVersion("Python", $(Get-PythonVersion))
diff --git a/images/macos/software-report/SoftwareReport.Toolcache.psm1 b/images/macos/software-report/SoftwareReport.Toolcache.psm1
index cad7873f2..a7296b203 100644
--- a/images/macos/software-report/SoftwareReport.Toolcache.psm1
+++ b/images/macos/software-report/SoftwareReport.Toolcache.psm1
@@ -35,11 +35,11 @@ function Get-ToolcacheGoVersions {
function Build-ToolcacheSection {
return @(
- [ToolVersionsListNode]::new("Ruby", $(Get-ToolcacheRubyVersions), '^\d+\.\d+', $false),
- [ToolVersionsListNode]::new("Python", $(Get-ToolcachePythonVersions), '^\d+\.\d+', $false),
- [ToolVersionsListNode]::new("PyPy", $(Get-ToolcachePyPyVersions), '^\d+\.\d+', $false),
- [ToolVersionsListNode]::new("Node.js", $(Get-ToolcacheNodeVersions), '^\d+', $false),
- [ToolVersionsListNode]::new("Go", $(Get-ToolcacheGoVersions), '^\d+\.\d+', $false)
+ [ToolVersionsListNode]::new("Ruby", $(Get-ToolcacheRubyVersions), '^\d+\.\d+', "List"),
+ [ToolVersionsListNode]::new("Python", $(Get-ToolcachePythonVersions), '^\d+\.\d+', "List"),
+ [ToolVersionsListNode]::new("PyPy", $(Get-ToolcachePyPyVersions), '^\d+\.\d+', "List"),
+ [ToolVersionsListNode]::new("Node.js", $(Get-ToolcacheNodeVersions), '^\d+', "List"),
+ [ToolVersionsListNode]::new("Go", $(Get-ToolcacheGoVersions), '^\d+\.\d+', "List")
)
}
@@ -48,6 +48,6 @@ function Get-PowerShellModules {
$modules | ForEach-Object {
$moduleName = $_
$moduleVersions = Get-Module -Name $moduleName -ListAvailable | Select-Object -ExpandProperty Version | Sort-Object -Unique
- return [ToolVersionsListNode]::new($moduleName, $moduleVersions, '^\d+', $true)
+ return [ToolVersionsListNode]::new($moduleName, $moduleVersions, '^\d+', "Inline")
}
}
\ No newline at end of file