Skip to content

Commit 40f475d

Browse files
committed
Associate comment details on #expect w/ test issue
If a swift-testing #expect macro has preceding comments they are sent along with the expectation failure text as entries in the EventMessage array. Pull them out of the array so they don't appear as their own separate issues and append them to the actual issue text. Also fixes up some of the issue message colorization to be more consistent with how swift-testing behaves on the command line.
1 parent d801d41 commit 40f475d

File tree

3 files changed

+175
-35
lines changed

3 files changed

+175
-35
lines changed

Diff for: src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts

+120-18
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export enum TestSymbol {
148148
difference = "difference",
149149
warning = "warning",
150150
details = "details",
151+
none = "none",
151152
}
152153

153154
export interface EventMessage {
@@ -222,9 +223,27 @@ export class SwiftTestingOutputParser {
222223
* @param chunk A chunk of stdout emitted during a test run.
223224
*/
224225
public parseStdout = (() => {
225-
const values = Object.values(TestSymbol).map(symbol =>
226-
regexEscapedString(SymbolRenderer.eventMessageSymbol(symbol))
227-
);
226+
const values = [
227+
...Object.values(TestSymbol).map(symbol =>
228+
regexEscapedString(
229+
// Trim the ANSI reset code from the search since some lines
230+
// are fully colorized from the symbol to the end of line.
231+
SymbolRenderer.eventMessageSymbol(symbol).replace(
232+
SymbolRenderer.resetANSIEscapeCode,
233+
""
234+
)
235+
)
236+
),
237+
// It is possible there is no symbol for a line produced by swift-testing,
238+
// for instance if the user has a multi line comment before a failing expectation
239+
// only the first line of the printed comment will have a symbol, but to make the
240+
// indentation consistent the subsequent lines will have three spaces. We don't want
241+
// to treat this as output produced by the user during the test run, so omit these.
242+
// This isn't ideal since this will swallow lines the user prints if they start with
243+
// three spaces, but until we have user output as part of the JSON event stream we have
244+
// this workaround.
245+
" ",
246+
];
228247

229248
// Build a regex of all the line beginnings that come out of swift-testing events.
230249
const isSwiftTestingLineBeginning = new RegExp(`^${values.join("|")}`);
@@ -313,13 +332,47 @@ export class SwiftTestingOutputParser {
313332
testIndex: number | undefined
314333
) {
315334
messages.forEach(message => {
316-
runState.recordOutput(
317-
testIndex,
318-
`${SymbolRenderer.eventMessageSymbol(message.symbol)} ${message.text}\r\n`
319-
);
335+
runState.recordOutput(testIndex, `${MessageRenderer.render(message)}\r\n`);
320336
});
321337
}
322338

339+
/**
340+
* Partitions a collection of messages in to issues and details about the issues.
341+
* This is used to print the issues first, followed by the details.
342+
*/
343+
private partitionIssueMessages(messages: EventMessage[]): {
344+
issues: EventMessage[];
345+
details: EventMessage[];
346+
} {
347+
return messages.reduce(
348+
(buckets, message) => {
349+
const key =
350+
message.symbol === "details" ||
351+
message.symbol === "default" ||
352+
message.symbol === "none"
353+
? "details"
354+
: "issues";
355+
return { ...buckets, [key]: [...buckets[key], message] };
356+
},
357+
{
358+
issues: [],
359+
details: [],
360+
}
361+
);
362+
}
363+
364+
/*
365+
* A multi line comment preceeding an issue will have a 'default' symbol for
366+
* all lines except the first one. To match the swift-testing command line we
367+
* should show no symbol on these lines.
368+
*/
369+
private transformIssueMessageSymbols(messages: EventMessage[]): EventMessage[] {
370+
return messages.map(message => ({
371+
...message,
372+
symbol: message.symbol === "default" ? TestSymbol.none : message.symbol,
373+
}));
374+
}
375+
323376
private parse(item: SwiftTestEvent, runState: ITestRunState) {
324377
if (
325378
item.kind === "test" &&
@@ -391,14 +444,31 @@ export class SwiftTestingOutputParser {
391444
sourceLocation.line,
392445
sourceLocation.column
393446
);
394-
item.payload.messages.forEach(message => {
395-
runState.recordIssue(testIndex, message.text, isKnown, location);
447+
448+
const messages = this.transformIssueMessageSymbols(item.payload.messages);
449+
const { issues, details } = this.partitionIssueMessages(messages);
450+
451+
// Order the details after the issue text.
452+
const additionalDetails = details
453+
.map(message => MessageRenderer.render(message))
454+
.join("\n");
455+
456+
issues.forEach(message => {
457+
runState.recordIssue(
458+
testIndex,
459+
additionalDetails.length > 0
460+
? `${MessageRenderer.render(message)}\n${additionalDetails}`
461+
: MessageRenderer.render(message),
462+
isKnown,
463+
location
464+
);
396465
});
397-
this.recordOutput(runState, item.payload.messages, testIndex);
466+
467+
this.recordOutput(runState, messages, testIndex);
398468

399469
if (item.payload._testCase && testID !== item.payload.testID) {
400470
const testIndex = this.getTestCaseIndex(runState, item.payload.testID);
401-
item.payload.messages.forEach(message => {
471+
messages.forEach(message => {
402472
runState.recordIssue(testIndex, message.text, isKnown, location);
403473
});
404474
}
@@ -439,6 +509,32 @@ export class SwiftTestingOutputParser {
439509
}
440510
}
441511

512+
export class MessageRenderer {
513+
/**
514+
* Converts a swift-testing `EventMessage` to a colorized symbol and message text.
515+
*
516+
* @param message An event message, typically found on an `EventRecordPayload`.
517+
* @returns A string colorized with ANSI escape codes.
518+
*/
519+
static render(message: EventMessage): string {
520+
return `${SymbolRenderer.eventMessageSymbol(message.symbol)} ${MessageRenderer.colorize(message.symbol, message.text)}`;
521+
}
522+
523+
private static colorize(symbolType: TestSymbol, message: string): string {
524+
const ansiEscapeCodePrefix = "\u{001B}[";
525+
const resetANSIEscapeCode = `${ansiEscapeCodePrefix}0m`;
526+
switch (symbolType) {
527+
case TestSymbol.details:
528+
case TestSymbol.skip:
529+
case TestSymbol.difference:
530+
case TestSymbol.passWithKnownIssue:
531+
return `${ansiEscapeCodePrefix}90m${message}${resetANSIEscapeCode}`;
532+
default:
533+
return message;
534+
}
535+
}
536+
}
537+
442538
export class SymbolRenderer {
443539
/**
444540
* Converts a swift-testing symbol identifier in to a colorized unicode symbol.
@@ -450,6 +546,9 @@ export class SymbolRenderer {
450546
return this.colorize(symbol, this.symbol(symbol));
451547
}
452548

549+
static ansiEscapeCodePrefix = "\u{001B}[";
550+
static resetANSIEscapeCode = `${SymbolRenderer.ansiEscapeCodePrefix}0m`;
551+
453552
// This is adapted from
454553
// https://github.com/apple/swift-testing/blob/786ade71421eb1d8a9c1d99c902cf1c93096e7df/Sources/Testing/Events/Recorder/Event.Symbol.swift#L102
455554
public static symbol(symbol: TestSymbol): string {
@@ -469,6 +568,8 @@ export class SymbolRenderer {
469568
return "\u{25B2}"; // Unicode: BLACK UP-POINTING TRIANGLE
470569
case TestSymbol.details:
471570
return "\u{2192}"; // Unicode: RIGHTWARDS ARROW
571+
case TestSymbol.none:
572+
return "";
472573
}
473574
} else {
474575
switch (symbol) {
@@ -486,28 +587,29 @@ export class SymbolRenderer {
486587
return "\u{26A0}\u{FE0E}"; // Unicode: WARNING SIGN + VARIATION SELECTOR-15 (disable emoji)
487588
case TestSymbol.details:
488589
return "\u{21B3}"; // Unicode: DOWNWARDS ARROW WITH TIP RIGHTWARDS
590+
case TestSymbol.none:
591+
return " ";
489592
}
490593
}
491594
}
492595

493596
// This is adapted from
494597
// https://github.com/apple/swift-testing/blob/786ade71421eb1d8a9c1d99c902cf1c93096e7df/Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift#L164
495598
private static colorize(symbolType: TestSymbol, symbol: string): string {
496-
const ansiEscapeCodePrefix = "\u{001B}[";
497-
const resetANSIEscapeCode = `${ansiEscapeCodePrefix}0m`;
498599
switch (symbolType) {
499600
case TestSymbol.default:
601+
case TestSymbol.details:
500602
case TestSymbol.skip:
501603
case TestSymbol.difference:
502604
case TestSymbol.passWithKnownIssue:
503-
return `${ansiEscapeCodePrefix}90m${symbol}${resetANSIEscapeCode}`;
605+
return `${SymbolRenderer.ansiEscapeCodePrefix}90m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
504606
case TestSymbol.pass:
505-
return `${ansiEscapeCodePrefix}92m${symbol}${resetANSIEscapeCode}`;
607+
return `${SymbolRenderer.ansiEscapeCodePrefix}92m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
506608
case TestSymbol.fail:
507-
return `${ansiEscapeCodePrefix}91m${symbol}${resetANSIEscapeCode}`;
609+
return `${SymbolRenderer.ansiEscapeCodePrefix}91m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
508610
case TestSymbol.warning:
509-
return `${ansiEscapeCodePrefix}93m${symbol}${resetANSIEscapeCode}`;
510-
case TestSymbol.details:
611+
return `${SymbolRenderer.ansiEscapeCodePrefix}93m${symbol}${SymbolRenderer.resetANSIEscapeCode}`;
612+
case TestSymbol.none:
511613
default:
512614
return symbol;
513615
}

Diff for: test/suite/testexplorer/SwiftTestingOutputParser.test.ts

+23-11
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
SourceLocation,
2525
TestSymbol,
2626
SymbolRenderer,
27+
MessageRenderer,
2728
} from "../../../src/TestExplorer/TestParsers/SwiftTestingOutputParser";
2829
import { TestRunState, TestStatus } from "./MockTestRunState";
2930
import { Readable } from "stream";
@@ -114,7 +115,7 @@ suite("SwiftTestingOutputParser Suite", () => {
114115
]);
115116
});
116117

117-
test("Failed test with one issue", async () => {
118+
async function performTestFailure(messages: EventMessage[]) {
118119
const testRunState = new TestRunState(["MyTests.MyTests/testFail()"], true);
119120
const issueLocation = {
120121
_filePath: "file:///some/file.swift",
@@ -124,25 +125,23 @@ suite("SwiftTestingOutputParser Suite", () => {
124125
const events = new TestEventStream([
125126
testEvent("runStarted"),
126127
testEvent("testCaseStarted", "MyTests.MyTests/testFail()"),
127-
testEvent(
128-
"issueRecorded",
129-
"MyTests.MyTests/testFail()",
130-
[{ text: "Expectation failed: bar == foo", symbol: TestSymbol.fail }],
131-
issueLocation
132-
),
128+
testEvent("issueRecorded", "MyTests.MyTests/testFail()", messages, issueLocation),
133129
testEvent("testCaseEnded", "MyTests.MyTests/testFail()"),
134130
testEvent("runEnded"),
135131
]);
136132

137133
await outputParser.watch("file:///mock/named/pipe", testRunState, events);
138134

135+
const renderedMessages = messages.map(message => MessageRenderer.render(message));
136+
const fullFailureMessage = renderedMessages.join("\n");
137+
139138
assert.deepEqual(testRunState.tests, [
140139
{
141140
name: "MyTests.MyTests/testFail()",
142141
status: TestStatus.failed,
143142
issues: [
144143
{
145-
message: "Expectation failed: bar == foo",
144+
message: fullFailureMessage,
146145
location: new vscode.Location(
147146
vscode.Uri.file(issueLocation._filePath),
148147
new vscode.Position(issueLocation.line - 1, issueLocation?.column ?? 0)
@@ -154,11 +153,24 @@ suite("SwiftTestingOutputParser Suite", () => {
154153
timing: {
155154
timestamp: 0,
156155
},
157-
output: [
158-
`\u001b[91m${SymbolRenderer.symbol(TestSymbol.fail)}\u001b[0m Expectation failed: bar == foo\r\n`,
159-
],
156+
output: renderedMessages.map(message => `${message}\r\n`),
160157
},
161158
]);
159+
}
160+
161+
test("Failed with an issue that has a comment", async () => {
162+
await performTestFailure([
163+
{ text: "Expectation failed: bar == foo", symbol: TestSymbol.fail },
164+
{ symbol: TestSymbol.details, text: "// One" },
165+
{ symbol: TestSymbol.details, text: "// Two" },
166+
{ symbol: TestSymbol.details, text: "// Three" },
167+
]);
168+
});
169+
170+
test("Failed test with one issue", async () => {
171+
await performTestFailure([
172+
{ text: "Expectation failed: bar == foo", symbol: TestSymbol.fail },
173+
]);
162174
});
163175

164176
test("Parameterized test", async () => {

Diff for: test/suite/testexplorer/TestExplorerIntegration.test.ts

+32-6
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import { TestRunProxy } from "../../../src/TestExplorer/TestRunner";
3131
import { Version } from "../../../src/utilities/version";
3232
import { TestKind } from "../../../src/TestExplorer/TestKind";
3333
import { mockNamespace } from "../../unit-tests/MockUtils";
34+
import {
35+
MessageRenderer,
36+
TestSymbol,
37+
} from "../../../src/TestExplorer/TestParsers/SwiftTestingOutputParser";
3438

3539
suite("Test Explorer Suite", function () {
3640
const MAX_TEST_RUN_TIME_MINUTES = 5;
@@ -196,7 +200,12 @@ suite("Test Explorer Suite", function () {
196200
failed: [
197201
{
198202
test: "PackageTests.testWithKnownIssueAndUnknownIssue()",
199-
issues: ["Expectation failed: 2 == 3"],
203+
issues: [
204+
MessageRenderer.render({
205+
symbol: TestSymbol.fail,
206+
text: "Expectation failed: 2 == 3",
207+
}),
208+
],
200209
},
201210
],
202211
});
@@ -387,7 +396,12 @@ suite("Test Explorer Suite", function () {
387396
failed: [
388397
{
389398
test: "PackageTests.topLevelTestFailing()",
390-
issues: ["Expectation failed: 1 == 2"],
399+
issues: [
400+
MessageRenderer.render({
401+
symbol: TestSymbol.fail,
402+
text: "Expectation failed: 1 == 2",
403+
}),
404+
],
391405
},
392406
],
393407
});
@@ -399,13 +413,16 @@ suite("Test Explorer Suite", function () {
399413
runProfile,
400414
"PackageTests.MixedSwiftTestingSuite"
401415
);
416+
402417
assertTestResults(testRun, {
403418
passed: ["PackageTests.MixedSwiftTestingSuite/testPassing()"],
404419
skipped: ["PackageTests.MixedSwiftTestingSuite/testDisabled()"],
405420
failed: [
406421
{
407422
test: "PackageTests.MixedSwiftTestingSuite/testFailing()",
408-
issues: ["testFailing() \u{203A} Expectation failed: 1 == 2"],
423+
issues: [
424+
`testFailing() \u{203A} ${MessageRenderer.render({ symbol: TestSymbol.fail, text: "Expectation failed: 1 == 2" })}`,
425+
],
409426
},
410427
{
411428
issues: [],
@@ -429,7 +446,12 @@ suite("Test Explorer Suite", function () {
429446
],
430447
failed: [
431448
{
432-
issues: ["2 \u{203A} Expectation failed: (arg → 2) != 2"],
449+
issues: [
450+
`2 \u{203A} ${MessageRenderer.render({
451+
symbol: TestSymbol.fail,
452+
text: "Expectation failed: (arg → 2) != 2",
453+
})}`,
454+
],
433455
test: "PackageTests.parameterizedTest(_:)/PackageTests.swift:51:2/argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [50])])",
434456
},
435457
{
@@ -453,7 +475,9 @@ suite("Test Explorer Suite", function () {
453475
failed: [
454476
{
455477
test: "PackageTests.MixedSwiftTestingSuite/testFailing()",
456-
issues: ["testFailing() \u{203A} Expectation failed: 1 == 2"],
478+
issues: [
479+
`testFailing() \u{203A} ${MessageRenderer.render({ symbol: TestSymbol.fail, text: "Expectation failed: 1 == 2" })}`,
480+
],
457481
},
458482
{
459483
issues: [],
@@ -480,7 +504,9 @@ suite("Test Explorer Suite", function () {
480504
failed: [
481505
{
482506
test: "PackageTests.MixedSwiftTestingSuite/testFailing()",
483-
issues: ["testFailing() \u{203A} Expectation failed: 1 == 2"],
507+
issues: [
508+
`testFailing() \u{203A} ${MessageRenderer.render({ symbol: TestSymbol.fail, text: "Expectation failed: 1 == 2" })}`,
509+
],
484510
},
485511
{
486512
issues: [],

0 commit comments

Comments
 (0)