Skip to content

Commit 585cb59

Browse files
authored
Support parameterized swift-testing tests (#797)
* 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 f7d0f4b commit 585cb59

12 files changed

+612
-126
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: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface TestClass extends Omit<Omit<LSPTestItem, "location">, "children
2424
children: TestClass[];
2525
}
2626

27+
export const runnableTag = new vscode.TestTag("runnable");
28+
2729
/**
2830
* Update Test Controller TestItems based off array of TestClasses.
2931
*
@@ -78,8 +80,10 @@ export function updateTests(
7880
) {
7981
const collection = testItem.parent ? testItem.parent.children : testController.items;
8082

81-
// TODO: This needs to take in to account parameterized tests with no URI, when they're added.
82-
if (testItem.children.size === 0) {
83+
if (
84+
testItem.children.size === 0 ||
85+
testItemHasParameterizedTestResultChildren(testItem)
86+
) {
8387
collection.delete(testItem.id);
8488
}
8589
}
@@ -96,6 +100,21 @@ export function updateTests(
96100
});
97101
}
98102

103+
/**
104+
* Returns true if all children have no URI.
105+
* This indicates the test item is parameterized and the children are the results.
106+
*/
107+
function testItemHasParameterizedTestResultChildren(testItem: vscode.TestItem) {
108+
return (
109+
testItem.children.size > 0 &&
110+
reduceTestItemChildren(
111+
testItem.children,
112+
(acc, child) => acc || child.uri !== undefined,
113+
false
114+
) === false
115+
);
116+
}
117+
99118
/**
100119
* Create a lookup of the incoming tests we can compare to the existing list of tests
101120
* to produce a list of tests that are no longer present. If a filterFile is specified we
@@ -140,11 +159,11 @@ function deepMergeTestItemChildren(existingItem: vscode.TestItem, newItem: vscod
140159
* Updates the existing `vscode.TestItem` if it exists with the same ID as the `TestClass`,
141160
* otherwise creates an add a new one. The location on the returned vscode.TestItem is always updated.
142161
*/
143-
function upsertTestItem(
162+
export function upsertTestItem(
144163
testController: vscode.TestController,
145164
testItem: TestClass,
146165
parent?: vscode.TestItem
147-
) {
166+
): vscode.TestItem {
148167
const collection = parent?.children ?? testController.items;
149168
const existingItem = collection.get(testItem.id);
150169
let newItem: vscode.TestItem;
@@ -161,6 +180,15 @@ function upsertTestItem(
161180
testItem.label,
162181
testItem.location?.uri
163182
);
183+
184+
// We want to keep existing children if they exist.
185+
if (existingItem) {
186+
const existingChildren: vscode.TestItem[] = [];
187+
existingItem.children.forEach(child => {
188+
existingChildren.push(child);
189+
});
190+
newItem.children.replace(existingChildren);
191+
}
164192
} else {
165193
newItem = existingItem;
166194
}
@@ -174,6 +202,11 @@ function upsertTestItem(
174202

175203
// Manually add the test style as a tag so we can filter by test type.
176204
newItem.tags = [{ id: testItem.style }, ...testItem.tags];
205+
206+
if (testItem.disabled === false) {
207+
newItem.tags = [...newItem.tags, runnableTag];
208+
}
209+
177210
newItem.label = testItem.label;
178211
newItem.range = testItem.location?.range;
179212

@@ -192,4 +225,6 @@ function upsertTestItem(
192225
testItem.children.forEach(child => {
193226
upsertTestItem(testController, child, newItem);
194227
});
228+
229+
return newItem;
195230
}

src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts

Lines changed: 157 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,8 +7,12 @@ import {
67
WindowsNamedPipeReader,
78
} from "./TestEventStreamReader";
89
import { ITestRunState } from "./TestRunState";
10+
import { TestClass } from "../TestDiscovery";
11+
import { sourceLocationToVSCodeLocation } from "../../utilities/utilities";
912

1013
// All events produced by a swift-testing run will be one of these three types.
14+
// Detailed information about swift-testing's JSON schema is available here:
15+
// https://github.com/apple/swift-testing/blob/main/Documentation/ABI/JSON.md
1116
export type SwiftTestEvent = MetadataRecord | TestRecord | EventRecord;
1217

1318
interface VersionedRecord {
@@ -21,7 +26,7 @@ interface MetadataRecord extends VersionedRecord {
2126

2227
interface TestRecord extends VersionedRecord {
2328
kind: "test";
24-
payload: Test;
29+
payload: TestSuite | TestFunction;
2530
}
2631

2732
export type EventRecordPayload =
@@ -43,15 +48,23 @@ interface Metadata {
4348
[key: string]: object; // Currently unstructured content
4449
}
4550

46-
interface Test {
47-
kind: "suite" | "function" | "parameterizedFunction";
51+
interface TestBase {
4852
id: string;
4953
name: string;
5054
_testCases?: TestCase[];
5155
sourceLocation: SourceLocation;
5256
}
5357

54-
interface TestCase {
58+
interface TestSuite extends TestBase {
59+
kind: "suite";
60+
}
61+
62+
interface TestFunction extends TestBase {
63+
kind: "function";
64+
isParameterized: boolean;
65+
}
66+
67+
export interface TestCase {
5568
id: string;
5669
displayName: string;
5770
}
@@ -76,6 +89,11 @@ interface BaseEvent {
7689
testID: string;
7790
}
7891

92+
interface TestCaseEvent {
93+
sourceLocation: SourceLocation;
94+
_testCase: TestCase;
95+
}
96+
7997
interface TestStarted extends BaseEvent {
8098
kind: "testStarted";
8199
}
@@ -84,19 +102,19 @@ interface TestEnded extends BaseEvent {
84102
kind: "testEnded";
85103
}
86104

87-
interface TestCaseStarted extends BaseEvent {
105+
interface TestCaseStarted extends BaseEvent, TestCaseEvent {
88106
kind: "testCaseStarted";
89107
}
90108

91-
interface TestCaseEnded extends BaseEvent {
109+
interface TestCaseEnded extends BaseEvent, TestCaseEvent {
92110
kind: "testCaseEnded";
93111
}
94112

95113
interface TestSkipped extends BaseEvent {
96114
kind: "testSkipped";
97115
}
98116

99-
interface IssueRecorded extends BaseEvent {
117+
interface IssueRecorded extends BaseEvent, TestCaseEvent {
100118
kind: "issueRecorded";
101119
issue: {
102120
sourceLocation: SourceLocation;
@@ -115,6 +133,12 @@ export interface SourceLocation {
115133

116134
export class SwiftTestingOutputParser {
117135
private completionMap = new Map<number, boolean>();
136+
private testCaseMap = new Map<string, Map<string, TestCase>>();
137+
138+
constructor(
139+
public testRunStarted: () => void,
140+
public addParameterizedTestCase: (testClass: TestClass, parentIndex: number) => void
141+
) {}
118142

119143
/**
120144
* Watches for test events on the named pipe at the supplied path.
@@ -155,31 +179,147 @@ export class SwiftTestingOutputParser {
155179
return !matches ? id : matches[1];
156180
}
157181

182+
private testCaseId(testId: string, testCaseId: string): string {
183+
const testCase = this.testCaseMap.get(testId)?.get(testCaseId);
184+
return testCase ? `${testId}/${this.idFromTestCase(testCase)}` : testId;
185+
}
186+
187+
// Test cases do not have a unique ID if their arguments are not serializable
188+
// with Codable. If they aren't, their id appears as `argumentIDs: nil`, and we
189+
// fall back to using the testCase display name as the test case ID. This isn't
190+
// ideal because its possible to have multiple test cases with the same display name,
191+
// but until we have a better solution for identifying test cases it will have to do.
192+
// SEE: rdar://119522099.
193+
private idFromTestCase(testCase: TestCase): string {
194+
return testCase.id === "argumentIDs: nil" ? testCase.displayName : testCase.id;
195+
}
196+
197+
private parameterizedFunctionTestCaseToTestClass(
198+
testId: string,
199+
testCase: TestCase,
200+
location: vscode.Location,
201+
index: number
202+
): TestClass {
203+
return {
204+
id: this.testCaseId(testId, this.idFromTestCase(testCase)),
205+
label: testCase.displayName,
206+
tags: [],
207+
children: [],
208+
style: "swift-testing",
209+
location: location,
210+
disabled: true,
211+
sortText: `${index}`.padStart(8, "0"),
212+
};
213+
}
214+
215+
private buildTestCaseMapForParameterizedTest(record: TestRecord) {
216+
const map = new Map<string, TestCase>();
217+
(record.payload._testCases ?? []).forEach(testCase => {
218+
map.set(this.idFromTestCase(testCase), testCase);
219+
});
220+
this.testCaseMap.set(record.payload.id, map);
221+
}
222+
223+
private getTestCaseIndex(runState: ITestRunState, testID: string): number {
224+
const fullNameIndex = runState.getTestItemIndex(testID, undefined);
225+
if (fullNameIndex === -1) {
226+
return runState.getTestItemIndex(this.testName(testID), undefined);
227+
}
228+
return fullNameIndex;
229+
}
230+
158231
private parse(item: SwiftTestEvent, runState: ITestRunState) {
159-
if (item.kind === "event") {
160-
if (item.payload.kind === "testCaseStarted" || item.payload.kind === "testStarted") {
232+
if (
233+
item.kind === "test" &&
234+
item.payload.kind === "function" &&
235+
item.payload.isParameterized &&
236+
item.payload._testCases
237+
) {
238+
// Store a map of [Test ID, [Test Case ID, TestCase]] so we can quickly
239+
// map an event.payload.testID back to a test case.
240+
this.buildTestCaseMapForParameterizedTest(item);
241+
242+
const testName = this.testName(item.payload.id);
243+
const testIndex = runState.getTestItemIndex(testName, undefined);
244+
// If a test has test cases it is paramterized and we need to notify
245+
// the caller that the TestClass should be added to the vscode.TestRun
246+
// before it starts.
247+
item.payload._testCases
248+
.map((testCase, index) =>
249+
this.parameterizedFunctionTestCaseToTestClass(
250+
item.payload.id,
251+
testCase,
252+
sourceLocationToVSCodeLocation(
253+
item.payload.sourceLocation._filePath,
254+
item.payload.sourceLocation.line,
255+
item.payload.sourceLocation.column
256+
),
257+
index
258+
)
259+
)
260+
.flatMap(testClass => (testClass ? [testClass] : []))
261+
.forEach(testClass => this.addParameterizedTestCase(testClass, testIndex));
262+
} else if (item.kind === "event") {
263+
if (item.payload.kind === "runStarted") {
264+
// Notify the runner that we've recieved all the test cases and
265+
// are going to start running tests now.
266+
this.testRunStarted();
267+
} else if (item.payload.kind === "testStarted") {
161268
const testName = this.testName(item.payload.testID);
162269
const testIndex = runState.getTestItemIndex(testName, undefined);
163270
runState.started(testIndex, item.payload.instant.absolute);
271+
} else if (item.payload.kind === "testCaseStarted") {
272+
const testID = this.testCaseId(
273+
item.payload.testID,
274+
this.idFromTestCase(item.payload._testCase)
275+
);
276+
const testIndex = this.getTestCaseIndex(runState, testID);
277+
runState.started(testIndex, item.payload.instant.absolute);
164278
} else if (item.payload.kind === "testSkipped") {
165279
const testName = this.testName(item.payload.testID);
166280
const testIndex = runState.getTestItemIndex(testName, undefined);
167281
runState.skipped(testIndex);
168282
} else if (item.payload.kind === "issueRecorded") {
169-
const testName = this.testName(item.payload.testID);
170-
const testIndex = runState.getTestItemIndex(testName, undefined);
283+
const testID = this.testCaseId(
284+
item.payload.testID,
285+
this.idFromTestCase(item.payload._testCase)
286+
);
287+
const testIndex = this.getTestCaseIndex(runState, testID);
171288
const sourceLocation = item.payload.issue.sourceLocation;
289+
const location = sourceLocationToVSCodeLocation(
290+
sourceLocation._filePath,
291+
sourceLocation.line,
292+
sourceLocation.column
293+
);
172294
item.payload.messages.forEach(message => {
173-
runState.recordIssue(testIndex, message.text, {
174-
file: sourceLocation._filePath,
175-
line: sourceLocation.line,
176-
column: sourceLocation.column,
177-
});
295+
runState.recordIssue(testIndex, message.text, location);
178296
});
179-
} else if (item.payload.kind === "testCaseEnded" || item.payload.kind === "testEnded") {
297+
298+
if (testID !== item.payload.testID) {
299+
// const testName = this.testName(item.payload.testID);
300+
const testIndex = this.getTestCaseIndex(runState, item.payload.testID);
301+
item.payload.messages.forEach(message => {
302+
runState.recordIssue(testIndex, message.text, location);
303+
});
304+
}
305+
} else if (item.payload.kind === "testEnded") {
180306
const testName = this.testName(item.payload.testID);
181307
const testIndex = runState.getTestItemIndex(testName, undefined);
182308

309+
// When running a single test the testEnded and testCaseEnded events
310+
// have the same ID, and so we'd end the same test twice.
311+
if (this.completionMap.get(testIndex)) {
312+
return;
313+
}
314+
this.completionMap.set(testIndex, true);
315+
runState.completed(testIndex, { timestamp: item.payload.instant.absolute });
316+
} else if (item.payload.kind === "testCaseEnded") {
317+
const testID = this.testCaseId(
318+
item.payload.testID,
319+
this.idFromTestCase(item.payload._testCase)
320+
);
321+
const testIndex = this.getTestCaseIndex(runState, testID);
322+
183323
// When running a single test the testEnded and testCaseEnded events
184324
// have the same ID, and so we'd end the same test twice.
185325
if (this.completionMap.get(testIndex)) {

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

0 commit comments

Comments
 (0)