Skip to content

Commit 18201e5

Browse files
authored
Add support for swift-testing (#775)
* Add support for swift-testing Support running swift-testing tests the same as XCTests. If a test run has both types, swift-testing tests will be run first followed by XCTests. First a list of XCTests and swift-testing tests to run is parsed from the test request. Test type is determined by a tag on each `vscode.TestItem[]`, either `"XCTest"` or `"swift-testing"`. swift-testing tests are launched by running the binary named <PackageName>PackageTests.swift-testing inside the build debug folder. This binary is run with the `--experimental-event-stream-output` flag which forwards test events (test started, complete, issue recorded, etc) to a named pipe. The `SwiftTestingOutputParser` watches this pipe for events as the tests are being run and translates them in to `ITestRunner` calls to record test progress in VSCode. There are different named pipe reader implementations between macOS/Linux and Windows.
1 parent 8f1c14a commit 18201e5

16 files changed

+1559
-522
lines changed

src/TestExplorer/TestDiscovery.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as vscode from "vscode";
1616
import { FolderContext } from "../FolderContext";
1717
import { TargetType } from "../SwiftPackage";
1818
import { LSPTestItem } from "../sourcekit-lsp/lspExtensions";
19+
import { reduceTestItemChildren } from "./TestUtils";
1920

2021
/** Test class definition */
2122
export interface TestClass extends Omit<Omit<LSPTestItem, "location">, "children"> {
@@ -76,7 +77,11 @@ export function updateTests(
7677
(!filterFile || testItem.uri?.fsPath === filterFile.fsPath)
7778
) {
7879
const collection = testItem.parent ? testItem.parent.children : testController.items;
79-
collection.delete(testItem.id);
80+
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+
collection.delete(testItem.id);
84+
}
8085
}
8186
}
8287

@@ -113,6 +118,24 @@ function createIncomingTestLookup(
113118
return dictionary;
114119
}
115120

121+
/**
122+
* Merges the TestItems recursively from the `existingItem` in to the `newItem`
123+
*/
124+
function deepMergeTestItemChildren(existingItem: vscode.TestItem, newItem: vscode.TestItem) {
125+
reduceTestItemChildren(
126+
existingItem.children,
127+
(collection, testItem: vscode.TestItem) => {
128+
const existing = collection.get(testItem.id);
129+
if (existing) {
130+
deepMergeTestItemChildren(existing, testItem);
131+
}
132+
collection.add(testItem);
133+
return collection;
134+
},
135+
newItem.children
136+
);
137+
}
138+
116139
/**
117140
* Updates the existing `vscode.TestItem` if it exists with the same ID as the `TestClass`,
118141
* otherwise creates an add a new one. The location on the returned vscode.TestItem is always updated.
@@ -122,12 +145,6 @@ function upsertTestItem(
122145
testItem: TestClass,
123146
parent?: vscode.TestItem
124147
) {
125-
// This is a temporary gate on adding swift-testing tests until there is code to
126-
// run them. See https://github.com/swift-server/vscode-swift/issues/757
127-
if (testItem.style === "swift-testing") {
128-
return;
129-
}
130-
131148
const collection = parent?.children ?? testController.items;
132149
const existingItem = collection.get(testItem.id);
133150
let newItem: vscode.TestItem;
@@ -148,11 +165,26 @@ function upsertTestItem(
148165
newItem = existingItem;
149166
}
150167

168+
// At this point all the test items that should have been deleted are out of the tree.
169+
// Its possible we're dropping a whole branch of test items on top of an existing one,
170+
// and we want to merge these branches instead of the new one replacing the existing one.
171+
if (existingItem) {
172+
deepMergeTestItemChildren(existingItem, newItem);
173+
}
174+
151175
// Manually add the test style as a tag so we can filter by test type.
152176
newItem.tags = [{ id: testItem.style }, ...testItem.tags];
153177
newItem.label = testItem.label;
154178
newItem.range = testItem.location?.range;
155179

180+
if (testItem.sortText) {
181+
newItem.sortText = testItem.sortText;
182+
} else if (!testItem.location) {
183+
// TestItems without a location should be sorted to the top.
184+
const zeros = ``.padStart(8, "0");
185+
newItem.sortText = `${zeros}:${testItem.label}`;
186+
}
187+
156188
// Performs an upsert based on whether a test item exists in the collection with the same id.
157189
// If no parent is provided operate on the testController's root items.
158190
collection.add(newItem);
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import * as readline from "readline";
2+
import { Readable } from "stream";
3+
import {
4+
INamedPipeReader,
5+
UnixNamedPipeReader,
6+
WindowsNamedPipeReader,
7+
} from "./TestEventStreamReader";
8+
import { ITestRunState } from "./TestRunState";
9+
10+
// All events produced by a swift-testing run will be one of these three types.
11+
export type SwiftTestEvent = MetadataRecord | TestRecord | EventRecord;
12+
13+
interface VersionedRecord {
14+
version: number;
15+
}
16+
17+
interface MetadataRecord extends VersionedRecord {
18+
kind: "metadata";
19+
payload: Metadata;
20+
}
21+
22+
interface TestRecord extends VersionedRecord {
23+
kind: "test";
24+
payload: Test;
25+
}
26+
27+
export type EventRecordPayload =
28+
| RunStarted
29+
| TestStarted
30+
| TestEnded
31+
| TestCaseStarted
32+
| TestCaseEnded
33+
| IssueRecorded
34+
| TestSkipped
35+
| RunEnded;
36+
37+
export interface EventRecord extends VersionedRecord {
38+
kind: "event";
39+
payload: EventRecordPayload;
40+
}
41+
42+
interface Metadata {
43+
[key: string]: object; // Currently unstructured content
44+
}
45+
46+
interface Test {
47+
kind: "suite" | "function" | "parameterizedFunction";
48+
id: string;
49+
name: string;
50+
_testCases?: TestCase[];
51+
sourceLocation: SourceLocation;
52+
}
53+
54+
interface TestCase {
55+
id: string;
56+
displayName: string;
57+
}
58+
59+
// Event types
60+
interface RunStarted {
61+
kind: "runStarted";
62+
}
63+
64+
interface RunEnded {
65+
kind: "runEnded";
66+
}
67+
68+
interface Instant {
69+
absolute: number;
70+
since1970: number;
71+
}
72+
73+
interface BaseEvent {
74+
instant: Instant;
75+
messages: EventMessage[];
76+
testID: string;
77+
}
78+
79+
interface TestStarted extends BaseEvent {
80+
kind: "testStarted";
81+
}
82+
83+
interface TestEnded extends BaseEvent {
84+
kind: "testEnded";
85+
}
86+
87+
interface TestCaseStarted extends BaseEvent {
88+
kind: "testCaseStarted";
89+
}
90+
91+
interface TestCaseEnded extends BaseEvent {
92+
kind: "testCaseEnded";
93+
}
94+
95+
interface TestSkipped extends BaseEvent {
96+
kind: "testSkipped";
97+
}
98+
99+
interface IssueRecorded extends BaseEvent {
100+
kind: "issueRecorded";
101+
issue: {
102+
sourceLocation: SourceLocation;
103+
};
104+
}
105+
106+
export interface EventMessage {
107+
text: string;
108+
}
109+
110+
export interface SourceLocation {
111+
_filePath: string;
112+
line: number;
113+
column: number;
114+
}
115+
116+
export class SwiftTestingOutputParser {
117+
private completionMap = new Map<number, boolean>();
118+
119+
/**
120+
* Watches for test events on the named pipe at the supplied path.
121+
* As events are read they are parsed and recorded in the test run state.
122+
*/
123+
public async watch(
124+
path: string,
125+
runState: ITestRunState,
126+
pipeReader?: INamedPipeReader
127+
): Promise<void> {
128+
// Creates a reader based on the platform unless being provided in a test context.
129+
const reader = pipeReader ?? this.createReader(path);
130+
const readlinePipe = new Readable({
131+
read() {},
132+
});
133+
134+
// Use readline to automatically chunk the data into lines,
135+
// and then take each line and parse it as JSON.
136+
const rl = readline.createInterface({
137+
input: readlinePipe,
138+
crlfDelay: Infinity,
139+
});
140+
141+
rl.on("line", line => this.parse(JSON.parse(line), runState));
142+
143+
reader.start(readlinePipe);
144+
}
145+
146+
private createReader(path: string): INamedPipeReader {
147+
return process.platform === "win32"
148+
? new WindowsNamedPipeReader(path)
149+
: new UnixNamedPipeReader(path);
150+
}
151+
152+
private testName(id: string): string {
153+
const nameMatcher = /^(.*\(.*\))\/(.*)\.swift:\d+:\d+$/;
154+
const matches = id.match(nameMatcher);
155+
return !matches ? id : matches[1];
156+
}
157+
158+
private parse(item: SwiftTestEvent, runState: ITestRunState) {
159+
if (item.kind === "event") {
160+
if (item.payload.kind === "testCaseStarted" || item.payload.kind === "testStarted") {
161+
const testName = this.testName(item.payload.testID);
162+
const testIndex = runState.getTestItemIndex(testName, undefined);
163+
runState.started(testIndex, item.payload.instant.absolute);
164+
} else if (item.payload.kind === "testSkipped") {
165+
const testName = this.testName(item.payload.testID);
166+
const testIndex = runState.getTestItemIndex(testName, undefined);
167+
runState.skipped(testIndex);
168+
} else if (item.payload.kind === "issueRecorded") {
169+
const testName = this.testName(item.payload.testID);
170+
const testIndex = runState.getTestItemIndex(testName, undefined);
171+
const sourceLocation = item.payload.issue.sourceLocation;
172+
item.payload.messages.forEach(message => {
173+
runState.recordIssue(testIndex, message.text, {
174+
file: sourceLocation._filePath,
175+
line: sourceLocation.line,
176+
column: sourceLocation.column,
177+
});
178+
});
179+
} else if (item.payload.kind === "testCaseEnded" || item.payload.kind === "testEnded") {
180+
const testName = this.testName(item.payload.testID);
181+
const testIndex = runState.getTestItemIndex(testName, undefined);
182+
183+
// When running a single test the testEnded and testCaseEnded events
184+
// have the same ID, and so we'd end the same test twice.
185+
if (this.completionMap.get(testIndex)) {
186+
return;
187+
}
188+
this.completionMap.set(testIndex, true);
189+
runState.completed(testIndex, { timestamp: item.payload.instant.absolute });
190+
}
191+
}
192+
}
193+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as fs from "fs";
2+
import * as net from "net";
3+
import { Readable } from "stream";
4+
5+
export interface INamedPipeReader {
6+
start(readable: Readable): Promise<void>;
7+
}
8+
9+
/**
10+
* Reads from a named pipe on Windows and forwards data to a `Readable` stream.
11+
* Note that the path must be in the Windows named pipe format of `\\.\pipe\pipename`.
12+
*/
13+
export class WindowsNamedPipeReader implements INamedPipeReader {
14+
constructor(private path: string) {}
15+
16+
public async start(readable: Readable) {
17+
return new Promise<void>((resolve, reject) => {
18+
try {
19+
const server = net.createServer(function (stream) {
20+
stream.on("data", data => readable.push(data));
21+
stream.on("error", () => server.close());
22+
stream.on("end", function () {
23+
readable.push(null);
24+
server.close();
25+
});
26+
});
27+
28+
server.listen(this.path, () => resolve());
29+
} catch (error) {
30+
reject(error);
31+
}
32+
});
33+
}
34+
}
35+
36+
/**
37+
* Reads from a unix FIFO pipe and forwards data to a `Readable` stream.
38+
* Note that the pipe at the supplied path should be created with `mkfifo`
39+
* before calling `start()`.
40+
*/
41+
export class UnixNamedPipeReader implements INamedPipeReader {
42+
constructor(private path: string) {}
43+
44+
public async start(readable: Readable) {
45+
return new Promise<void>((resolve, reject) => {
46+
fs.open(this.path, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, (err, fd) => {
47+
try {
48+
const pipe = new net.Socket({ fd, readable: true });
49+
pipe.on("data", data => readable.push(data));
50+
pipe.on("error", () => fs.close(fd));
51+
pipe.on("end", () => {
52+
readable.push(null);
53+
fs.close(fd);
54+
});
55+
56+
resolve();
57+
} catch (error) {
58+
reject(error);
59+
}
60+
});
61+
});
62+
}
63+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { MarkdownString } from "vscode";
2+
3+
/**
4+
* Interface for setting this test runs state
5+
*/
6+
export interface ITestRunState {
7+
// excess data from previous parse that was not processed
8+
excess?: string;
9+
// failed test state
10+
failedTest?: {
11+
testIndex: number;
12+
message: string;
13+
file: string;
14+
lineNumber: number;
15+
complete: boolean;
16+
};
17+
18+
// get test item index from test name on non Darwin platforms
19+
getTestItemIndex(id: string, filename: string | undefined): number;
20+
21+
// set test index to be started
22+
started(index: number, startTime?: number): void;
23+
24+
// set test index to have passed.
25+
// If a start time was provided to `started` then the duration is computed as endTime - startTime,
26+
// otherwise the time passed is assumed to be the duration.
27+
completed(index: number, timing: { duration: number } | { timestamp: number }): void;
28+
29+
// record an issue against a test
30+
recordIssue(
31+
index: number,
32+
message: string | MarkdownString,
33+
location?: { file: string; line: number; column?: number }
34+
): void;
35+
36+
// set test index to have been skipped
37+
skipped(index: number): void;
38+
39+
// started suite
40+
startedSuite(name: string): void;
41+
42+
// passed suite
43+
passedSuite(name: string): void;
44+
45+
// failed suite
46+
failedSuite(name: string): void;
47+
}

0 commit comments

Comments
 (0)