Files
sourcekit-lsp/Tests/BuildSystemIntegrationTests/BuildServerBuildSystemTests.swift
Alex Hoppen 4883ee7088 Merge pull request #2088 from ahoppen/cancellation-issue
Wait for request to be sent to sourcekitd before cancelling it in `SwiftSourceKitPluginTests.testCancellation`
2025-03-26 14:39:25 -07:00

651 lines
22 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//===----------------------------------------------------------------------===//
//
// 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
)
)
assertContains(try XCTUnwrap(firstOptions).compilerArguments, "-DFIRST")
let secondOptions = try await project.testClient.send(
SourceKitOptionsRequest(
textDocument: TextDocumentIdentifier(uri),
target: DocumentURI(string: "bsp://second"),
prepareTarget: false,
allowFallbackSettings: false
)
)
assertContains(try XCTUnwrap(secondOptions).compilerArguments, "-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`
assertContains(try XCTUnwrap(optionsWithoutTarget).compilerArguments, "-DFIRST")
}
func testDontBlockBuildServerInitializationIfBuildSystemIsUnresponsive() async throws {
// A build server that responds to the initialize request but not to any other requests.
final class UnresponsiveBuildServer: CustomBuildServer {
let inProgressRequestsTracker = CustomBuildServerInProgressRequestTracker()
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
)
}
func testShutdownHangs() async throws {
let startTime = Date()
do {
_ = try await ExternalBuildServerTestProject(
files: [
"Test.swift": ""
],
buildServer: """
import time
class BuildServer(AbstractBuildServer):
def workspace_build_targets(self, request: Dict[str, object]) -> Dict[str, object]:
return { "targets": [] }
def buildtarget_sources(self, request: Dict[str, object]) -> Dict[str, object]:
return { "items": [] }
def shutdown(self, request: Dict[str, object]) -> Dict[str, object]:
time.sleep(60)
assert False
""",
options: .testDefault(experimentalFeatures: [.sourceKitOptionsRequest])
)
}
// Check that we didn't wait the full 60 seconds for the build server to shut down and instead terminated it.
XCTAssert(Date().timeIntervalSince(startTime) < 50)
}
func testCancelPreparationOnLspShutdown() async throws {
actor BuildServer: CustomBuildServer {
let inProgressRequestsTracker = CustomBuildServerInProgressRequestTracker()
private let projectRoot: URL
let preparationStarted: XCTestExpectation
let preparationFinished: XCTestExpectation
init(projectRoot: URL, connectionToSourceKitLSP: any Connection) {
self.projectRoot = projectRoot
self.preparationStarted = XCTestExpectation(description: "Preparation started")
self.preparationFinished = XCTestExpectation(description: "Preparation finished")
}
func initializeBuildRequest(_ request: InitializeBuildRequest) async throws -> InitializeBuildResponse {
return try initializationResponseSupportingBackgroundIndexing(
projectRoot: projectRoot,
outputPathsProvider: false
)
}
func buildTargetSourcesRequest(_ request: BuildTargetSourcesRequest) -> BuildTargetSourcesResponse {
return dummyTargetSourcesResponse(files: [DocumentURI(projectRoot.appendingPathComponent("Test.swift"))])
}
func textDocumentSourceKitOptionsRequest(
_ request: TextDocumentSourceKitOptionsRequest
) -> TextDocumentSourceKitOptionsResponse? {
return TextDocumentSourceKitOptionsResponse(compilerArguments: [request.textDocument.uri.pseudoPath])
}
func prepareTarget(_ request: BuildTargetPrepareRequest) async throws -> VoidResponse {
preparationStarted.fulfill()
await assertThrowsError(try await Task.sleep(for: .seconds(defaultTimeout))) { error in
XCTAssert(error is CancellationError)
}
preparationFinished.fulfill()
return VoidResponse()
}
}
let preparationFinished: XCTestExpectation
do {
let project = try await CustomBuildServerTestProject(
files: [
"Test.swift": """
func 1⃣myTestFunc() {}
"""
],
buildServer: BuildServer.self,
enableBackgroundIndexing: true,
pollIndex: false
)
try await fulfillmentOfOrThrow(project.buildServer().preparationStarted)
preparationFinished = try project.buildServer().preparationFinished
}
try await fulfillmentOfOrThrow(preparationFinished)
}
}