Skip to content

Commit

Permalink
Add support for swift-testing
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
plemarquand committed May 1, 2024
1 parent d57319c commit ca47432
Show file tree
Hide file tree
Showing 13 changed files with 1,106 additions and 405 deletions.
6 changes: 0 additions & 6 deletions src/TestExplorer/TestDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,6 @@ function upsertTestItem(
testItem: TestClass,
parent?: vscode.TestItem
) {
// This is a temporary gate on adding swift-testing tests until there is code to
// run them. See https://github.com/swift-server/vscode-swift/issues/757
if (testItem.style === "swift-testing") {
return;
}

const collection = parent?.children ?? testController.items;
const existingItem = collection.get(testItem.id);
let newItem: vscode.TestItem;
Expand Down
2 changes: 1 addition & 1 deletion src/TestExplorer/TestExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export class TestExplorer {
// If the LSP cannot produce a list of tests it throws and
// we fall back to discovering tests with SPM.
await this.discoverTestsInWorkspaceLSP();
} catch {
} catch (error) {
this.folderContext.workspaceContext.outputChannel.logDiagnostic(
"workspace/tests LSP request not supported, falling back to SPM to discover tests.",
"Test Discovery"
Expand Down
177 changes: 177 additions & 0 deletions src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import * as readline from "readline";
import { Readable } from "stream";
import {
INamedPipeReader,
UnixNamedPipeReader,
WindowsNamedPipeReader,
} from "./TestEventStreamReader";
import { ITestRunState } from "./TestRunState";

// All events produced by a swift-testing run will be one of these three types.
export type SwiftTestEvent = MetadataRecord | TestRecord | EventRecord;

interface VersionedRecord {
version: number;
}

interface MetadataRecord extends VersionedRecord {
kind: "metadata";
payload: Metadata;
}

interface TestRecord extends VersionedRecord {
kind: "test";
payload: Test;
}

export type EventRecordPayload =
| RunStarted
| TestStarted
| TestEnded
| TestCaseStarted
| TestCaseEnded
| IssueRecorded
| TestSkipped
| RunEnded;

export interface EventRecord extends VersionedRecord {
kind: "event";
payload: EventRecordPayload;
}

interface Metadata {
[key: string]: object; // Currently unstructured content
}

interface Test {
kind: "suite" | "function" | "parameterizedFunction";
id: string;
name: string;
testCases?: TestCase[];
sourceLocation: SourceLocation;
}

interface TestCase {
id: string;
displayName: string;
}

// Event types
interface RunStarted {
kind: "runStarted";
}

interface RunEnded {
kind: "runEnded";
}

interface BaseEvent {
timestamp: number;
message: EventMessage[];
testID: string;
}

interface TestStarted extends BaseEvent {
kind: "testStarted";
}

interface TestEnded extends BaseEvent {
kind: "testEnded";
}

interface TestCaseStarted extends BaseEvent {
kind: "testCaseStarted";
}

interface TestCaseEnded extends BaseEvent {
kind: "testCaseEnded";
}

interface TestSkipped extends BaseEvent {
kind: "testSkipped";
}

interface IssueRecorded extends BaseEvent {
kind: "issueRecorded";
sourceLocation: SourceLocation;
}

export interface EventMessage {
text: string;
}

export interface SourceLocation {
_filePath: string;
line: number;
column: number;
}

export class SwiftTestingOutputParser {
/**
* Watches for test events on the named pipe at the supplied path.
* As events are read they are parsed and recorded in the test run state.
*/
public async watch(
path: string,
runState: ITestRunState,
pipeReader?: INamedPipeReader
): Promise<void> {
// Creates a reader based on the platform unless being provided in a test context.
const reader = pipeReader ?? this.createReader(path);
const readlinePipe = new Readable({
read() {},
});

// Use readline to automatically chunk the data into lines,
// and then take each line and parse it as JSON.
const rl = readline.createInterface({
input: readlinePipe,
crlfDelay: Infinity,
});

rl.on("line", line => this.parse(JSON.parse(line), runState));

reader.start(readlinePipe);
}

private createReader(path: string): INamedPipeReader {
return process.platform === "win32"
? new WindowsNamedPipeReader(path)
: new UnixNamedPipeReader(path);
}

private testName(id: string): string {
const nameMatcher = /^(.*\(.*\))\/(.*)\.swift:\d+:\d+$/;
const matches = id.match(nameMatcher);
return !matches ? id : matches[1];
}

private parse(item: SwiftTestEvent, runState: ITestRunState) {
if (item.kind === "event") {
if (item.payload.kind === "testCaseStarted" || item.payload.kind === "testStarted") {
const testName = this.testName(item.payload.testID);
const testIndex = runState.getTestItemIndex(testName, undefined);
runState.started(testIndex, item.payload.timestamp);
} else if (item.payload.kind === "testSkipped") {
const testName = this.testName(item.payload.testID);
const testIndex = runState.getTestItemIndex(testName, undefined);
runState.skipped(testIndex);
} else if (item.payload.kind === "issueRecorded") {
const testName = this.testName(item.payload.testID);
const testIndex = runState.getTestItemIndex(testName, undefined);
const sourceLocation = item.payload.sourceLocation;
item.payload.message.forEach(message => {
runState.recordIssue(testIndex, message.text, {
file: sourceLocation._filePath,
line: sourceLocation.line,
column: sourceLocation.column,
});
});
} else if (item.payload.kind === "testCaseEnded" || item.payload.kind === "testEnded") {
const testName = this.testName(item.payload.testID);
const testIndex = runState.getTestItemIndex(testName, undefined);
runState.completed(testIndex, { timestamp: item.payload.timestamp });
}
}
}
}
63 changes: 63 additions & 0 deletions src/TestExplorer/TestParsers/TestEventStreamReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as fs from "fs";
import * as net from "net";
import { Readable } from "stream";

export interface INamedPipeReader {
start(readable: Readable): Promise<void>;
}

/**
* Reads from a named pipe on Windows and forwards data to a `Readable` stream.
* Note that the path must be in the Windows named pipe format of `\\.\pipe\pipename`.
*/
export class WindowsNamedPipeReader implements INamedPipeReader {
constructor(private path: string) {}

public async start(readable: Readable) {
return new Promise<void>((resolve, reject) => {
try {
const server = net.createServer(function (stream) {
stream.on("data", data => readable.push(data));
stream.on("error", () => server.close());
stream.on("end", function () {
readable.push(null);
server.close();
});
});

server.listen(this.path, () => resolve());
} catch (error) {
reject(error);
}
});
}
}

/**
* Reads from a unix FIFO pipe and forwards data to a `Readable` stream.
* Note that the pipe at the supplied path should be created with `mkfifo`
* before calling `start()`.
*/
export class UnixNamedPipeReader implements INamedPipeReader {
constructor(private path: string) {}

public async start(readable: Readable) {
return new Promise<void>((resolve, reject) => {
fs.open(this.path, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, (err, fd) => {
try {
const pipe = new net.Socket({ fd, readable: true });
pipe.on("data", data => readable.push(data));
pipe.on("error", () => fs.close(fd));
pipe.on("end", () => {
readable.push(null);
fs.close(fd);
});

resolve();
} catch (error) {
reject(error);
}
});
});
}
}
47 changes: 47 additions & 0 deletions src/TestExplorer/TestParsers/TestRunState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { MarkdownString } from "vscode";

/**
* Interface for setting this test runs state
*/
export interface ITestRunState {
// excess data from previous parse that was not processed
excess?: string;
// failed test state
failedTest?: {
testIndex: number;
message: string;
file: string;
lineNumber: number;
complete: boolean;
};

// get test item index from test name on non Darwin platforms
getTestItemIndex(id: string, filename: string | undefined): number;

// set test index to be started
started(index: number, startTime?: number): void;

// set test index to have passed.
// If a start time was provided to `started` then the duration is computed as endTime - startTime,
// otherwise the time passed is assumed to be the duration.
completed(index: number, timing: { duration: number } | { timestamp: number }): void;

// record an issue against a test
recordIssue(
index: number,
message: string | MarkdownString,
location?: { file: string; line: number; column?: number }
): void;

// set test index to have been skipped
skipped(index: number): void;

// started suite
startedSuite(name: string): void;

// passed suite
passedSuite(name: string): void;

// failed suite
failedSuite(name: string): void;
}
Loading

0 comments on commit ca47432

Please sign in to comment.