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);
+ }
+}