From 568eebccc0741d65d3afdce5e5c3269aea178ed6 Mon Sep 17 00:00:00 2001 From: Maria Zhelezova <43066499+mazhelez@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:30:54 +0200 Subject: [PATCH 1/5] Add unstableSince timestamp to tolerated tests Record when each tolerated test first became unstable via a new 'unstableSince' field on unstable-tests artifact entries. A single Set-UnstableSince pass runs just before the artifact is written and applies one rule per entry: if 'unstableSince' is already set, leave it; otherwise stamp the current UTC time. Because the sliding-window updater fully recomputes the list each run (rebuilding entries from scratch), the previous artifact is consulted so an already-recorded timestamp counts as 'already set' and survives the recompute instead of resetting hourly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TestTolerance/TestTolerance.Test.ps1 | 80 ++++++++++++++++++- .../scripts/TestTolerance/TestTolerance.psm1 | 76 +++++++++++++++++- .../UpdateUnstableTestsArtifact.ps1 | 31 ++++--- .../UpdateUnstableTestsCombined.ps1 | 29 ++++--- docs/features/test-tolerance/DESIGN.md | 8 +- 5 files changed, 197 insertions(+), 27 deletions(-) diff --git a/build/scripts/TestTolerance/TestTolerance.Test.ps1 b/build/scripts/TestTolerance/TestTolerance.Test.ps1 index a31c99fa76..f09d5981be 100644 --- a/build/scripts/TestTolerance/TestTolerance.Test.ps1 +++ b/build/scripts/TestTolerance/TestTolerance.Test.ps1 @@ -66,7 +66,7 @@ Describe "TestTolerance" { "branch": "main", "updatedAt": "2026-04-24T00:00:00Z", "tests": [ - { "extensionId": "ext-1", "codeunitId": 100, "codeunitName": "My Codeunit", "testMethod": "TestA", "reason": "timing", "linkedIssue": "https://example/issues/1" }, + { "extensionId": "ext-1", "codeunitId": 100, "codeunitName": "My Codeunit", "testMethod": "TestA", "reason": "timing", "linkedIssue": "https://example/issues/1", "unstableSince": "2026-04-24T00:00:00.0000000Z" }, { "codeunitId": 200, "codeunitName": "Other Codeunit", "testMethod": "TestB" } ] } @@ -403,6 +403,71 @@ Describe "TestTolerance" { } } + Context "ConvertTo-UnstableTestEntry" { + It "leaves unstableSince empty for a test that has none (stamped later by Set-UnstableSince)" { + $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1' } + + $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' + + $entry.PSObject.Properties['unstableSince'] | Should -Not -BeNullOrEmpty + $entry.unstableSince | Should -Be '' + } + + It "uses the test's own UnstableSince when it carries one" { + $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; UnstableSince = '2026-02-02T00:00:00.0000000Z' } + + $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' + $entry.unstableSince | Should -Be '2026-02-02T00:00:00.0000000Z' + } + } + + Context "Set-UnstableSince" { + It "stamps a fresh UTC ISO 8601 timestamp for a test with no unstableSince" { + $tests = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '' } + ) + $before = (Get-Date).ToUniversalTime() + + $result = @(Set-UnstableSince -Tests $tests -ExistingTests @()) + + $result[0].unstableSince | Should -Not -BeNullOrEmpty + $parsed = [datetimeoffset]::Parse($result[0].unstableSince).UtcDateTime + $parsed | Should -BeGreaterOrEqual $before.AddSeconds(-5) + $parsed | Should -BeLessOrEqual (Get-Date).ToUniversalTime().AddSeconds(5) + } + + It "keeps an unstableSince that is already set on the entry" { + $tests = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '2026-01-01T00:00:00.0000000Z' } + ) + + $result = @(Set-UnstableSince -Tests $tests -ExistingTests @()) + $result[0].unstableSince | Should -Be '2026-01-01T00:00:00.0000000Z' + } + + It "preserves a timestamp from the previous artifact matched by test key (survives full recompute)" { + $tests = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '' } + ) + $existing = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '2025-12-31T00:00:00.0000000Z' } + ) + + $result = @(Set-UnstableSince -Tests $tests -ExistingTests $existing) + $result[0].unstableSince | Should -Be '2025-12-31T00:00:00.0000000Z' + } + + It "adds an unstableSince property when the entry lacks one entirely" { + $tests = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1' } + ) + + $result = @(Set-UnstableSince -Tests $tests -ExistingTests @()) + $result[0].PSObject.Properties['unstableSince'] | Should -Not -BeNullOrEmpty + $result[0].unstableSince | Should -Not -BeNullOrEmpty + } + } + Context "Add-FailedTestsToUnstableTests" { It "appends new failed tests to an empty existing list" { $failed = @{ @@ -472,6 +537,19 @@ Describe "TestTolerance" { $merged[2].testMethod | Should -Be 'T2' } + It "preserves an existing entry's unstableSince and leaves new entries empty (stamped later)" { + $existing = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; reason = 'pre-existing'; unstableSince = '2026-01-01T00:00:00.0000000Z' } + ) + $failed = @{ + 'ext-2::400::t2' = [pscustomobject]@{ ExtensionId = 'ext-2'; CodeunitId = 400; CodeunitName = 'B'; TestMethod = 'T2'; FailureMessage = 'm2'; SourceRunId = '1000' } + } + + $merged = @(Add-FailedTestsToUnstableTests -ExistingTests $existing -FailedTests $failed -Repository 'owner/repo') + $merged[0].unstableSince | Should -Be '2026-01-01T00:00:00.0000000Z' + $merged[1].unstableSince | Should -Be '' + } + It "uses the test's own Reason when the failed test carries one" { $failed = @{ 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm1'; SourceRunId = '2001'; Reason = 'Auto-detected: failed on 3 distinct PRs' } diff --git a/build/scripts/TestTolerance/TestTolerance.psm1 b/build/scripts/TestTolerance/TestTolerance.psm1 index b8b6ea5c7c..3cdaa97f87 100644 --- a/build/scripts/TestTolerance/TestTolerance.psm1 +++ b/build/scripts/TestTolerance/TestTolerance.psm1 @@ -171,11 +171,11 @@ function Get-UnstableTestKey { "branch": "main", "updatedAt": "2026-04-24T00:00:00Z", "tests": [ - { "extensionId": "...", "codeunitId": 137404, "codeunitName": "SCM Manufacturing", "testMethod": "TestSomething", "reason": "...", "linkedIssue": "..." } + { "extensionId": "...", "codeunitId": 137404, "codeunitName": "SCM Manufacturing", "testMethod": "TestSomething", "reason": "...", "linkedIssue": "...", "unstableSince": "2026-04-24T00:00:00.0000000Z" } ] } - The 'reason' and 'linkedIssue' fields are optional. The function returns an empty + The 'reason', 'linkedIssue' and 'unstableSince' fields are optional. The function returns an empty hashtable when the file does not exist, so callers can treat "no artifact" as "no unstable tests yet". @@ -907,6 +907,9 @@ function Get-FailedTestsFromRuns { Update-UnstableTestsList). 'Reason' overrides the entry reason; when empty, the test's own Reason property (if any) is used. 'Repository' is used to build the sourceRunUrl from the test's SourceRunId. + The 'unstableSince' timestamp is left empty here; Set-UnstableSince stamps it just before the artifact + is written so the "set once, then leave it" rule lives in a single place. + .Parameter Test A single failed/unstable test object with PascalCase properties. .Parameter Reason @@ -929,6 +932,7 @@ function ConvertTo-UnstableTestEntry { $sourceRunId = if ($Test.PSObject.Properties['SourceRunId']) { [string]$Test.SourceRunId } else { '' } $reasonValue = if ($Reason) { $Reason } elseif ($Test.PSObject.Properties['Reason']) { [string]$Test.Reason } else { '' } $linkedIssue = if ($Test.PSObject.Properties['LinkedIssue']) { [string]$Test.LinkedIssue } else { '' } + $unstableSince = if ($Test.PSObject.Properties['UnstableSince']) { [string]$Test.UnstableSince } else { '' } return [pscustomobject][ordered]@{ extensionId = if ($Test.PSObject.Properties['ExtensionId']) { [string]$Test.ExtensionId } else { '' } @@ -939,10 +943,77 @@ function ConvertTo-UnstableTestEntry { failureDetail = if ($Test.PSObject.Properties['FailureDetail']) { [string]$Test.FailureDetail } else { '' } reason = $reasonValue linkedIssue = $linkedIssue + unstableSince = $unstableSince sourceRunUrl = if ($Repository -and $sourceRunId) { "https://github.com/$Repository/actions/runs/$sourceRunId" } else { '' } } } +<# +.Synopsis + Ensures every unstable test entry has an 'unstableSince' timestamp: set it once, then leave it. +.Description + Applies one rule to each entry about to be written to the artifact: if 'unstableSince' is already set, + leave it; otherwise set it. This is the only place that assigns the timestamp, so a test's clock starts + when it first appears in the list and is preserved on every later update while it stays unstable. + + Because the sliding window fully recomputes the list each run (rebuilding entries from scratch, so their + 'unstableSince' starts empty), the previous artifact's entries are passed as 'ExistingTests': an entry + that already had a timestamp there is treated as "already set" and reuses it, otherwise the current UTC + time is stamped. +.Parameter Tests + The list of artifact entries (camelCase) about to be written. Entries are updated in place. +.Parameter ExistingTests + The 'tests' array from the previous artifact, used so timestamps survive a full recompute. +#> +function Set-UnstableSince { + [CmdletBinding()] + [OutputType([System.Collections.IList])] + param( + [System.Collections.IList] $Tests = @(), + + [System.Collections.IList] $ExistingTests = @() + ) + + $now = (Get-Date).ToUniversalTime().ToString('o') + + # Timestamps already recorded in the previous artifact, so they survive a full recompute. + $existingSince = @{} + foreach ($entry in $ExistingTests) { + if ($null -eq $entry) { continue } + if (-not ($entry.PSObject.Properties['unstableSince'])) { continue } + if ([string]::IsNullOrWhiteSpace([string]$entry.unstableSince)) { continue } + $existingSince[(Get-EntryUnstableTestKey -Entry $entry)] = $entry.unstableSince + } + + foreach ($entry in $Tests) { + if ($null -eq $entry) { continue } + + # Already set: leave it. Otherwise reuse the previous artifact's value, else stamp now. + $current = if ($entry.PSObject.Properties['unstableSince']) { [string]$entry.unstableSince } else { '' } + if (-not [string]::IsNullOrWhiteSpace($current)) { continue } + + $key = Get-EntryUnstableTestKey -Entry $entry + $value = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { $now } + + if ($entry.PSObject.Properties['unstableSince']) { $entry.unstableSince = $value } + else { $entry | Add-Member -NotePropertyName 'unstableSince' -NotePropertyValue $value } + } + + return $Tests +} + +<# +.Synopsis + Computes the 'extensionId::codeunit::testMethod' key for an artifact entry (camelCase properties). +#> +function Get-EntryUnstableTestKey { + param($Entry) + $method = if ($Entry.PSObject.Properties['testMethod']) { [string]$Entry.testMethod } else { '' } + $extId = if ($Entry.PSObject.Properties['extensionId']) { [string]$Entry.extensionId } else { '' } + $cuId = if ($Entry.PSObject.Properties['codeunitId']) { [int]$Entry.codeunitId } else { 0 } + return Get-UnstableTestKey -CodeunitId $cuId -TestMethod $method -ExtensionId $extId +} + <# .Synopsis Writes the per-branch unstable tests artifact JSON to disk. @@ -1607,6 +1678,7 @@ Export-ModuleMember -Function ` Find-UnstableTestRunIds, ` Get-FailedTestsFromRuns, ` ConvertTo-UnstableTestEntry, ` + Set-UnstableSince, ` Save-UnstableTestsArtifact, ` Add-FailedTestsToUnstableTests, ` Select-CrossPrUnstableTests, ` diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 index 2a0aa1d2d0..ff209f011f 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 @@ -72,21 +72,22 @@ try { Write-Host "::notice::Observed $($failedTests.Count) distinct failed test(s) across $($RunIds.Count) run(s)." # --- 2. Build the unstable tests list (additive merge or full recompute) --- - if ($Additive) { - $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $downloadDir - - $existingTests = @() - if ($existingPath -and (Test-Path $existingPath)) { - $existing = Get-Content -Raw -Path $existingPath | ConvertFrom-Json - if (($existing.PSObject.Properties['tests']) -and $existing.tests) { - $existingTests = @($existing.tests) - } - Write-Host "Existing unstable tests list for branch '$Branch' has $($existingTests.Count) test(s)." - } - else { - Write-Host "No existing unstable tests artifact found for branch '$Branch'. Starting from an empty list." + # Load the existing artifact so each still-unstable test's 'unstableSince' timestamp can be preserved + # (via Set-UnstableSince below) rather than reset when the list is recomputed or appended to. + $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $downloadDir + $existingTests = @() + if ($existingPath -and (Test-Path $existingPath)) { + $existing = Get-Content -Raw -Path $existingPath | ConvertFrom-Json + if (($existing.PSObject.Properties['tests']) -and $existing.tests) { + $existingTests = @($existing.tests) } + Write-Host "Existing unstable tests list for branch '$Branch' has $($existingTests.Count) test(s)." + } + else { + Write-Host "No existing unstable tests artifact found for branch '$Branch'." + } + if ($Additive) { if ($failedTests.Count -eq 0) { Write-Host "::warning::No failed tests found in the supplied run(s). The unstable tests list will be rewritten unchanged." } @@ -98,6 +99,10 @@ try { $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) } + # Stamp 'unstableSince' once per test: keep any timestamp a test already has (including one carried in + # the previous artifact), stamp the rest with the current UTC time. + $tests = @(Set-UnstableSince -Tests ([System.Collections.IList]$tests) -ExistingTests ([System.Collections.IList]$existingTests)) + # --- 3. Write artifact --- Save-UnstableTestsArtifact -Branch $Branch -RunIds $RunIds -Tests ([System.Collections.IList]$tests) -OutputPath $OutputPath } diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 index f60a66930a..3d2c61bfe3 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 @@ -85,6 +85,20 @@ New-Item -ItemType Directory -Path $cicdWorkDir -Force | Out-Null New-Item -ItemType Directory -Path $prWorkDir -Force | Out-Null try { + # Load the existing artifact once. It serves two purposes: preserving each test's 'unstableSince' + # timestamp across the Path A full recompute (which rebuilds every entry from scratch, and would + # otherwise reset the timestamp to "now"), and acting as the fallback base list when there is no + # CI/CD window to recompute from. + $existingTests = @() + $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $existingDir + if ($existingPath -and (Test-Path $existingPath)) { + $existing = Get-Content -Raw -Path $existingPath | ConvertFrom-Json + if (($existing.PSObject.Properties['tests']) -and $existing.tests) { + $existingTests = @($existing.tests) + } + Write-Host "Existing unstable tests list for '$Branch' has $($existingTests.Count) test(s)." + } + # --- Path A: recompute the base list from the recent CI/CD window --- Write-Host "::group::Path A · Recompute from recent CI/CD runs (branch '$Branch')" $cicdRunIds = @(Find-UnstableTestRunIds ` @@ -104,15 +118,8 @@ try { } else { # No CI/CD runs to recompute from. Do NOT wipe the list: fall back to the existing artifact as the - # base so Path B stays purely additive on top of it. - $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $existingDir - if ($existingPath -and (Test-Path $existingPath)) { - $existing = Get-Content -Raw -Path $existingPath | ConvertFrom-Json - if (($existing.PSObject.Properties['tests']) -and $existing.tests) { - $baseEntries = @($existing.tests) - } - Write-Host "Existing unstable tests list for '$Branch' has $($baseEntries.Count) test(s)." - } + # base so Path B stays purely additive on top of it. Existing entries keep their unstableSince. + $baseEntries = @($existingTests) Write-Host "::endgroup::" Write-Host "::warning::Path A (CI/CD): no completed runs with test results for '$Branch'. Preserving the existing list ($($baseEntries.Count) test(s)) as the base." } @@ -136,6 +143,10 @@ try { # --- Merge Path B additively on top of the Path A base and write once --- $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$baseEntries) -FailedTests $crossPrFailed -Repository $repo) + # Stamp 'unstableSince' once per test: keep any timestamp a test already has (including one carried in + # the previous artifact, so it survives the Path A full recompute); stamp the rest with the current UTC time. + $tests = @(Set-UnstableSince -Tests ([System.Collections.IList]$tests) -ExistingTests ([System.Collections.IList]$existingTests)) + $allRunIds = @() $allRunIds += @($cicdRunIds) $allRunIds += @($crossPrFailed.Values | ForEach-Object { [string]$_.SourceRunId } | Where-Object { $_ }) diff --git a/docs/features/test-tolerance/DESIGN.md b/docs/features/test-tolerance/DESIGN.md index 14703d9fff..8c5afc7750 100644 --- a/docs/features/test-tolerance/DESIGN.md +++ b/docs/features/test-tolerance/DESIGN.md @@ -2,7 +2,7 @@ > Status: Draft — core decisions made, implementation complete > Owner: Engineering Systems -> Last updated: 2026-04-28 +> Last updated: 2026-07-24 ## 1. Summary @@ -95,7 +95,11 @@ The correlation logic lives in `TestTolerance.psm1`, split into a computational, Each test is identified by a three-part normalized key: `extensionId::codeunit::testMethod` (all lowercase). The `extensionId` is read from the `` element in the test results XML; it is empty-string for suites without that property. Two tests with the same codeunit and testMethod name but different extension IDs are treated as distinct tests. -> **Schema migration note:** Unstable-tests artifacts produced before the `extensionId` field was added use the old two-part key format (`codeunit::testMethod`). When a run downloads such an artifact, no matches will be found and tolerance will silently degrade to zero for that run. The artifact will be overwritten with the new three-part key format after the next UpdateUnstableTests run completes. +**Unstable-since timestamp** + +Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. Just before the artifact is written, a single pass (`Set-UnstableSince`) applies one rule to every entry: if it already has an `unstableSince` value, keep it; otherwise stamp the current UTC time. Because the sliding window (Path A) fully recomputes the list on every run, "already set" also consults the previous artifact — an entry whose timestamp was recorded on an earlier run is treated as already set and preserved — so the field reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. + +> **Schema migration note:** Unstable-tests artifacts produced before the `extensionId` field was added use the old two-part key format (`codeunit::testMethod`). When a run downloads such an artifact, no matches will be found and tolerance will silently degrade to zero for that run. The artifact will be overwritten with the new three-part key format after the next UpdateUnstableTests run completes. Likewise, artifacts produced before the `unstableSince` field existed carry no timestamp; the first run after this change stamps `unstableSince` to that run's time for every surviving test, and the value is then preserved on subsequent runs. **Artifact lifecycle** From 471b92423b29254ae3f7cca6ffcba9714b4f97d5 Mon Sep 17 00:00:00 2001 From: Maria Zhelezova <43066499+mazhelez@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:46:16 +0200 Subject: [PATCH 2/5] Stamp unstableSince at entry creation instead of a separate pass Remove the standalone Set-UnstableSince pass and stamp 'unstableSince' when the entry is built in ConvertTo-UnstableTestEntry. Each driver computes one timestamp per run and passes it through, so every entry created in the same run shares the same value. Timestamps still survive the hourly full recompute: Update-UnstableTestsList now carries each still-unstable test's prior 'unstableSince' forward from the previous artifact, so a rebuilt entry keeps its original time instead of being restamped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TestTolerance/TestTolerance.Test.ps1 | 85 ++++++-------- .../scripts/TestTolerance/TestTolerance.psm1 | 106 +++++++----------- .../UpdateUnstableTestsArtifact.ps1 | 15 ++- .../UpdateUnstableTestsCombined.ps1 | 15 +-- docs/features/test-tolerance/DESIGN.md | 2 +- 5 files changed, 93 insertions(+), 130 deletions(-) diff --git a/build/scripts/TestTolerance/TestTolerance.Test.ps1 b/build/scripts/TestTolerance/TestTolerance.Test.ps1 index f09d5981be..d492c0a91d 100644 --- a/build/scripts/TestTolerance/TestTolerance.Test.ps1 +++ b/build/scripts/TestTolerance/TestTolerance.Test.ps1 @@ -401,70 +401,55 @@ Describe "TestTolerance" { $result['ext-1::300::t1'].FailureMessage | Should -Be 'm1' $result['ext-1::300::t1'].FailureDetail | Should -Be 's1' } - } - Context "ConvertTo-UnstableTestEntry" { - It "leaves unstableSince empty for a test that has none (stamped later by Set-UnstableSince)" { - $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1' } - - $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' + It "leaves UnstableSince empty for a newly unstable test with no prior entry" { + $failed = @{ + 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm' } + } - $entry.PSObject.Properties['unstableSince'] | Should -Not -BeNullOrEmpty - $entry.unstableSince | Should -Be '' + $result = Update-UnstableTestsList -FailedTests $failed + $result['ext-1::300::t1'].UnstableSince | Should -Be '' } - It "uses the test's own UnstableSince when it carries one" { - $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; UnstableSince = '2026-02-02T00:00:00.0000000Z' } + It "carries forward UnstableSince from the existing artifact for a still-unstable test (survives full recompute)" { + $failed = @{ + 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm' } + } + $existing = @( + [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '2025-12-31T00:00:00.0000000Z' } + ) - $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' - $entry.unstableSince | Should -Be '2026-02-02T00:00:00.0000000Z' + $result = Update-UnstableTestsList -FailedTests $failed -ExistingTests $existing + $result['ext-1::300::t1'].UnstableSince | Should -Be '2025-12-31T00:00:00.0000000Z' } } - Context "Set-UnstableSince" { - It "stamps a fresh UTC ISO 8601 timestamp for a test with no unstableSince" { - $tests = @( - [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '' } - ) - $before = (Get-Date).ToUniversalTime() + Context "ConvertTo-UnstableTestEntry" { + It "stamps the provided UnstableSince for a test that has none" { + $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1' } - $result = @(Set-UnstableSince -Tests $tests -ExistingTests @()) + $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' -UnstableSince '2026-03-03T00:00:00.0000000Z' - $result[0].unstableSince | Should -Not -BeNullOrEmpty - $parsed = [datetimeoffset]::Parse($result[0].unstableSince).UtcDateTime - $parsed | Should -BeGreaterOrEqual $before.AddSeconds(-5) - $parsed | Should -BeLessOrEqual (Get-Date).ToUniversalTime().AddSeconds(5) + $entry.unstableSince | Should -Be '2026-03-03T00:00:00.0000000Z' } - It "keeps an unstableSince that is already set on the entry" { - $tests = @( - [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '2026-01-01T00:00:00.0000000Z' } - ) - - $result = @(Set-UnstableSince -Tests $tests -ExistingTests @()) - $result[0].unstableSince | Should -Be '2026-01-01T00:00:00.0000000Z' - } + It "defaults UnstableSince to the current UTC time when none is provided" { + $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1' } + $before = (Get-Date).ToUniversalTime() - It "preserves a timestamp from the previous artifact matched by test key (survives full recompute)" { - $tests = @( - [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '' } - ) - $existing = @( - [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; unstableSince = '2025-12-31T00:00:00.0000000Z' } - ) + $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' - $result = @(Set-UnstableSince -Tests $tests -ExistingTests $existing) - $result[0].unstableSince | Should -Be '2025-12-31T00:00:00.0000000Z' + $entry.unstableSince | Should -Not -BeNullOrEmpty + $parsed = [datetimeoffset]::Parse($entry.unstableSince).UtcDateTime + $parsed | Should -BeGreaterOrEqual $before.AddSeconds(-5) + $parsed | Should -BeLessOrEqual (Get-Date).ToUniversalTime().AddSeconds(5) } - It "adds an unstableSince property when the entry lacks one entirely" { - $tests = @( - [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1' } - ) + It "uses the test's own UnstableSince over the provided value" { + $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; UnstableSince = '2026-02-02T00:00:00.0000000Z' } - $result = @(Set-UnstableSince -Tests $tests -ExistingTests @()) - $result[0].PSObject.Properties['unstableSince'] | Should -Not -BeNullOrEmpty - $result[0].unstableSince | Should -Not -BeNullOrEmpty + $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' -UnstableSince '2026-03-03T00:00:00.0000000Z' + $entry.unstableSince | Should -Be '2026-02-02T00:00:00.0000000Z' } } @@ -537,7 +522,7 @@ Describe "TestTolerance" { $merged[2].testMethod | Should -Be 'T2' } - It "preserves an existing entry's unstableSince and leaves new entries empty (stamped later)" { + It "preserves an existing entry's unstableSince and stamps new entries with the run timestamp" { $existing = @( [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; reason = 'pre-existing'; unstableSince = '2026-01-01T00:00:00.0000000Z' } ) @@ -545,9 +530,9 @@ Describe "TestTolerance" { 'ext-2::400::t2' = [pscustomobject]@{ ExtensionId = 'ext-2'; CodeunitId = 400; CodeunitName = 'B'; TestMethod = 'T2'; FailureMessage = 'm2'; SourceRunId = '1000' } } - $merged = @(Add-FailedTestsToUnstableTests -ExistingTests $existing -FailedTests $failed -Repository 'owner/repo') + $merged = @(Add-FailedTestsToUnstableTests -ExistingTests $existing -FailedTests $failed -Repository 'owner/repo' -UnstableSince '2026-05-05T00:00:00.0000000Z') $merged[0].unstableSince | Should -Be '2026-01-01T00:00:00.0000000Z' - $merged[1].unstableSince | Should -Be '' + $merged[1].unstableSince | Should -Be '2026-05-05T00:00:00.0000000Z' } It "uses the test's own Reason when the failed test carries one" { diff --git a/build/scripts/TestTolerance/TestTolerance.psm1 b/build/scripts/TestTolerance/TestTolerance.psm1 index 3cdaa97f87..1d1533e035 100644 --- a/build/scripts/TestTolerance/TestTolerance.psm1 +++ b/build/scripts/TestTolerance/TestTolerance.psm1 @@ -696,8 +696,12 @@ function Receive-UnstableTestsArtifact { identity fields plus an auto-detected reason of the form: "Auto-detected: failed in at least 1 of the last CI/CD run(s)" - This function does not inspect passed tests or merge with an existing unstable list; it - only transforms the supplied failed-test set into the artifact format. + This function does not inspect passed tests; it transforms the supplied failed-test set into the + artifact format. It does, however, carry forward each test's 'unstableSince' from the previous + artifact (passed as 'ExistingTests'): because the sliding window rebuilds the list from scratch each + run, seeding UnstableSince here lets a test that was already unstable keep its original timestamp + instead of being restamped. Tests with no prior entry are left without a timestamp so the entry + builder stamps the current run time. Returns the updated hashtable keyed by 'extensionId::codeunit::testMethod'. @@ -705,6 +709,9 @@ function Receive-UnstableTestsArtifact { Hashtable of failed tests keyed by test key to convert into unstable-test entries. .Parameter RunCount Number of CI/CD runs the window covered; used only to build the auto-detected reason text. +.Parameter ExistingTests + The 'tests' array from the previous artifact (camelCase entries), used to preserve each still-unstable + test's original 'unstableSince' across the full recompute. #> function Update-UnstableTestsList { [CmdletBinding()] @@ -713,9 +720,21 @@ function Update-UnstableTestsList { [Parameter(Mandatory = $true)] [hashtable] $FailedTests, - [int] $RunCount = 0 + [int] $RunCount = 0, + + [System.Collections.IList] $ExistingTests = @() ) + # Prior 'unstableSince' values keyed by test key, so an already-unstable test keeps its original + # timestamp when the list is recomputed from scratch. + $existingSince = @{} + foreach ($entry in $ExistingTests) { + if ($null -eq $entry) { continue } + if (-not ($entry.PSObject.Properties['unstableSince'])) { continue } + if ([string]::IsNullOrWhiteSpace([string]$entry.unstableSince)) { continue } + $existingSince[(Get-EntryUnstableTestKey -Entry $entry)] = [string]$entry.unstableSince + } + $result = @{} foreach ($key in @($FailedTests.Keys)) { $ft = $FailedTests[$key] @@ -730,6 +749,7 @@ function Update-UnstableTestsList { SourceRunId = if ($ft.PSObject.Properties['SourceRunId']) { $ft.SourceRunId } else { '' } Reason = "Auto-detected: failed in at least 1 of the last $RunCount CI/CD run(s)" LinkedIssue = '' + UnstableSince = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { '' } } } @@ -907,8 +927,10 @@ function Get-FailedTestsFromRuns { Update-UnstableTestsList). 'Reason' overrides the entry reason; when empty, the test's own Reason property (if any) is used. 'Repository' is used to build the sourceRunUrl from the test's SourceRunId. - The 'unstableSince' timestamp is left empty here; Set-UnstableSince stamps it just before the artifact - is written so the "set once, then leave it" rule lives in a single place. + The 'unstableSince' timestamp is stamped here, when the entry is created: if the test already carries + an UnstableSince value (e.g. preserved from the previous artifact), keep it; otherwise use the run + timestamp passed via 'UnstableSince'. Passing that value in (rather than reading the clock here) keeps + it identical for every entry created during the same run. .Parameter Test A single failed/unstable test object with PascalCase properties. @@ -916,6 +938,9 @@ function Get-FailedTestsFromRuns { Overrides the entry reason. When empty, the test's own Reason property is used. .Parameter Repository Repository in '/' form, used to build the sourceRunUrl from the test's SourceRunId. +.Parameter UnstableSince + Timestamp to stamp on a test that has no UnstableSince of its own. Defaults to the current UTC time; + callers pass a single value computed once per run so all newly created entries share it. #> function ConvertTo-UnstableTestEntry { [CmdletBinding()] @@ -926,13 +951,16 @@ function ConvertTo-UnstableTestEntry { [string] $Reason = '', - [string] $Repository = '' + [string] $Repository = '', + + [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) ) $sourceRunId = if ($Test.PSObject.Properties['SourceRunId']) { [string]$Test.SourceRunId } else { '' } $reasonValue = if ($Reason) { $Reason } elseif ($Test.PSObject.Properties['Reason']) { [string]$Test.Reason } else { '' } $linkedIssue = if ($Test.PSObject.Properties['LinkedIssue']) { [string]$Test.LinkedIssue } else { '' } - $unstableSince = if ($Test.PSObject.Properties['UnstableSince']) { [string]$Test.UnstableSince } else { '' } + $ownSince = if ($Test.PSObject.Properties['UnstableSince']) { [string]$Test.UnstableSince } else { '' } + $unstableSince = if (-not [string]::IsNullOrWhiteSpace($ownSince)) { $ownSince } else { $UnstableSince } return [pscustomobject][ordered]@{ extensionId = if ($Test.PSObject.Properties['ExtensionId']) { [string]$Test.ExtensionId } else { '' } @@ -948,60 +976,6 @@ function ConvertTo-UnstableTestEntry { } } -<# -.Synopsis - Ensures every unstable test entry has an 'unstableSince' timestamp: set it once, then leave it. -.Description - Applies one rule to each entry about to be written to the artifact: if 'unstableSince' is already set, - leave it; otherwise set it. This is the only place that assigns the timestamp, so a test's clock starts - when it first appears in the list and is preserved on every later update while it stays unstable. - - Because the sliding window fully recomputes the list each run (rebuilding entries from scratch, so their - 'unstableSince' starts empty), the previous artifact's entries are passed as 'ExistingTests': an entry - that already had a timestamp there is treated as "already set" and reuses it, otherwise the current UTC - time is stamped. -.Parameter Tests - The list of artifact entries (camelCase) about to be written. Entries are updated in place. -.Parameter ExistingTests - The 'tests' array from the previous artifact, used so timestamps survive a full recompute. -#> -function Set-UnstableSince { - [CmdletBinding()] - [OutputType([System.Collections.IList])] - param( - [System.Collections.IList] $Tests = @(), - - [System.Collections.IList] $ExistingTests = @() - ) - - $now = (Get-Date).ToUniversalTime().ToString('o') - - # Timestamps already recorded in the previous artifact, so they survive a full recompute. - $existingSince = @{} - foreach ($entry in $ExistingTests) { - if ($null -eq $entry) { continue } - if (-not ($entry.PSObject.Properties['unstableSince'])) { continue } - if ([string]::IsNullOrWhiteSpace([string]$entry.unstableSince)) { continue } - $existingSince[(Get-EntryUnstableTestKey -Entry $entry)] = $entry.unstableSince - } - - foreach ($entry in $Tests) { - if ($null -eq $entry) { continue } - - # Already set: leave it. Otherwise reuse the previous artifact's value, else stamp now. - $current = if ($entry.PSObject.Properties['unstableSince']) { [string]$entry.unstableSince } else { '' } - if (-not [string]::IsNullOrWhiteSpace($current)) { continue } - - $key = Get-EntryUnstableTestKey -Entry $entry - $value = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { $now } - - if ($entry.PSObject.Properties['unstableSince']) { $entry.unstableSince = $value } - else { $entry | Add-Member -NotePropertyName 'unstableSince' -NotePropertyValue $value } - } - - return $Tests -} - <# .Synopsis Computes the 'extensionId::codeunit::testMethod' key for an artifact entry (camelCase properties). @@ -1092,6 +1066,9 @@ function Save-UnstableTestsArtifact { Hashtable of newly observed failures keyed by test key to merge in additively. .Parameter Repository Repository in '/' form, used to build sourceRunUrl for newly added entries. +.Parameter UnstableSince + Timestamp stamped on newly added entries (existing entries keep their own). Defaults to the current + UTC time; callers pass a single value computed once per run so all new entries share it. #> function Add-FailedTestsToUnstableTests { [CmdletBinding()] @@ -1102,7 +1079,9 @@ function Add-FailedTestsToUnstableTests { [Parameter(Mandatory = $true)] [hashtable] $FailedTests, - [string] $Repository = '' + [string] $Repository = '', + + [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) ) $merged = New-Object System.Collections.Generic.List[object] @@ -1137,7 +1116,7 @@ function Add-FailedTestsToUnstableTests { # empty reason lets ConvertTo-UnstableTestEntry fall back to the test's own Reason property. # Tests without a Reason (the additive-from-run path) get the default run-based reason. $reason = if (($ft.PSObject.Properties['Reason']) -and $ft.Reason) { '' } else { "Manually added from CI/CD run $sourceRunId" } - $merged.Add((ConvertTo-UnstableTestEntry -Test $ft -Reason $reason -Repository $Repository)) | Out-Null + $merged.Add((ConvertTo-UnstableTestEntry -Test $ft -Reason $reason -Repository $Repository -UnstableSince $UnstableSince)) | Out-Null $seenKeys[$k] = $true Write-Host "ADDED UNSTABLE: $k" $added++ @@ -1678,7 +1657,6 @@ Export-ModuleMember -Function ` Find-UnstableTestRunIds, ` Get-FailedTestsFromRuns, ` ConvertTo-UnstableTestEntry, ` - Set-UnstableSince, ` Save-UnstableTestsArtifact, ` Add-FailedTestsToUnstableTests, ` Select-CrossPrUnstableTests, ` diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 index ff209f011f..828882db0b 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 @@ -72,8 +72,11 @@ try { Write-Host "::notice::Observed $($failedTests.Count) distinct failed test(s) across $($RunIds.Count) run(s)." # --- 2. Build the unstable tests list (additive merge or full recompute) --- + # Single timestamp for this run so every newly stamped test shares the same 'unstableSince'. + $now = (Get-Date).ToUniversalTime().ToString('o') + # Load the existing artifact so each still-unstable test's 'unstableSince' timestamp can be preserved - # (via Set-UnstableSince below) rather than reset when the list is recomputed or appended to. + # (carried forward when the list is recomputed or appended to) rather than reset to this run's time. $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $downloadDir $existingTests = @() if ($existingPath -and (Test-Path $existingPath)) { @@ -92,17 +95,13 @@ try { Write-Host "::warning::No failed tests found in the supplied run(s). The unstable tests list will be rewritten unchanged." } - $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$existingTests) -FailedTests $failedTests -Repository $repo) + $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$existingTests) -FailedTests $failedTests -Repository $repo -UnstableSince $now) } else { - $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count - $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) + $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) + $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo -UnstableSince $now }) } - # Stamp 'unstableSince' once per test: keep any timestamp a test already has (including one carried in - # the previous artifact), stamp the rest with the current UTC time. - $tests = @(Set-UnstableSince -Tests ([System.Collections.IList]$tests) -ExistingTests ([System.Collections.IList]$existingTests)) - # --- 3. Write artifact --- Save-UnstableTestsArtifact -Branch $Branch -RunIds $RunIds -Tests ([System.Collections.IList]$tests) -OutputPath $OutputPath } diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 index 3d2c61bfe3..1b862019b9 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 @@ -85,6 +85,9 @@ New-Item -ItemType Directory -Path $cicdWorkDir -Force | Out-Null New-Item -ItemType Directory -Path $prWorkDir -Force | Out-Null try { + # Single timestamp for this run so every newly stamped test shares the same 'unstableSince'. + $now = (Get-Date).ToUniversalTime().ToString('o') + # Load the existing artifact once. It serves two purposes: preserving each test's 'unstableSince' # timestamp across the Path A full recompute (which rebuilds every entry from scratch, and would # otherwise reset the timestamp to "now"), and acting as the fallback base list when there is no @@ -111,8 +114,8 @@ try { $baseEntries = @() if ($cicdRunIds.Count -gt 0) { $cicdFailed = Get-FailedTestsFromRuns -RunIds $cicdRunIds -Repository $repo -WorkDirectory $cicdWorkDir - $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count - $baseEntries = @($recomputed.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) + $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) + $baseEntries = @($recomputed.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo -UnstableSince $now }) Write-Host "::endgroup::" Write-Host "::notice::Path A (CI/CD): recomputed $($baseEntries.Count) unstable test(s) from $($cicdRunIds.Count) run(s) on '$Branch'." } @@ -141,11 +144,9 @@ try { } # --- Merge Path B additively on top of the Path A base and write once --- - $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$baseEntries) -FailedTests $crossPrFailed -Repository $repo) - - # Stamp 'unstableSince' once per test: keep any timestamp a test already has (including one carried in - # the previous artifact, so it survives the Path A full recompute); stamp the rest with the current UTC time. - $tests = @(Set-UnstableSince -Tests ([System.Collections.IList]$tests) -ExistingTests ([System.Collections.IList]$existingTests)) + # New cross-PR entries are stamped with this run's timestamp; existing entries keep their own + # 'unstableSince', and Path A recomputed entries kept theirs via Update-UnstableTestsList above. + $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$baseEntries) -FailedTests $crossPrFailed -Repository $repo -UnstableSince $now) $allRunIds = @() $allRunIds += @($cicdRunIds) diff --git a/docs/features/test-tolerance/DESIGN.md b/docs/features/test-tolerance/DESIGN.md index 8c5afc7750..4cfa57b2e9 100644 --- a/docs/features/test-tolerance/DESIGN.md +++ b/docs/features/test-tolerance/DESIGN.md @@ -97,7 +97,7 @@ Each test is identified by a three-part normalized key: `extensionId::codeunit:: **Unstable-since timestamp** -Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. Just before the artifact is written, a single pass (`Set-UnstableSince`) applies one rule to every entry: if it already has an `unstableSince` value, keep it; otherwise stamp the current UTC time. Because the sliding window (Path A) fully recomputes the list on every run, "already set" also consults the previous artifact — an entry whose timestamp was recorded on an earlier run is treated as already set and preserved — so the field reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. +Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is stamped when the entry is created (`ConvertTo-UnstableTestEntry`), and all entries created during a single run share one timestamp computed once at the start of that run. Because the sliding window (Path A) fully recomputes the list on every run, `Update-UnstableTestsList` carries each still-unstable test's `unstableSince` forward from the previous artifact, so a test that was already unstable keeps its original timestamp instead of being restamped. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. > **Schema migration note:** Unstable-tests artifacts produced before the `extensionId` field was added use the old two-part key format (`codeunit::testMethod`). When a run downloads such an artifact, no matches will be found and tolerance will silently degrade to zero for that run. The artifact will be overwritten with the new three-part key format after the next UpdateUnstableTests run completes. Likewise, artifacts produced before the `unstableSince` field existed carry no timestamp; the first run after this change stamps `unstableSince` to that run's time for every surviving test, and the value is then preserved on subsequent runs. From e4829b914f338f1c614d17f5cd495e64a1f94063 Mon Sep 17 00:00:00 2001 From: Maria Zhelezova <43066499+mazhelez@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:11:41 +0200 Subject: [PATCH 3/5] Stamp new tests' unstableSince in Update-UnstableTestsList Previously a newly unstable test left Update-UnstableTestsList with an empty 'unstableSince' that ConvertTo-UnstableTestEntry stamped later. That intermediate empty value was confusing and split the stamping across two functions. Now Update-UnstableTestsList assigns the run timestamp directly for a test with no prior entry (and still carries a prior timestamp forward), so its output is already correct. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TestTolerance/TestTolerance.Test.ps1 | 17 +++++++++++++++-- build/scripts/TestTolerance/TestTolerance.psm1 | 9 +++++++-- .../UpdateUnstableTestsArtifact.ps1 | 2 +- .../UpdateUnstableTestsCombined.ps1 | 2 +- docs/features/test-tolerance/DESIGN.md | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/build/scripts/TestTolerance/TestTolerance.Test.ps1 b/build/scripts/TestTolerance/TestTolerance.Test.ps1 index d492c0a91d..14f82dd326 100644 --- a/build/scripts/TestTolerance/TestTolerance.Test.ps1 +++ b/build/scripts/TestTolerance/TestTolerance.Test.ps1 @@ -402,13 +402,26 @@ Describe "TestTolerance" { $result['ext-1::300::t1'].FailureDetail | Should -Be 's1' } - It "leaves UnstableSince empty for a newly unstable test with no prior entry" { + It "stamps UnstableSince with the provided run timestamp for a newly unstable test with no prior entry" { $failed = @{ 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm' } } + $result = Update-UnstableTestsList -FailedTests $failed -UnstableSince '2026-05-05T00:00:00.0000000Z' + $result['ext-1::300::t1'].UnstableSince | Should -Be '2026-05-05T00:00:00.0000000Z' + } + + It "defaults UnstableSince to the current UTC time for a newly unstable test when none is provided" { + $failed = @{ + 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm' } + } + $before = (Get-Date).ToUniversalTime() + $result = Update-UnstableTestsList -FailedTests $failed - $result['ext-1::300::t1'].UnstableSince | Should -Be '' + $result['ext-1::300::t1'].UnstableSince | Should -Not -BeNullOrEmpty + $parsed = [datetimeoffset]::Parse($result['ext-1::300::t1'].UnstableSince).UtcDateTime + $parsed | Should -BeGreaterOrEqual $before.AddSeconds(-5) + $parsed | Should -BeLessOrEqual (Get-Date).ToUniversalTime().AddSeconds(5) } It "carries forward UnstableSince from the existing artifact for a still-unstable test (survives full recompute)" { diff --git a/build/scripts/TestTolerance/TestTolerance.psm1 b/build/scripts/TestTolerance/TestTolerance.psm1 index 1d1533e035..845e5feda1 100644 --- a/build/scripts/TestTolerance/TestTolerance.psm1 +++ b/build/scripts/TestTolerance/TestTolerance.psm1 @@ -712,6 +712,9 @@ function Receive-UnstableTestsArtifact { .Parameter ExistingTests The 'tests' array from the previous artifact (camelCase entries), used to preserve each still-unstable test's original 'unstableSince' across the full recompute. +.Parameter UnstableSince + Timestamp assigned to a newly unstable test that has no prior entry. Defaults to the current UTC time; + callers pass a single value computed once per run so all newly stamped tests share it. #> function Update-UnstableTestsList { [CmdletBinding()] @@ -722,7 +725,9 @@ function Update-UnstableTestsList { [int] $RunCount = 0, - [System.Collections.IList] $ExistingTests = @() + [System.Collections.IList] $ExistingTests = @(), + + [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) ) # Prior 'unstableSince' values keyed by test key, so an already-unstable test keeps its original @@ -749,7 +754,7 @@ function Update-UnstableTestsList { SourceRunId = if ($ft.PSObject.Properties['SourceRunId']) { $ft.SourceRunId } else { '' } Reason = "Auto-detected: failed in at least 1 of the last $RunCount CI/CD run(s)" LinkedIssue = '' - UnstableSince = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { '' } + UnstableSince = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { $UnstableSince } } } diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 index 828882db0b..48406b3dcb 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 @@ -98,7 +98,7 @@ try { $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$existingTests) -FailedTests $failedTests -Repository $repo -UnstableSince $now) } else { - $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) + $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) -UnstableSince $now $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo -UnstableSince $now }) } diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 index 1b862019b9..4e75a15a8a 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 @@ -114,7 +114,7 @@ try { $baseEntries = @() if ($cicdRunIds.Count -gt 0) { $cicdFailed = Get-FailedTestsFromRuns -RunIds $cicdRunIds -Repository $repo -WorkDirectory $cicdWorkDir - $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) + $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) -UnstableSince $now $baseEntries = @($recomputed.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo -UnstableSince $now }) Write-Host "::endgroup::" Write-Host "::notice::Path A (CI/CD): recomputed $($baseEntries.Count) unstable test(s) from $($cicdRunIds.Count) run(s) on '$Branch'." diff --git a/docs/features/test-tolerance/DESIGN.md b/docs/features/test-tolerance/DESIGN.md index 4cfa57b2e9..7701bb6529 100644 --- a/docs/features/test-tolerance/DESIGN.md +++ b/docs/features/test-tolerance/DESIGN.md @@ -97,7 +97,7 @@ Each test is identified by a three-part normalized key: `extensionId::codeunit:: **Unstable-since timestamp** -Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is stamped when the entry is created (`ConvertTo-UnstableTestEntry`), and all entries created during a single run share one timestamp computed once at the start of that run. Because the sliding window (Path A) fully recomputes the list on every run, `Update-UnstableTestsList` carries each still-unstable test's `unstableSince` forward from the previous artifact, so a test that was already unstable keeps its original timestamp instead of being restamped. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. +Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is assigned when a test is first added to the list, and all tests newly stamped during a single run share one timestamp computed once at the start of that run. Because the sliding window (Path A) fully recomputes the list on every run, `Update-UnstableTestsList` carries each still-unstable test's `unstableSince` forward from the previous artifact, so a test that was already unstable keeps its original timestamp instead of being restamped; only a test with no prior entry gets the current run's timestamp. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. > **Schema migration note:** Unstable-tests artifacts produced before the `extensionId` field was added use the old two-part key format (`codeunit::testMethod`). When a run downloads such an artifact, no matches will be found and tolerance will silently degrade to zero for that run. The artifact will be overwritten with the new three-part key format after the next UpdateUnstableTests run completes. Likewise, artifacts produced before the `unstableSince` field existed carry no timestamp; the first run after this change stamps `unstableSince` to that run's time for every surviving test, and the value is then preserved on subsequent runs. From 84752eb049a207a67a9962769ac4936ac607e89a Mon Sep 17 00:00:00 2001 From: Maria Zhelezova <43066499+mazhelez@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:44:12 +0200 Subject: [PATCH 4/5] Simplify unstableSince handling to read as a first-class column Treats 'unstableSince' as a designed-in field rather than a bolted-on one: removes a stale doc comment that claimed new tests are left without a timestamp, drops the redundant -UnstableSince argument from the entry factory calls that follow Update-UnstableTestsList (those entries already carry their timestamp), and rewrites the surrounding comments and DESIGN notes in plain, native terms. Behavior is unchanged; all 61 tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../scripts/TestTolerance/TestTolerance.psm1 | 32 +++++++++---------- .../UpdateUnstableTestsArtifact.ps1 | 9 +++--- .../UpdateUnstableTestsCombined.ps1 | 11 +++---- docs/features/test-tolerance/DESIGN.md | 2 +- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/build/scripts/TestTolerance/TestTolerance.psm1 b/build/scripts/TestTolerance/TestTolerance.psm1 index 845e5feda1..6b072830de 100644 --- a/build/scripts/TestTolerance/TestTolerance.psm1 +++ b/build/scripts/TestTolerance/TestTolerance.psm1 @@ -693,15 +693,13 @@ function Receive-UnstableTestsArtifact { tests hashtable by marking every failed test key as unstable. Each returned entry is keyed by 'extensionId::codeunit::testMethod' and contains the test - identity fields plus an auto-detected reason of the form: + identity fields, an auto-detected reason of the form "Auto-detected: failed in at least 1 of the last CI/CD run(s)" + and the test's 'unstableSince' timestamp. - This function does not inspect passed tests; it transforms the supplied failed-test set into the - artifact format. It does, however, carry forward each test's 'unstableSince' from the previous - artifact (passed as 'ExistingTests'): because the sliding window rebuilds the list from scratch each - run, seeding UnstableSince here lets a test that was already unstable keep its original timestamp - instead of being restamped. Tests with no prior entry are left without a timestamp so the entry - builder stamps the current run time. + Because the sliding window rebuilds the list from scratch each run, a still-unstable test keeps the + 'unstableSince' it had in the previous artifact ('ExistingTests'); a test that is unstable for the + first time is stamped with 'UnstableSince' (this run's time). Returns the updated hashtable keyed by 'extensionId::codeunit::testMethod'. @@ -710,11 +708,11 @@ function Receive-UnstableTestsArtifact { .Parameter RunCount Number of CI/CD runs the window covered; used only to build the auto-detected reason text. .Parameter ExistingTests - The 'tests' array from the previous artifact (camelCase entries), used to preserve each still-unstable - test's original 'unstableSince' across the full recompute. + The 'tests' array from the previous artifact (camelCase entries), used to keep each still-unstable + test's original 'unstableSince' across the recompute. .Parameter UnstableSince - Timestamp assigned to a newly unstable test that has no prior entry. Defaults to the current UTC time; - callers pass a single value computed once per run so all newly stamped tests share it. + Timestamp stamped on a test that is unstable for the first time this run. Defaults to the current UTC + time; callers pass one value per run so every newly stamped test shares it. #> function Update-UnstableTestsList { [CmdletBinding()] @@ -730,8 +728,8 @@ function Update-UnstableTestsList { [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) ) - # Prior 'unstableSince' values keyed by test key, so an already-unstable test keeps its original - # timestamp when the list is recomputed from scratch. + # Prior 'unstableSince' values keyed by test key, so a still-unstable test keeps its original + # timestamp when the list is rebuilt from scratch. $existingSince = @{} foreach ($entry in $ExistingTests) { if ($null -eq $entry) { continue } @@ -932,10 +930,10 @@ function Get-FailedTestsFromRuns { Update-UnstableTestsList). 'Reason' overrides the entry reason; when empty, the test's own Reason property (if any) is used. 'Repository' is used to build the sourceRunUrl from the test's SourceRunId. - The 'unstableSince' timestamp is stamped here, when the entry is created: if the test already carries - an UnstableSince value (e.g. preserved from the previous artifact), keep it; otherwise use the run - timestamp passed via 'UnstableSince'. Passing that value in (rather than reading the clock here) keeps - it identical for every entry created during the same run. + Every entry records 'unstableSince', the UTC time the test was first added to the list. A test that + already carries an UnstableSince (preserved from the previous artifact) keeps it; a test being added + for the first time is stamped with 'UnstableSince'. Passing that value in, rather than reading the + clock here, keeps it identical for every entry created during the same run. .Parameter Test A single failed/unstable test object with PascalCase properties. diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 index 48406b3dcb..7499f17e9e 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 @@ -72,11 +72,11 @@ try { Write-Host "::notice::Observed $($failedTests.Count) distinct failed test(s) across $($RunIds.Count) run(s)." # --- 2. Build the unstable tests list (additive merge or full recompute) --- - # Single timestamp for this run so every newly stamped test shares the same 'unstableSince'. + # One timestamp per run, shared by every test that becomes unstable this run. $now = (Get-Date).ToUniversalTime().ToString('o') - # Load the existing artifact so each still-unstable test's 'unstableSince' timestamp can be preserved - # (carried forward when the list is recomputed or appended to) rather than reset to this run's time. + # Load the existing artifact so each still-unstable test keeps its original 'unstableSince' + # (carried forward through both the recompute and the additive merge). $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $downloadDir $existingTests = @() if ($existingPath -and (Test-Path $existingPath)) { @@ -99,7 +99,8 @@ try { } else { $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) -UnstableSince $now - $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo -UnstableSince $now }) + # Entries already carry their 'unstableSince' (kept or stamped by Update-UnstableTestsList). + $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) } # --- 3. Write artifact --- diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 index 4e75a15a8a..afb4392450 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 @@ -85,13 +85,11 @@ New-Item -ItemType Directory -Path $cicdWorkDir -Force | Out-Null New-Item -ItemType Directory -Path $prWorkDir -Force | Out-Null try { - # Single timestamp for this run so every newly stamped test shares the same 'unstableSince'. + # One timestamp per run, shared by every test that becomes unstable this run. $now = (Get-Date).ToUniversalTime().ToString('o') - # Load the existing artifact once. It serves two purposes: preserving each test's 'unstableSince' - # timestamp across the Path A full recompute (which rebuilds every entry from scratch, and would - # otherwise reset the timestamp to "now"), and acting as the fallback base list when there is no - # CI/CD window to recompute from. + # Load the existing artifact once: it carries each test's 'unstableSince' forward across the Path A + # recompute, and acts as the fallback base list when there is no CI/CD window to recompute from. $existingTests = @() $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $existingDir if ($existingPath -and (Test-Path $existingPath)) { @@ -115,7 +113,8 @@ try { if ($cicdRunIds.Count -gt 0) { $cicdFailed = Get-FailedTestsFromRuns -RunIds $cicdRunIds -Repository $repo -WorkDirectory $cicdWorkDir $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) -UnstableSince $now - $baseEntries = @($recomputed.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo -UnstableSince $now }) + # Entries already carry their 'unstableSince' (kept or stamped by Update-UnstableTestsList). + $baseEntries = @($recomputed.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) Write-Host "::endgroup::" Write-Host "::notice::Path A (CI/CD): recomputed $($baseEntries.Count) unstable test(s) from $($cicdRunIds.Count) run(s) on '$Branch'." } diff --git a/docs/features/test-tolerance/DESIGN.md b/docs/features/test-tolerance/DESIGN.md index 7701bb6529..f28ea11f2b 100644 --- a/docs/features/test-tolerance/DESIGN.md +++ b/docs/features/test-tolerance/DESIGN.md @@ -97,7 +97,7 @@ Each test is identified by a three-part normalized key: `extensionId::codeunit:: **Unstable-since timestamp** -Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is assigned when a test is first added to the list, and all tests newly stamped during a single run share one timestamp computed once at the start of that run. Because the sliding window (Path A) fully recomputes the list on every run, `Update-UnstableTestsList` carries each still-unstable test's `unstableSince` forward from the previous artifact, so a test that was already unstable keeps its original timestamp instead of being restamped; only a test with no prior entry gets the current run's timestamp. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. +Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is assigned when a test is first added, and every test newly stamped during a single run shares one value computed once at the start of that run. Because the sliding window (Path A) rebuilds the list from scratch on every run, `Update-UnstableTestsList` reads each still-unstable test's `unstableSince` from the previous artifact and carries it forward; only a test with no prior entry is stamped with the current run's time. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. > **Schema migration note:** Unstable-tests artifacts produced before the `extensionId` field was added use the old two-part key format (`codeunit::testMethod`). When a run downloads such an artifact, no matches will be found and tolerance will silently degrade to zero for that run. The artifact will be overwritten with the new three-part key format after the next UpdateUnstableTests run completes. Likewise, artifacts produced before the `unstableSince` field existed carry no timestamp; the first run after this change stamps `unstableSince` to that run's time for every surviving test, and the value is then preserved on subsequent runs. From adb77c5ea0e2b1c2d1276be0a84374c0a78214e9 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:48:19 +0200 Subject: [PATCH 5/5] Simplify unstableSince: stamp inline, drop threaded parameter and helper Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TestTolerance/TestTolerance.Test.ps1 | 35 ++++------- .../scripts/TestTolerance/TestTolerance.psm1 | 61 +++++-------------- .../UpdateUnstableTestsArtifact.ps1 | 7 +-- .../UpdateUnstableTestsCombined.ps1 | 9 +-- docs/features/test-tolerance/DESIGN.md | 2 +- 5 files changed, 33 insertions(+), 81 deletions(-) diff --git a/build/scripts/TestTolerance/TestTolerance.Test.ps1 b/build/scripts/TestTolerance/TestTolerance.Test.ps1 index 14f82dd326..d46877183a 100644 --- a/build/scripts/TestTolerance/TestTolerance.Test.ps1 +++ b/build/scripts/TestTolerance/TestTolerance.Test.ps1 @@ -402,16 +402,7 @@ Describe "TestTolerance" { $result['ext-1::300::t1'].FailureDetail | Should -Be 's1' } - It "stamps UnstableSince with the provided run timestamp for a newly unstable test with no prior entry" { - $failed = @{ - 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm' } - } - - $result = Update-UnstableTestsList -FailedTests $failed -UnstableSince '2026-05-05T00:00:00.0000000Z' - $result['ext-1::300::t1'].UnstableSince | Should -Be '2026-05-05T00:00:00.0000000Z' - } - - It "defaults UnstableSince to the current UTC time for a newly unstable test when none is provided" { + It "stamps UnstableSince with the current UTC time for a newly unstable test with no prior entry" { $failed = @{ 'ext-1::300::t1' = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; FailureMessage = 'm' } } @@ -438,15 +429,7 @@ Describe "TestTolerance" { } Context "ConvertTo-UnstableTestEntry" { - It "stamps the provided UnstableSince for a test that has none" { - $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1' } - - $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' -UnstableSince '2026-03-03T00:00:00.0000000Z' - - $entry.unstableSince | Should -Be '2026-03-03T00:00:00.0000000Z' - } - - It "defaults UnstableSince to the current UTC time when none is provided" { + It "stamps UnstableSince with the current UTC time for a test that has none" { $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1' } $before = (Get-Date).ToUniversalTime() @@ -458,10 +441,10 @@ Describe "TestTolerance" { $parsed | Should -BeLessOrEqual (Get-Date).ToUniversalTime().AddSeconds(5) } - It "uses the test's own UnstableSince over the provided value" { + It "keeps the test's own UnstableSince when it already has one" { $test = [pscustomobject]@{ ExtensionId = 'ext-1'; CodeunitId = 300; CodeunitName = 'A'; TestMethod = 'T1'; UnstableSince = '2026-02-02T00:00:00.0000000Z' } - $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' -UnstableSince '2026-03-03T00:00:00.0000000Z' + $entry = ConvertTo-UnstableTestEntry -Test $test -Repository 'owner/repo' $entry.unstableSince | Should -Be '2026-02-02T00:00:00.0000000Z' } } @@ -535,17 +518,21 @@ Describe "TestTolerance" { $merged[2].testMethod | Should -Be 'T2' } - It "preserves an existing entry's unstableSince and stamps new entries with the run timestamp" { + It "preserves an existing entry's unstableSince and stamps new entries with the current time" { $existing = @( [pscustomobject]@{ extensionId = 'ext-1'; codeunitId = 300; codeunitName = 'A'; testMethod = 'T1'; reason = 'pre-existing'; unstableSince = '2026-01-01T00:00:00.0000000Z' } ) $failed = @{ 'ext-2::400::t2' = [pscustomobject]@{ ExtensionId = 'ext-2'; CodeunitId = 400; CodeunitName = 'B'; TestMethod = 'T2'; FailureMessage = 'm2'; SourceRunId = '1000' } } + $before = (Get-Date).ToUniversalTime() - $merged = @(Add-FailedTestsToUnstableTests -ExistingTests $existing -FailedTests $failed -Repository 'owner/repo' -UnstableSince '2026-05-05T00:00:00.0000000Z') + $merged = @(Add-FailedTestsToUnstableTests -ExistingTests $existing -FailedTests $failed -Repository 'owner/repo') $merged[0].unstableSince | Should -Be '2026-01-01T00:00:00.0000000Z' - $merged[1].unstableSince | Should -Be '2026-05-05T00:00:00.0000000Z' + $merged[1].unstableSince | Should -Not -BeNullOrEmpty + $parsed = [datetimeoffset]::Parse($merged[1].unstableSince).UtcDateTime + $parsed | Should -BeGreaterOrEqual $before.AddSeconds(-5) + $parsed | Should -BeLessOrEqual (Get-Date).ToUniversalTime().AddSeconds(5) } It "uses the test's own Reason when the failed test carries one" { diff --git a/build/scripts/TestTolerance/TestTolerance.psm1 b/build/scripts/TestTolerance/TestTolerance.psm1 index 6b072830de..da584ff20e 100644 --- a/build/scripts/TestTolerance/TestTolerance.psm1 +++ b/build/scripts/TestTolerance/TestTolerance.psm1 @@ -699,7 +699,7 @@ function Receive-UnstableTestsArtifact { Because the sliding window rebuilds the list from scratch each run, a still-unstable test keeps the 'unstableSince' it had in the previous artifact ('ExistingTests'); a test that is unstable for the - first time is stamped with 'UnstableSince' (this run's time). + first time is stamped with this run's time. Returns the updated hashtable keyed by 'extensionId::codeunit::testMethod'. @@ -710,9 +710,6 @@ function Receive-UnstableTestsArtifact { .Parameter ExistingTests The 'tests' array from the previous artifact (camelCase entries), used to keep each still-unstable test's original 'unstableSince' across the recompute. -.Parameter UnstableSince - Timestamp stamped on a test that is unstable for the first time this run. Defaults to the current UTC - time; callers pass one value per run so every newly stamped test shares it. #> function Update-UnstableTestsList { [CmdletBinding()] @@ -723,19 +720,18 @@ function Update-UnstableTestsList { [int] $RunCount = 0, - [System.Collections.IList] $ExistingTests = @(), - - [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) + [System.Collections.IList] $ExistingTests = @() ) - # Prior 'unstableSince' values keyed by test key, so a still-unstable test keeps its original - # timestamp when the list is rebuilt from scratch. + # A newly unstable test is stamped with this run's time; still-unstable tests keep the prior value. + $now = (Get-Date).ToUniversalTime().ToString('o') $existingSince = @{} foreach ($entry in $ExistingTests) { - if ($null -eq $entry) { continue } - if (-not ($entry.PSObject.Properties['unstableSince'])) { continue } - if ([string]::IsNullOrWhiteSpace([string]$entry.unstableSince)) { continue } - $existingSince[(Get-EntryUnstableTestKey -Entry $entry)] = [string]$entry.unstableSince + $props = $entry.PSObject.Properties + if ((-not $props['unstableSince']) -or [string]::IsNullOrWhiteSpace([string]$entry.unstableSince) -or (-not $props['testMethod'])) { continue } + $extId = if ($props['extensionId']) { [string]$entry.extensionId } else { '' } + $cuId = if ($props['codeunitId']) { [int]$entry.codeunitId } else { 0 } + $existingSince[(Get-UnstableTestKey -CodeunitId $cuId -TestMethod ([string]$entry.testMethod) -ExtensionId $extId)] = [string]$entry.unstableSince } $result = @{} @@ -752,7 +748,7 @@ function Update-UnstableTestsList { SourceRunId = if ($ft.PSObject.Properties['SourceRunId']) { $ft.SourceRunId } else { '' } Reason = "Auto-detected: failed in at least 1 of the last $RunCount CI/CD run(s)" LinkedIssue = '' - UnstableSince = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { $UnstableSince } + UnstableSince = if ($existingSince.ContainsKey($key)) { $existingSince[$key] } else { $now } } } @@ -930,10 +926,8 @@ function Get-FailedTestsFromRuns { Update-UnstableTestsList). 'Reason' overrides the entry reason; when empty, the test's own Reason property (if any) is used. 'Repository' is used to build the sourceRunUrl from the test's SourceRunId. - Every entry records 'unstableSince', the UTC time the test was first added to the list. A test that - already carries an UnstableSince (preserved from the previous artifact) keeps it; a test being added - for the first time is stamped with 'UnstableSince'. Passing that value in, rather than reading the - clock here, keeps it identical for every entry created during the same run. + Every entry records 'unstableSince', an ISO 8601 UTC timestamp. If the test already carries one it is + kept; otherwise the current time is stamped, marking when the test was first added to the list. .Parameter Test A single failed/unstable test object with PascalCase properties. @@ -941,9 +935,6 @@ function Get-FailedTestsFromRuns { Overrides the entry reason. When empty, the test's own Reason property is used. .Parameter Repository Repository in '/' form, used to build the sourceRunUrl from the test's SourceRunId. -.Parameter UnstableSince - Timestamp to stamp on a test that has no UnstableSince of its own. Defaults to the current UTC time; - callers pass a single value computed once per run so all newly created entries share it. #> function ConvertTo-UnstableTestEntry { [CmdletBinding()] @@ -954,16 +945,13 @@ function ConvertTo-UnstableTestEntry { [string] $Reason = '', - [string] $Repository = '', - - [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) + [string] $Repository = '' ) $sourceRunId = if ($Test.PSObject.Properties['SourceRunId']) { [string]$Test.SourceRunId } else { '' } $reasonValue = if ($Reason) { $Reason } elseif ($Test.PSObject.Properties['Reason']) { [string]$Test.Reason } else { '' } $linkedIssue = if ($Test.PSObject.Properties['LinkedIssue']) { [string]$Test.LinkedIssue } else { '' } - $ownSince = if ($Test.PSObject.Properties['UnstableSince']) { [string]$Test.UnstableSince } else { '' } - $unstableSince = if (-not [string]::IsNullOrWhiteSpace($ownSince)) { $ownSince } else { $UnstableSince } + $unstableSince = if (($Test.PSObject.Properties['UnstableSince']) -and $Test.UnstableSince) { [string]$Test.UnstableSince } else { (Get-Date).ToUniversalTime().ToString('o') } return [pscustomobject][ordered]@{ extensionId = if ($Test.PSObject.Properties['ExtensionId']) { [string]$Test.ExtensionId } else { '' } @@ -979,18 +967,6 @@ function ConvertTo-UnstableTestEntry { } } -<# -.Synopsis - Computes the 'extensionId::codeunit::testMethod' key for an artifact entry (camelCase properties). -#> -function Get-EntryUnstableTestKey { - param($Entry) - $method = if ($Entry.PSObject.Properties['testMethod']) { [string]$Entry.testMethod } else { '' } - $extId = if ($Entry.PSObject.Properties['extensionId']) { [string]$Entry.extensionId } else { '' } - $cuId = if ($Entry.PSObject.Properties['codeunitId']) { [int]$Entry.codeunitId } else { 0 } - return Get-UnstableTestKey -CodeunitId $cuId -TestMethod $method -ExtensionId $extId -} - <# .Synopsis Writes the per-branch unstable tests artifact JSON to disk. @@ -1069,9 +1045,6 @@ function Save-UnstableTestsArtifact { Hashtable of newly observed failures keyed by test key to merge in additively. .Parameter Repository Repository in '/' form, used to build sourceRunUrl for newly added entries. -.Parameter UnstableSince - Timestamp stamped on newly added entries (existing entries keep their own). Defaults to the current - UTC time; callers pass a single value computed once per run so all new entries share it. #> function Add-FailedTestsToUnstableTests { [CmdletBinding()] @@ -1082,9 +1055,7 @@ function Add-FailedTestsToUnstableTests { [Parameter(Mandatory = $true)] [hashtable] $FailedTests, - [string] $Repository = '', - - [string] $UnstableSince = ((Get-Date).ToUniversalTime().ToString('o')) + [string] $Repository = '' ) $merged = New-Object System.Collections.Generic.List[object] @@ -1119,7 +1090,7 @@ function Add-FailedTestsToUnstableTests { # empty reason lets ConvertTo-UnstableTestEntry fall back to the test's own Reason property. # Tests without a Reason (the additive-from-run path) get the default run-based reason. $reason = if (($ft.PSObject.Properties['Reason']) -and $ft.Reason) { '' } else { "Manually added from CI/CD run $sourceRunId" } - $merged.Add((ConvertTo-UnstableTestEntry -Test $ft -Reason $reason -Repository $Repository -UnstableSince $UnstableSince)) | Out-Null + $merged.Add((ConvertTo-UnstableTestEntry -Test $ft -Reason $reason -Repository $Repository)) | Out-Null $seenKeys[$k] = $true Write-Host "ADDED UNSTABLE: $k" $added++ diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 index 7499f17e9e..a2171325ad 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsArtifact.ps1 @@ -72,9 +72,6 @@ try { Write-Host "::notice::Observed $($failedTests.Count) distinct failed test(s) across $($RunIds.Count) run(s)." # --- 2. Build the unstable tests list (additive merge or full recompute) --- - # One timestamp per run, shared by every test that becomes unstable this run. - $now = (Get-Date).ToUniversalTime().ToString('o') - # Load the existing artifact so each still-unstable test keeps its original 'unstableSince' # (carried forward through both the recompute and the additive merge). $existingPath = Receive-UnstableTestsArtifact -Branch $Branch -OutputDirectory $downloadDir @@ -95,10 +92,10 @@ try { Write-Host "::warning::No failed tests found in the supplied run(s). The unstable tests list will be rewritten unchanged." } - $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$existingTests) -FailedTests $failedTests -Repository $repo -UnstableSince $now) + $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$existingTests) -FailedTests $failedTests -Repository $repo) } else { - $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) -UnstableSince $now + $updatedTests = Update-UnstableTestsList -FailedTests $failedTests -RunCount $RunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) # Entries already carry their 'unstableSince' (kept or stamped by Update-UnstableTestsList). $tests = @($updatedTests.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) } diff --git a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 index afb4392450..7b930dc931 100644 --- a/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 +++ b/build/scripts/TestTolerance/UpdateUnstableTestsCombined.ps1 @@ -85,9 +85,6 @@ New-Item -ItemType Directory -Path $cicdWorkDir -Force | Out-Null New-Item -ItemType Directory -Path $prWorkDir -Force | Out-Null try { - # One timestamp per run, shared by every test that becomes unstable this run. - $now = (Get-Date).ToUniversalTime().ToString('o') - # Load the existing artifact once: it carries each test's 'unstableSince' forward across the Path A # recompute, and acts as the fallback base list when there is no CI/CD window to recompute from. $existingTests = @() @@ -112,7 +109,7 @@ try { $baseEntries = @() if ($cicdRunIds.Count -gt 0) { $cicdFailed = Get-FailedTestsFromRuns -RunIds $cicdRunIds -Repository $repo -WorkDirectory $cicdWorkDir - $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) -UnstableSince $now + $recomputed = Update-UnstableTestsList -FailedTests $cicdFailed -RunCount $cicdRunIds.Count -ExistingTests ([System.Collections.IList]$existingTests) # Entries already carry their 'unstableSince' (kept or stamped by Update-UnstableTestsList). $baseEntries = @($recomputed.Values | ForEach-Object { ConvertTo-UnstableTestEntry -Test $_ -Repository $repo }) Write-Host "::endgroup::" @@ -143,9 +140,9 @@ try { } # --- Merge Path B additively on top of the Path A base and write once --- - # New cross-PR entries are stamped with this run's timestamp; existing entries keep their own + # New cross-PR entries are stamped with the current time; existing entries keep their own # 'unstableSince', and Path A recomputed entries kept theirs via Update-UnstableTestsList above. - $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$baseEntries) -FailedTests $crossPrFailed -Repository $repo -UnstableSince $now) + $tests = @(Add-FailedTestsToUnstableTests -ExistingTests ([System.Collections.IList]$baseEntries) -FailedTests $crossPrFailed -Repository $repo) $allRunIds = @() $allRunIds += @($cicdRunIds) diff --git a/docs/features/test-tolerance/DESIGN.md b/docs/features/test-tolerance/DESIGN.md index f28ea11f2b..ea3964cb9b 100644 --- a/docs/features/test-tolerance/DESIGN.md +++ b/docs/features/test-tolerance/DESIGN.md @@ -97,7 +97,7 @@ Each test is identified by a three-part normalized key: `extensionId::codeunit:: **Unstable-since timestamp** -Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is assigned when a test is first added, and every test newly stamped during a single run shares one value computed once at the start of that run. Because the sliding window (Path A) rebuilds the list from scratch on every run, `Update-UnstableTestsList` reads each still-unstable test's `unstableSince` from the previous artifact and carries it forward; only a test with no prior entry is stamped with the current run's time. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. +Each entry records an `unstableSince` field: an ISO 8601 UTC timestamp of when the test first entered the unstable list. The timestamp is assigned when a test is first added. Because the sliding window (Path A) rebuilds the list from scratch on every run, `Update-UnstableTestsList` reads each still-unstable test's `unstableSince` from the previous artifact and carries it forward; only a test with no prior entry is stamped with the current time. The field therefore reflects *since when* the test has been continuously unstable rather than the last recompute time. A test that self-heals (drops off the list) and later returns starts a fresh `unstableSince`. > **Schema migration note:** Unstable-tests artifacts produced before the `extensionId` field was added use the old two-part key format (`codeunit::testMethod`). When a run downloads such an artifact, no matches will be found and tolerance will silently degrade to zero for that run. The artifact will be overwritten with the new three-part key format after the next UpdateUnstableTests run completes. Likewise, artifacts produced before the `unstableSince` field existed carry no timestamp; the first run after this change stamps `unstableSince` to that run's time for every surviving test, and the value is then preserved on subsequent runs.