Skip to content

Commit 0296cbe

Browse files
authored
Fix flakey XCTest parallel test failure reporting (#1343)
The `ParallelXCTestRunStateProxy` in `XCTestOutputParser.ts` wraps the test run state and only allows issues to be recorded using the terminal output of the test run. The remaining test run state is filled in with the xml xUnit output. This is because SwiftPM only dumps test output to the terminal if there is a failure. Using these two sources we can (almost) fully reconstruct a regular test run. When parsing XCTest output from the terminal failure messages might be broken up over multiple lines. In order to capture all these lines and group them in to a single failure message even if the message is broken up across terminal buffer chunks we maintain an `excess` variable on the TestRunState, which we check at the beginning of chunk parsing to know if we need to continue an error message. However, the `ParallelXCTestRunStateProxy` that wraps the test run state was not forwarding along the setting of `excess`. Instead `excess` was set on the proxy, which is recreated for every chunk of output parsed. This manifested as XCTests _sometimes_ not correctly reporting their error message when run in the Parallel test profile, instead reporting the message "Failed". Issue: #1334
1 parent bd64d77 commit 0296cbe

File tree

4 files changed

+62
-18
lines changed

4 files changed

+62
-18
lines changed

src/TestExplorer/TestParsers/XCTestOutputParser.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ export class ParallelXCTestOutputParser implements IXCTestOutputParser {
9292
public parseResult(output: string, runState: ITestRunState) {
9393
// From 5.7 to 5.10 running with the --parallel option dumps the test results out
9494
// to the console with no newlines, so it isn't possible to distinguish where errors
95-
// begin and end. Consequently we can't record them, and so we manually mark them
96-
// as passed or failed here with a manufactured issue.
95+
// begin and end. Consequently we can't record them. For these versions we rely on the
96+
// generated xunit XML, which we can parse and mark tests as passed or failed here with
97+
// manufactured issues.
9798
// Don't attempt to parse the console output of parallel tests between 5.7 and 5.10
9899
// as it doesn't have newlines. You might get lucky and find the output is split
99100
// in the right spot, but more often than not we wont be able to parse it.
@@ -112,8 +113,42 @@ export class ParallelXCTestOutputParser implements IXCTestOutputParser {
112113

113114
/* eslint-disable @typescript-eslint/no-unused-vars */
114115
class ParallelXCTestRunStateProxy implements ITestRunState {
116+
// Note this must remain stateless as its recreated on
117+
// every `parseResult` call in `ParallelXCTestOutputParser`
115118
constructor(private runState: ITestRunState) {}
116119

120+
get excess(): typeof this.runState.excess {
121+
return this.runState.excess;
122+
}
123+
124+
set excess(value: typeof this.runState.excess) {
125+
this.runState.excess = value;
126+
}
127+
128+
get activeSuite(): typeof this.runState.activeSuite {
129+
return this.runState.activeSuite;
130+
}
131+
132+
set activeSuite(value: typeof this.runState.activeSuite) {
133+
this.runState.activeSuite = value;
134+
}
135+
136+
get pendingSuiteOutput(): typeof this.runState.pendingSuiteOutput {
137+
return this.runState.pendingSuiteOutput;
138+
}
139+
140+
set pendingSuiteOutput(value: typeof this.runState.pendingSuiteOutput) {
141+
this.runState.pendingSuiteOutput = value;
142+
}
143+
144+
get failedTest(): typeof this.runState.failedTest {
145+
return this.runState.failedTest;
146+
}
147+
148+
set failedTest(value: typeof this.runState.failedTest) {
149+
this.runState.failedTest = value;
150+
}
151+
117152
getTestItemIndex(id: string, filename: string | undefined): number {
118153
return this.runState.getTestItemIndex(id, filename);
119154
}

src/TestExplorer/TestXUnitParser.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import * as xml2js from "xml2js";
16-
import { TestRunnerTestRunState } from "./TestRunner";
17-
import { OutputChannel } from "vscode";
16+
import { ITestRunState } from "./TestParsers/TestRunState";
17+
import { SwiftOutputChannel } from "../ui/SwiftOutputChannel";
1818

1919
export interface TestResults {
2020
tests: number;
@@ -49,8 +49,8 @@ export class TestXUnitParser {
4949

5050
async parse(
5151
buffer: string,
52-
runState: TestRunnerTestRunState,
53-
outputChannel: OutputChannel
52+
runState: ITestRunState,
53+
outputChannel: SwiftOutputChannel
5454
): Promise<TestResults | undefined> {
5555
const xml = await xml2js.parseStringPromise(buffer);
5656
try {
@@ -63,7 +63,8 @@ export class TestXUnitParser {
6363
}
6464

6565
// eslint-disable-next-line @typescript-eslint/no-unused-vars
66-
async parseXUnit(xUnit: XUnit, runState: TestRunnerTestRunState): Promise<TestResults> {
66+
async parseXUnit(xUnit: XUnit, runState: ITestRunState): Promise<TestResults> {
67+
// eslint-disable-next-line no-console
6768
let tests = 0;
6869
let failures = 0;
6970
let errors = 0;
@@ -77,7 +78,7 @@ export class TestXUnitParser {
7778
testsuite.testcase.forEach(testcase => {
7879
className = testcase.$.classname;
7980
const id = `${className}/${testcase.$.name}`;
80-
const index = runState.getTestItemIndex(id);
81+
const index = runState.getTestItemIndex(id, undefined);
8182

8283
// From 5.7 to 5.10 running with the --parallel option dumps the test results out
8384
// to the console with no newlines, so it isn't possible to distinguish where errors
@@ -86,7 +87,8 @@ export class TestXUnitParser {
8687
if (!!testcase.failure && !this.hasMultiLineParallelTestOutput) {
8788
runState.recordIssue(
8889
index,
89-
testcase.failure.shift()?.$.message ?? "Test Failed"
90+
testcase.failure.shift()?.$.message ?? "Test Failed",
91+
false
9092
);
9193
}
9294
runState.completed(index, { duration: testcase.$.time });

test/integration-tests/testexplorer/TestExplorerIntegration.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ suite("Test Explorer Suite", function () {
563563
});
564564
});
565565

566-
test(`Runs failing test (${runProfile})`, async function () {
566+
test(`swift-testing Runs failing test (${runProfile})`, async function () {
567567
const testRun = await runTest(
568568
testExplorer,
569569
runProfile,
@@ -585,7 +585,7 @@ suite("Test Explorer Suite", function () {
585585
});
586586
});
587587

588-
test(`Runs Suite (${runProfile})`, async function () {
588+
test(`swift-testing Runs Suite (${runProfile})`, async function () {
589589
const testRun = await runTest(
590590
testExplorer,
591591
runProfile,
@@ -610,7 +610,7 @@ suite("Test Explorer Suite", function () {
610610
});
611611
});
612612

613-
test(`Runs parameterized test (${runProfile})`, async function () {
613+
test(`swift-testing Runs parameterized test (${runProfile})`, async function () {
614614
const testId = "PackageTests.parameterizedTest(_:)";
615615
const testRun = await runTest(testExplorer, runProfile, testId);
616616

@@ -660,7 +660,7 @@ suite("Test Explorer Suite", function () {
660660
assert.deepEqual(unrunnableChildren, [true, true, true]);
661661
});
662662

663-
test(`Runs Suite (${runProfile})`, async function () {
663+
test(`swift-testing Runs Suite (${runProfile})`, async function () {
664664
const testRun = await runTest(
665665
testExplorer,
666666
runProfile,
@@ -685,7 +685,7 @@ suite("Test Explorer Suite", function () {
685685
});
686686
});
687687

688-
test(`Runs All (${runProfile})`, async function () {
688+
test(`swift-testing Runs All (${runProfile})`, async function () {
689689
const testRun = await runTest(
690690
testExplorer,
691691
runProfile,
@@ -724,7 +724,7 @@ suite("Test Explorer Suite", function () {
724724
});
725725

726726
suite(`XCTests (${runProfile})`, () => {
727-
test("Runs passing test", async function () {
727+
test(`XCTest Runs passing test (${runProfile})`, async function () {
728728
const testRun = await runTest(
729729
testExplorer,
730730
runProfile,
@@ -739,7 +739,7 @@ suite("Test Explorer Suite", function () {
739739
});
740740
});
741741

742-
test("Runs failing test", async function () {
742+
test(`XCTest Runs failing test (${runProfile})`, async function () {
743743
const testRun = await runTest(
744744
testExplorer,
745745
runProfile,
@@ -760,7 +760,7 @@ suite("Test Explorer Suite", function () {
760760
});
761761
});
762762

763-
test("Runs Suite", async function () {
763+
test(`XCTest Runs Suite (${runProfile})`, async function () {
764764
const testRun = await runTest(
765765
testExplorer,
766766
runProfile,

test/integration-tests/testexplorer/utilities.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,11 @@ export function assertTestResults(
160160
skipped: (state.skipped ?? []).sort(),
161161
errored: (state.errored ?? []).sort(),
162162
unknown: 0,
163-
}
163+
},
164+
`
165+
Build Output:
166+
${testRun.runState.output.join("\n")}
167+
`
164168
);
165169
}
166170

@@ -280,6 +284,9 @@ export async function runTest(
280284
const testItems = await gatherTests(testExplorer.controller, ...tests);
281285
const request = new vscode.TestRunRequest(testItems);
282286

287+
// The first promise is the return value, the second promise builds and runs
288+
// the tests, populating the TestRunProxy with results and blocking the return
289+
// of that TestRunProxy until the test run is complete.
283290
return (
284291
await Promise.all([
285292
eventPromise(testExplorer.onCreateTestRun),

0 commit comments

Comments
 (0)