diff --git a/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs b/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs index e4fe5c1..6786c09 100644 --- a/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs +++ b/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs @@ -53,7 +53,7 @@ public void WithoutOneTheAnswerIsWhateverPythonMeansHere() [Test] public async Task AnImportResolvesIntoAnEnvironmentThatIsNotInTheWorktree() { - if (LanguageServers.Python() is not { } spec) + if (await PythonServer.ResolveAsync() is not { } spec) { Assert.Ignore("no Python language server available"); return; diff --git a/tests/Stampeded.Core.Tests/PythonLspTests.cs b/tests/Stampeded.Core.Tests/PythonLspTests.cs index e99b23a..be59e8d 100644 --- a/tests/Stampeded.Core.Tests/PythonLspTests.cs +++ b/tests/Stampeded.Core.Tests/PythonLspTests.cs @@ -15,7 +15,7 @@ public class PythonLspTests [Test] public async Task PyrightAnswersDefinitionsAndReferencesAcrossFiles() { - if (LanguageServers.Python() is not { } spec) + if (await PythonServer.ResolveAsync() is not { } spec) { Assert.Ignore("no Python language server available"); return; diff --git a/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs b/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs index 20d34e0..78bcaa1 100644 --- a/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs +++ b/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs @@ -15,7 +15,7 @@ public class PythonProjectConfigTests [Test] public async Task AProjectConfigNamingItsOwnVenvDoesNotBlindTheReview() { - if (LanguageServers.Python() is not { } spec) + if (await PythonServer.ResolveAsync() is not { } spec) { Assert.Ignore("no Python language server available"); return; diff --git a/tests/Stampeded.Core.Tests/PythonServer.cs b/tests/Stampeded.Core.Tests/PythonServer.cs new file mode 100644 index 0000000..327ef1a --- /dev/null +++ b/tests/Stampeded.Core.Tests/PythonServer.cs @@ -0,0 +1,49 @@ +using Stampeded.Core.Infra; +using Stampeded.Core.Lsp; + +namespace Stampeded.Core.Tests; + +/// +/// The Python language server a test talks to, with its download kept out of the test's own +/// time budget. +/// +/// On a machine with node and no pyright, answers with +/// npx, and the first npx start downloads the package from the npm registry before the server +/// says a word. A test hands one three-minute token to the handshake and its requests, which +/// is generous for a language server and nothing at all for a registry having a slow morning: +/// the download alone has been measured at 16 seconds and at 109 seconds on the same runner +/// image. Every CI runner is fresh, so the first pyright test in a run always paid for it, and +/// failed with a cancellation inside initialize that named neither the download nor its +/// duration. +/// +/// So the download happens here, once per test assembly, under a budget of its own, through +/// - which logs the command with its elapsed time, so a +/// slow registry shows up as a number in the test output. npx keys its cache on the package +/// spec, so pyright --version puts the package where pyright-langserver then +/// finds it. A download that fails or runs out its budget fails every pyright test with that +/// reason, rather than being skipped: a registry that cannot be reached is a fact about the +/// run, not about the machine. +/// +static class PythonServer +{ + static readonly Lazy downloaded = new(DownloadAsync); + + /// The server spec, or null on a machine with no way to run one; for the npx + /// form, only after the package is in the npm cache. + public static async Task ResolveAsync() + { + if (LanguageServers.Python() is not { } spec) + return null; + if (spec.Arguments.Contains("--package")) + await downloaded.Value; + return spec; + } + + static async Task DownloadAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + await ExternalTool.RunAsync(LanguageServers.OnPath("npx")!, + ["--yes", "--package", "pyright", "--", "pyright", "--version"], + Path.GetTempPath(), timeout.Token); + } +}