-
Notifications
You must be signed in to change notification settings - Fork 84
Add support for swift-testing #775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
plemarquand
merged 20 commits into
swiftlang:main
from
plemarquand:swift-testing-support
May 13, 2024
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
cd3fe01
Add support for swift-testing
plemarquand 6b24486
Rename JSON event .message -> .messages
plemarquand f2312b8
Cleanup named pipe after use, fixup Linux
plemarquand ee3de77
Refactor common launch configuration
plemarquand c449996
Capture minimal number of test arguments
plemarquand cba99a7
Merge test items on existing branches
plemarquand 4808a22
Dont trigger a workspace/tests LSP request after a test run
plemarquand 1df1a92
Fix test runs on test target with both types of test
plemarquand 7461343
Cleanup named pipe after standard sessions
plemarquand 8163432
Fix running a single test reporting its issues twice
plemarquand 586e200
Fixup test args tests by adding suites to a test target
plemarquand 1981538
Update to new JSON event format and flag
plemarquand fe83164
Address comments
plemarquand 8c708da
Update to changed event JSON format
plemarquand c269035
Cleanup unused code
plemarquand ebc6553
Fixup tests to use new event schema
plemarquand 087c80c
Make named pipe creation more clear
plemarquand 17e4e95
Use standard path when running parallel for swift-testing
plemarquand c927bc7
Simplify XCTestOutputParser
plemarquand de1397c
Cleanup named pipe when debugging
plemarquand File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
193 changes: 193 additions & 0 deletions
193
src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
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 Instant { | ||
absolute: number; | ||
since1970: number; | ||
} | ||
|
||
interface BaseEvent { | ||
instant: Instant; | ||
messages: 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"; | ||
issue: { | ||
sourceLocation: SourceLocation; | ||
}; | ||
} | ||
|
||
export interface EventMessage { | ||
text: string; | ||
} | ||
|
||
export interface SourceLocation { | ||
_filePath: string; | ||
line: number; | ||
column: number; | ||
} | ||
|
||
export class SwiftTestingOutputParser { | ||
private completionMap = new Map<number, boolean>(); | ||
|
||
/** | ||
* 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.instant.absolute); | ||
} 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.issue.sourceLocation; | ||
item.payload.messages.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); | ||
|
||
// When running a single test the testEnded and testCaseEnded events | ||
// have the same ID, and so we'd end the same test twice. | ||
if (this.completionMap.get(testIndex)) { | ||
return; | ||
} | ||
this.completionMap.set(testIndex, true); | ||
runState.completed(testIndex, { timestamp: item.payload.instant.absolute }); | ||
} | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}); | ||
}); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.