Skip to content

Commit ca47432

Browse files
committed
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. TODO: Coverage on swift-testing tests is not supported until swiftlang/swift-package-manager#7518 is available.
1 parent d57319c commit ca47432

File tree

13 files changed

+1106
-405
lines changed

13 files changed

+1106
-405
lines changed

src/TestExplorer/TestDiscovery.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,6 @@ function upsertTestItem(
122122
testItem: TestClass,
123123
parent?: vscode.TestItem
124124
) {
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-
131125
const collection = parent?.children ?? testController.items;
132126
const existingItem = collection.get(testItem.id);
133127
let newItem: vscode.TestItem;

src/TestExplorer/TestExplorer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export class TestExplorer {
183183
// If the LSP cannot produce a list of tests it throws and
184184
// we fall back to discovering tests with SPM.
185185
await this.discoverTestsInWorkspaceLSP();
186-
} catch {
186+
} catch (error) {
187187
this.folderContext.workspaceContext.outputChannel.logDiagnostic(
188188
"workspace/tests LSP request not supported, falling back to SPM to discover tests.",
189189
"Test Discovery"
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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 BaseEvent {
69+
timestamp: number;
70+
message: EventMessage[];
71+
testID: string;
72+
}
73+
74+
interface TestStarted extends BaseEvent {
75+
kind: "testStarted";
76+
}
77+
78+
interface TestEnded extends BaseEvent {
79+
kind: "testEnded";
80+
}
81+
82+
interface TestCaseStarted extends BaseEvent {
83+
kind: "testCaseStarted";
84+
}
85+
86+
interface TestCaseEnded extends BaseEvent {
87+
kind: "testCaseEnded";
88+
}
89+
90+
interface TestSkipped extends BaseEvent {
91+
kind: "testSkipped";
92+
}
93+
94+
interface IssueRecorded extends BaseEvent {
95+
kind: "issueRecorded";
96+
sourceLocation: SourceLocation;
97+
}
98+
99+
export interface EventMessage {
100+
text: string;
101+
}
102+
103+
export interface SourceLocation {
104+
_filePath: string;
105+
line: number;
106+
column: number;
107+
}
108+
109+
export class SwiftTestingOutputParser {
110+
/**
111+
* Watches for test events on the named pipe at the supplied path.
112+
* As events are read they are parsed and recorded in the test run state.
113+
*/
114+
public async watch(
115+
path: string,
116+
runState: ITestRunState,
117+
pipeReader?: INamedPipeReader
118+
): Promise<void> {
119+
// Creates a reader based on the platform unless being provided in a test context.
120+
const reader = pipeReader ?? this.createReader(path);
121+
const readlinePipe = new Readable({
122+
read() {},
123+
});
124+
125+
// Use readline to automatically chunk the data into lines,
126+
// and then take each line and parse it as JSON.
127+
const rl = readline.createInterface({
128+
input: readlinePipe,
129+
crlfDelay: Infinity,
130+
});
131+
132+
rl.on("line", line => this.parse(JSON.parse(line), runState));
133+
134+
reader.start(readlinePipe);
135+
}
136+
137+
private createReader(path: string): INamedPipeReader {
138+
return process.platform === "win32"
139+
? new WindowsNamedPipeReader(path)
140+
: new UnixNamedPipeReader(path);
141+
}
142+
143+
private testName(id: string): string {
144+
const nameMatcher = /^(.*\(.*\))\/(.*)\.swift:\d+:\d+$/;
145+
const matches = id.match(nameMatcher);
146+
return !matches ? id : matches[1];
147+
}
148+
149+
private parse(item: SwiftTestEvent, runState: ITestRunState) {
150+
if (item.kind === "event") {
151+
if (item.payload.kind === "testCaseStarted" || item.payload.kind === "testStarted") {
152+
const testName = this.testName(item.payload.testID);
153+
const testIndex = runState.getTestItemIndex(testName, undefined);
154+
runState.started(testIndex, item.payload.timestamp);
155+
} else if (item.payload.kind === "testSkipped") {
156+
const testName = this.testName(item.payload.testID);
157+
const testIndex = runState.getTestItemIndex(testName, undefined);
158+
runState.skipped(testIndex);
159+
} else if (item.payload.kind === "issueRecorded") {
160+
const testName = this.testName(item.payload.testID);
161+
const testIndex = runState.getTestItemIndex(testName, undefined);
162+
const sourceLocation = item.payload.sourceLocation;
163+
item.payload.message.forEach(message => {
164+
runState.recordIssue(testIndex, message.text, {
165+
file: sourceLocation._filePath,
166+
line: sourceLocation.line,
167+
column: sourceLocation.column,
168+
});
169+
});
170+
} else if (item.payload.kind === "testCaseEnded" || item.payload.kind === "testEnded") {
171+
const testName = this.testName(item.payload.testID);
172+
const testIndex = runState.getTestItemIndex(testName, undefined);
173+
runState.completed(testIndex, { timestamp: item.payload.timestamp });
174+
}
175+
}
176+
}
177+
}
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)