Skip to content

Commit eaabdf1

Browse files
authored
Fix missing Test Result output on Linux when using print (#1401)
node-pty on Linux suffers from a long standing issue where the last chunk of output before a program exits is sometimes dropped, especially if that program produces a lot of output immediately before exiting. See microsoft/node-pty#72 For swift processes that don't require input and may produce a lot of output that could potentially be truncated we can add a new `ReadOnlySwiftProcess` that spawns a child process using node's built in `child_process`. These spawned child processes emit their full output before exiting without the need to resort to flakey workarounds on the node-pty process. When creating new `SwiftExecution`s in future `readOnlyTerminal` should be set to `true` unless the process may require user input. Issue: #1393
1 parent da1d5fb commit eaabdf1

File tree

9 files changed

+157
-10
lines changed

9 files changed

+157
-10
lines changed

assets/test/defaultPackage/Tests/PackageTests/PackageTests.swift

+8
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ struct MixedSwiftTestingSuite {
102102
}
103103
#expect(2 == 3)
104104
}
105+
106+
@Test func testLotsOfOutput() {
107+
var string = ""
108+
for i in 1...100_000 {
109+
string += "\(i)\n"
110+
}
111+
print(string)
112+
}
105113
#endif
106114

107115
#if swift(>=6.1)

src/TestExplorer/TestRunArguments.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ export class TestRunArguments {
9393
const terminator = hasChildren ? "/" : "$";
9494
// Debugging XCTests requires exact matches, so we don't need a trailing terminator.
9595
return isDebug ? arg.id : `${arg.id}${terminator}`;
96-
} else {
96+
} else if (hasChildren) {
9797
// Append a trailing slash to match a suite name exactly.
9898
// This prevents TestTarget.MySuite matching TestTarget.MySuite2.
9999
return `${arg.id}/`;
100100
}
101+
return arg.id;
101102
});
102103
}
103104

src/TestExplorer/TestRunner.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,8 @@ export class TestRunner {
715715
presentationOptions: { reveal: vscode.TaskRevealKind.Never },
716716
},
717717
this.folderContext.workspaceContext.toolchain,
718-
{ ...process.env, ...testBuildConfig.env }
718+
{ ...process.env, ...testBuildConfig.env },
719+
{ readOnlyTerminal: process.platform !== "win32" }
719720
);
720721

721722
task.execution.onDidWrite(str => {

src/tasks/SwiftExecution.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import * as vscode from "vscode";
16-
import { SwiftProcess, SwiftPtyProcess } from "./SwiftProcess";
16+
import { ReadOnlySwiftProcess, SwiftProcess, SwiftPtyProcess } from "./SwiftProcess";
1717
import { SwiftPseudoterminal } from "./SwiftPseudoterminal";
1818

1919
export interface SwiftExecutionOptions extends vscode.ProcessExecutionOptions {
2020
presentation?: vscode.TaskPresentationOptions;
21+
readOnlyTerminal?: boolean;
2122
}
2223

2324
/**
@@ -30,11 +31,15 @@ export class SwiftExecution extends vscode.CustomExecution {
3031
public readonly command: string,
3132
public readonly args: string[],
3233
public readonly options: SwiftExecutionOptions,
33-
private readonly swiftProcess: SwiftProcess = new SwiftPtyProcess(command, args, options)
34+
private readonly swiftProcess: SwiftProcess = options.readOnlyTerminal
35+
? new ReadOnlySwiftProcess(command, args, options)
36+
: new SwiftPtyProcess(command, args, options)
3437
) {
3538
super(async () => {
3639
return new SwiftPseudoterminal(swiftProcess, options.presentation || {});
3740
});
41+
42+
this.swiftProcess = swiftProcess;
3843
this.onDidWrite = swiftProcess.onDidWrite;
3944
this.onDidClose = swiftProcess.onDidClose;
4045
}

src/tasks/SwiftProcess.ts

+89
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import type * as nodePty from "node-pty";
16+
import * as child_process from "child_process";
1617
import * as vscode from "vscode";
1718

1819
import { requireNativeModule } from "../utilities/native";
@@ -153,3 +154,91 @@ export class SwiftPtyProcess implements SwiftProcess {
153154

154155
onDidClose: vscode.Event<number | void> = this.closeEmitter.event;
155156
}
157+
158+
/**
159+
* A {@link SwiftProcess} that spawns a child process and does not bind to stdio.
160+
*
161+
* Use this for Swift tasks that do not need to accept input, as its lighter weight and
162+
* less error prone than using a spawned node-pty process.
163+
*
164+
* Specifically node-pty on Linux suffers from a long standing issue where the last chunk
165+
* of output before a program exits is sometimes dropped, especially if that program produces
166+
* a lot of output immediately before exiting. See https://github.com/microsoft/node-pty/issues/72
167+
*/
168+
export class ReadOnlySwiftProcess implements SwiftProcess {
169+
private readonly spawnEmitter: vscode.EventEmitter<void> = new vscode.EventEmitter<void>();
170+
private readonly writeEmitter: vscode.EventEmitter<string> = new vscode.EventEmitter<string>();
171+
private readonly errorEmitter: vscode.EventEmitter<Error> = new vscode.EventEmitter<Error>();
172+
private readonly closeEmitter: vscode.EventEmitter<number | void> = new vscode.EventEmitter<
173+
number | void
174+
>();
175+
176+
private spawnedProcess: child_process.ChildProcessWithoutNullStreams | undefined;
177+
178+
constructor(
179+
public readonly command: string,
180+
public readonly args: string[],
181+
private readonly options: vscode.ProcessExecutionOptions = {}
182+
) {}
183+
184+
spawn(): void {
185+
try {
186+
this.spawnedProcess = child_process.spawn(this.command, this.args, {
187+
cwd: this.options.cwd,
188+
env: { ...process.env, ...this.options.env },
189+
});
190+
191+
this.spawnedProcess.stdout.on("data", data => {
192+
this.writeEmitter.fire(data.toString());
193+
});
194+
195+
this.spawnedProcess.stderr.on("data", data => {
196+
this.writeEmitter.fire(data.toString());
197+
});
198+
199+
this.spawnedProcess.on("error", error => {
200+
this.errorEmitter.fire(new Error(`${error}`));
201+
this.closeEmitter.fire();
202+
});
203+
204+
this.spawnedProcess.once("exit", code => {
205+
this.closeEmitter.fire(code ?? undefined);
206+
this.dispose();
207+
});
208+
} catch (error) {
209+
this.errorEmitter.fire(new Error(`${error}`));
210+
this.closeEmitter.fire();
211+
this.dispose();
212+
}
213+
}
214+
215+
handleInput(_s: string): void {
216+
// Do nothing
217+
}
218+
219+
terminate(signal?: NodeJS.Signals): void {
220+
if (!this.spawnedProcess) {
221+
return;
222+
}
223+
this.spawnedProcess.kill(signal);
224+
this.dispose();
225+
}
226+
227+
setDimensions(_dimensions: vscode.TerminalDimensions): void {
228+
// Do nothing
229+
}
230+
231+
dispose(): void {
232+
this.spawnedProcess?.stdout.removeAllListeners();
233+
this.spawnedProcess?.stderr.removeAllListeners();
234+
this.spawnedProcess?.removeAllListeners();
235+
}
236+
237+
onDidSpawn: vscode.Event<void> = this.spawnEmitter.event;
238+
239+
onDidWrite: vscode.Event<string> = this.writeEmitter.event;
240+
241+
onDidThrowError: vscode.Event<Error> = this.errorEmitter.event;
242+
243+
onDidClose: vscode.Event<number | void> = this.closeEmitter.event;
244+
}

src/tasks/SwiftTaskProvider.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ export function createSwiftTask(
268268
name: string,
269269
config: TaskConfig,
270270
toolchain: SwiftToolchain,
271-
cmdEnv: { [key: string]: string } = {}
271+
cmdEnv: { [key: string]: string } = {},
272+
options: { readOnlyTerminal: boolean } = { readOnlyTerminal: false }
272273
): SwiftTask {
273274
const swift = toolchain.getToolchainExecutable("swift");
274275
args = toolchain.buildFlags.withAdditionalFlags(args);
@@ -313,6 +314,7 @@ export function createSwiftTask(
313314
cwd: fullCwd,
314315
env: env,
315316
presentation,
317+
readOnlyTerminal: options.readOnlyTerminal,
316318
})
317319
);
318320
// This doesn't include any quotes added by VS Code.

test/integration-tests/testexplorer/TestExplorerIntegration.test.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { beforeEach, afterEach } from "mocha";
2020
import { TestExplorer } from "../../../src/TestExplorer/TestExplorer";
2121
import {
2222
assertContains,
23+
assertContainsTrimmed,
2324
assertTestControllerHierarchy,
2425
assertTestResults,
2526
eventPromise,
@@ -190,6 +191,7 @@ suite("Test Explorer Suite", function () {
190191
["testPassing()", "testFailing()", "testDisabled()"],
191192
"testWithKnownIssue()",
192193
"testWithKnownIssueAndUnknownIssue()",
194+
"testLotsOfOutput()",
193195
"testAttachment()",
194196
"DuplicateSuffixTests",
195197
["testPassing()", "testPassingSuffix()"],
@@ -230,6 +232,29 @@ suite("Test Explorer Suite", function () {
230232
}
231233
});
232234

235+
test("captures lots of output", async () => {
236+
const testRun = await runTest(
237+
testExplorer,
238+
TestKind.standard,
239+
"PackageTests.testLotsOfOutput()"
240+
);
241+
242+
assertTestResults(testRun, {
243+
passed: ["PackageTests.testLotsOfOutput()"],
244+
});
245+
246+
// Right now the swift-testing "test run complete" text is being emitted
247+
// in the middle of the print, so the last line is actually end end of our
248+
// huge string. If they fix this in future this `find` ensures the test wont break.
249+
const needle = "100000";
250+
const lastTenLines = testRun.runState.output.slice(-10).join("\n");
251+
assertContainsTrimmed(
252+
testRun.runState.output,
253+
needle,
254+
`Expected all test output to be captured, but it was truncated. Last 10 lines of output were: ${lastTenLines}`
255+
);
256+
});
257+
233258
test("attachments", async function () {
234259
// Attachments were introduced in 6.1
235260
if (workspaceContext.swiftVersion.isLessThan(new Version(6, 1, 0))) {
@@ -535,9 +560,11 @@ suite("Test Explorer Suite", function () {
535560
"PackageTests.topLevelTestPassing()"
536561
);
537562

538-
assertContains(
563+
// Use assertContainsTrimmed to ignore the line ending differences
564+
// across platforms (windows vs linux/darwin)
565+
assertContainsTrimmed(
539566
testRun.runState.output,
540-
"A print statement in a test.\r\r\n"
567+
"A print statement in a test."
541568
);
542569
assertTestResults(testRun, {
543570
passed: ["PackageTests.topLevelTestPassing()"],

test/integration-tests/testexplorer/TestRunArguments.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ suite("TestRunArguments Suite", () => {
240240
const testArgs = new TestRunArguments(runRequestByIds([anotherSwiftTestId]), false);
241241
assertRunArguments(testArgs, {
242242
xcTestArgs: [],
243-
swiftTestArgs: [`${anotherSwiftTestId}/`],
243+
swiftTestArgs: [anotherSwiftTestId],
244244
testItems: [swiftTestSuiteId, testTargetId, anotherSwiftTestId],
245245
});
246246
});

test/integration-tests/testexplorer/utilities.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,23 @@ export function assertTestControllerHierarchy(
110110
*
111111
* @param array The array to check.
112112
* @param value The value to check for.
113+
* @param message An optional message to display if the assertion fails.
113114
*/
114-
export function assertContains<T>(array: T[], value: T) {
115-
assert.ok(array.includes(value), `${value} is not in ${array}`);
115+
export function assertContains<T>(array: T[], value: T, message?: string) {
116+
assert.ok(array.includes(value), message ?? `${value} is not in ${array}`);
117+
}
118+
119+
/**
120+
* Asserts that an array of strings contains the value ignoring
121+
* leading/trailing whitespace.
122+
*
123+
* @param array The array to check.
124+
* @param value The value to check for.
125+
* @param message An optional message to display if the assertion fails.
126+
*/
127+
export function assertContainsTrimmed(array: string[], value: string, message?: string) {
128+
const found = array.find(row => row.trim() === value);
129+
assert.ok(found, message ?? `${value} is not in ${array}`);
116130
}
117131

118132
/**

0 commit comments

Comments
 (0)