Skip to content

Align CTRF retry reporting and runId with ctrf-io/ctrf#58 - #10343

Merged
Evangelink merged 11 commits into
mainfrom
dev/amauryleve/improved-eureka
Jul 30, 2026
Merged

Align CTRF retry reporting and runId with ctrf-io/ctrf#58#10343
Evangelink merged 11 commits into
mainfrom
dev/amauryleve/improved-eureka

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Follow-up to the answers we got on ctrf-io/ctrf#58, the spec issue I opened from #10293. Closes nothing on its own — #10293 stays open for the parts that are still product decisions (see Deliberately not in scope).

What upstream confirmed, and what it cost us

Answer from the CTRF maintainer Our state Action
retryAttempts[] holds attempts 1..N-1 — the history preceding the final attempt, initial execution included. The final attempt is excluded because its outcome and diagnostics are the test object. Already correct — the "third shape" I described in the issue turned out to be the intended one. Comment only, no behavior change.
retries == retryAttempts.length, so the final attempt number is retries + 1. Already correct. Comment only.
Per-process documents of one logical run, and any document merged from them, should share a runId while each keeps its own reportId. We emitted no runId at all. New.
In a merged document, the test object's duration is the final attempt's. N/A — no retry-aware merge existed. Encoded in the new merge mode.

Changes

runId (CTRF §5.4). CtrfReportEngine now emits a top-level runId, resolved as TESTINGPLATFORM_LOGICAL_RUN_IDTESTINGPLATFORM_DOTNETTEST_EXECUTIONID → a fresh GUID. RetryOrchestrator establishes that variable once before launching its attempts, so every attempt process stamps the same value.

A caveat worth being explicit about, because my first draft got it wrong: TESTINGPLATFORM_DOTNETTEST_EXECUTIONID is per root test application, not per dotnet test invocation — each module is its own execution tree with its own id (004-protocol-dotnet-test-pipe.md). So this correlates the processes of one test application (its retry attempts, its controller), not the modules of a multi-project run. Correlating those, or shards across machines, means setting TESTINGPLATFORM_LOGICAL_RUN_ID explicitly — which is now possible, and documented on the constant.

CtrfReportMerger. Carries runId over when every accepted input agrees on the same non-empty value (dropped on disagreement, or when any input lacks one, rather than picking an arbitrary run to speak for the whole merge) while still deriving its own deterministic reportId.

New opt-in CtrfMergeMode.CollapseRetryAttempts. Folds rows describing the same logical test into one: last attempt wins; earlier attempts — including in-process retries an input already nested — flatten into retryAttempts[] renumbered 1..N-1; retries and flaky recomputed per §9.20/§9.22 (recomputed, not inherited: an input's own flaky flag only describes the attempt that produced it); the row keeps the final attempt's duration; summary re-derived so summary.tests counts logical tests — the 4-test/8-execution scenario from the issue reports 4. Attempts are projected onto the §11 attempt shape, so test-only fields are dropped and rawStatus, which has no attempt-level slot, moves under extra.

Concatenate remains the default: MTP test UIDs are only unique within an assembly, so collapsing by identity across modules would fuse same-named tests from different assemblies.

Robustness fixes to CtrfReportMerger, found while writing the tests. These are on the pre-existing default path too, not just the new mode. tests[] comes from a file we did not write, and a document could clear the merger's reportFormat/results gate and still crash it:

  • a non-object element ("tests": ["oops", null, 42]) threw from the summary counter loop, from TryReadLong in the ingestion loop, and from the collapse helpers. Such rows are now rejected at ingestion — carrying one through would turn a defect localized to one input into a schema-invalid merged document, and dropping it matches how a whole non-CTRF input is already rejected. With tests[] filtered once at the boundary, the collapse helpers take JsonObject and the invariant is enforced by the type system rather than by repeated guards;
  • a non-object extra (it is free-form, so a foreign producer may well put a string there) threw during identity resolution;
  • wrong-typed values threw even in well-formed-looking rows, because the explicit (string?) conversion on a JsonNode throws for a non-string rather than yielding null — a numeric status, a suite segment that is not a string, a numeric status inside a nested retryAttempts[]. All string reads now go through one helper that treats another JSON type as absent.

Testing

  • New acceptance test RetryFailedTests_CtrfReports_ShareRunIdButNotReportId runs --retry-failed-tests 3 --report-ctrf and asserts the per-attempt documents share a runId and carry distinct reportIds. This is the only test that covers the cross-process contract — the engine unit tests mock IEnvironment and would pass even if the orchestrator seeding were deleted. Enabling it meant adding the CtrfReport package and provider to the shared RetryFailedTests asset; registering a report provider is inert without its --report-* option, and the other 13 tests in that class assert on extension-scoped globs, so none are perturbed (all 44 pass).
  • Unit tests for runId resolution and its fallbacks, merger runId propagation, the collapse mode, and the malformed/wrong-typed input cases.
  • Every new test was mutation-verified: reverting the production logic it pins makes it fail. That is how the last two robustness bugs were found rather than assumed.
  • 845 unit tests and 59 CTRF + retry acceptance tests pass; build.cmd -pack -c Debug is clean.

Deliberately not in scope

CollapseRetryAttempts has no production caller yet, by design. Wiring it needs a CtrfArtifactPostProcessor (mechanical — TrxArtifactPostProcessor is the template) plus a retry-triggered post-processing entry point, and #10293 still lists the policy questions that entry point encodes as undecided: collapse vs. concatenate per format, what formats without a retry concept should do, and whether the per-attempt files are kept. Those are maintainer calls, not conformance fixes, so this PR ships the mechanism they will consume — opt-in and unit-tested — and leaves the wiring to the follow-up. Note CtrfReportMerger has had no production caller since it was introduced in #9805; this PR does not change that.

The JUnit question raised in #10293 is likewise untouched — there is no upstream spec to conform to there, so it is purely a product decision.

Review notes

Three review rounds were run over this. The findings worth knowing about, because they changed the shape of the code: the original runId comments claimed the execution id correlates the modules of one dotnet test invocation, which the repo's own protocol spec contradicts; and the malformed-input handling went from "carry through" to "reject at ingestion" once it was clear the former produces a schema-invalid merged document.

Evangelink and others added 3 commits July 30, 2026 12:31
The CTRF maintainers confirmed the retry model our engine already emits:
`retryAttempts[]` is the attempt history *preceding* the final attempt
(attempts 1..N-1, initial execution included), the final attempt is
excluded because its outcome is the test object, and `retries` equals
`retryAttempts.length` so the final attempt is `retries + 1`. Documented
that confirmation where the shape is produced; no behavior change.

They also answered the cross-process (`--retry-failed-tests`) question:
the per-attempt documents and any document merged from them describe the
same logical run and should share a `runId` while each keeps its own
`reportId`, and the merged test object should carry the final attempt's
`duration`.

- Emit `runId` (CTRF 5.4), resolved from a shared logical-run id
  (`TESTINGPLATFORM_LOGICAL_RUN_ID`, then the `dotnet test` execution id,
  then a fresh id) so every process of one run agrees on it.
- Seed that variable in `RetryOrchestrator` so all attempts inherit it,
  preserving an id an outer orchestrator already set.
- `CtrfReportMerger` carries a `runId` over when every input agrees, and
  still derives its own `reportId`.
- Add an opt-in `CtrfMergeMode.CollapseRetryAttempts` merge mode: last
  attempt wins, earlier attempts (including in-process ones already
  nested by an input) flatten into `retryAttempts[]` renumbered 1..N-1,
  `retries`/`flaky` are recomputed, and the row keeps the final attempt's
  duration. Concatenation stays the default because MTP UIDs are only
  unique within an assembly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Correct the logical-run scope, and harden the merger against malformed
input.

`runId` scope. Four comments claimed the `dotnet test` execution id
correlates the modules of one invocation. It does not: it is minted per
root test application and propagated only to that process tree, so
sibling modules of a multi-project run legitimately get different ids
(docs/mstest-runner-protocol/004-protocol-dotnet-test-pipe.md). The
comments now describe the real scope and state that correlating modules
or machines requires setting TESTINGPLATFORM_LOGICAL_RUN_ID explicitly.
`RetryOrchestrator` now mirrors the engine's resolution chain
(`existing ?? execution id ?? fresh`), so a retried module stays part of
its own execution tree's run instead of forming a separate one, and uses
the same `"D"` GUID format as the engine.

Malformed `tests[]` rows. A non-object element (a bare string, or a JSON
null left by a lazy producer) threw `InvalidOperationException` from four
places, three of them on the pre-existing `Concatenate` path: the summary
counter loop, `TryReadLong` in the ingestion loop, `GetTestIdentity`'s
`extra` lookup, and the collapse helpers. Such rows are now rejected at
ingestion — carrying one through would turn a defect localized to one
input into a schema-invalid merged document, whereas dropping it matches
how a whole non-CTRF input is already rejected. With `tests[]` filtered
once at the boundary, the collapse helpers take `JsonObject` and the
invariant is enforced by the type system rather than by repeated guards.
`TryReadLong` keeps its guard for a non-object `results.summary`.

Tests. Add an acceptance test asserting that the per-attempt CTRF
documents of one `--retry-failed-tests` run share a `runId` and carry
distinct `reportId`s — the only test that covers the cross-process
contract, since the engine unit tests mock `IEnvironment`. Add merger
tests for malformed rows (both modes) and for a non-object `extra`. Each
new test was mutation-verified to fail when the production logic it pins
is reverted.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Rejecting non-object `tests[]` elements proved each row is an object, but
not that the values read out of it are strings. The explicit `(string?)`
conversion on a `JsonNode` THROWS for a non-string value rather than
yielding null, so a CTRF document that clears the merger's format gate
could still crash it from four sites: the summary counter loop's `status`
(both merge modes), a `suite` segment during identity resolution, and the
`status` of a rebuilt attempt and of a collapsed row.

Route these through a shared `ReadString` helper that treats a value of
any other JSON type as absent, so an unreadable status classifies as
`other` instead of aborting the merge. A non-string `suite` segment falls
back to its JSON text, which keeps the identity key distinct and
deterministic rather than silently fusing two different suites. The
`reportFormat`, `runId`, `testId`, `extra.uid` and `name` reads already
used the safe pattern and now share the helper, so the unsafe conversion
has no remaining foothold in the file.

Extend the malformed-row test with a numeric `status`, a `suite` holding
a number and an object, and a nested `retryAttempts[]` entry with a
numeric `status`. Reverting any of the four guards fails it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 12:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns CTRF reporting with upstream retry and logical-run semantics.

Changes:

  • Adds shared runId propagation across retry processes.
  • Adds retry-aware CTRF merging and malformed-input handling.
  • Expands unit and acceptance coverage.
Show a summary per file
File Description
CtrfReportMergerTests.cs Tests merging, retries, run IDs, and malformed inputs.
CtrfReportEngineTests.cs Tests runId resolution.
RetryFailedTestsTests.cs Verifies cross-process report correlation.
CtrfReportTests.cs Updates expected CTRF shape.
EnvironmentVariableConstants.cs Defines the logical-run environment variable.
RetryOrchestrator.cs Seeds a shared logical-run ID.
Microsoft.Testing.Extensions.CtrfReport.csproj Links environment constants.
InternalAPI.Unshipped.txt Tracks new merger APIs.
CtrfReportMerger.cs Implements run-ID propagation and retry collapsing.
CtrfReportEngine.TestCollapsing.cs Documents retry semantics.
CtrfReportEngine.JsonTestWriter.cs Documents retry serialization.
CtrfReportEngine.JsonSerializer.cs Emits CTRF runId.
CtrfMergeMode.cs Defines concatenation and retry-collapse modes.

Review details

  • Files reviewed: 13/13 changed files
  • Comments generated: 4
  • Review effort level: Medium

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Grade Tests on PR (on open / sync) workflow. · sonnet46 135 AIC · ⌖ 4.16 AIC · ⊞ 11.8K ·

Comments that could not be inline-anchored

test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CtrfReportTests.cs:106

🧪 Grade B (80–89) — Verifies path routing and file existence but skips report content validation that sibling tests perform.

Add AssertCtrfReportShape(customFilePath) to verify the generated document is well-formed — the file exists but its JSON content is unchecked.

        AssertCtrfReportShape(customFilePath);
    }
test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs:577

🧪 Grade B (80–89) — Verifies runId is omitted but does not confirm the merged document still has a valid reportId.

Add Assert.IsNotNull((string?)merged[&quot;reportId&quot;]) to confirm the merged artifact's own id is still present when runId is suppressed.

        Assert.IsNull(merged[&quot;runId&quot;]);
        Assert.IsNotNull((string?)merged[&quot;reportId&quot;]);
test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs:590

🧪 Grade B (80–89) — Verifies runId is omitted but does not confirm the merged document still has a valid reportId.

Add Assert.IsNotNull((string?)merged[&quot;reportId&quot;]) to confirm the merged artifact's own id is still present when runId is suppressed.

        Assert.IsNull(merged[&quot;runId&quot;]);
        Assert.IsNotNull((string?)merged[&quot;reportId&quot;]);

@Evangelink
Evangelink enabled auto-merge (squash) July 30, 2026 14:33
@Evangelink Evangelink added the state/needs-review Awaiting review from the team. label Jul 30, 2026
Both merge modes derived `reportId` from the accepted inputs alone, so
merging the same inputs concatenated and collapsed produced two
materially different documents under one id. CTRF 5.3 wants a distinct
`reportId` for a materially changed document, so fold the mode into the
derivation. Determinism per mode is unchanged: the same inputs merged the
same way still reproduce the same id.

The acceptance test only exercised the fresh-GUID branch of the
orchestrator's run-id resolution, so neither an explicitly supplied
correlation id being preserved nor the dotnet test execution id being
adopted was covered. Parameterize it over all three branches and assert
the expected id, not just that the attempts agree. Making the orchestrator
overwrite the incoming id now fails four of the six cases.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 14:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (3)

src/Platform/Microsoft.Testing.Platform/Helpers/EnvironmentVariableConstants.cs:69

  • This shared file is already compiled into the Platform, Retry, TrxReport, Extensions.MSBuild, HangDump, and HotReload assemblies, all of which track these constants in InternalAPI*.txt (for example, Retry’s shipped file lists the neighboring constants). None of their unshipped files declares this new member, so PublicApiAnalyzers will report RS0016 in each consuming build. Add the new constant to every API-tracked consumer’s InternalAPI.Unshipped.txt.
    public const string TESTINGPLATFORM_LOGICAL_RUN_ID = nameof(TESTINGPLATFORM_LOGICAL_RUN_ID);

src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj:55

  • Linking this source introduces the entire internal EnvironmentVariableConstants type and all of its public constants into the CTRF assembly, but the CTRF shipped/unshipped API files contain no entries for that type. This will produce RS0016 for the newly embedded surface. Either avoid embedding the whole constants file (for example, use narrowly scoped private names) or baseline every introduced symbol in this project’s InternalAPI.Unshipped.txt.
    <Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Helpers\EnvironmentVariableConstants.cs" Link="Helpers\EnvironmentVariableConstants.cs" />

test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs:561

  • This null case does not reliably exercise the uncorrelated fallback. TestHost.ExecuteAsync copies parent environment variables unless they appear in WellKnownEnvironmentVariables.ToSkipEnvironmentVariables, and the new TESTINGPLATFORM_LOGICAL_RUN_ID is not filtered there. If a developer or CI job sets the supported variable to a non-GUID value, this case inherits it and fails the GUID assertion instead of testing GUID generation. Add the variable to the skip list; the two explicit seeded cases will still inject it through environmentVariables.
            yield return (tfm, null);
  • Files reviewed: 13/13 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated content by GitHub Copilot. Generated by the Grade Tests on PR (on open / sync) workflow. · sonnet46 115.3 AIC · ⌖ 4.27 AIC · ⊞ 11.8K ·

`TestHost.ExecuteAsync` copies the parent process environment into each
child test host except for the variables in `ToSkipEnvironmentVariables`,
and `TESTINGPLATFORM_LOGICAL_RUN_ID` was not among them. That makes the
new run-id acceptance test observe an id it did not choose whenever the
variable happens to be set in the developer's shell or the CI job — which
is precisely the usage this PR documents for correlating several modules
or machines, so adopting the feature would have broken the repo's own
tests.

Skip it, next to `TESTINGPLATFORM_DOTNETTEST_EXECUTIONID`, which is
already skipped for the same reason. The two seeded cases are unaffected:
they inject the variable through the explicit dictionary, which wins over
the ambient copy.

Verified by setting a non-GUID value in the parent environment: without
the entry it fails 4 of the 6 matrix cases — the uncorrelated case plus
both execution-id cases, since an ambient logical run id also outranks the
seeded execution id — and passes all 6 with it.

Also assert that suppressing `runId` leaves the merged document's own
`reportId` intact, which the run-id omission tests did not cover.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 15:07
Pins that a supplied correlation id does not leak into the document's own
artifact id on the two seeded resolution paths. The generated-id test
already checked this for the uncorrelated path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (1)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:18

  • Concatenate no longer combines tests[] entirely “as-is”: non-object elements are discarded during ingestion. Update this summary so callers are not led to expect malformed rows to survive the default merge path.
///   <item><description><c>results.tests[]</c> arrays are concatenated as-is, unless <see cref="CtrfMergeMode.CollapseRetryAttempts"/> asks for successive attempts of the same test to be folded into one row.</description></item>
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

Copilot AI review requested due to automatic review settings July 30, 2026 15:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (1)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:454

  • The delimiter-based fallback key is ambiguous because suite segments and test names are not escaped. For example, the schema-valid identities suite: ["A"], name: "B\u001fC" and suite: ["A", "B"], name: "C" produce the same string, causing collapse mode to fuse unrelated tests and lose one result. Serialize the identity components structurally instead.
                identity.Append('\u001f').Append(
                    segment is JsonValue segmentValue && segmentValue.TryGetValue(out string? segmentText)
                        ? segmentText
                        : segment?.ToJsonString());
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

@github-actions

This comment has been minimized.

The suite/name fallback key separated its components without escaping
them, but a CTRF `name` and suite segment are arbitrary non-empty strings
that may contain the separator. So `suite: ["A"], name: "B\u001fC"` and
`suite: ["A", "B"], name: "C"` flattened to the same key, and collapse
mode fused two unrelated tests into one, silently dropping a result.

Length-prefix each component so the encoding is unambiguous whatever the
components contain. The `testId` and `extra.uid` keys are single-component
and carry distinct literal prefixes, so they were never ambiguous.

Also correct the class summary: `Concatenate` no longer combines `tests[]`
entirely as-is, since elements that are not Test objects are dropped
during ingestion.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 15:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (2)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:447

  • For valid reports without an explicit identifier, suite plus name is not necessarily unique: parameterized rows can share both while differing in parameters, and tests in different files can differ only by filePath. This fallback will collapse those unrelated rows and silently lose a result. Include stable discriminators such as canonical parameters/file path, or leave ambiguous rows uncollapsed.
        if (ReadString(test, "name") is not { Length: > 0 } name)
        {
            return null;
        }

        var identity = new StringBuilder("name");
        if (test["suite"] is JsonArray suite)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:439

  • CTRF’s legacy id is still a valid test-case identifier (§9.1), and consumers are told to prefer testId only when both are present. Ignoring an id-only report makes it fall through to suite/name, so distinct same-named tests can be fused and one result lost. Check id after testId before using the producer-specific UID.

This issue also appears on line 441 of the same file.

        // `extra` is free-form, so a foreign producer may well have written a string or an array there; indexing
        // anything but an object by property name throws.
        if (test["extra"] is JsonObject extra && ReadString(extra, "uid") is { Length: > 0 } uid)
        {
            return $"uid\u001f{uid}";
        }
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

@github-actions

This comment has been minimized.

Two gaps in the collapse identity, both of which silently lose a result by
fusing unrelated tests.

CTRF 9.1 defines `id` as a stable test-case identifier that consumers
treat as legacy, preferring `testId` only when both are present. It was
not consulted at all, so an id-only report fell through to the suite/name
heuristic and distinct same-named tests could be fused. Check it after
`testId`.

Suite plus name is also not unique by itself: parameterized rows share
both while differing only in `parameters` (9.30), and same-named tests in
different files differ only by `filePath` (9.19). Fold both into the
fallback key. Should a producer serialize `parameters` inconsistently
between attempts, the effect is an unfolded retry -- a duplicated row
rather than a lost result, which is the safe direction to fail in.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 15:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (2)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:581

  • A wrong-typed status is still copied verbatim when a test row becomes a prior attempt. For example, collapsing two rows with the same identity where the first has "status": 7 emits retryAttempts[0].status: 7; CTRF requires this field to be one of the string status values, so the new tolerance path can still produce a schema-invalid merged document. Read it through ReadString and fall back to "other", matching the summary classification.
            ["status"] = test["status"]?.DeepClone() ?? "other",

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:565

  • When a row with existing nested retries is combined with a later row, these attempts bypass the section-11 projection and are cloned wholesale. A numeric nested status or any test-only/unknown field therefore survives into the merged retry history even though CTRF requires a string status and sets additionalProperties: false. Route nested objects through ToRetryAttempt before the later renumbering so the same whitelist and status fallback apply.

This issue also appears on line 581 of the same file.

                history.Add(attemptObject.DeepClone());
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

@github-actions

This comment has been minimized.

Two ways a foreign document could put schema-invalid content into the
merged retry history.

A promoted test row copied `status` verbatim, so a row carrying
`"status": 7` emitted an attempt with a numeric status even though CTRF
11.3 constrains it to five string values. Normalize anything outside that
vocabulary -- wrong-typed, or a status the producer invented -- to
`other`, which is both legal and the bucket the summary already counts it
in.

Attempts an input had already nested were cloned wholesale, bypassing the
section 11 projection entirely, so a numeric nested status or any
test-only field survived into the merged history despite
`additionalProperties: false`. Route them through the same projection as a
promoted row, which applies the whitelist and the status normalization.
Nothing our own engine writes is lost: every field it emits on a nested
attempt is either in the whitelist or inside `extra`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 15:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (2)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:145

  • Checking only for JsonObject does not establish the CTRF Test invariant. The added malformed-input test deliberately supplies "status": 7, and this clone preserves that number in results.tests[] even though the counter loop classifies it as other; CTRF requires name, status, and duration with status as one of the string enum values. Missing or wrong-typed required fields likewise pass through, so the merger can still emit the schema-invalid artifact this filtering is intended to prevent. Validate the required Test fields and either reject the row or normalize the cloned object before appending it.
                    mergedTests.Add(testObject.DeepClone());

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:497

  • This early return skips the new retry-attempt projection whenever a logical test occurs in only one input row. That is a normal retry scenario: a test can recover through in-process retries in the first process and therefore never be selected for a later orchestrated attempt. Its existing retryAttempts[] is then copied without renumbering or validation, so a foreign row with a non-string status or a forbidden test-only field remains schema-invalid despite AppendNestedAttempts being designed to sanitize it. Process a final row's nested history even when there are no cross-report priors; only return early when neither kind of prior attempt exists.
        if (priors.Count == 0)
        {
            return collapsed;
        }
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

Successive review rounds have asked the merger to validate or repair Test
rows it passes through: reject or normalize rows with a missing or
wrong-typed required field, and reshape the `retryAttempts[]` of a row
that had nothing merged into it.

Both are declined, for one reason worth stating in the code rather than
re-arguing each round. The merger guarantees the shape of what it
SYNTHESIZES -- the summary, the identity fields, and the retry attempt
objects it builds and renumbers -- and relays verbatim what it passes
through. It drops only elements that cannot be a Test at all, since those
alone break the array's item type.

Repairing a Test row would mean either dropping it, losing a real result,
or rewriting it, fabricating an outcome the producer never reported. Both
are worse than relaying a defect that belongs to the input document. The
corollary is that merging a single document with unique identities leaves
its tests[] untouched: this combines reports, it is not a CTRF validator.

That also explains the asymmetry the second comment noticed. A row with
cross-report priors gets a rebuilt history because the merger must
renumber it into a contiguous 1..N-1, so that array is its own; a row
without priors keeps the producer's array untouched.

No behavior change. Adds a test pinning the pass-through so it cannot be
eroded silently.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 16:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (1)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:313

  • This wrapper method only forwards to another Task-returning overload and doesn't need an async state machine. Consider removing async/await and returning the inner task directly to avoid unnecessary allocations/overhead.
    internal static async Task MergeToFileAsync(
        IReadOnlyList<string> inputPaths,
        string outputPath,
        CancellationToken cancellationToken)
        => await MergeToFileAsync(inputPaths, outputPath, CtrfMergeMode.Concatenate, cancellationToken).ConfigureAwait(false);
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Low

The three-argument overload only forwards to the four-argument one, so
async/await bought nothing but a state machine allocation. Exception
timing is unchanged: the inner overload is itself async, so its guard
clauses already fault the returned task rather than throwing
synchronously, which is what the ThrowsExactlyAsync tests observe either
way.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8ad2ea8a-3349-4979-b27c-dd52bb3b7190
Copilot AI review requested due to automatic review settings July 30, 2026 16:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (4)

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportEngine.JsonSerializer.cs:76

  • ResolveRunId() generates a fresh GUID when no correlation env var is present, which means multiple calls to GenerateReportAsync in the same process (or any scenario producing more than one CTRF document per logical run) would stamp different runId values even though they belong to the same logical run. Cache the resolved value (e.g., resolve once in the engine/serializer constructor or store a lazy _runId field) so all documents produced by the same process/run share a stable runId when no external correlation is provided.
            writer.WriteString("runId", ResolveRunId());

src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportEngine.JsonSerializer.cs:183

  • ResolveRunId() generates a fresh GUID when no correlation env var is present, which means multiple calls to GenerateReportAsync in the same process (or any scenario producing more than one CTRF document per logical run) would stamp different runId values even though they belong to the same logical run. Cache the resolved value (e.g., resolve once in the engine/serializer constructor or store a lazy _runId field) so all documents produced by the same process/run share a stable runId when no external correlation is provided.
    private string ResolveRunId()
    {
        string? runId = _environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_LOGICAL_RUN_ID);
        if (RoslynString.IsNullOrEmpty(runId))
        {
            runId = _environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID);
        }

        return RoslynString.IsNullOrEmpty(runId) ? Guid.NewGuid().ToString("D") : runId!;
    }

test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs:585

  • This test assumes the 'uncorrelated' case (when seededVariable is null) will mint a GUID runId, but the product logic explicitly falls back to TESTINGPLATFORM_DOTNETTEST_EXECUTIONID when present. If that variable is inherited from the acceptance test process environment, the assertion can fail and become environment-dependent. To make the test deterministic, explicitly clear/unset both TESTINGPLATFORM_LOGICAL_RUN_ID and TESTINGPLATFORM_DOTNETTEST_EXECUTIONID in environmentVariables for the null matrix case (or relax the assertion to accept the execution id fallback when present).
        Dictionary<string, string?> environmentVariables = new()
        {
            { EnvironmentVariableConstants.TESTINGPLATFORM_TELEMETRY_OPTOUT, "1" },
            { "METHOD1", "1" },
            { "RESULTDIR", resultDirectory },
        };

test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs:625

  • This test assumes the 'uncorrelated' case (when seededVariable is null) will mint a GUID runId, but the product logic explicitly falls back to TESTINGPLATFORM_DOTNETTEST_EXECUTIONID when present. If that variable is inherited from the acceptance test process environment, the assertion can fail and become environment-dependent. To make the test deterministic, explicitly clear/unset both TESTINGPLATFORM_LOGICAL_RUN_ID and TESTINGPLATFORM_DOTNETTEST_EXECUTIONID in environmentVariables for the null matrix case (or relax the assertion to accept the execution id fallback when present).
        else
        {
            Assert.IsTrue(Guid.TryParse(runIds[0], out _), $"An uncorrelated run must mint a GUID run id, got '{runIds[0]}'.");
        }
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Low

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test quality grade — PR #10343

GradeTestMutationNotesHow to improve
A (90–100) new CtrfReportEngineTests.
GenerateReportAsync_
RunId_
UsesTheSharedLogicalRunId
2/2 killed Exact equality on runId and inequality on reportId kills both primary mutation points.
A (90–100) new CtrfReportEngineTests.
GenerateReportAsync_
RunId_
FallsBackToTheDotnetTestExecutionId
2/2 killed Clear AAA; verifies the fallback branch with equality + inequality assertions.
A (90–100) new CtrfReportEngineTests.
GenerateReportAsync_
RunId_
IsGenerated_
WhenNothingCorrelatedTheProcess
2/2 killed Guid format check and inequality guard kill the generated-id path; no meaningful surviving mutations.
A (90–100) new CtrfReportMergerTests.
Merge_
CarriesRunId_
WhenEveryInputReportsTheSameOne
3/3 killed Equality on runId plus two inequality guards kill all meaningful mutations.
A (90–100) new CtrfReportMergerTests.
Merge_
OmitsRunId_
WhenInputsBelongToDifferentRuns
2/2 killed Null assertion kills the suppression path; reportId presence guards the independent artifact contract.
A (90–100) new CtrfReportMergerTests.
Merge_
OmitsRunId_
WhenAnInputHasNone
2/2 killed Distinct scenario from differing-runId; null + reportId-present assertions are meaningful.
A (90–100) new CtrfReportMergerTests.
Merge_
DefaultMode_
DoesNotCollapseRepeatedTests
2/2 killed Count and summary.tests assertions together kill the collapse-vs-concatenate discrimination.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
FoldsEarlierAttemptsIntoRetryHistory
5/5 killed Thorough: status, flaky, retries count, attempt numbers, messages, and final vs per-attempt duration all checked.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
CountsLogicalTestsOnce
5/5 killed Summary counters, test count, per-test status, retries, and null checks cover all meaningful paths.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
FlattensInProcessRetriesOfEachAttempt
3/3 killed Attempt messages and numbers verify the ordering and flattening of pre-existing retryAttempts.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
ProjectsAttemptsOntoRetryAttemptShape
4/4 killed Tests both promoted and suppressed fields; rawStatus relocation and absent test-only fields all checked.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
DropsStaleFlakyFlag_
WhenFinalAttemptFails
3/3 killed Status, null flaky, and zero summary.flaky together kill the stale-flag regression.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
UsesNameAndSuite_
WhenNoIdentifierIsAvailable
3/3 killed Count + per-row status + per-row retries kill mis-fusion and no-retry mutations.
A (90–100) new CtrfReportMergerTests.
Merge_
RejectsMalformedTestRows_
SoTheMergedDocumentStaysValid
4/4 killed Count, specific named rows, status bucket math, and "other" classification all checked; parametrized over both modes.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
ToleratesNonObjectExtra
2/2 killed Count + flaky + retries confirm collapse-on-name-identity despite non-object extra values.
A (90–100) new CtrfReportMergerTests.
Merge_
ReportId_
IsDeterministicPerModeAndDiffersBetweenModes
4/4 killed Cross-mode inequality, intra-mode reproducibility, default-overload equivalence, and Guid format all verified.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
DoesNotFuseTestsWhoseNameContainsTheIdentitySeparator
2/2 killed Count + per-row status + null retries confirm no false fusion on separator-containing names.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
UsesLegacyIdWhenTestIdIsAbsent
3/3 killed Count, status, flaky, and retries on the per-row level kill id-based fusion and non-collapse mutations.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
DoesNotFuseRowsThatDifferOnlyByParametersOrFilePath
2/2 killed Count + sequence equality on statuses kill premature-fusion and count-off-by-one mutations.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
KeepsRetryHistorySchemaValid
4/4 killed Normalized status, absent test-only field, count, and attempt-number sequence verified for both nested and promoted rows.
A (90–100) new CtrfReportMergerTests.
Merge_
CollapseRetryAttempts_
LeavesAnUnmergedRowExactlyAsWritten
3/3 killed Byte-exact JSON equality plus null retries/flaky guards the verbatim-relay contract end-to-end.
A (90–100) new RetryFailedTestsTests.
RetryFailedTests_
CtrfReports_
ShareRunIdButNotReportId
4/4 killed Cross-process contract verified end-to-end; equality, inequality, Guid parse, and seeded-id path all exercised via DynamicData.

This advisory comment was generated automatically. Grades are heuristic and informational — they do not block merging. Re-run with /grade-tests.

🤖 Automated content by GitHub Copilot. Generated by the Grade Tests on PR (on open / sync) workflow. · sonnet46 168.4 AIC · ⌖ 5.12 AIC · ⊞ 11.8K · [◷]( · )

@Evangelink
Evangelink merged commit e74ad22 into main Jul 30, 2026
31 of 32 checks passed
@Evangelink
Evangelink deleted the dev/amauryleve/improved-eureka branch July 30, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

state/needs-review Awaiting review from the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants