Files
sourcekit-lsp/Tests/BuildSystemIntegrationTests/BuildServerBuildSystemTests.swift
Alex Hoppen 5c60d1d39c Add a new test project type that uses a custom build server
This allows us to more easily test behavior for build servers that have different behavior than SwiftPM and compile commands without having to implement the build server in Python.
2025-03-05 23:54:32 -08:00

563 lines
19 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import BuildServerProtocol
import BuildSystemIntegration
import Foundation
import LanguageServerProtocol
import LanguageServerProtocolExtensions
import SKOptions
import SKTestSupport
import SourceKitLSP
import SwiftExtensions
import TSCBasic
import XCTest
#if os(Windows)
import WinSDK
#endif
final class BuildServerBuildSystemTests: XCTestCase {
func testBuildSettingsFromBuildServer() async throws {
let project = try await ExternalBuildServerTestProject(
files: [
"Test.swift": """
#if DEBUG
#error("DEBUG SET")
#else
#error("DEBUG NOT SET")
#endif
"""
],
buildServer: """
class BuildServer(AbstractBuildServer):
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"targets": [
{
"id": {"uri": "bsp://dummy"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"items": [
{
"target": {"uri": "bsp://dummy"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DDEBUG", $SDK_ARGS]
}
"""
)
let (uri, _) = try project.openDocument("Test.swift")
try await repeatUntilExpectedResult {
let diags = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
return diags.fullReport?.items.map(\.message) == ["DEBUG SET"]
}
}
func testBuildTargetsChanged() async throws {
try SkipUnless.longTestsEnabled()
let project = try await ExternalBuildServerTestProject(
files: [
"Test.swift": """
#if DEBUG
#error("DEBUG SET")
#else
#error("DEBUG NOT SET")
#endif
"""
],
buildServer: """
class BuildServer(AbstractBuildServer):
has_changed_targets: bool = False
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
if self.has_changed_targets:
return {
"targets": [
{
"id": {"uri": "bsp://dummy"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
else:
return {"targets": []}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
assert self.has_changed_targets
return {
"items": [
{
"target": {"uri": "bsp://dummy"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
assert self.has_changed_targets
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DDEBUG", $SDK_ARGS]
}
def workspace_did_change_watched_files(self, notification: Dict[str, object]) -> None:
self.has_changed_targets = True
self.send_notification("buildTarget/didChange", {})
"""
)
let (uri, _) = try project.openDocument("Test.swift")
// Initially, we shouldn't have any diagnostics because Test.swift is not part of any target
let initialDiagnostics = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
XCTAssertEqual(initialDiagnostics.fullReport?.items, [])
// We use an arbitrary file change to signal to the BSP server that it should send the targets changed notification
project.testClient.send(
DidChangeWatchedFilesNotification(changes: [
FileEvent(uri: try DocumentURI(string: "file:///dummy"), type: .created)
])
)
// But then the 1s timer in the build server should fire, we get a `buildTarget/didChange` notification and we have
// build settings for Test.swift
try await repeatUntilExpectedResult {
let diags = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
return diags.fullReport?.items.map(\.message) == ["DEBUG SET"]
}
}
func testSettingsOfSingleFileChanged() async throws {
try SkipUnless.longTestsEnabled()
let project = try await ExternalBuildServerTestProject(
files: [
"Test.swift": """
#if DEBUG
#error("DEBUG SET")
#else
#error("DEBUG NOT SET")
#endif
"""
],
buildServer: """
class BuildServer(AbstractBuildServer):
has_changed_settings: bool = False
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"targets": [
{
"id": {"uri": "bsp://dummy"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"items": [
{
"target": {"uri": "bsp://dummy"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
if self.has_changed_settings:
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DDEBUG", $SDK_ARGS]
}
else:
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", $SDK_ARGS]
}
def workspace_did_change_watched_files(self, notification: Dict[str, object]) -> None:
self.has_changed_settings = True
self.send_notification("buildTarget/didChange", {})
"""
)
let (uri, _) = try project.openDocument("Test.swift")
// Initially, we don't have -DDEBUG set, so we should get `DEBUG NOT SET`
let initialDiagnostics = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
XCTAssertEqual(initialDiagnostics.fullReport?.items.map(\.message), ["DEBUG NOT SET"])
// We use an arbitrary file change to signal to the BSP server that it should send the targets changed notification
project.testClient.send(
DidChangeWatchedFilesNotification(changes: [
FileEvent(uri: try DocumentURI(string: "file:///dummy"), type: .created)
])
)
// But then the 1s timer in the build server should fire, we get a `buildTarget/didChange` notification and we get
// build settings for Test.swift that include -DDEBUG
try await repeatUntilExpectedResult {
let diags = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
return diags.fullReport?.items.map(\.message) == ["DEBUG SET"]
}
}
func testCrashRecovery() async throws {
try SkipUnless.longTestsEnabled()
let project = try await ExternalBuildServerTestProject(
files: [
"Crash.swift": "",
"Test.swift": """
#if DEBUG
#error("DEBUG SET")
#else
#error("DEBUG NOT SET")
#endif
""",
"should_crash": "dummy file to indicate that BSP server should crash",
],
buildServer: """
import threading
import os
class BuildServer(AbstractBuildServer):
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"targets": [
{
"id": {"uri": "bsp://dummy"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"items": [
{
"target": {"uri": "bsp://dummy"},
"sources": [
{"uri": "$TEST_DIR_URL/Crash.swift", "kind": 1, "generated": False},
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False},
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
if os.path.exists(r"$TEST_DIR/should_crash"):
assert False
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DDEBUG", $SDK_ARGS]
}
"""
)
// Check that we still get results for Test.swift (after relaunching the BSP server)
let (uri, _) = try project.openDocument("Test.swift")
// While the BSP server is crashing, we shouldn't get any build settings and thus get empty diagnostics.
let diagnosticsBeforeCrash = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
XCTAssertEqual(diagnosticsBeforeCrash.fullReport?.items, [])
try FileManager.default.removeItem(at: project.scratchDirectory.appendingPathComponent("should_crash"))
try await repeatUntilExpectedResult(timeout: .seconds(20)) {
let diagnostics = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
return diagnostics.fullReport?.items.map(\.message) == ["DEBUG SET"]
}
}
func testBuildServerConfigAtLegacyLocation() async throws {
let project = try await ExternalBuildServerTestProject(
files: [
"Test.swift": """
#if DEBUG
#error("DEBUG SET")
#else
#error("DEBUG NOT SET")
#endif
"""
],
buildServerConfigLocation: "buildServer.json",
buildServer: """
class BuildServer(AbstractBuildServer):
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"targets": [
{
"id": {"uri": "bsp://dummy"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"items": [
{
"target": {"uri": "bsp://dummy"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DDEBUG", $SDK_ARGS]
}
"""
)
let (uri, _) = try project.openDocument("Test.swift")
try await repeatUntilExpectedResult {
let diags = try await project.testClient.send(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))
)
return diags.fullReport?.items.map(\.message) == ["DEBUG SET"]
}
}
func testBuildSettingsDataPassThrough() async throws {
let project = try await ExternalBuildServerTestProject(
files: [
"Test.swift": ""
],
buildServer: """
class BuildServer(AbstractBuildServer):
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"targets": [
{
"id": {"uri": "bsp://dummy"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"items": [
{
"target": {"uri": "bsp://dummy"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"compilerArguments": [r"$TEST_DIR/Test.swift"],
"data": {"custom": "value"}
}
""",
options: .testDefault(experimentalFeatures: [.sourceKitOptionsRequest])
)
let (uri, _) = try project.openDocument("Test.swift")
let options = try await project.testClient.send(
SourceKitOptionsRequest(
textDocument: TextDocumentIdentifier(uri),
prepareTarget: false,
allowFallbackSettings: false
)
)
XCTAssertEqual(options.data, LSPAny.dictionary(["custom": .string("value")]))
}
func testBuildSettingsForFilePartOfMultipleTargets() async throws {
let project = try await ExternalBuildServerTestProject(
files: [
"Test.swift": ""
],
buildServer: """
class BuildServer(AbstractBuildServer):
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"targets": [
{
"id": {"uri": "bsp://first"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
},
{
"id": {"uri": "bsp://second"},
"tags": [],
"languageIds": [],
"dependencies": [],
"capabilities": {},
}
]
}
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return {
"items": [
{
"target": {"uri": "bsp://first"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
},
{
"target": {"uri": "bsp://second"},
"sources": [
{"uri": "$TEST_DIR_URL/Test.swift", "kind": 1, "generated": False}
],
}
]
}
def textdocument_sourcekitoptions(self, request: Dict[str, object]) -> Dict[str, object]:
target_uri = request["target"]["uri"]
if target_uri == "bsp://first":
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DFIRST"]
}
elif target_uri == "bsp://second":
return {
"compilerArguments": [r"$TEST_DIR/Test.swift", "-DSECOND"]
}
else:
assert False, f"Unknown target {target_uri}"
""",
options: .testDefault(experimentalFeatures: [.sourceKitOptionsRequest])
)
let (uri, _) = try project.openDocument("Test.swift")
let firstOptions = try await project.testClient.send(
SourceKitOptionsRequest(
textDocument: TextDocumentIdentifier(uri),
target: DocumentURI(string: "bsp://first"),
prepareTarget: false,
allowFallbackSettings: false
)
)
XCTAssert(try XCTUnwrap(firstOptions).compilerArguments.contains("-DFIRST"))
let secondOptions = try await project.testClient.send(
SourceKitOptionsRequest(
textDocument: TextDocumentIdentifier(uri),
target: DocumentURI(string: "bsp://second"),
prepareTarget: false,
allowFallbackSettings: false
)
)
XCTAssert(try XCTUnwrap(secondOptions).compilerArguments.contains("-DSECOND"))
let optionsWithoutTarget = try await project.testClient.send(
SourceKitOptionsRequest(
textDocument: TextDocumentIdentifier(uri),
prepareTarget: false,
allowFallbackSettings: false
)
)
// We currently pick the canonical target alphabetically, which means that `bsp://first` wins over `bsp://second`
XCTAssert(try XCTUnwrap(optionsWithoutTarget).compilerArguments.contains("-DFIRST"))
}
func testDontBlockBuildServerInitializationIfBuildSystemIsUnresponsive() async throws {
// A build server that responds to the initialize request but not to any other requests.
final class UnresponsiveBuildServer: CustomBuildServer {
init(projectRoot: URL, connectionToSourceKitLSP: any Connection) {}
func buildTargetSourcesRequest(_ request: BuildTargetSourcesRequest) async throws -> BuildTargetSourcesResponse {
#if os(Windows)
Sleep(60 * 60 * 1000 /*ms*/)
#else
sleep(60 * 60 /*s*/)
#endif
XCTFail("Build server should be terminated before finishing the timeout")
throw ResponseError.methodNotFound(BuildTargetSourcesRequest.method)
}
func textDocumentSourceKitOptionsRequest(
_ request: TextDocumentSourceKitOptionsRequest
) async throws -> TextDocumentSourceKitOptionsResponse? {
#if os(Windows)
Sleep(60 * 60 * 1000 /*ms*/)
#else
sleep(60 * 60 /*s*/)
#endif
XCTFail("Build server should be terminated before finishing the timeout")
throw ResponseError.methodNotFound(TextDocumentSourceKitOptionsRequest.method)
}
}
// Creating the `CustomBuildServerTestProject` waits for the initialize response and times out if it doesn't receive one.
// Make sure that we get that response back.
_ = try await CustomBuildServerTestProject(
files: ["Test.swift": ""],
buildServer: UnresponsiveBuildServer.self
)
}
}