diff --git a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs index 8a84d7db7..4cab49320 100644 --- a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs +++ b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO.Abstractions; @@ -10,9 +11,10 @@ namespace OpenCode.Sdk.Tests.Harness; /// /// What every source-run server in this assembly needs before its first test: the pinned submodule -/// checked out, its dependencies installed, and on PATH the Bun the pin's packageManager -/// names. It is checked once, before discovery, because TUnit reports a failed discovery hook as one -/// error for the module, where a failed session hook fails every test at 0 ms with the same message. +/// checked out at this repository's recorded commit, its dependencies installed, and on PATH the Bun +/// the pin's packageManager names. It is checked once, before discovery, because TUnit reports +/// a failed discovery hook as one error for the module, where a failed session hook fails every test +/// at 0 ms with the same message. /// A missing piece refuses the whole module, a run filtered to tests that start no server included: /// every gate and CI leg prepares the checkout, so an unprepared one is a mistake to name before it /// costs a run, not a mode to support. One exception is explicit: @@ -61,6 +63,8 @@ public static async Task Report_An_Allowed_Bun_Mismatch_Async(CancellationToken /// This repository's root. /// What bun --version printed, or null when no Bun is on PATH. /// The value of , or null when it is unset. + /// The submodule's gitlink in this repository's index, or null when unreadable. + /// The submodule's checked-out HEAD, or null when unreadable. /// The caller's token. /// /// The problems in the order to fix them, empty when the checkout is prepared, and the Bun @@ -71,6 +75,8 @@ internal static async Task FindAsync( string repositoryRoot, string? bunVersion, string? bunMismatch, + string? recordedCommit, + string? checkedOutCommit, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fileSystem); @@ -93,6 +99,20 @@ internal static async Task FindAsync( return new Readiness(problems, AllowedMismatch: null); } + foreach (var (commit, name) in new[] { (recordedCommit, "recorded gitlink"), (checkedOutCommit, "checked-out HEAD") }) + { + if (string.IsNullOrWhiteSpace(commit)) + { + problems.Add($"Cannot read the pin's {name} at '{submodule}'. Run: git submodule update --init --depth 1 external/opencode"); + } + } + + if (!string.IsNullOrWhiteSpace(recordedCommit) && !string.IsNullOrWhiteSpace(checkedOutCommit) + && !string.Equals(recordedCommit, checkedOutCommit, StringComparison.Ordinal)) + { + problems.Add($"The pin's checkout at '{submodule}' is {checkedOutCommit}, but this repository records {recordedCommit}. Run: git submodule update --init --depth 1 external/opencode"); + } + string? allowedMismatch = null; var pinned = await PinnedBunVersionAsync(fileSystem, submodule, cancellationToken).ConfigureAwait(false); if (pinned.Problem is { } unpinned) @@ -153,11 +173,28 @@ internal static string Describe(string module, string repositoryRoot, IReadOnlyL var fileSystem = new RealFileSystem(); var repositoryRoot = new PinnedServerCommand(fileSystem).RepositoryRoot; var bunVersion = await ReadBunVersionAsync(cancellationToken).ConfigureAwait(false); + var submodule = Submodule(fileSystem, repositoryRoot); + var commits = new List(); + foreach (var arguments in new[] { $"-C \"{repositoryRoot}\" ls-files -s -- external/opencode", $"-C \"{submodule}\" rev-parse HEAD" }) + { + try + { + commits.Add(await ReadProcessOutputAsync("git", arguments, "git " + arguments, cancellationToken).ConfigureAwait(false)); + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception) + { + commits.Add(null); + } + } + + var gitlink = commits[0]?.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); var readiness = await FindAsync( fileSystem, repositoryRoot, bunVersion, Environment.GetEnvironmentVariable(BunMismatchVariable), + gitlink is { Length: 4 } && gitlink[0] == "160000" && gitlink[2] == "0" ? gitlink[1] : null, + commits[1], cancellationToken).ConfigureAwait(false); return (repositoryRoot, readiness); } @@ -198,12 +235,20 @@ private static async Task PinnedBunVersionAsync(IFileSystem fileSyste return null; } + return await ReadProcessOutputAsync( + bun.IsBatchScript ? "cmd.exe" : bun.Path, + bun.IsBatchScript ? "/d /c \"\"" + bun.Path + "\" --version\"" : "--version", + bun.Path + " --version", cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadProcessOutputAsync(string fileName, string arguments, string command, CancellationToken cancellationToken) + { using var process = new Process { StartInfo = new ProcessStartInfo { - FileName = bun.IsBatchScript ? "cmd.exe" : bun.Path, - Arguments = bun.IsBatchScript ? "/d /c \"\"" + bun.Path + "\" --version\"" : "--version", + FileName = fileName, + Arguments = arguments, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, @@ -213,7 +258,7 @@ private static async Task PinnedBunVersionAsync(IFileSystem fileSyste if (!process.Start()) { - throw new InvalidOperationException($"'{bun.Path} --version' did not start."); + throw new InvalidOperationException($"'{command}' did not start."); } // The readers drain on their own token: cancelling them first could fill a pipe the child @@ -236,7 +281,7 @@ private static async Task PinnedBunVersionAsync(IFileSystem fileSyste return process.ExitCode is 0 ? output.Trim() : throw new InvalidOperationException( - $"'{bun.Path} --version' exited with code {process.ExitCode.ToString(CultureInfo.InvariantCulture)}: {error.Trim()}"); + $"'{command}' exited with code {process.ExitCode.ToString(CultureInfo.InvariantCulture)}: {error.Trim()}"); } /// What the pinned server still needs, and the Bun mismatch the opt-in allowed. diff --git a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs index a033e70d7..d1de63154 100644 --- a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs +++ b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs @@ -1,5 +1,6 @@ using Testably.Abstractions.Testing; using Testably.Abstractions.Testing.Initializer; +using TUnit.Assertions.Enums; namespace OpenCode.Sdk.Tests.Harness; @@ -12,6 +13,8 @@ public sealed class PinnedServerPrerequisitesTests { private const string PinnedBun = "1.4.2"; private const string OtherBun = "1.4.3"; + private const string RecordedCommit = "1111111111111111111111111111111111111111"; + private const string OtherCommit = "2222222222222222222222222222222222222222"; private const string PinnedPackageJson = "{\"packageManager\":\"bun@" + PinnedBun + "\"}"; [Test] @@ -20,7 +23,7 @@ public async Task FindAsync_Should_Name_Only_The_Submodule_When_The_Pinned_Sourc var fileSystem = new MockFileSystem(); var root = Root(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, PinnedBun, bunMismatch: null, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, PinnedBun, bunMismatch: null); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("git submodule update --init --depth 1 external/opencode"); @@ -33,7 +36,7 @@ public async Task FindAsync_Should_Name_The_Install_When_The_Dependencies_Are_Mi var root = Root(fileSystem); CheckOut(fileSystem, root, PinnedPackageJson); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, PinnedBun, bunMismatch: null, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, PinnedBun, bunMismatch: null); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("bun install --frozen-lockfile --ignore-scripts"); @@ -45,7 +48,7 @@ public async Task FindAsync_Should_Name_Both_Versions_When_The_Bun_On_Path_Is_No var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, OtherBun, bunMismatch: null, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, OtherBun, bunMismatch: null); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("The bun on PATH is " + OtherBun); @@ -60,7 +63,7 @@ public async Task FindAsync_Should_Name_The_Pinned_Bun_When_None_Is_On_Path() var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, bunVersion: null, bunMismatch: null, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, bunVersion: null, bunMismatch: null); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("No bun is on PATH. Install bun " + PinnedBun); @@ -74,7 +77,7 @@ public async Task FindAsync_Should_Refuse_A_Pin_That_Names_No_Bun() CheckOut(fileSystem, root, "{\"packageManager\":\"pnpm@9.0.0\"}"); Install(fileSystem, root); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, PinnedBun, bunMismatch: null, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, PinnedBun, bunMismatch: null); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("names no bun@ packageManager"); @@ -86,7 +89,7 @@ public async Task FindAsync_Should_Pass_A_Prepared_Checkout_With_The_Pinned_Bun( var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, PinnedBun, bunMismatch: null, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, PinnedBun, bunMismatch: null); await Assert.That(readiness.Problems).IsEmpty(); await Assert.That(readiness.AllowedMismatch).IsNull(); @@ -98,8 +101,8 @@ public async Task FindAsync_Should_Allow_And_Report_Another_Bun_When_The_Mismatc var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync( - fileSystem, root, OtherBun, PinnedServerPrerequisites.AllowBunMismatch, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync( + fileSystem, root, OtherBun, PinnedServerPrerequisites.AllowBunMismatch); await Assert.That(readiness.Problems).IsEmpty(); await Assert.That(readiness.AllowedMismatch) @@ -113,8 +116,8 @@ public async Task FindAsync_Should_Refuse_No_Bun_On_Path_When_The_Mismatch_Is_Al var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync( - fileSystem, root, bunVersion: null, PinnedServerPrerequisites.AllowBunMismatch, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync( + fileSystem, root, bunVersion: null, PinnedServerPrerequisites.AllowBunMismatch); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("No bun is on PATH. Install bun " + PinnedBun); @@ -128,8 +131,8 @@ public async Task FindAsync_Should_Still_Name_The_Install_When_The_Mismatch_Is_A var root = Root(fileSystem); CheckOut(fileSystem, root, PinnedPackageJson); - var readiness = await PinnedServerPrerequisites.FindAsync( - fileSystem, root, OtherBun, PinnedServerPrerequisites.AllowBunMismatch, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync( + fileSystem, root, OtherBun, PinnedServerPrerequisites.AllowBunMismatch); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("bun install --frozen-lockfile --ignore-scripts"); @@ -141,8 +144,8 @@ public async Task FindAsync_Should_Report_Nothing_When_The_Mismatch_Is_Allowed_B var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync( - fileSystem, root, PinnedBun, PinnedServerPrerequisites.AllowBunMismatch, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync( + fileSystem, root, PinnedBun, PinnedServerPrerequisites.AllowBunMismatch); await Assert.That(readiness.Problems).IsEmpty(); await Assert.That(readiness.AllowedMismatch).IsNull(); @@ -161,7 +164,7 @@ public async Task FindAsync_Should_Refuse_Any_Other_Value_Of_The_Mismatch_Variab var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, PinnedBun, value, CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, PinnedBun, value); await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]) @@ -174,7 +177,7 @@ public async Task FindAsync_Should_Refuse_Another_Bun_When_The_Mismatch_Variable var fileSystem = new MockFileSystem(); var root = Prepared(fileSystem); - var readiness = await PinnedServerPrerequisites.FindAsync(fileSystem, root, OtherBun, "yes", CancellationToken.None); + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, OtherBun, "yes"); await Assert.That(readiness.Problems).Count().IsEqualTo(2); await Assert.That(readiness.Problems[0]).Contains("the only value it takes is 'allow'"); @@ -192,6 +195,72 @@ public async Task Describe_Should_Name_The_Module_And_List_Every_Problem() await Assert.That(message).EndsWith(Environment.NewLine + " - first" + Environment.NewLine + " - second"); } + [Test] + public async Task FindAsync_Should_Name_A_Wrong_Checkout_Before_A_Missing_Install() + { + var fileSystem = new MockFileSystem(); + var root = Root(fileSystem); + CheckOut(fileSystem, root, PinnedPackageJson); + var submodule = fileSystem.Path.Combine(root, "external", "opencode"); + + var readiness = await PinnedServerPrerequisites.FindAsync( + fileSystem, root, PinnedBun, bunMismatch: null, RecordedCommit, OtherCommit, CancellationToken.None); + + await Assert.That(readiness.Problems).IsEquivalentTo([ + $"The pin's checkout at '{submodule}' is 2222222222222222222222222222222222222222, but this repository records 1111111111111111111111111111111111111111. Run: git submodule update --init --depth 1 external/opencode", + $"The pinned server dependencies are not installed under '{submodule}'. Run there: bun install --frozen-lockfile --ignore-scripts", + ], CollectionOrdering.Matching); + } + + [Test] + [Arguments(null, RecordedCommit, "recorded gitlink")] + [Arguments(RecordedCommit, null, "checked-out HEAD")] + public async Task FindAsync_Should_Refuse_An_Unreadable_Commit(string? recorded, string? checkedOut, string unreadable) + { + var fileSystem = new MockFileSystem(); + var root = Prepared(fileSystem); + var submodule = fileSystem.Path.Combine(root, "external", "opencode"); + + var readiness = await PinnedServerPrerequisites.FindAsync( + fileSystem, root, PinnedBun, bunMismatch: null, recorded, checkedOut, CancellationToken.None); + + await Assert.That(readiness.Problems).IsEquivalentTo( + [$"Cannot read the pin's {unreadable} at '{submodule}'. Run: git submodule update --init --depth 1 external/opencode"], CollectionOrdering.Matching); + } + + [Test] + public async Task FindAsync_Should_Not_Add_Commit_Problems_When_Source_Is_Missing() + { + var fileSystem = new MockFileSystem(); + var root = Root(fileSystem); + var entry = fileSystem.Path.Combine(root, "external", "opencode", "packages", "cli", "src", "index.ts"); + + var readiness = await PinnedServerPrerequisites.FindAsync( + fileSystem, root, PinnedBun, bunMismatch: null, recordedCommit: null, checkedOutCommit: null, CancellationToken.None); + + await Assert.That(readiness.Problems).IsEquivalentTo( + [$"The pinned server source is missing at '{entry}'. Run: git submodule update --init --depth 1 external/opencode"], CollectionOrdering.Matching); + } + + [Test] + public async Task FindAsync_Should_Name_A_Mismatch_Typo_Before_Missing_Source() + { + var fileSystem = new MockFileSystem(); + var root = Root(fileSystem); + var entry = fileSystem.Path.Combine(root, "external", "opencode", "packages", "cli", "src", "index.ts"); + + var readiness = await FindWithMatchingCommitsAsync(fileSystem, root, PinnedBun, "Allow"); + + await Assert.That(readiness.Problems).IsEquivalentTo([ + "OPENCODE_SDK_TESTS_BUN_MISMATCH is 'Allow', but the only value it takes is 'allow'. Unset it to require the pinned bun.", + $"The pinned server source is missing at '{entry}'. Run: git submodule update --init --depth 1 external/opencode", + ], CollectionOrdering.Matching); + } + + private static Task FindWithMatchingCommitsAsync( + MockFileSystem fileSystem, string root, string? bunVersion, string? bunMismatch) => + PinnedServerPrerequisites.FindAsync(fileSystem, root, bunVersion, bunMismatch, RecordedCommit, RecordedCommit, CancellationToken.None); + private static string Root(MockFileSystem fileSystem) => fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "repository"); diff --git a/tools/jscpd/tests.baseline.json b/tools/jscpd/tests.baseline.json index 592d1c164..704c80b7d 100644 --- a/tools/jscpd/tests.baseline.json +++ b/tools/jscpd/tests.baseline.json @@ -171,7 +171,6 @@ "479fdc49d7da2d6e": 1, "47c6dec036a94d60": 1, "47d341670708569a": 1, - "482c654299e74350": 1, "4889b45748ec205e": 1, "48c32c7eaffb48de": 1, "48c83373cc2d7d8d": 1, @@ -208,7 +207,6 @@ "567e66ef84568376": 2, "5684435994da0e61": 1, "573c75d83c9a3c95": 1, - "57470feca6bbb65f": 1, "57f025d61c1ae125": 1, "580bb3a45cc1aa00": 1, "5890b6797611cdbf": 1,