Skip to content

Commit d0c543f

Browse files
authored
Support a Unified Test Binary (#986)
With the merge of swiftlang/swift-package-manager#7766 a `swift build --build-tests` produces a single binary that contains both XCTests and swift-testing tests. Update the build configs when debugging tests to use this new unified test binary if it exists. To determine if we're running a unified binary on macOS we can check for the presence of the swiftpm-testing-helper, which is used to run the swift-testing tests in the unified binary. This utility isn't required on Windows and Linux as the test binary can be invoked directly. To determine if we're running a unified binary we check if the latest build produced a .swift-testing binary in the build folder. If it doesn't exist, or if it does exist but its `mtime` is older than the last build start time, we know its a unified binary. The `mtime` check is required in case the user updates their toolchain to a version that produces a unified binary but still has a .swift-testing binary in their .build folder left over from an old build. In the future we can remove these checks once we're confident users on 6.0 and main toolchains have moved on to versions that generate a unified test binary. Tentatively I propose this to be around when Swift 6.0 is finalized and released. This change only affects debugging tests. All other testing code paths use `swift test` which lets SwiftPM automatically handle the switch to the new unified binary.
1 parent a596d31 commit d0c543f

File tree

4 files changed

+173
-43
lines changed

4 files changed

+173
-43
lines changed

src/TestExplorer/TestParsers/TestEventStreamReader.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ export class UnixNamedPipeReader implements INamedPipeReader {
7474
});
7575

7676
readable.on("drain", () => pipe.resume());
77-
78-
pipe.on("error", () => fs.close(fd));
77+
pipe.on("error", () => pipe.close());
7978
pipe.on("end", () => {
8079
readable.push(null);
8180
fs.close(fd);

src/TestExplorer/TestRunner.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -425,11 +425,12 @@ export class TestRunner {
425425
await execFile("mkfifo", [fifoPipePath], undefined, this.folderContext);
426426
}
427427

428-
const testBuildConfig = TestingDebugConfigurationFactory.swiftTestingConfig(
428+
const testBuildConfig = await TestingDebugConfigurationFactory.swiftTestingConfig(
429429
this.folderContext,
430430
fifoPipePath,
431431
this.testKind,
432432
this.testArgs.swiftTestArgs,
433+
new Date(),
433434
true
434435
);
435436

@@ -467,7 +468,7 @@ export class TestRunner {
467468
}
468469

469470
if (this.testArgs.hasXCTests) {
470-
const testBuildConfig = TestingDebugConfigurationFactory.xcTestConfig(
471+
const testBuildConfig = await TestingDebugConfigurationFactory.xcTestConfig(
471472
this.folderContext,
472473
this.testKind,
473474
this.testArgs.xcTestArgs,
@@ -710,6 +711,8 @@ export class TestRunner {
710711
});
711712
subscriptions.push(buildTask);
712713

714+
const buildStartTime = new Date();
715+
713716
// Perform a build all before the tests are run. We want to avoid the "Debug Anyway" dialog
714717
// since choosing that with no prior build, when debugging swift-testing tests, will cause
715718
// the extension to start listening to the fifo pipe when no results will be produced,
@@ -731,13 +734,15 @@ export class TestRunner {
731734
await execFile("mkfifo", [fifoPipePath], undefined, this.folderContext);
732735
}
733736

734-
const swiftTestBuildConfig = TestingDebugConfigurationFactory.swiftTestingConfig(
735-
this.folderContext,
736-
fifoPipePath,
737-
this.testKind,
738-
this.testArgs.swiftTestArgs,
739-
true
740-
);
737+
const swiftTestBuildConfig =
738+
await TestingDebugConfigurationFactory.swiftTestingConfig(
739+
this.folderContext,
740+
fifoPipePath,
741+
this.testKind,
742+
this.testArgs.swiftTestArgs,
743+
buildStartTime,
744+
true
745+
);
741746

742747
if (swiftTestBuildConfig !== null) {
743748
swiftTestBuildConfig.testType = TestLibrary.swiftTesting;
@@ -765,7 +770,7 @@ export class TestRunner {
765770

766771
// create launch config for testing
767772
if (this.testArgs.hasXCTests) {
768-
const xcTestBuildConfig = TestingDebugConfigurationFactory.xcTestConfig(
773+
const xcTestBuildConfig = await TestingDebugConfigurationFactory.xcTestConfig(
769774
this.folderContext,
770775
this.testKind,
771776
this.testArgs.xcTestArgs,

src/debugger/buildConfig.ts

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import * as os from "os";
1616
import * as path from "path";
1717
import * as vscode from "vscode";
18+
import * as fs from "fs/promises";
1819
import configuration from "../configuration";
1920
import { FolderContext } from "../FolderContext";
2021
import { BuildFlags } from "../toolchain/BuildFlags";
@@ -32,35 +33,38 @@ import { TestKind, isDebugging, isRelease } from "../TestExplorer/TestKind";
3233
* and `xcTestConfig` functions to create
3334
*/
3435
export class TestingDebugConfigurationFactory {
35-
public static swiftTestingConfig(
36+
public static async swiftTestingConfig(
3637
ctx: FolderContext,
3738
fifoPipePath: string,
3839
testKind: TestKind,
3940
testList: string[],
41+
buildStartTime: Date,
4042
expandEnvVariables = false
41-
): vscode.DebugConfiguration | null {
43+
): Promise<vscode.DebugConfiguration | null> {
4244
return new TestingDebugConfigurationFactory(
4345
ctx,
4446
fifoPipePath,
4547
testKind,
4648
TestLibrary.swiftTesting,
4749
testList,
50+
buildStartTime,
4851
expandEnvVariables
4952
).build();
5053
}
5154

52-
public static xcTestConfig(
55+
public static async xcTestConfig(
5356
ctx: FolderContext,
5457
testKind: TestKind,
5558
testList: string[],
5659
expandEnvVariables = false
57-
): vscode.DebugConfiguration | null {
60+
): Promise<vscode.DebugConfiguration | null> {
5861
return new TestingDebugConfigurationFactory(
5962
ctx,
6063
"",
6164
testKind,
6265
TestLibrary.xctest,
6366
testList,
67+
null,
6468
expandEnvVariables
6569
).build();
6670
}
@@ -71,6 +75,7 @@ export class TestingDebugConfigurationFactory {
7175
private testKind: TestKind,
7276
private testLibrary: TestLibrary,
7377
private testList: string[],
78+
private buildStartTime: Date | null,
7479
private expandEnvVariables = false
7580
) {}
7681

@@ -82,7 +87,7 @@ export class TestingDebugConfigurationFactory {
8287
* - Test Kind (coverage, debugging)
8388
* - Test Library (XCTest, swift-testing)
8489
*/
85-
private build(): vscode.DebugConfiguration | null {
90+
private async build(): Promise<vscode.DebugConfiguration | null> {
8691
if (!this.hasTestTarget) {
8792
return null;
8893
}
@@ -98,7 +103,7 @@ export class TestingDebugConfigurationFactory {
98103
}
99104

100105
/* eslint-disable no-case-declarations */
101-
private buildWindowsConfig(): vscode.DebugConfiguration | null {
106+
private async buildWindowsConfig(): Promise<vscode.DebugConfiguration | null> {
102107
if (isDebugging(this.testKind)) {
103108
const testEnv = {
104109
...swiftRuntimeEnv(),
@@ -114,8 +119,8 @@ export class TestingDebugConfigurationFactory {
114119

115120
return {
116121
...this.baseConfig,
117-
program: this.testExecutableOutputPath,
118-
args: this.debuggingTestExecutableArgs,
122+
program: await this.testExecutableOutputPath(),
123+
args: await this.debuggingTestExecutableArgs(),
119124
env: testEnv,
120125
};
121126
} else {
@@ -124,39 +129,69 @@ export class TestingDebugConfigurationFactory {
124129
}
125130

126131
/* eslint-disable no-case-declarations */
127-
private buildLinuxConfig(): vscode.DebugConfiguration | null {
132+
private async buildLinuxConfig(): Promise<vscode.DebugConfiguration | null> {
128133
if (isDebugging(this.testKind) && this.testLibrary === TestLibrary.xctest) {
129134
return {
130135
...this.baseConfig,
131-
program: this.testExecutableOutputPath,
132-
args: this.debuggingTestExecutableArgs,
136+
program: await this.testExecutableOutputPath(),
137+
args: await this.debuggingTestExecutableArgs(),
133138
env: {
134139
...swiftRuntimeEnv(),
135140
...configuration.folder(this.ctx.workspaceFolder).testEnvironmentVariables,
136141
},
137142
};
138143
} else {
139-
return this.buildDarwinConfig();
144+
return await this.buildDarwinConfig();
140145
}
141146
}
142147

143-
private buildDarwinConfig(): vscode.DebugConfiguration | null {
148+
private async buildDarwinConfig(): Promise<vscode.DebugConfiguration | null> {
144149
switch (this.testLibrary) {
145150
case TestLibrary.swiftTesting:
146151
switch (this.testKind) {
147152
case TestKind.debugRelease:
148153
case TestKind.debug:
149-
// In the debug case we need to build the .swift-testing executable and then
154+
// In the debug case we need to build the testing executable and then
150155
// launch it with LLDB instead of going through `swift test`.
151156
const toolchain = this.ctx.workspaceContext.toolchain;
152157
const libraryPath = toolchain.swiftTestingLibraryPath();
153158
const frameworkPath = toolchain.swiftTestingFrameworkPath();
159+
const swiftPMTestingHelperPath = toolchain.swiftPMTestingHelperPath;
160+
161+
// Toolchains that contain https://github.com/swiftlang/swift-package-manager/commit/844bd137070dcd18d0f46dd95885ef7907ea0697
162+
// produce a single testing binary for both xctest and swift-testing (called <ProductName>.xctest).
163+
// We can continue to invoke it with the xctest utility, but to run swift-testing tests
164+
// we need to invoke then using the swiftpm-testing-helper utility. If this helper utility exists
165+
// then we know we're working with a unified binary.
166+
if (swiftPMTestingHelperPath) {
167+
const result = {
168+
...this.baseConfig,
169+
program: swiftPMTestingHelperPath,
170+
args: this.addBuildOptionsToArgs(
171+
this.addTestsToArgs(
172+
this.addSwiftTestingFlagsArgs([
173+
"--test-bundle-path",
174+
this.unifiedTestingOutputPath(),
175+
"--testing-library",
176+
"swift-testing",
177+
])
178+
)
179+
),
180+
env: {
181+
...this.testEnv,
182+
...this.sanitizerRuntimeEnvironment,
183+
DYLD_FRAMEWORK_PATH: frameworkPath,
184+
DYLD_LIBRARY_PATH: libraryPath,
185+
SWT_SF_SYMBOLS_ENABLED: "0",
186+
},
187+
};
188+
return result;
189+
}
190+
154191
const result = {
155192
...this.baseConfig,
156-
program: this.swiftTestingOutputPath,
157-
args: this.addBuildOptionsToArgs(
158-
this.addTestsToArgs(this.addSwiftTestingFlagsArgs([]))
159-
),
193+
program: await this.testExecutableOutputPath(),
194+
args: await this.debuggingTestExecutableArgs(),
160195
env: {
161196
...this.testEnv,
162197
...this.sanitizerRuntimeEnvironment,
@@ -208,7 +243,7 @@ export class TestingDebugConfigurationFactory {
208243
return {
209244
...this.baseConfig,
210245
program: path.join(xcTestPath, "xctest"),
211-
args: this.addXCTestExecutableTestsToArgs([this.xcTestOutputPath]),
246+
args: this.addXCTestExecutableTestsToArgs([this.xcTestOutputPath()]),
212247
env: {
213248
...this.testEnv,
214249
...this.sanitizerRuntimeEnvironment,
@@ -315,7 +350,7 @@ export class TestingDebugConfigurationFactory {
315350
targetCreateCommands: [`file -a ${arch} ${xctestPath}/xctest`],
316351
processCreateCommands: [
317352
...envCommands,
318-
`process launch -w ${folder} -- ${testFilterArg} ${this.xcTestOutputPath}`,
353+
`process launch -w ${folder} -- ${testFilterArg} ${this.xcTestOutputPath()}`,
319354
],
320355
preLaunchTask: `swift: Build All${nameSuffix}`,
321356
};
@@ -387,37 +422,82 @@ export class TestingDebugConfigurationFactory {
387422
return isRelease(this.testKind) ? "release" : "debug";
388423
}
389424

390-
private get xcTestOutputPath(): string {
425+
private xcTestOutputPath(): string {
391426
return path.join(
392427
this.buildDirectory,
393428
this.artifactFolderForTestKind,
394-
this.ctx.swiftPackage.name + "PackageTests.xctest"
429+
`${this.ctx.swiftPackage.name}PackageTests.xctest`
395430
);
396431
}
397432

398-
private get swiftTestingOutputPath(): string {
433+
private swiftTestingOutputPath(): string {
399434
return path.join(
400435
this.buildDirectory,
401436
this.artifactFolderForTestKind,
402437
`${this.ctx.swiftPackage.name}PackageTests.swift-testing`
403438
);
404439
}
405440

406-
private get testExecutableOutputPath(): string {
441+
private async isUnifiedTestingBinary(): Promise<boolean> {
442+
// Toolchains that contain https://github.com/swiftlang/swift-package-manager/commit/844bd137070dcd18d0f46dd95885ef7907ea0697
443+
// no longer produce a .swift-testing binary, instead we want to use `unifiedTestingOutputPath`.
444+
// In order to determine if we're working with a unified binary we need to check if the .swift-testing
445+
// binary _doesn't_ exist, and if it does exist we need to check that it wasn't built by an old toolchain
446+
// and is still in the .build directory. We do this by checking its mtime and seeing if it is after
447+
// the `buildStartTime`.
448+
449+
// TODO: When Swift 6 is released and enough time has passed that we're sure no one is building the .swift-testing
450+
// binary anymore this fs.stat workaround can be removed and `swiftTestingPath` can be returned. The
451+
// `buildStartTime` argument can be removed and the build config generation can be made synchronous again.
452+
453+
try {
454+
const stat = await fs.stat(this.swiftTestingOutputPath());
455+
return !this.buildStartTime || stat.mtime.getTime() < this.buildStartTime.getTime();
456+
} catch {
457+
// If the .swift-testing binary doesn't exist then swift-testing tests are in the unified binary.
458+
return true;
459+
}
460+
}
461+
462+
private unifiedTestingOutputPath(): string {
463+
// The unified binary that contains both swift-testing and XCTests
464+
// is named the same as the old style .xctest binary. The swiftpm-testing-helper
465+
// requires the full path to the binary.
466+
if (process.platform === "darwin") {
467+
return path.join(
468+
this.xcTestOutputPath(),
469+
"Contents",
470+
"MacOS",
471+
`${this.ctx.swiftPackage.name}PackageTests`
472+
);
473+
} else {
474+
return this.xcTestOutputPath();
475+
}
476+
}
477+
478+
private async testExecutableOutputPath(): Promise<string> {
407479
switch (this.testLibrary) {
408480
case TestLibrary.swiftTesting:
409-
return this.swiftTestingOutputPath;
481+
return (await this.isUnifiedTestingBinary())
482+
? this.unifiedTestingOutputPath()
483+
: this.swiftTestingOutputPath();
410484
case TestLibrary.xctest:
411-
return this.xcTestOutputPath;
485+
return this.xcTestOutputPath();
412486
}
413487
}
414488

415-
private get debuggingTestExecutableArgs(): string[] {
489+
private async debuggingTestExecutableArgs(): Promise<string[]> {
416490
switch (this.testLibrary) {
417-
case TestLibrary.swiftTesting:
491+
case TestLibrary.swiftTesting: {
492+
const isUnifiedBinary = await this.isUnifiedTestingBinary();
493+
const swiftTestingArgs = isUnifiedBinary
494+
? ["--testing-library", "swift-testing"]
495+
: [];
496+
418497
return this.addBuildOptionsToArgs(
419-
this.addTestsToArgs(this.addSwiftTestingFlagsArgs([]))
498+
this.addTestsToArgs(this.addSwiftTestingFlagsArgs(swiftTestingArgs))
420499
);
500+
}
421501
case TestLibrary.xctest:
422502
return [this.testList.join(",")];
423503
}

0 commit comments

Comments
 (0)