Skip to content

Commit bc46f22

Browse files
committed
Route swift-testing output to /dev/stdout, CONOUT$
Currently we are merging two streams of information to produce swift-testing test output, the JSON event stream written to a named pipe and the stream of data from stdout. This captures both testing events and user outputs produced by things like `print()` statements. However, this approach interleaves the two sources in an arbitrary order. Print statements produced during a test run are typically read after events on the named pipe, which makes it difficult to tell what prints belong to what test. In order to enforce the correct order we can configure swift-testing to write its events to /dev/stdout (or CONOUT$ on Windows). Swift-testing aquires a lock to the `fd` it is writing events and output to, so the order of events and prints is correctly enforced. This also simplifies a lot of the code that worked with the JSON event stream as we can now rely solely on the task's stdout/stderr output without the need for named pipes. When parsing stdout we try and parse a JSON event that matches the swift-testing event schema, and if we fail we print the line directly. If it is parseable, we process it as a swift-testing JSON event and omit it from the test run output.
1 parent ad15f0e commit bc46f22

13 files changed

+380
-451
lines changed

src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts

Lines changed: 47 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,11 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import * as vscode from "vscode";
16-
import * as readline from "readline";
17-
import { Readable } from "stream";
18-
import {
19-
INamedPipeReader,
20-
UnixNamedPipeReader,
21-
WindowsNamedPipeReader,
22-
} from "./TestEventStreamReader";
2316
import { ITestRunState } from "./TestRunState";
2417
import { TestClass } from "../TestDiscovery";
25-
import { regexEscapedString, sourceLocationToVSCodeLocation } from "../../utilities/utilities";
26-
import { exec } from "child_process";
18+
import { sourceLocationToVSCodeLocation } from "../../utilities/utilities";
19+
import { StringColor } from "../../utilities/ansi";
20+
import { ITestOutputParser } from "./XCTestOutputParser";
2721

2822
// All events produced by a swift-testing run will be one of these three types.
2923
// Detailed information about swift-testing's JSON schema is available here:
@@ -162,109 +156,57 @@ export interface SourceLocation {
162156
column: number;
163157
}
164158

165-
export class SwiftTestingOutputParser {
159+
export class SwiftTestingOutputParser implements ITestOutputParser {
160+
public logs: string[] = [];
161+
166162
private completionMap = new Map<number, boolean>();
167163
private testCaseMap = new Map<string, Map<string, TestCase>>();
168-
private path?: string;
164+
private preambleComplete = false;
169165

170166
constructor(
171167
public testRunStarted: () => void,
172168
public addParameterizedTestCase: (testClass: TestClass, parentIndex: number) => void
173169
) {}
174170

175171
/**
176-
* Watches for test events on the named pipe at the supplied path.
177-
* As events are read they are parsed and recorded in the test run state.
178-
*/
179-
public async watch(
180-
path: string,
181-
runState: ITestRunState,
182-
pipeReader?: INamedPipeReader
183-
): Promise<void> {
184-
this.path = path;
185-
186-
// Creates a reader based on the platform unless being provided in a test context.
187-
const reader = pipeReader ?? this.createReader(path);
188-
const readlinePipe = new Readable({
189-
read() {},
190-
});
191-
192-
// Use readline to automatically chunk the data into lines,
193-
// and then take each line and parse it as JSON.
194-
const rl = readline.createInterface({
195-
input: readlinePipe,
196-
crlfDelay: Infinity,
197-
});
198-
199-
rl.on("line", line => this.parse(JSON.parse(line), runState));
200-
201-
reader.start(readlinePipe);
202-
}
203-
204-
/**
205-
* Closes the FIFO pipe after a test run. This must be called at the
206-
* end of a run regardless of the run's success or failure.
172+
* Parse test run output looking for both raw output and JSON events.
173+
* @param output A chunk of stdout emitted during a test run.
174+
* @param runState The test run state to be updated by the output
175+
* @param logger A logging function to capture output not associated with a specific test.
207176
*/
208-
public async close() {
209-
if (!this.path) {
210-
return;
211-
}
212-
213-
await new Promise<void>(resolve => {
214-
exec(`echo '{}' > ${this.path}`, () => {
215-
resolve();
216-
});
217-
});
218-
}
219-
220-
/**
221-
* Parses stdout of a test run looking for lines that were not captured by
222-
* a JSON event and injecting them in to the test run output.
223-
* @param chunk A chunk of stdout emitted during a test run.
224-
*/
225-
public parseStdout = (() => {
226-
const values = [
227-
...Object.values(TestSymbol)
228-
.filter(symbol => symbol !== TestSymbol.none)
229-
.map(symbol =>
230-
regexEscapedString(
231-
// Trim the ANSI reset code from the search since some lines
232-
// are fully colorized from the symbol to the end of line.
233-
SymbolRenderer.eventMessageSymbol(symbol).replace(
234-
SymbolRenderer.resetANSIEscapeCode,
235-
""
236-
)
237-
)
238-
),
239-
// It is possible there is no symbol for a line produced by swift-testing,
240-
// for instance if the user has a multi line comment before a failing expectation
241-
// only the first line of the printed comment will have a symbol, but to make the
242-
// indentation consistent the subsequent lines will have three spaces. We don't want
243-
// to treat this as output produced by the user during the test run, so omit these.
244-
// This isn't ideal since this will swallow lines the user prints if they start with
245-
// three spaces, but until we have user output as part of the JSON event stream we have
246-
// this workaround.
247-
" ",
248-
];
249-
250-
// Build a regex of all the line beginnings that come out of swift-testing events.
251-
const isSwiftTestingLineBeginning = new RegExp(`^${values.join("|")}`);
252-
253-
return (chunk: string, runState: ITestRunState) => {
254-
for (const line of chunk.split("\n")) {
255-
// Any line in stdout that fails to match as a swift-testing line is treated
256-
// as a user printed value and recorded to the test run output with no associated test.
257-
if (line.trim().length > 0 && isSwiftTestingLineBeginning.test(line) === false) {
258-
runState.recordOutput(undefined, `${line}\r\n`);
177+
parseResult(output: string, runState: ITestRunState, logger: (output: string) => void): void {
178+
this.logs.push(output);
179+
180+
for (const line of output.split("\n")) {
181+
if (line.startsWith("{")) {
182+
try {
183+
// On Windows lines end will end with some ANSI characters, so
184+
// work around that by trying to parse from the start of the line
185+
// to the last '}' character.
186+
const closingBrace = line.lastIndexOf("}");
187+
if (closingBrace === -1) {
188+
continue;
189+
}
190+
191+
const maybeJSON = line.substring(0, closingBrace + 1);
192+
193+
// TODO: Validate against the event schema
194+
const event = JSON.parse(maybeJSON) as SwiftTestEvent;
195+
this.parse(event, runState);
196+
this.preambleComplete = true;
197+
continue;
198+
} catch {
199+
// Output wasn't valid JSON, continue and treat it like regular output
259200
}
260201
}
261-
};
262-
})();
263202

264-
private createReader(path: string): INamedPipeReader {
265-
return process.platform === "win32"
266-
? new WindowsNamedPipeReader(path)
267-
: new UnixNamedPipeReader(path);
203+
// Any line in stdout that fails to match as a swift-testing line is treated
204+
// as a user printed value and recorded to the test run output with no associated test.
205+
const trimmed = line.trim();
206+
if (this.preambleComplete && trimmed.length > 0) {
207+
logger(`${trimmed}\r\n`);
208+
}
209+
}
268210
}
269211

270212
private testName(id: string): string {
@@ -506,7 +448,7 @@ export class SwiftTestingOutputParser {
506448
return;
507449
}
508450

509-
this.recordOutput(runState, item.payload.messages, undefined);
451+
// this.recordOutput(runState, item.payload.messages, undefined);
510452
}
511453
}
512454
}
@@ -523,14 +465,12 @@ export class MessageRenderer {
523465
}
524466

525467
private static colorize(symbolType: TestSymbol, message: string): string {
526-
const ansiEscapeCodePrefix = "\u{001B}[";
527-
const resetANSIEscapeCode = `${ansiEscapeCodePrefix}0m`;
528468
switch (symbolType) {
529469
case TestSymbol.details:
530470
case TestSymbol.skip:
531471
case TestSymbol.difference:
532472
case TestSymbol.passWithKnownIssue:
533-
return `${ansiEscapeCodePrefix}90m${message}${resetANSIEscapeCode}`;
473+
return StringColor.default(message);
534474
default:
535475
return message;
536476
}
@@ -548,9 +488,6 @@ export class SymbolRenderer {
548488
return this.colorize(symbol, this.symbol(symbol));
549489
}
550490

551-
static ansiEscapeCodePrefix = "\u{001B}[";
552-
static resetANSIEscapeCode = `${SymbolRenderer.ansiEscapeCodePrefix}0m`;
553-
554491
// This is adapted from
555492
// https://github.com/apple/swift-testing/blob/786ade71421eb1d8a9c1d99c902cf1c93096e7df/Sources/Testing/Events/Recorder/Event.Symbol.swift#L102
556493
public static symbol(symbol: TestSymbol): string {
@@ -604,13 +541,13 @@ export class SymbolRenderer {
604541
case TestSymbol.skip:
605542
case TestSymbol.difference:
606543
case TestSymbol.passWithKnownIssue:
607-
return `${SymbolRenderer.ansiEscapeCodePrefix}90m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
544+
return StringColor.default(symbol);
608545
case TestSymbol.pass:
609-
return `${SymbolRenderer.ansiEscapeCodePrefix}92m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
546+
return StringColor.green(symbol);
610547
case TestSymbol.fail:
611-
return `${SymbolRenderer.ansiEscapeCodePrefix}91m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
548+
return StringColor.red(symbol);
612549
case TestSymbol.warning:
613-
return `${SymbolRenderer.ansiEscapeCodePrefix}93m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
550+
return StringColor.yellow(symbol);
614551
case TestSymbol.none:
615552
default:
616553
return symbol;

src/TestExplorer/TestParsers/TestEventStreamReader.ts

Lines changed: 0 additions & 90 deletions
This file was deleted.

src/TestExplorer/TestParsers/XCTestOutputParser.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,16 @@ export const nonDarwinTestRegex = {
6969
failedSuite: /^Test Suite '(.*)' failed/,
7070
};
7171

72-
export interface IXCTestOutputParser {
73-
parseResult(output: string, runState: ITestRunState): void;
72+
export interface ITestOutputParser {
73+
logs: string[];
74+
parseResult(output: string, runState: ITestRunState, logger: (output: string) => void): void;
7475
}
7576

76-
export class ParallelXCTestOutputParser implements IXCTestOutputParser {
77+
export class ParallelXCTestOutputParser implements ITestOutputParser {
7778
private outputParser: XCTestOutputParser;
7879

80+
public logs: string[] = [];
81+
7982
/**
8083
* Create an ParallelXCTestOutputParser.
8184
* Optional regex can be supplied for tests.
@@ -88,6 +91,8 @@ export class ParallelXCTestOutputParser implements IXCTestOutputParser {
8891
}
8992

9093
public parseResult(output: string, runState: ITestRunState) {
94+
this.logs = [output];
95+
9196
// From 5.7 to 5.10 running with the --parallel option dumps the test results out
9297
// to the console with no newlines, so it isn't possible to distinguish where errors
9398
// begin and end. Consequently we can't record them, and so we manually mark them
@@ -133,7 +138,9 @@ class ParallelXCTestRunStateProxy implements ITestRunState {
133138
}
134139
/* eslint-enable @typescript-eslint/no-unused-vars */
135140

136-
export class XCTestOutputParser implements IXCTestOutputParser {
141+
export class XCTestOutputParser implements ITestOutputParser {
142+
public logs: string[] = [];
143+
137144
private regex: TestRegex;
138145

139146
/**
@@ -149,6 +156,8 @@ export class XCTestOutputParser implements IXCTestOutputParser {
149156
* @param output Output from `swift test`
150157
*/
151158
public parseResult(output: string, runState: ITestRunState) {
159+
this.logs.push(output);
160+
152161
const output2 = output.replace(/\r\n/g, "\n");
153162
const lines = output2.split("\n");
154163
if (runState.excess) {

0 commit comments

Comments
 (0)