Skip to content

Commit 4655951

Browse files
committed
Support parameterized swift-testing tests
Swift-testing emits a `test` event at the beginning of a test run that enumerates all the parameterized test cases to be executed. This patch waits for the `test` event and then generates `vscode.TestItem`s for each parameterized test execution, parenting them to their test. Then when we recieve a `runStarted` event we enqueue all the tests along with the newly created parameterized `TestItem`s. Before a new test run starts we remove the existing parameterized `TestItems` so we can regenerate them, as there may be a different number of parameterized tests run with every execution.
1 parent 18201e5 commit 4655951

File tree

11 files changed

+564
-117
lines changed

11 files changed

+564
-117
lines changed

src/SwiftTaskProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export async function getBuildAllTask(folderContext: FolderContext): Promise<vsc
187187
if (!task) {
188188
throw Error("Build All Task does not exist");
189189
}
190+
190191
return task;
191192
}
192193

@@ -252,7 +253,7 @@ export function createSwiftTask(
252253
const fullCwd = config.cwd.fsPath;
253254

254255
/* Currently there seems to be a bug in vscode where kicking off two tasks
255-
with the same definition but different scopes messes with the task
256+
with the same definition but different scopes messes with the task
256257
completion code. When that is resolved we will go back to the code below
257258
where we only store the relative cwd instead of the full cwd
258259

src/TestExplorer/TestDiscovery.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ function deepMergeTestItemChildren(existingItem: vscode.TestItem, newItem: vscod
140140
* Updates the existing `vscode.TestItem` if it exists with the same ID as the `TestClass`,
141141
* otherwise creates an add a new one. The location on the returned vscode.TestItem is always updated.
142142
*/
143-
function upsertTestItem(
143+
export function upsertTestItem(
144144
testController: vscode.TestController,
145145
testItem: TestClass,
146146
parent?: vscode.TestItem
147-
) {
147+
): vscode.TestItem {
148148
const collection = parent?.children ?? testController.items;
149149
const existingItem = collection.get(testItem.id);
150150
let newItem: vscode.TestItem;
@@ -161,6 +161,15 @@ function upsertTestItem(
161161
testItem.label,
162162
testItem.location?.uri
163163
);
164+
165+
// We want to keep existing children if they exist.
166+
if (existingItem) {
167+
const existingChildren: vscode.TestItem[] = [];
168+
existingItem.children.forEach(child => {
169+
existingChildren.push(child);
170+
});
171+
newItem.children.replace(existingChildren);
172+
}
164173
} else {
165174
newItem = existingItem;
166175
}
@@ -192,4 +201,6 @@ function upsertTestItem(
192201
testItem.children.forEach(child => {
193202
upsertTestItem(testController, child, newItem);
194203
});
204+
205+
return newItem;
195206
}

src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as vscode from "vscode";
12
import * as readline from "readline";
23
import { Readable } from "stream";
34
import {
@@ -6,6 +7,7 @@ import {
67
WindowsNamedPipeReader,
78
} from "./TestEventStreamReader";
89
import { ITestRunState } from "./TestRunState";
10+
import { TestClass } from "../TestDiscovery";
911

1012
// All events produced by a swift-testing run will be one of these three types.
1113
export type SwiftTestEvent = MetadataRecord | TestRecord | EventRecord;
@@ -21,7 +23,7 @@ interface MetadataRecord extends VersionedRecord {
2123

2224
interface TestRecord extends VersionedRecord {
2325
kind: "test";
24-
payload: Test;
26+
payload: TestSuite | TestFunction;
2527
}
2628

2729
export type EventRecordPayload =
@@ -43,15 +45,23 @@ interface Metadata {
4345
[key: string]: object; // Currently unstructured content
4446
}
4547

46-
interface Test {
47-
kind: "suite" | "function" | "parameterizedFunction";
48+
interface TestBase {
4849
id: string;
4950
name: string;
5051
_testCases?: TestCase[];
5152
sourceLocation: SourceLocation;
5253
}
5354

54-
interface TestCase {
55+
interface TestSuite extends TestBase {
56+
kind: "suite";
57+
}
58+
59+
interface TestFunction extends TestBase {
60+
kind: "function";
61+
isParameterized: boolean;
62+
}
63+
64+
export interface TestCase {
5565
id: string;
5666
displayName: string;
5767
}
@@ -76,6 +86,11 @@ interface BaseEvent {
7686
testID: string;
7787
}
7888

89+
interface TestCaseEvent {
90+
sourceLocation: SourceLocation;
91+
_testCase: TestCase;
92+
}
93+
7994
interface TestStarted extends BaseEvent {
8095
kind: "testStarted";
8196
}
@@ -84,19 +99,19 @@ interface TestEnded extends BaseEvent {
8499
kind: "testEnded";
85100
}
86101

87-
interface TestCaseStarted extends BaseEvent {
102+
interface TestCaseStarted extends BaseEvent, TestCaseEvent {
88103
kind: "testCaseStarted";
89104
}
90105

91-
interface TestCaseEnded extends BaseEvent {
106+
interface TestCaseEnded extends BaseEvent, TestCaseEvent {
92107
kind: "testCaseEnded";
93108
}
94109

95110
interface TestSkipped extends BaseEvent {
96111
kind: "testSkipped";
97112
}
98113

99-
interface IssueRecorded extends BaseEvent {
114+
interface IssueRecorded extends BaseEvent, TestCaseEvent {
100115
kind: "issueRecorded";
101116
issue: {
102117
sourceLocation: SourceLocation;
@@ -115,6 +130,12 @@ export interface SourceLocation {
115130

116131
export class SwiftTestingOutputParser {
117132
private completionMap = new Map<number, boolean>();
133+
private testCaseMap = new Map<string, Map<string, TestCase>>();
134+
135+
constructor(
136+
public testRunStarted: () => void,
137+
public addParameterizedTestCase: (testClass: TestClass, parentIndex: number) => void
138+
) {}
118139

119140
/**
120141
* Watches for test events on the named pipe at the supplied path.
@@ -155,31 +176,131 @@ export class SwiftTestingOutputParser {
155176
return !matches ? id : matches[1];
156177
}
157178

179+
private testCaseId(testId: string, testCaseId: string): string {
180+
const testCase = this.testCaseMap.get(testId)?.get(testCaseId);
181+
return testCase ? this.createTestCaseId(testCase) : testId;
182+
}
183+
184+
// Test cases do not have a unique ID if their arguments are not serializable
185+
// with Codable. If they aren't, their id appears as `argumentIDs: nil`, and we
186+
// fall back to using the testCase display name as the test case ID. This isn't
187+
// ideal because its possible to have multiple test cases with the same display name,
188+
// but until we have a better solution for identifying test cases it will have to do.
189+
// SEE: rdar://119522099.
190+
private createTestCaseId(testCase: TestCase): string {
191+
return testCase.id === "argumentIDs: nil" ? testCase.displayName : testCase.id;
192+
}
193+
194+
private parameterizedFunctionTestCaseToTestClass(
195+
testCase: TestCase,
196+
location: vscode.Location,
197+
index: number
198+
): TestClass {
199+
return {
200+
id: this.createTestCaseId(testCase),
201+
label: testCase.displayName,
202+
tags: [],
203+
children: [],
204+
style: "swift-testing",
205+
location: location,
206+
disabled: true,
207+
sortText: `${index}`.padStart(8, "0"),
208+
};
209+
}
210+
211+
private buildTestCaseMapForParameterizedTest(record: TestRecord) {
212+
const map = new Map<string, TestCase>();
213+
(record.payload._testCases ?? []).forEach(testCase => {
214+
map.set(this.createTestCaseId(testCase), testCase);
215+
});
216+
this.testCaseMap.set(record.payload.id, map);
217+
}
218+
158219
private parse(item: SwiftTestEvent, runState: ITestRunState) {
159-
if (item.kind === "event") {
160-
if (item.payload.kind === "testCaseStarted" || item.payload.kind === "testStarted") {
220+
if (
221+
item.kind === "test" &&
222+
item.payload.kind === "function" &&
223+
item.payload.isParameterized &&
224+
item.payload._testCases
225+
) {
226+
// Store a map of [Test ID, [Test Case ID, TestCase]] so we can quickly
227+
// map an event.payload.testID back to a test case.
228+
this.buildTestCaseMapForParameterizedTest(item);
229+
230+
const testName = this.testName(item.payload.id);
231+
const testIndex = runState.getTestItemIndex(testName, undefined);
232+
// If a test has test cases it is paramterized and we need to notify
233+
// the caller that the TestClass should be added to the vscode.TestRun
234+
// before it starts.
235+
item.payload._testCases
236+
.map((testCase, index) =>
237+
this.parameterizedFunctionTestCaseToTestClass(
238+
testCase,
239+
sourceLocationToVSCodeLocation(item.payload.sourceLocation),
240+
index
241+
)
242+
)
243+
.flatMap(testClass => (testClass ? [testClass] : []))
244+
.forEach(testClass => this.addParameterizedTestCase(testClass, testIndex));
245+
} else if (item.kind === "event") {
246+
if (item.payload.kind === "runStarted") {
247+
// Notify the runner that we've recieved all the test cases and
248+
// are going to start running tests now.
249+
this.testRunStarted();
250+
} else if (item.payload.kind === "testStarted") {
161251
const testName = this.testName(item.payload.testID);
162252
const testIndex = runState.getTestItemIndex(testName, undefined);
163253
runState.started(testIndex, item.payload.instant.absolute);
254+
} else if (item.payload.kind === "testCaseStarted") {
255+
const testID = this.testCaseId(
256+
item.payload.testID,
257+
this.createTestCaseId(item.payload._testCase)
258+
);
259+
const testName = this.testName(testID);
260+
const testIndex = runState.getTestItemIndex(testName, undefined);
261+
runState.started(testIndex, item.payload.instant.absolute);
164262
} else if (item.payload.kind === "testSkipped") {
165263
const testName = this.testName(item.payload.testID);
166264
const testIndex = runState.getTestItemIndex(testName, undefined);
167265
runState.skipped(testIndex);
168266
} else if (item.payload.kind === "issueRecorded") {
169-
const testName = this.testName(item.payload.testID);
267+
const testID = this.testCaseId(
268+
item.payload.testID,
269+
this.createTestCaseId(item.payload._testCase)
270+
);
271+
const testName = this.testName(testID);
170272
const testIndex = runState.getTestItemIndex(testName, undefined);
171-
const sourceLocation = item.payload.issue.sourceLocation;
273+
const location = sourceLocationToVSCodeLocation(item.payload.issue.sourceLocation);
172274
item.payload.messages.forEach(message => {
173-
runState.recordIssue(testIndex, message.text, {
174-
file: sourceLocation._filePath,
175-
line: sourceLocation.line,
176-
column: sourceLocation.column,
177-
});
275+
runState.recordIssue(testIndex, message.text, location);
178276
});
179-
} else if (item.payload.kind === "testCaseEnded" || item.payload.kind === "testEnded") {
277+
278+
if (testID !== item.payload.testID) {
279+
const testName = this.testName(item.payload.testID);
280+
const testIndex = runState.getTestItemIndex(testName, undefined);
281+
item.payload.messages.forEach(message => {
282+
runState.recordIssue(testIndex, message.text, location);
283+
});
284+
}
285+
} else if (item.payload.kind === "testEnded") {
180286
const testName = this.testName(item.payload.testID);
181287
const testIndex = runState.getTestItemIndex(testName, undefined);
182288

289+
// When running a single test the testEnded and testCaseEnded events
290+
// have the same ID, and so we'd end the same test twice.
291+
if (this.completionMap.get(testIndex)) {
292+
return;
293+
}
294+
this.completionMap.set(testIndex, true);
295+
runState.completed(testIndex, { timestamp: item.payload.instant.absolute });
296+
} else if (item.payload.kind === "testCaseEnded") {
297+
const testID = this.testCaseId(
298+
item.payload.testID,
299+
this.createTestCaseId(item.payload._testCase)
300+
);
301+
const testName = this.testName(testID);
302+
const testIndex = runState.getTestItemIndex(testName, undefined);
303+
183304
// When running a single test the testEnded and testCaseEnded events
184305
// have the same ID, and so we'd end the same test twice.
185306
if (this.completionMap.get(testIndex)) {
@@ -191,3 +312,10 @@ export class SwiftTestingOutputParser {
191312
}
192313
}
193314
}
315+
316+
function sourceLocationToVSCodeLocation(sourceLocation: SourceLocation): vscode.Location {
317+
return new vscode.Location(
318+
vscode.Uri.file(sourceLocation._filePath),
319+
new vscode.Position(sourceLocation.line - 1, sourceLocation?.column ?? 0)
320+
);
321+
}

src/TestExplorer/TestParsers/TestRunState.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MarkdownString } from "vscode";
1+
import * as vscode from "vscode";
22

33
/**
44
* Interface for setting this test runs state
@@ -26,11 +26,12 @@ export interface ITestRunState {
2626
// otherwise the time passed is assumed to be the duration.
2727
completed(index: number, timing: { duration: number } | { timestamp: number }): void;
2828

29-
// record an issue against a test
29+
// record an issue against a test.
30+
// If a `testCase` is provided a new TestItem will be created under the TestItem at the supplied index.
3031
recordIssue(
3132
index: number,
32-
message: string | MarkdownString,
33-
location?: { file: string; line: number; column?: number }
33+
message: string | vscode.MarkdownString,
34+
location?: vscode.Location
3435
): void;
3536

3637
// set test index to have been skipped

src/TestExplorer/TestParsers/XCTestOutputParser.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
import * as vscode from "vscode";
1516
import { ITestRunState } from "./TestRunState";
1617

1718
/** Regex for parsing XCTest output */
@@ -228,6 +229,10 @@ export class XCTestOutputParser {
228229
runState.failedTest = undefined;
229230
}
230231

232+
private sourceLocationToVSCodeLocation(file: string, lineNumber: number): vscode.Location {
233+
return new vscode.Location(vscode.Uri.file(file), new vscode.Position(lineNumber - 1, 0));
234+
}
235+
231236
/** Start capture error message */
232237
private startErrorMessage(
233238
testIndex: number,
@@ -238,10 +243,11 @@ export class XCTestOutputParser {
238243
) {
239244
// If we were already capturing an error record it and start a new one
240245
if (runState.failedTest) {
241-
runState.recordIssue(testIndex, runState.failedTest.message, {
242-
file: runState.failedTest.file,
243-
line: runState.failedTest.lineNumber,
244-
});
246+
const location = this.sourceLocationToVSCodeLocation(
247+
runState.failedTest.file,
248+
runState.failedTest.lineNumber
249+
);
250+
runState.recordIssue(testIndex, runState.failedTest.message, location);
245251
runState.failedTest.complete = true;
246252
}
247253
runState.failedTest = {
@@ -269,10 +275,11 @@ export class XCTestOutputParser {
269275
) {
270276
if (testIndex !== -1) {
271277
if (runState.failedTest) {
272-
runState.recordIssue(testIndex, runState.failedTest.message, {
273-
file: runState.failedTest.file,
274-
line: runState.failedTest.lineNumber,
275-
});
278+
const location = this.sourceLocationToVSCodeLocation(
279+
runState.failedTest.file,
280+
runState.failedTest.lineNumber
281+
);
282+
runState.recordIssue(testIndex, runState.failedTest.message, location);
276283
} else {
277284
runState.recordIssue(testIndex, "Failed");
278285
}

0 commit comments

Comments
 (0)