Align CTRF retry reporting and runId with ctrf-io/ctrf#58 - #10343
Conversation
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
There was a problem hiding this comment.
Pull request overview
Aligns CTRF reporting with upstream retry and logical-run semantics.
Changes:
- Adds shared
runIdpropagation 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
There was a problem hiding this comment.
🤖 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["reportId"]) to confirm the merged artifact's own id is still present when runId is suppressed.
Assert.IsNull(merged["runId"]);
Assert.IsNotNull((string?)merged["reportId"]);
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["reportId"]) to confirm the merged artifact's own id is still present when runId is suppressed.
Assert.IsNull(merged["runId"]);
Assert.IsNotNull((string?)merged["reportId"]);
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
There was a problem hiding this comment.
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’sInternalAPI.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
EnvironmentVariableConstantstype 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’sInternalAPI.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
nullcase does not reliably exercise the uncorrelated fallback.TestHost.ExecuteAsynccopies parent environment variables unless they appear inWellKnownEnvironmentVariables.ToSkipEnvironmentVariables, and the newTESTINGPLATFORM_LOGICAL_RUN_IDis 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 throughenvironmentVariables.
yield return (tfm, null);
- Files reviewed: 13/13 changed files
- Comments generated: 0 new
- Review effort level: Medium
There was a problem hiding this comment.
🤖 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
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
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:18
Concatenateno longer combinestests[]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
There was a problem hiding this comment.
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"andsuite: ["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
This comment has been minimized.
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
There was a problem hiding this comment.
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 byfilePath. 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
idis still a valid test-case identifier (§9.1), and consumers are told to prefertestIdonly when both are present. Ignoring anid-only report makes it fall through to suite/name, so distinct same-named tests can be fused and one result lost. CheckidaftertestIdbefore 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
This comment has been minimized.
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
There was a problem hiding this comment.
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": 7emitsretryAttempts[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 throughReadStringand 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 throughToRetryAttemptbefore 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
This comment has been minimized.
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
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs:145
- Checking only for
JsonObjectdoes not establish the CTRF Test invariant. The added malformed-input test deliberately supplies"status": 7, and this clone preserves that number inresults.tests[]even though the counter loop classifies it asother; CTRF requiresname,status, anddurationwithstatusas 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 despiteAppendNestedAttemptsbeing 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
There was a problem hiding this comment.
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 removingasync/awaitand 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
There was a problem hiding this comment.
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 toGenerateReportAsyncin the same process (or any scenario producing more than one CTRF document per logical run) would stamp differentrunIdvalues 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_runIdfield) so all documents produced by the same process/run share a stablerunIdwhen 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 toGenerateReportAsyncin the same process (or any scenario producing more than one CTRF document per logical run) would stamp differentrunIdvalues 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_runIdfield) so all documents produced by the same process/run share a stablerunIdwhen 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
seededVariableis null) will mint a GUIDrunId, but the product logic explicitly falls back toTESTINGPLATFORM_DOTNETTEST_EXECUTIONIDwhen 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 bothTESTINGPLATFORM_LOGICAL_RUN_IDandTESTINGPLATFORM_DOTNETTEST_EXECUTIONIDinenvironmentVariablesfor 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
seededVariableis null) will mint a GUIDrunId, but the product logic explicitly falls back toTESTINGPLATFORM_DOTNETTEST_EXECUTIONIDwhen 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 bothTESTINGPLATFORM_LOGICAL_RUN_IDandTESTINGPLATFORM_DOTNETTEST_EXECUTIONIDinenvironmentVariablesfor 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
🧪 Test quality grade — PR #10343
This advisory comment was generated automatically. Grades are heuristic and informational — they do not block merging. Re-run with
|
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
retryAttempts[]holds attempts1..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.retries == retryAttempts.length, so the final attempt number isretries + 1.runIdwhile each keeps its ownreportId.runIdat all.durationis the final attempt's.Changes
runId(CTRF §5.4).CtrfReportEnginenow emits a top-levelrunId, resolved asTESTINGPLATFORM_LOGICAL_RUN_ID→TESTINGPLATFORM_DOTNETTEST_EXECUTIONID→ a fresh GUID.RetryOrchestratorestablishes 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_EXECUTIONIDis per root test application, not perdotnet testinvocation — 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 settingTESTINGPLATFORM_LOGICAL_RUN_IDexplicitly — which is now possible, and documented on the constant.CtrfReportMerger. CarriesrunIdover 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 deterministicreportId.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 intoretryAttempts[]renumbered1..N-1;retriesandflakyrecomputed per §9.20/§9.22 (recomputed, not inherited: an input's ownflakyflag only describes the attempt that produced it); the row keeps the final attempt'sduration; summary re-derived sosummary.testscounts 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 andrawStatus, which has no attempt-level slot, moves underextra.Concatenateremains 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'sreportFormat/resultsgate and still crash it:"tests": ["oops", null, 42]) threw from the summary counter loop, fromTryReadLongin 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. Withtests[]filtered once at the boundary, the collapse helpers takeJsonObjectand the invariant is enforced by the type system rather than by repeated guards;extra(it is free-form, so a foreign producer may well put a string there) threw during identity resolution;(string?)conversion on aJsonNodethrows for a non-string rather than yielding null — a numericstatus, asuitesegment that is not a string, a numericstatusinside a nestedretryAttempts[]. All string reads now go through one helper that treats another JSON type as absent.Testing
RetryFailedTests_CtrfReports_ShareRunIdButNotReportIdruns--retry-failed-tests 3 --report-ctrfand asserts the per-attempt documents share arunIdand carry distinctreportIds. This is the only test that covers the cross-process contract — the engine unit tests mockIEnvironmentand would pass even if the orchestrator seeding were deleted. Enabling it meant adding the CtrfReport package and provider to the sharedRetryFailedTestsasset; 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).runIdresolution and its fallbacks, mergerrunIdpropagation, the collapse mode, and the malformed/wrong-typed input cases.build.cmd -pack -c Debugis clean.Deliberately not in scope
CollapseRetryAttemptshas no production caller yet, by design. Wiring it needs aCtrfArtifactPostProcessor(mechanical —TrxArtifactPostProcessoris 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. NoteCtrfReportMergerhas 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
runIdcomments claimed the execution id correlates the modules of onedotnet testinvocation, 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.